Skip to content
File

Blob: src/worker/routes/authTokens.ts

typescript202 lines
1import { safeParseJsonRequest } from "@/shared/web";
2import { validateSlugForRoute } from "@/shared/slugs";
3import { json, newPrefixedId } from "@/worker/common";
4import {
5 findNamespaceBySlug,
6 findRepositoryByNamespaceAndSlug,
7 insertPatWithGrants,
8 listNamespacesForUser,
9 listPatsForUser,
10 listRepositoriesForUser,
11 revokePatById,
12} from "@/worker/db/d1/dal";
13import { sameOriginViolation } from "@/worker/auth/origin";
14import { loadViewer } from "@/worker/auth/session";
15import {
16 generatePatPlaintext,
17 hashPatPlaintext,
18 validatePatName,
19 viewerIsNamespaceMember,
20} from "@/worker/auth/pat";
21import type { AppRouter } from "./hono";
22import { renderUiDocumentResponse } from "./uiResponse";
23import { safeRedirect, summarizeTokens } from "./authShared";
24import { PatCreateRequestSchema } from "./requestSchemas";
25 
26export 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}