File
Blob: src/worker/routes/authTokens.ts
| 1 | import { safeParseJsonRequest } from "@/shared/web"; |
| 2 | import { validateSlugForRoute } from "@/shared/slugs"; |
| 3 | import { json, newPrefixedId } from "@/worker/common"; |
| 4 | import { |
| 5 | findNamespaceBySlug, |
| 6 | findRepositoryByNamespaceAndSlug, |
| 7 | insertPatWithGrants, |
| 8 | listNamespacesForUser, |
| 9 | listPatsForUser, |
| 10 | listRepositoriesForUser, |
| 11 | revokePatById, |
| 12 | } from "@/worker/db/d1/dal"; |
| 13 | import { sameOriginViolation } from "@/worker/auth/origin"; |
| 14 | import { loadViewer } from "@/worker/auth/session"; |
| 15 | import { |
| 16 | generatePatPlaintext, |
| 17 | hashPatPlaintext, |
| 18 | validatePatName, |
| 19 | viewerIsNamespaceMember, |
| 20 | } from "@/worker/auth/pat"; |
| 21 | import type { AppRouter } from "./hono"; |
| 22 | import { renderUiDocumentResponse } from "./uiResponse"; |
| 23 | import { safeRedirect, summarizeTokens } from "./authShared"; |
| 24 | import { PatCreateRequestSchema } from "./requestSchemas"; |
| 25 | |
| 26 | export function registerAuthTokenRoutes(router: AppRouter) { |
| 27 | router.get(`/auth/account`, async (c) => { |
| 28 | const viewer = await loadViewer(c); |
| 29 | if (!viewer) return safeRedirect(c, "/auth"); |
| 30 | const db = c.var.db; |
| 31 | const namespaces = await listNamespacesForUser(db, viewer.userId); |
| 32 | const repositoryRows = await listRepositoriesForUser(db, viewer.userId); |
| 33 | const tokenRows = await listPatsForUser(db, viewer.userId); |
| 34 | const tokens = await summarizeTokens(db, tokenRows); |
| 35 | return renderUiDocumentResponse( |
| 36 | c.env, |
| 37 | "account", |
| 38 | { |
| 39 | userId: viewer.userId, |
| 40 | primaryNamespaceSlug: viewer.primaryNamespaceSlug, |
| 41 | namespaces: namespaces.map((ns) => ({ id: ns.id, slug: ns.slug })), |
| 42 | repositories: repositoryRows.map((row) => ({ |
| 43 | id: row.repository.id, |
| 44 | slug: row.repository.slug, |
| 45 | namespaceSlug: row.namespace.slug, |
| 46 | visibility: row.repository.visibility, |
| 47 | updatedAt: row.repository.updatedAt, |
| 48 | })), |
| 49 | tokens, |
| 50 | }, |
| 51 | { failureBody: "Failed to render page\n", viewer } |
| 52 | ); |
| 53 | }); |
| 54 | |
| 55 | router.get(`/auth/api/tokens`, async (c) => { |
| 56 | const viewer = await loadViewer(c); |
| 57 | if (!viewer) return json({ error: "Unauthorized" }, 401); |
| 58 | const db = c.var.db; |
| 59 | const tokens = await listPatsForUser(db, viewer.userId); |
| 60 | const summaries = await summarizeTokens(db, tokens); |
| 61 | return json({ tokens: summaries }); |
| 62 | }); |
| 63 | |
| 64 | router.post(`/auth/api/tokens`, async (c) => { |
| 65 | const log = c.var.logFor({ service: "AuthPat" }); |
| 66 | const violation = sameOriginViolation(c); |
| 67 | if (violation) { |
| 68 | log.warn("pat:create-same-origin-violation"); |
| 69 | return violation; |
| 70 | } |
| 71 | const viewer = await loadViewer(c); |
| 72 | if (!viewer) { |
| 73 | log.info("pat:create-not-authenticated"); |
| 74 | return json({ error: "Unauthorized" }, 401); |
| 75 | } |
| 76 | |
| 77 | // Required tagged-union body shape: |
| 78 | // { scope: "namespace", name, namespaceSlug, level } |
| 79 | // { scope: "repo", name, namespaceSlug, repoSlug, level } |
| 80 | // `scope` and `level` are both required so contract drift surfaces as |
| 81 | // a 400 instead of silently coercing. `level === "push"` includes pull |
| 82 | // access by construction (see `pat_*_grants.level` CHECK). |
| 83 | const rawBody = await safeParseJsonRequest(c.req.raw); |
| 84 | const parsedBody = PatCreateRequestSchema.safeParse(rawBody); |
| 85 | if (!parsedBody.success) { |
| 86 | log.warn("pat:create-invalid-scope"); |
| 87 | return json({ error: "Body must include scope: 'namespace' or 'repo'" }, 400); |
| 88 | } |
| 89 | const body = parsedBody.data; |
| 90 | |
| 91 | const nameValidation = validatePatName(body.name); |
| 92 | if (!nameValidation.ok) { |
| 93 | log.warn("pat:create-invalid-name", { reason: nameValidation.reason }); |
| 94 | return json({ error: "Invalid token name" }, 400); |
| 95 | } |
| 96 | const level = body.level; |
| 97 | if (level === null) { |
| 98 | log.warn("pat:create-invalid-level"); |
| 99 | return json({ error: "Body must include level: 'pull' or 'push'" }, 400); |
| 100 | } |
| 101 | const slugValidation = validateSlugForRoute(body.namespaceSlug); |
| 102 | if (!slugValidation.ok) { |
| 103 | log.warn("pat:create-invalid-namespace-slug", { reason: slugValidation.reason }); |
| 104 | return json({ error: "Invalid namespace slug" }, 400); |
| 105 | } |
| 106 | let repoSlug: string | null = null; |
| 107 | if (body.scope === "repo") { |
| 108 | const repoSlugValidation = validateSlugForRoute(body.repoSlug); |
| 109 | if (!repoSlugValidation.ok) { |
| 110 | log.warn("pat:create-invalid-repo-slug", { reason: repoSlugValidation.reason }); |
| 111 | return json({ error: "Invalid repo slug" }, 400); |
| 112 | } |
| 113 | repoSlug = repoSlugValidation.slug; |
| 114 | } |
| 115 | const db = c.var.db; |
| 116 | const namespace = await findNamespaceBySlug(db, slugValidation.slug); |
| 117 | if (!namespace) { |
| 118 | log.warn("pat:create-namespace-not-found", { namespaceSlug: slugValidation.slug }); |
| 119 | return json({ error: "Namespace not found" }, 404); |
| 120 | } |
| 121 | if (!(await viewerIsNamespaceMember(db, viewer.userId, namespace.id))) { |
| 122 | log.warn("pat:create-not-member", { |
| 123 | userId: viewer.userId, |
| 124 | namespaceId: namespace.id, |
| 125 | }); |
| 126 | return json({ error: "Forbidden" }, 403); |
| 127 | } |
| 128 | |
| 129 | let repoId: string | null = null; |
| 130 | if (body.scope === "repo" && repoSlug !== null) { |
| 131 | const repository = await findRepositoryByNamespaceAndSlug(db, namespace.id, repoSlug); |
| 132 | if (!repository) { |
| 133 | log.warn("pat:create-repo-not-found", { |
| 134 | namespaceId: namespace.id, |
| 135 | repoSlug, |
| 136 | }); |
| 137 | return json({ error: "Repo not found" }, 404); |
| 138 | } |
| 139 | repoId = repository.id; |
| 140 | } |
| 141 | |
| 142 | const generated = generatePatPlaintext(); |
| 143 | const hash = await hashPatPlaintext(generated.plaintext); |
| 144 | const now = Date.now(); |
| 145 | const patId = newPrefixedId("pat"); |
| 146 | await insertPatWithGrants(db, { |
| 147 | pat: { |
| 148 | id: patId, |
| 149 | userId: viewer.userId, |
| 150 | name: nameValidation.name, |
| 151 | prefix: generated.publicPrefix, |
| 152 | hash, |
| 153 | createdAt: now, |
| 154 | expiresAt: null, |
| 155 | revokedAt: null, |
| 156 | lastUsedAt: null, |
| 157 | }, |
| 158 | namespaceGrants: |
| 159 | body.scope === "namespace" ? [{ patId, namespaceId: namespace.id, level }] : [], |
| 160 | repoGrants: body.scope === "repo" && repoId !== null ? [{ patId, repoId, level }] : [], |
| 161 | }); |
| 162 | log.info("pat:create-ok", { |
| 163 | userId: viewer.userId, |
| 164 | patId, |
| 165 | prefix: generated.publicPrefix, |
| 166 | scope: body.scope, |
| 167 | namespaceSlug: slugValidation.slug, |
| 168 | repoSlug, |
| 169 | level, |
| 170 | }); |
| 171 | return json({ id: patId, plaintext: generated.plaintext, prefix: generated.publicPrefix }); |
| 172 | }); |
| 173 | |
| 174 | router.delete(`/auth/api/tokens/:patId`, async (c) => { |
| 175 | const log = c.var.logFor({ service: "AuthPat" }); |
| 176 | const violation = sameOriginViolation(c); |
| 177 | if (violation) { |
| 178 | log.warn("pat:revoke-same-origin-violation"); |
| 179 | return violation; |
| 180 | } |
| 181 | const viewer = await loadViewer(c); |
| 182 | if (!viewer) return json({ error: "Unauthorized" }, 401); |
| 183 | const patId = c.req.param("patId"); |
| 184 | const result = await revokePatById(c.var.db, patId, viewer.userId, Date.now()); |
| 185 | if (result.ok) { |
| 186 | log.info("pat:revoke-ok", { userId: viewer.userId, patId }); |
| 187 | return json({ ok: true }); |
| 188 | } |
| 189 | if (result.reason === "not-owner") { |
| 190 | log.warn("pat:revoke-not-owner", { userId: viewer.userId, patId }); |
| 191 | return json({ error: "Forbidden" }, 403); |
| 192 | } |
| 193 | if (result.reason === "not-found") { |
| 194 | log.warn("pat:revoke-not-found", { userId: viewer.userId, patId }); |
| 195 | return json({ error: "Not found" }, 404); |
| 196 | } |
| 197 | // Re-revoke is idempotent; surface as a 200 but record it for visibility. |
| 198 | log.debug("pat:revoke-already-revoked", { userId: viewer.userId, patId }); |
| 199 | return json({ ok: true }); |
| 200 | }); |
| 201 | } |