Skip to content
File

Blob: src/worker/routes/authShared.ts

typescript89 lines
1import type { TokensIslandSummary } from "@/client/islands/tokens";
2import type { AppContext } from "./hono";
3import type { RouteCacheSyncMessage } from "@/worker/tasks/queue";
4 
5import { listPatGrantsByIds, type PersonalAccessTokenRow } from "@/worker/db/d1/dal";
6import type { Db } from "@/worker/db/d1/client";
7import 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.
13export 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.
41export 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 
80export 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 
86export function errorRedirect(c: AppContext, code: string): Response {
87 return safeRedirect(c, `/auth?error=${encodeURIComponent(code)}`);
88}