File
Blob: src/worker/routes/authShared.ts
| 1 | import type { TokensIslandSummary } from "@/client/islands/tokens"; |
| 2 | import type { AppContext } from "./hono"; |
| 3 | import type { RouteCacheSyncMessage } from "@/worker/tasks/queue"; |
| 4 | |
| 5 | import { listPatGrantsByIds, type PersonalAccessTokenRow } from "@/worker/db/d1/dal"; |
| 6 | import type { Db } from "@/worker/db/d1/client"; |
| 7 | import type { Logger } from "@/worker/common"; |
| 8 | |
| 9 | // Best-effort enqueue of a `route-cache-sync` task after a D1 mutation that |
| 10 | // changes ROUTES KV state (repo create, visibility flip, future rename). |
| 11 | // D1 is canonical and the resolver D1 fallback covers the gap until the |
| 12 | // queue drains, so a send failure is logged but does not fail the request. |
| 13 | export function enqueueRouteCacheSync( |
| 14 | c: AppContext, |
| 15 | log: Logger, |
| 16 | payload: { repositoryId: string; namespaceSlug: string; repoSlug: string } |
| 17 | ): void { |
| 18 | const message: RouteCacheSyncMessage = { |
| 19 | kind: "route-cache-sync", |
| 20 | repositoryId: payload.repositoryId, |
| 21 | namespaceSlug: payload.namespaceSlug, |
| 22 | repoSlug: payload.repoSlug, |
| 23 | enqueuedAt: Date.now(), |
| 24 | }; |
| 25 | c.executionCtx.waitUntil( |
| 26 | c.env.REPO_TASKS_QUEUE.send(message).catch((error) => { |
| 27 | log.warn("route-cache-sync:enqueue-failed", { |
| 28 | repositoryId: payload.repositoryId, |
| 29 | namespaceSlug: payload.namespaceSlug, |
| 30 | repoSlug: payload.repoSlug, |
| 31 | error: String(error), |
| 32 | }); |
| 33 | }) |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | // Build the wire-shape summary that the management UI consumes. Grants are |
| 38 | // fetched in two batched queries (one per grant table) and grouped by PAT |
| 39 | // id, so an arbitrary number of tokens still costs the same fixed number |
| 40 | // of round trips. |
| 41 | export async function summarizeTokens( |
| 42 | db: Db, |
| 43 | tokens: PersonalAccessTokenRow[] |
| 44 | ): Promise<TokensIslandSummary[]> { |
| 45 | if (tokens.length === 0) return []; |
| 46 | const ids = tokens.map((row) => row.id); |
| 47 | const grants = await listPatGrantsByIds(db, ids); |
| 48 | const namespaceByPatId = new Map<string, TokensIslandSummary["namespaceGrants"]>(); |
| 49 | for (const grant of grants.namespaceGrants) { |
| 50 | const list = namespaceByPatId.get(grant.patId) ?? []; |
| 51 | list.push({ |
| 52 | namespaceSlug: grant.namespaceSlug, |
| 53 | level: grant.level, |
| 54 | }); |
| 55 | namespaceByPatId.set(grant.patId, list); |
| 56 | } |
| 57 | const repoByPatId = new Map<string, TokensIslandSummary["repoGrants"]>(); |
| 58 | for (const grant of grants.repoGrants) { |
| 59 | const list = repoByPatId.get(grant.patId) ?? []; |
| 60 | list.push({ |
| 61 | namespaceSlug: grant.namespaceSlug, |
| 62 | repoSlug: grant.repoSlug, |
| 63 | level: grant.level, |
| 64 | }); |
| 65 | repoByPatId.set(grant.patId, list); |
| 66 | } |
| 67 | return tokens.map((token) => ({ |
| 68 | id: token.id, |
| 69 | name: token.name, |
| 70 | prefix: token.prefix, |
| 71 | createdAt: token.createdAt, |
| 72 | expiresAt: token.expiresAt ?? undefined, |
| 73 | revokedAt: token.revokedAt ?? undefined, |
| 74 | lastUsedAt: token.lastUsedAt ?? undefined, |
| 75 | namespaceGrants: namespaceByPatId.get(token.id) ?? [], |
| 76 | repoGrants: repoByPatId.get(token.id) ?? [], |
| 77 | })); |
| 78 | } |
| 79 | |
| 80 | export function safeRedirect(c: AppContext, url: string, status: 302 | 303 = 302): Response { |
| 81 | // Hono's c.redirect uses 302 by default. Sign-out uses 303 to force a GET on |
| 82 | // the target after a same-origin POST. |
| 83 | return c.redirect(url, status); |
| 84 | } |
| 85 | |
| 86 | export function errorRedirect(c: AppContext, code: string): Response { |
| 87 | return safeRedirect(c, `/auth?error=${encodeURIComponent(code)}`); |
| 88 | } |