File
Blob: src/worker/routes/authRepositories.ts
| 1 | import { safeParseJsonRequest } from "@/shared/web"; |
| 2 | import { validateSlugForRoute } from "@/shared/slugs"; |
| 3 | import { json, newPrefixedId } from "@/worker/common"; |
| 4 | import { |
| 5 | findNamespaceById, |
| 6 | findNamespaceBySlug, |
| 7 | findRepositoryById, |
| 8 | insertRepositoryIfNew, |
| 9 | listRepositoriesForUser, |
| 10 | updateRepositoryVisibility, |
| 11 | } from "@/worker/db/d1/dal"; |
| 12 | import { sameOriginViolation } from "@/worker/auth/origin"; |
| 13 | import { loadViewer } from "@/worker/auth/session"; |
| 14 | import { viewerIsNamespaceMember } from "@/worker/auth/pat"; |
| 15 | import type { AppRouter } from "./hono"; |
| 16 | import { enqueueRouteCacheSync } from "./authShared"; |
| 17 | import { RepositoryCreateRequestSchema, RepositoryVisibilityRequestSchema } from "./requestSchemas"; |
| 18 | |
| 19 | export function registerAuthRepositoryRoutes(router: AppRouter) { |
| 20 | router.get(`/auth/api/repositories`, async (c) => { |
| 21 | const viewer = await loadViewer(c); |
| 22 | if (!viewer) return json({ error: "Unauthorized" }, 401); |
| 23 | const rows = await listRepositoriesForUser(c.var.db, viewer.userId); |
| 24 | return json({ |
| 25 | repositories: rows.map((row) => ({ |
| 26 | id: row.repository.id, |
| 27 | slug: row.repository.slug, |
| 28 | namespaceSlug: row.namespace.slug, |
| 29 | visibility: row.repository.visibility, |
| 30 | updatedAt: row.repository.updatedAt, |
| 31 | })), |
| 32 | }); |
| 33 | }); |
| 34 | |
| 35 | router.post(`/auth/api/repositories`, async (c) => { |
| 36 | const log = c.var.logFor({ service: "RepoCreate" }); |
| 37 | const violation = sameOriginViolation(c); |
| 38 | if (violation) { |
| 39 | log.warn("repo-create:same-origin-violation"); |
| 40 | return violation; |
| 41 | } |
| 42 | const viewer = await loadViewer(c); |
| 43 | if (!viewer) { |
| 44 | log.info("repo-create:not-authenticated"); |
| 45 | return json({ error: "Unauthorized" }, 401); |
| 46 | } |
| 47 | const rawBody = await safeParseJsonRequest(c.req.raw); |
| 48 | const parsedBody = RepositoryCreateRequestSchema.safeParse(rawBody); |
| 49 | const body = parsedBody.success |
| 50 | ? parsedBody.data |
| 51 | : { namespaceSlug: "", slug: "", visibility: null }; |
| 52 | if (body.visibility === null) { |
| 53 | log.warn("repo-create:invalid-visibility"); |
| 54 | return json({ ok: false, reason: "invalid-visibility" } as const, 400); |
| 55 | } |
| 56 | const visibility = body.visibility; |
| 57 | const namespaceValidation = validateSlugForRoute(body.namespaceSlug); |
| 58 | if (!namespaceValidation.ok) { |
| 59 | log.warn("repo-create:invalid-slug", { field: "namespaceSlug" }); |
| 60 | return json({ ok: false, reason: "invalid-slug" } as const, 400); |
| 61 | } |
| 62 | const slugValidation = validateSlugForRoute(body.slug); |
| 63 | if (!slugValidation.ok) { |
| 64 | log.warn("repo-create:invalid-slug", { field: "slug" }); |
| 65 | return json({ ok: false, reason: "invalid-slug" } as const, 400); |
| 66 | } |
| 67 | const db = c.var.db; |
| 68 | const namespace = await findNamespaceBySlug(db, namespaceValidation.slug); |
| 69 | if (!namespace) { |
| 70 | log.warn("repo-create:namespace-not-found", { namespaceSlug: namespaceValidation.slug }); |
| 71 | return json({ ok: false, reason: "namespace-not-found" } as const, 404); |
| 72 | } |
| 73 | if (!(await viewerIsNamespaceMember(db, viewer.userId, namespace.id))) { |
| 74 | log.warn("repo-create:not-member", { |
| 75 | userId: viewer.userId, |
| 76 | namespaceId: namespace.id, |
| 77 | }); |
| 78 | return json({ ok: false, reason: "not-member" } as const, 403); |
| 79 | } |
| 80 | const now = Date.now(); |
| 81 | const repositoryId = newPrefixedId("repo"); |
| 82 | // Fresh repositories use an opaque RepoDO storage identity. Existing D1 |
| 83 | // rows may store a slash-shaped `doName`, so the `repo:` prefix keeps new |
| 84 | // identities unambiguous without inspecting URL slugs. |
| 85 | const doName = `repo:${repositoryId.slice("repo_".length)}`; |
| 86 | const inserted = await insertRepositoryIfNew(db, { |
| 87 | id: repositoryId, |
| 88 | namespaceId: namespace.id, |
| 89 | createdBy: viewer.userId, |
| 90 | slug: slugValidation.slug, |
| 91 | doName, |
| 92 | visibility, |
| 93 | createdAt: now, |
| 94 | updatedAt: now, |
| 95 | }); |
| 96 | if (!inserted) { |
| 97 | // Race lost: another writer committed `(namespaceId, slug)` between |
| 98 | // our membership check and the insert. The user sees this as |
| 99 | // slug-taken and can pick another name. |
| 100 | log.warn("repo-create:slug-taken", { |
| 101 | namespaceId: namespace.id, |
| 102 | slug: slugValidation.slug, |
| 103 | }); |
| 104 | return json({ ok: false, reason: "slug-taken" } as const, 409); |
| 105 | } |
| 106 | enqueueRouteCacheSync(c, log, { |
| 107 | repositoryId: inserted.id, |
| 108 | namespaceSlug: namespaceValidation.slug, |
| 109 | repoSlug: slugValidation.slug, |
| 110 | }); |
| 111 | log.info("repo-create:ok", { |
| 112 | userId: viewer.userId, |
| 113 | repositoryId: inserted.id, |
| 114 | namespaceSlug: namespaceValidation.slug, |
| 115 | slug: slugValidation.slug, |
| 116 | visibility, |
| 117 | }); |
| 118 | return json({ |
| 119 | ok: true, |
| 120 | id: inserted.id, |
| 121 | namespaceSlug: namespaceValidation.slug, |
| 122 | slug: slugValidation.slug, |
| 123 | visibility, |
| 124 | updatedAt: now, |
| 125 | } as const); |
| 126 | }); |
| 127 | |
| 128 | router.patch(`/auth/api/repositories/:repositoryId`, async (c) => { |
| 129 | const log = c.var.logFor({ service: "RepoVisibility" }); |
| 130 | const violation = sameOriginViolation(c); |
| 131 | if (violation) { |
| 132 | log.warn("repo-visibility:same-origin-violation"); |
| 133 | return violation; |
| 134 | } |
| 135 | const viewer = await loadViewer(c); |
| 136 | if (!viewer) return json({ error: "Unauthorized" }, 401); |
| 137 | const repositoryId = c.req.param("repositoryId"); |
| 138 | const rawBody = await safeParseJsonRequest(c.req.raw); |
| 139 | const parsedBody = RepositoryVisibilityRequestSchema.safeParse(rawBody); |
| 140 | const body = parsedBody.success ? parsedBody.data : { visibility: null }; |
| 141 | if (body.visibility === null) { |
| 142 | log.warn("repo-visibility:invalid-payload", { repositoryId }); |
| 143 | return json({ ok: false, reason: "invalid-payload" } as const, 400); |
| 144 | } |
| 145 | const visibility = body.visibility; |
| 146 | const db = c.var.db; |
| 147 | const repo = await findRepositoryById(db, repositoryId); |
| 148 | if (!repo) { |
| 149 | log.warn("repo-visibility:not-found", { repositoryId }); |
| 150 | return json({ ok: false, reason: "not-found" } as const, 404); |
| 151 | } |
| 152 | if (!(await viewerIsNamespaceMember(db, viewer.userId, repo.namespaceId))) { |
| 153 | log.warn("repo-visibility:not-member", { |
| 154 | userId: viewer.userId, |
| 155 | repositoryId, |
| 156 | namespaceId: repo.namespaceId, |
| 157 | }); |
| 158 | return json({ ok: false, reason: "not-member" } as const, 403); |
| 159 | } |
| 160 | const result = await updateRepositoryVisibility(db, repositoryId, visibility, Date.now()); |
| 161 | if (!result.ok) { |
| 162 | log.warn("repo-visibility:not-found", { repositoryId }); |
| 163 | return json({ ok: false, reason: "not-found" } as const, 404); |
| 164 | } |
| 165 | if (result.previous !== result.current) { |
| 166 | // Reconcile ROUTES KV via the queue. The consumer reads D1 at |
| 167 | // execution time and converges KV to canonical state, so a |
| 168 | // public->private flip drops the route candidate and a |
| 169 | // private->public flip puts it. We capture the slugs from the |
| 170 | // current row so the consumer can drop any stale captured key as |
| 171 | // well as set the canonical key. |
| 172 | const namespace = await findNamespaceById(db, repo.namespaceId); |
| 173 | if (namespace) { |
| 174 | enqueueRouteCacheSync(c, log, { |
| 175 | repositoryId, |
| 176 | namespaceSlug: namespace.slug, |
| 177 | repoSlug: repo.slug, |
| 178 | }); |
| 179 | } else { |
| 180 | log.warn("repo-visibility:namespace-missing-for-sync", { |
| 181 | repositoryId, |
| 182 | namespaceId: repo.namespaceId, |
| 183 | }); |
| 184 | } |
| 185 | } |
| 186 | log.info("repo-visibility:ok", { |
| 187 | userId: viewer.userId, |
| 188 | repositoryId, |
| 189 | previous: result.previous, |
| 190 | current: result.current, |
| 191 | }); |
| 192 | return json({ |
| 193 | ok: true, |
| 194 | id: repositoryId, |
| 195 | visibility: result.current, |
| 196 | previous: result.previous, |
| 197 | } as const); |
| 198 | }); |
| 199 | } |