Blob: docs/tessera-ownership-migration-plan.md
tessera Ownership Migration Plan
This plan modernizes git-on-cloudflare repository ownership and moves human identity to tessera OIDC while preserving the existing Git storage model: Workers remain stateless, RepoDO remains the per-repository metadata authority for refs and pack catalog state, and R2 remains the pack/object data plane.
The implementation is split into three phases so identity, route resolution, authorization, caching, and legacy removal do not all land in one deploy.
Current Source Anchors
- Worker route order lives in
src/worker/index.ts.registerAuthRoutes(router)is currently beforeregisterUiRoutes(router), and that must remain true because/:ownerwould otherwise shadow auth routes. - Git Smart HTTP routes in
src/worker/routes/git.tscurrently derive the storage identity withrepoKey(owner, repo)and pass that value to upload-pack, receive-pack, andcapabilityAdvertisement(). - UI browse routes in
src/worker/routes/ui.tsand route handlers undersrc/worker/routes/ui/also deriverepoKey(owner, repo)directly. - Legacy owner listing lives in
src/worker/registry/owner.tsoverOWNER_REGISTRYkeys shaped asowner:<owner>:<repo>. - Legacy push auth lives in
src/worker/auth/verify.tsandsrc/worker/do/auth/authDO.ts. - Repo metadata SQLite is inside RepoDO under
src/worker/do/repo/db/; new global ownership tables must not be added there. - Current cache helpers in
src/worker/cache/cache.tsread shared Workers Cache before loader execution. Private repo support must add an explicit bypass path before these helpers are called. - Existing limiter usage is centered on
CacheContextandgetLimiter()insrc/worker/git/operations/limits.ts. New outbound DO RPC and R2 calls in request-scoped code must use that limiter.
Patterns To Reuse
- Keep a dedicated global D1 folder instead of mixing global metadata with RepoDO SQLite:
src/worker/db/d1/client.tssrc/worker/db/d1/schema.tssrc/worker/db/d1/dal/*.tssrc/worker/db/d1/index.ts
- Follow the typed Drizzle client shape used in
~/code/anvil/src/worker/db/d1/client.ts; keep future executor/bookmark support inside the D1 client helper rather than leaking it into DAL call sites. - Follow goc's own DAL rule by keeping all global D1 app reads and writes behind the D1 DAL. Avoid raw Drizzle queries outside
src/worker/db/d1/dal/. - Use the flamemail OIDC pattern for tessera relying-party code:
openid-clientv6 owns discovery, authorization URL construction, code exchange, PKCE, and ID token validation.- OIDC transaction state is sealed in an HttpOnly cookie derived from
TESSERA_OIDC_CLIENT_SECRET. - Plaintext issuers are allowed only for loopback local development.
- Use anvil and bland naming conventions for bindings: the D1 binding is
DB. Do not introduce names such asGOC_DB.
Cloudflare Constraints Checked
- D1 app code should use bindings through
env.DB. D1batch()is the grouped statement primitive and executes statements sequentially as one batch; do not plan around D1transaction()in Workers app code. - D1 enforces foreign keys by default, so migration ordering and
ON DELETEchoices matter. - Workers KV is eventually consistent. Writes and negative lookups can remain stale in other locations for 60 seconds or more, so KV cannot be an authorization source and must not contain
visibility. - Workers Cache API data is local to the data center where it was written.
cache.delete()is not a global purge, so cache deletion cannot be the correctness mechanism for public-to-private transitions. - Queues support dashboard-sent messages, explicit
ack()andretry(), and retry/DLQ configuration. Backfill messages must be idempotent. - Durable Object delete migrations use
deleted_classesand delete stored data for that class. RemovingAuthDurableObjectis a Phase 3 cutover step, not a Phase 1 or 2 cleanup.
Reference docs checked:
- D1 Database API: https://developers.cloudflare.com/d1/worker-api/d1-database/
- D1 foreign keys: https://developers.cloudflare.com/d1/sql-api/foreign-keys/
- Workers KV consistency: https://developers.cloudflare.com/kv/concepts/how-kv-works/
- Workers Cache API: https://developers.cloudflare.com/workers/runtime-apis/cache/
- Queues batching and retries: https://developers.cloudflare.com/queues/configuration/batching-retries/
- Queues dashboard messages: https://developers.cloudflare.com/queues/examples/send-messages-from-dash/
- Durable Object migrations: https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
Cloudflare Primitive Map
- Worker routes: existing Hono routes in
src/worker/routes/*; new auth/session, route resolver, PAT, and repo-admin gates stay in Worker code. - Durable Object classes and identity keys:
RepoDurableObjectremains addressed withenv.REPO_DO.idFromName(doName). Existing legacy rows keepdoName = "<owner>/<repo>"; new rows usedoName = "repo:<uuid>". - D1 database: new global
DBbinding for users, namespaces, repositories, PATs, and grants. RepoDO SQLite stays dedicated to per-repo pack metadata. - R2 bucket: existing
REPO_BUCKETremains the data plane. Do not introduce Worker -> DO -> R2 chains for new request flows. - KV namespaces: keep
OWNER_REGISTRYonly for legacy listing/backfill until Phase 3. AddROUTESfor route read acceleration with keysrepo-route:v1:<namespace>/<repo>. - Queues: extend existing
REPO_MAINT_QUEUEfor legacy backfill messages. Do not create a second queue unless queue isolation becomes operationally necessary. - External systems: tessera OIDC provider at
TESSERA_OIDC_ISSUER, productionhttps://auth.limic.dev.
Global D1 Model
Add drizzle-d1.config.ts, drizzle/d1/, and a D1 binding in wrangler.jsonc:
"d1_databases": [
{
"binding": "DB",
"database_name": "git-on-cloudflare",
"database_id": "REPLACE_WITH_DATABASE_ID",
"migrations_dir": "drizzle/d1"
}
]Add scripts:
db:gen:d1:drizzle-kit generate --config drizzle-d1.config.tsdb:gen:repo: existing repo-DO migration commanddb:gen: run both, or keep the existing command and document that D1 changes requiredb:gen:d1
Use integer millisecond timestamps via Drizzle where possible, matching tessera and anvil.
Tables
users
id TEXT PRIMARY KEYtessera_sub TEXT UNIQUE NOT NULLcreated_at INTEGER NOT NULL
namespaces
id TEXT PRIMARY KEYslug TEXT UNIQUE NOT NULLcreated_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICTcreated_at INTEGER NOT NULL- There is no normal user-delete flow. If one is added later, namespace ownership must be transferred or explicitly rejected before user deletion.
namespace_memberships
namespace_id TEXT NOT NULL REFERENCES namespaces(id) ON DELETE CASCADEuser_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADEcreated_at INTEGER NOT NULL- Primary key:
(namespace_id, user_id) - Any membership means owner-level access for this migration. Roles are intentionally deferred.
repositories
id TEXT PRIMARY KEYnamespace_id TEXT NOT NULL REFERENCES namespaces(id) ON DELETE CASCADEcreated_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICTslug TEXT NOT NULLdo_name TEXT UNIQUE NOT NULLvisibility TEXT NOT NULLcreated_at INTEGER NOT NULLupdated_at INTEGER NOT NULL- Unique:
(namespace_id, slug) - Check:
visibility IN ('public', 'private')
personal_access_tokens
id TEXT PRIMARY KEYuser_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADEname TEXT NOT NULLprefix TEXT UNIQUE NOT NULLhash TEXT NOT NULLcreated_at INTEGER NOT NULLexpires_at INTEGERrevoked_at INTEGERlast_used_at INTEGER- Plaintext token is shown once as
goc_<short-prefix>_<secret>. Public token prefixing is allowed; internal DB and binding names stay unprefixed. - Hash the full plaintext token, not just the secret segment.
last_used_atupdates must bectx.waitUntil()best-effort and coarse-throttled.
pat_namespace_grants
pat_id TEXT NOT NULL REFERENCES personal_access_tokens(id) ON DELETE CASCADEnamespace_id TEXT NOT NULL REFERENCES namespaces(id) ON DELETE CASCADElevel TEXT NOT NULLwithCHECK (level IN ('pull','push'))- Primary key:
(pat_id, namespace_id)
pat_repo_grants
pat_id TEXT NOT NULL REFERENCES personal_access_tokens(id) ON DELETE CASCADErepo_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADElevel TEXT NOT NULLwithCHECK (level IN ('pull','push'))- Primary key:
(pat_id, repo_id)
level = 'push' includes pull/fetch/read access by construction; the
absence of a grant row encodes no-access. There is no separate
can_pull / can_push pair, and no can_admin. Web admin uses a local
goc session plus namespace membership.
Index Audit
The prompt's base indexes are necessary but not sufficient for all planned lookup paths. Add these indexes:
users(tessera_sub)via unique constraint: OIDC callback upsert and lookup.namespaces(slug)via unique constraint: route resolution by:owner.namespace_memberships(namespace_id, user_id)via primary key: authorization check for a known namespace and user.namespace_memberships(user_id, namespace_id): "my namespaces" and "my repositories" pages.repositories(namespace_id, slug)via unique constraint: route resolution by namespace and repo slug.repositories(do_name)via unique constraint: reverse lookup and safety checks.repositories(namespace_id, updated_at DESC, slug): owner page and namespace repo listing without scanning all repos in a namespace.personal_access_tokens(prefix)via unique constraint: PAT prefix lookup before hash verification.personal_access_tokens(user_id, created_at DESC): PAT management page.pat_namespace_grants(namespace_id): admin grant listing and cleanup by namespace.pat_repo_grants(repo_id): admin grant listing and cleanup by repo.
Authorization checks by PAT use the composite grant primary keys:
pat_namespace_grants(pat_id, namespace_id)pat_repo_grants(pat_id, repo_id)
No separate level index is needed because the level value is read after the targeted grant row is found by composite primary key.
Naming Decisions
- Use
DBfor the D1 binding. - Use
ROUTESfor the new route KV binding. - Keep existing
OWNER_REGISTRYonly while legacy compatibility exists. - Use
repositoryIdforrepositories.id. - Use
namespaceIdfornamespaces.id. - Use
doNameforenv.REPO_DO.idFromName(doName). - Use
routeNamespaceSlugandrouteRepoSlugfor URL params. - Do not pass bare
owner,repo, orrepoIdacross route, auth, and storage boundaries when aRepositoryRouteobject is available. - Keep public prefixes only where they help users/operators identify tokens or cookies:
goc_PATs,goc_sess_opaque session token,__Host-goc_session, and__Host-goc_oidc.
Phase 1: Identity, D1 Model, UI Shell, And Automatic Backfill
Goal: add tessera identity and global D1 ownership metadata without moving Git traffic through the new resolver.
1. Add D1, Drizzle, And DAL
Implementation tasks:
- Add
drizzle-d1.config.ts,src/worker/db/d1/schema.ts,src/worker/db/d1/client.ts, andsrc/worker/db/d1/dal/. - Add DAL modules:
users.tsnamespaces.tsrepositories.tstokens.tsroute-cache.tsif KV refresh helpers should sit next to DB mutations
- Export all D1 DAL functions from
src/worker/db/d1/index.ts. - Keep application D1 operations in Drizzle. Do not use
env.DB.prepare,env.DB.exec, or handwritten SQL for request handling. - Use Drizzle D1
batch()for grouped writes whose rows can be inserted in one transaction without conditional branching, such as PAT creation with grants (dal/tokens.ts:insertPatWithGrants). The first-login user/namespace/membership flow does NOT fitbatch(): the membership insert depends on whether the namespace claim won its slug race, which is not knowable until the claim insert returns, and a FK violation inside a batch would roll back the unrelated user insert. Run those three writes sequentially instead. Do not usetransaction()in Worker app code. - Add named result unions for DAL operations that can conflict:
- namespace slug taken
- repository slug taken
- PAT not found/revoked/expired
- Add structured logging with
createLoggeraround D1 writes, route-KV writes, queue sends, and backfill operations.
Acceptance:
npm run db:gen:d1produces a D1 migration underdrizzle/d1/.npm run typecheckpasses after generated Env types includeDBandROUTES.
2. Add OIDC Relying-Party Support
Implementation tasks:
- Add runtime dependency
openid-client. - Add
src/worker/auth/oidc.tsbased on the flamemail pattern:- normalize issuer URL
- allow
http://only for loopback issuers - cache discovery by issuer/client id for a short TTL
- generate PKCE S256 verifier/challenge,
state, andnonce - seal the transaction in
__Host-goc_oidc - exchange callback with
openid-client.authorizationCodeGrant() - read verified ID token claims once
- Add env vars:
TESSERA_OIDC_ISSUERTESSERA_OIDC_CLIENT_ID- secret
TESSERA_OIDC_CLIENT_SECRET - secret
SESSION_SECRETfor sealing the local goc browser session cookie
- Register goc in tessera
/admin/clients; production issuer ishttps://auth.limic.dev. - Add local session support:
GET /authrenders the sign-in surface for anonymous users and account links for signed-in users.GET /auth/startstarts tessera authorization code + PKCE and sets__Host-goc_oidc.POST /auth/sign-outclears the local session cookie after same-origin validation.GET /auth/callbackmints a local goc session after OIDC verification.- Session cookie
__Host-goc_sessionstores a sealed payload with user id, created time, and expiry, usingSESSION_SECRET. - Stateless sealed sessions cannot be individually server-revoked without adding a revocation store;
SESSION_SECRETrotation is the coarse invalidation mechanism. - Cookie mutations use
Secure,HttpOnly,SameSite=Lax,Path=/, and same-origin checks for sign-out. - Session reads fail closed if
SESSION_SECRETis missing, cookie unsealing fails, the payload is expired, or the D1 user row is missing.
- OIDC callback user binding:
- upsert
users(tessera_sub)by verifiedsub - do not use tessera ID tokens as goc sessions
- treat
preferred_usernameas a convenience slug candidate only, not ownership proof
- upsert
Route decision:
/authbecomes the tessera sign-in/account surface.- Move legacy AuthDO UI to
/auth/legacy. - Keep deprecated legacy JSON aliases under
/auth/api/usersduring Phase 1 if needed for operator scripts, but implement them as wrappers to/auth/legacy/api/usersand mark them for Phase 3 removal. - Keep
registerAuthRoutes(router)beforeregisterUiRoutes(router).
3. Add Slug Policy
Implementation tasks:
- Add a shared slug validator, for example
src/shared/slugs.ts. - Use it for namespace slugs and repository slugs.
- Reserve slugs that conflict with Worker-owned routes or common static paths:
authsign-insign-outapiassets_cachefavicon.icorobots.txt
- Lowercase route slugs and reject ambiguous Unicode. Keep the policy ASCII unless the product intentionally supports more later.
Acceptance:
preferred_usernamecreates a namespace only when it passes the same namespace slug policy and is unused.- Invalid or taken
preferred_usernamestill creates the user and session, but no namespace is imported.
4. Add Signed-In UI Shell
Implementation tasks:
- Extend SSR shell data so
AppLayoutandHeadercan render anonymous vs signed-in states. - Header behavior:
- anonymous users see
Sign in - signed-in users see links to namespaces and PATs, plus sign-out
- legacy AuthDO is not linked from primary nav
- anonymous users see
- Add pages through the existing view registry:
namespacesoraccounttokensrepository settingsshell for/:owner/:repo/admin
- Add client entrypoints only for interactive PAT creation/revocation and visibility toggles.
- Keep UI rendering through
renderUiView()andsrc/client/server/registry.tsx.
Phase 1 UI reads D1 for signed-in pages only. Existing owner/repo public UI routes continue to use legacy routing and repoKey(owner, repo).
5. Add PAT Management But Do Not Enforce It Yet
Implementation tasks:
- Add PAT create/list/revoke endpoints under session auth.
- PAT creation returns plaintext once:
- example:
goc_abcd1234_<random-secret> - DB
prefix:goc_abcd1234 - DB
hash: hash of full plaintext token
- example:
- Create-token request body is
{ scope, name, namespaceSlug, [repoSlug,] level }wherelevelis'pull'or'push'. Missing or invalidlevelreturns 400. - Store namespace and repo grants in D1 with the supplied
level. - Do not add a PAT admin permission.
- Do not wire Git receive-pack or upload-pack to PAT authorization until Phase 2.
6. Add Automatic Legacy Backfill
Queue payload:
type LegacyBackfillMessage = {
kind: "legacy-backfill";
userId: string;
namespaceSlug: string;
cursor?: string;
};Implementation tasks:
- Extend
RepoMaintenanceQueueMessageinsrc/worker/maintenance/queue.ts. - Add
src/worker/maintenance/legacyBackfill.ts. - On first login for a new tessera
sub:- if
preferred_usernameis valid and unused, create user, namespace, membership, and enqueue a backfill message - otherwise create only the user and sealed session cookie
- if
- Queue consumer behavior:
- list
OWNER_REGISTRYwith prefixowner:<namespaceSlug>: - for each repo key suffix, upsert:
- namespace slug =
namespaceSlug - namespace membership =
userId - repository slug = key suffix
doName = "${namespaceSlug}/${repoSlug}"visibility = "public"because legacy reads are public
- namespace slug =
- write
ROUTES.put("repo-route:v1:<namespace>/<repo>", { repositoryId, namespaceId, doName, updatedAt }) - do not include
visibilityin KV - if list is incomplete, re-enqueue with the cursor
- acknowledge each message only after its D1/KV work is complete or intentionally retried
- list
- Duplicate messages must be idempotent through unique constraints and upserts.
- Queue recovery is an operator workflow through the Cloudflare dashboard by sending the same JSON payload. Do not expose a product UI button for backfill.
KV failure policy:
- D1 is the source of truth. If a route-KV write fails after a successful D1 upsert, log
backfill:route-cache-put-failedwith namespace, repo, and repository id, then retry the queue message unless the failure is clearly permanent. - KV must never contain
visibility.
Phase 1 Tests
- D1 migration generation and fresh apply.
- OIDC start/callback with a mocked provider or fetch boundary mock following tessera's
scripts/test-client/run.ts. - Sealed session cookie creation, lookup, tamper rejection,
SESSION_SECRETfailure, and expiry. - User upsert by
tessera_sub. - Valid
preferred_usernamecreates namespace/membership and enqueues backfill. - Invalid or taken
preferred_usernamecreates only the user and sealed session cookie. - Backfill duplicate messages are idempotent.
- Backfill cursor continuation re-enqueues and resumes.
- Route KV payload omits
visibility. - PAT create/list/revoke and prefix-then-hash verification, without Git enforcement yet. Cover both
level = 'pull'andlevel = 'push'round-trips, plus 400 responses for missing or invalidlevel.
Phase 2: Resolver, ACLs, PATs, Admin, And Cache Policy
Goal: all repo-serving route handlers use a resolver and D1 authorization, while legacy fallback remains available during compatibility.
1. Introduce Repository Route Context
Add a named route context, for example:
type RepositoryRoute = {
routeNamespaceSlug: string;
routeRepoSlug: string;
namespaceId: string;
repositoryId: string;
doName: string;
visibility: "public" | "private";
source: "kv" | "d1" | "legacy";
};Implementation tasks:
- Add resolver code under
src/worker/repositories/resolve.tsorsrc/worker/routes/repositories/resolve.ts. - Route handlers pass this context, not separate owner/repo strings, across auth and cache decisions.
env.REPO_DO.idFromName()must receiveroute.doName.repoKey(owner, repo)must disappear from route handlers for resolved D1 routes.- Keep lower-level Git operation parameters as-is if renaming them would make Phase 2 too broad, but boundary comments must say the value is the RepoDO
doName.
Resolver behavior in Phase 2 compatibility:
- KV hit: treat as a candidate only, then read D1 by ids/slugs to verify existence and current visibility.
- KV miss: read D1 by namespace slug and repo slug.
- D1 miss: legacy fallback to old
owner/repobehavior when compatibility is enabled.
Scanner-shield checkpoint:
- Early Phase 2 keeps D1 fallback on anonymous KV miss to reveal backfill misses.
- After backfill metrics are clean, enable anonymous KV-miss 404 for public routes.
- Phase 3 makes anonymous KV-miss 404 mandatory.
- Authenticated session routes and PAT routes always retain D1 fallback on KV miss because private repos may not be discoverable through a public route index.
2. Update Git Smart HTTP Routes
Route families:
GET /:owner/:repo/info/refsPOST /:owner/:repo/git-upload-packPOST /:owner/:repo/git-receive-pack
Implementation tasks:
- Resolve route context before capability advertisement or pack handling.
- For
git-upload-packandinfo/refs?service=git-upload-pack:- public repo: anonymous allowed
- private repo: require signed-in session membership or any PAT grant (
level = 'pull'orlevel = 'push')
- For
git-receive-pack:- require PAT with
level = 'push' - Phase 2 compatibility may fall back to AuthDO owner token for legacy/imported public repos only
- do not allow AuthDO fallback to bypass private repo policy
- require PAT with
- Git Basic username must equal
route.routeNamespaceSlug. - Git Basic password is the PAT plaintext.
- Validate route owner from resolved context before PAT verification.
- Keep
Retry-After: 10behavior for receive lease conflicts.
Limiter/logging:
- Any new outbound DO RPC in route handlers must use
getLimiter(cacheCtx).run("do:<operation>", fn). - Existing receive path already has a receive-scoped limiter; keep new resolver/auth D1 work outside DO/R2 limiter because D1 is not covered by the goc limiter rule, but log it.
3. Update UI Browse And API Routes
Route families:
/:owner/:owner/:repo/:owner/:repo/tree/:owner/:repo/blob/:owner/:repo/raw/:owner/:repo/rawpath/:owner/:repo/commits/:owner/:repo/commits/fragments/:oid/:owner/:repo/commit/:oid/:owner/:repo/commit/:oid/diff/:owner/:repo/api/refs/:owner/:repo/admin
Implementation tasks:
- Owner page:
- anonymous users see public D1 repos plus Phase 2 legacy-compatible public entries
- namespace members see public and private D1 repos
- private repos are not disclosed to anonymous users
- Repo data pages:
- resolve route context first
- authorize before any shared cache lookup
- return 404 for anonymous private repo requests to avoid existence disclosure
- return 403 only when the user is authenticated but lacks membership/grants
- Admin page:
- require local goc session
- require namespace membership
- no PAT admin access
- no Basic/AuthDO admin access for the new admin page
- Account page repo-create UI:
- on
/auth/account, the existing Repositories section gains a "+ New repository" affordance that reveals an inline form with asluginput, a private/public toggle (defaultprivate), and a Create button - on success, the new repo appears in the table and the form collapses
- the Repositories
EmptyStategains the same affordance so first-time signed-in users have a path off the empty state without legacy push - this is the surface that closes the catch-22 documented in
### 6(a token can only grant against an existing repo, but until this UI lands there is no way for a signed-in user to create the first repo once legacy backfill is removed in Phase 3)
- on
- JSON admin routes in
src/worker/routes/admin.tsmust move to session membership authorization. If a legacy Basic fallback is retained for one compatibility release, scope it to explicitly named legacy endpoints and test it.
4. Implement PAT Authorization
Implementation tasks:
- Add
src/worker/auth/pat.tswith named result unions:- malformed
- username mismatch
- token not found
- token revoked
- token expired
- grant missing
- ok
- Parse public prefix from the plaintext token before hashing.
- Hash the full plaintext token and compare to the stored hash with constant-time comparison.
- Check grants in this order:
- repo grant for exact
repositoryId - namespace grant for
namespaceId
- repo grant for exact
- Any present grant (
level = 'pull'orlevel = 'push') authorizes fetch/clone and private UI read APIs when PAT credentials are used;level = 'push'includes pull access by construction. level = 'push'authorizes receive-pack.- PATs never authorize web admin operations.
- Update
last_used_atwithctx.waitUntil()only when the previous value is older than a coarse threshold, for example 15 minutes. Do not write D1 on every Git request.
5. Define Cache Policy
Rules:
- D1 is the source of truth for visibility and authorization.
ROUTESis only a route candidate cache.- Shared Workers Cache may be read or written only after D1 confirms the repo currently exists and is public.
- Private repo responses must set
Cache-Control: no-store. - Private repo handlers must bypass shared JSON and object cache reads and writes.
- Public-to-private correctness must not depend on KV deletion or
cache.delete().
Implementation tasks:
- Add a small cache policy type, for example:
type SharedCachePolicy = "allow-shared-cache" | "bypass-shared-cache";- Extend
CacheContext.memo.flagsor cache helper signatures so private paths bypass:- refs cache
- README cache
- tree/blob metadata cache
- commits/diff cache
- object cache
- Existing
readLooseObjectRaw()already honorsno-cache-read; extend the pattern so object cache writes are also skipped for private routes. - Update every route family listed above to resolve and authorize before calling cache helpers.
- On public responses, use existing TTLs after D1 visibility check.
- On private responses, do not call
cacheOrLoadJSON,cacheOrLoadJSONWithTTL, orcacheOrLoadObjectunless they receive an explicit bypass mode.
Cache key decision:
- Use
doNameas the storage cache scope for object reads because it is the RepoDO/R2 storage identity. - Never use D1
repositoryIdas a substitute fordoName. - The D1 visibility gate must happen before cache key construction and cache lookup.
6. Define Mutation And KV Failure Policy
Repo create — endpoint and UI:
The mechanics below need a session-authorized surface. Add it as part of Phase 2 alongside the resolver and ACLs work — without it, the catch-22 of "PAT requires existing repo, push requires PAT, legacy backfill is being removed" has no resolution.
Endpoint:
POST /auth/api/repositoriesAuth:
loadViewer-required session; same-origin check viasameOriginViolation. PATs cannot create repositories — repo creation stays a session-only mutation so slug typos in the token form cannot create ghost repos.Body (required fields, no server-side defaults):
{ namespaceSlug: string; slug: string; visibility: "public" | "private"; }Validation:
namespaceSlugandslugpassvalidateSlugForRoute(existing helper already used by the tokens route)- Namespace exists (
findNamespaceBySlug) - Viewer is a member (
viewerIsNamespaceMember) (namespaceId, slug)is not already taken (relies onuq_repositories_namespace_slugunique index)
Mechanics — the existing numbered list below applies, with these specifics:
id = newPrefixedId("repo"),doName = "repo:<id-suffix>"- D1 insert via existing
insertRepositoryIfNew(src/worker/db/d1/dal/repositories.ts); a returnedundefinedmeans the slug was taken between validation and insert (race) — return{ ok: false, reason: "slug-taken" }. ROUTES.put("repo-route:v1:<namespace>/<slug>", record)is best-effort; failure logsrepo-create:route-cache-put-failedbut the endpoint still returns 200. Anonymous KV-miss serving may briefly 404 a freshly-created public repo until KV converges; authenticated/PAT paths resolve through D1 fallback immediately (Phase 2 §1).
Response — tagged union per the project convention:
| { ok: true; id: string; namespaceSlug: string; slug: string; visibility: "public" | "private" } | { ok: false; reason: "invalid-slug" | "namespace-not-found" | "not-member" | "slug-taken" }Logging:
repo-create:*structured events withuserId,namespaceId,slug,repositoryId.Visibility default: the UI defaults the toggle to
"private". There is no schema default —repositories.visibilityisnotNullwith no.default()atsrc/worker/db/d1/schema/repositories.ts, so the wire field is required. Legacy backfill continues to import as"public"because legacy reads were public (rule unchanged from the legacy backfill section).
Repo create:
- Session membership authorizes namespace mutation.
- D1 creates repository row with new
doName = "repo:<uuid>". ROUTES.put()writes the route candidate.- KV put failure logs a warning and returns success with an admin-visible stale-route warning; a repair action can refresh KV from D1.
Repo rename:
- D1 updates
repositories.slugandupdated_at. - Delete old route KV key and put new route KV key.
- If either KV operation fails, log and expose repair state. Correctness still comes from D1.
Repo visibility change:
- D1 updates visibility first.
- For
public -> private, delete route KV as a best-effort privacy optimization. - Do not rely on KV deletion or cache deletion for correctness.
- All serving paths must read D1 visibility before any shared cache read.
Repo delete:
- D1 deletes the repository row and cascades grants. The schema does not add a tombstone column in this migration.
- Delete route KV as best effort.
- Existing RepoDO/R2 physical purge remains a separate operator/admin operation unless a new cleanup workflow is designed. Do not add a Worker -> DO -> R2 chain for route deletion.
7. Phase 2 Tests
- Resolver:
- KV hit then D1 verification
- KV miss then D1 fallback
- D1 miss then legacy fallback in compatibility mode
- anonymous KV miss 404 after scanner-shield flag/checkpoint
- UUID-style
doNamereaches the correct RepoDO
- Git ACL:
- public fetch anonymous succeeds
- private fetch anonymous returns non-disclosing 404
- private fetch with PAT
level = 'pull'succeeds - private fetch with PAT
level = 'push'succeeds (push includes pull) - push with PAT
level = 'push'succeeds - push with PAT
level = 'pull'fails - Basic username mismatch fails closed
- AuthDO fallback works only in the scoped compatibility cases
- UI ACL:
- anonymous private overview/tree/blob/raw/commits/refs API do not disclose existence
- namespace member can browse private repo
/:owner/:repo/adminrequires session membership- PAT-only admin access fails
- Cache:
- public route uses shared cache only after D1 public check
- private route bypasses cache read/write
- public-to-private does not serve stale public cache even when KV/cache deletion is delayed
- KV:
- route cache writes omit
visibility - create/rename/delete update or delete KV keys
- KV failure logs and leaves D1 as source of truth
- route cache writes omit
- Repo create endpoint (
POST /auth/api/repositories):- signed-in member POST returns
{ ok: true, ... }and the repo appears inlistRepositoriesForUser - non-member POST returns
{ ok: false, reason: "not-member" } - unknown namespace returns
{ ok: false, reason: "namespace-not-found" } - duplicate slug returns
{ ok: false, reason: "slug-taken" } - invalid slug (per
validateSlugForRoute) returns{ ok: false, reason: "invalid-slug" } ROUTES.put()failure is logged but the endpoint still returns 200 with the new row- newly-created repo immediately resolves through
resolveRepositoryRoutevia the D1 path - PAT-authenticated POST is rejected (session-only mutation)
- signed-in member POST returns
Phase 3: Cutover And Legacy Removal
Goal: require D1 ownership metadata for all repo routes and remove AuthDO, legacy owner registry, legacy route fallback, and automatic backfill.
1. Cutover Preconditions
- Backfill metrics show no remaining legacy repos outside explicit operator exceptions.
- Route resolver logs show D1 rows for normal traffic.
- Anonymous KV-miss scanner shield has already been enabled and monitored in Phase 2.
- PAT rollout has replaced legacy owner push tokens for active push users.
- Admin UI uses tessera sessions for repository admin tasks.
Rollback posture:
- Before the Phase 3 deploy, normal code rollback is acceptable.
- After the
deleted_classesmigration forAuthDurableObject, AuthDO data is intentionally gone. Treat failures as forward-fix unless an operator has a separate backup. - Do not invent a strict public/private rollback policy. The practical safety boundary is D1 visibility checks before data serving.
2. Remove Legacy AuthDO
Implementation tasks:
- Delete
src/worker/do/auth/. - Remove
AuthDurableObjectexport fromsrc/worker/index.ts. - Remove
AUTH_DObinding fromwrangler.jsonc. - Add a new ordered Durable Object migration:
{
"tag": "v3",
"deleted_classes": ["AuthDurableObject"],
}- Remove
AUTH_ADMIN_TOKENusage and tests. - Remove legacy
/auth/legacyand/auth/api/userscompatibility routes. - Remove
src/worker/auth/verify.tsor reducesrc/worker/auth/to session/PAT/OIDC modules only.
3. Require D1 Rows For All Repo Routes
Implementation tasks:
- Remove resolver legacy fallback.
- Remove all route-handler calls to
repoKey(owner, repo). - All Git and UI repo routes require a
RepositoryRoute. - Existing legacy repositories still work because their D1 row stores
doName = "<legacy-owner>/<legacy-repo>". - New repositories continue to use
doName = "repo:<uuid>". - Public anonymous KV miss returns 404 without D1 fallback.
- Authenticated session and PAT routes may query D1 directly on KV miss.
4. Replace Owner Registry Listing
Implementation tasks:
- Replace
listReposForOwner()call sites with D1 DAL queries. - Remove
src/worker/registry/owner.tsif no longer used. - Remove
OWNER_REGISTRYbinding fromwrangler.jsonc. - Keep
ROUTESfor route acceleration only. - If a public repo listing cache is useful, derive it from D1 into KV with non-sensitive payloads. Do not store
visibility.
5. Remove Backfill And Compatibility Code
Implementation tasks:
- Remove
legacy-backfillfromRepoMaintenanceQueueMessage. - Delete backfill handler/tests.
- Remove manual dashboard backfill instructions from operator docs after cutover.
- Remove AuthDO fallback from PAT/auth logic.
- Remove compatibility feature flags added for Phase 2 scanner-shield rollout once the strict behavior is default.
6. Normalize Naming At The Boundary
Implementation tasks:
- Route handlers must use
RepositoryRoute. - Storage access helpers should take
doNamewhen they callenv.REPO_DO.idFromName(). - Comments and docs must stop describing
repoIdasowner/repo. - If deeper Git read helpers still use a
repoIdparameter, add or update comments that it is the RepoDO storage identity, not the D1 repository id. Prefer renaming touched call sites todoNamewhen local churn is manageable.
7. Phase 3 Tests
- No route falls back to
owner/repowhen a D1 row is missing. - Legacy repositories with D1 rows and
doName = "<owner>/<repo>"still fetch/push with D1 PATs. - New repositories with UUID-style
doNamefetch/push and browse correctly. - Anonymous KV miss returns 404.
- Authenticated/PAT KV miss queries D1.
- AuthDO routes, bindings, exports, and tests are absent.
OWNER_REGISTRYis absent from generated Env types.- Backfill queue messages are rejected or ignored as unknown after removal.
Validation Commands
Run the smallest useful set per phase:
- Phase 1 D1/auth work:
npm run db:gen:d1npm run typecheck- targeted worker tests for OIDC/session/PAT/backfill
- Phase 2 resolver/auth/cache work:
npm run typechecknpm run test:workers -- test/<new-resolver-tests>.worker.test.ts- existing Git worker tests touched by route changes
- Phase 3 removal:
npm run typechecknpm run test:workersnpm run format:check
Do not run the 42 MiB pack-indexer fixture unless intentionally validating pack-indexer behavior.
Final Consistency Review
- Internal binding names are unprefixed:
DB,ROUTES. - Public token/cookie prefixes remain explicit:
goc_,goc_sess_,__Host-goc_session,__Host-goc_oidc. - KV never stores
visibility. - D1 is the source of truth for repository existence, visibility, memberships, PATs, and grants.
- Public and private serving paths both read D1 before shared Workers Cache access.
- Phase 2 compatibility and Phase 3 scanner-shield behavior are not contradictory: Phase 2 starts with D1 fallback for anonymous KV misses, then enables anonymous KV-miss 404 after backfill is proven; Phase 3 makes that strict behavior mandatory.
- No new plan step requires Worker -> DO -> R2 for route deletion or authorization.
- RepoDO remains the only transactional authority for per-repo refs and pack catalog state.
- The implementation can be assigned phase-by-phase without requiring identity, resolver, admin, cache, and legacy removal to land in one PR.