Skip to content
File

Blob: src/worker/routes/authRepositories.ts

typescript200 lines
1import { safeParseJsonRequest } from "@/shared/web";
2import { validateSlugForRoute } from "@/shared/slugs";
3import { json, newPrefixedId } from "@/worker/common";
4import {
5 findNamespaceById,
6 findNamespaceBySlug,
7 findRepositoryById,
8 insertRepositoryIfNew,
9 listRepositoriesForUser,
10 updateRepositoryVisibility,
11} from "@/worker/db/d1/dal";
12import { sameOriginViolation } from "@/worker/auth/origin";
13import { loadViewer } from "@/worker/auth/session";
14import { viewerIsNamespaceMember } from "@/worker/auth/pat";
15import type { AppRouter } from "./hono";
16import { enqueueRouteCacheSync } from "./authShared";
17import { RepositoryCreateRequestSchema, RepositoryVisibilityRequestSchema } from "./requestSchemas";
18 
19export 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}