Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

workflow-artifacts.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

workflow-artifacts.tsBlame390 lines · 1 contributor
abfa9adClaude1/**
2 * REST API for workflow run artifacts (Block C1 / Sprint 1 — Agent 6).
3 *
4 * Mount point: `/api/v1/...` — the main thread wires this in via
5 * `app.route('/', artifactsRoutes)` in `app.tsx` (out of scope for this agent).
6 *
7 * Endpoints
8 * ---------
9 * POST /api/v1/runs/:runId/artifacts → create artifact (multipart or JSON+base64)
10 * GET /api/v1/runs/:runId/artifacts → list artifact metadata
11 * GET /api/v1/artifacts/:artifactId/download → download binary
12 * DELETE /api/v1/artifacts/:artifactId → delete
13 *
14 * Auth
15 * ----
16 * `Authorization: Bearer <glc_...>` PAT. The token row in `api_tokens`
17 * carries comma-separated scopes (see `src/routes/tokens.tsx`). For write
18 * operations we require the token to have `repo` / `write` / `admin`; for
19 * deletes we require `admin`. List/download allow public-repo anonymous
20 * access (falls back to session cookie or unauthenticated for public repos).
21 *
22 * NOTE: we intentionally DO NOT use the existing `requireAuth` middleware
23 * here — it redirects cookie-less requests to `/login` which is wrong for
24 * an API client. Instead we resolve the bearer ourselves at the top of each
25 * handler.
26 */
27
28import { Hono } from "hono";
29import { and, eq } from "drizzle-orm";
30import { db } from "../db";
31import {
32 apiTokens,
33 repositories,
34 users,
35 workflowRuns,
36} from "../db/schema";
37import type { User } from "../db/schema";
38import { sha256Hex } from "../lib/oauth";
39import { softAuth } from "../middleware/auth";
40import type { AuthEnv } from "../middleware/auth";
41import {
42 uploadArtifact,
43 listArtifacts,
44 downloadArtifact,
45 deleteArtifact,
46 getRunRepositoryId,
47 getArtifactRunId,
48 MAX_ARTIFACT_BYTES,
49} from "../lib/workflow-artifacts";
50
51const app = new Hono<AuthEnv>();
52
53// Soft-auth so that public-repo GET requests with no creds still resolve
54// `c.get("user") === null` cleanly (rather than touching the DB inside each
55// handler). Write/delete handlers re-resolve the bearer themselves below
56// because they also need the PAT scope list.
57app.use("/api/v1/runs/*", softAuth);
58app.use("/api/v1/artifacts/*", softAuth);
59
60// ---------------------------------------------------------------------------
61// Bearer-PAT helper (~30 lines per sprint notes).
62// Returns the user + scopes if the header carries a valid `glc_` PAT, else
63// null. We don't handle `glct_` OAuth tokens here — the API surface for
64// workflow artifacts is PAT-oriented.
65// ---------------------------------------------------------------------------
66
67async function resolveBearer(
68 authHeader: string | undefined
69): Promise<{ user: User; scopes: string[] } | null> {
70 if (!authHeader) return null;
71 const lower = authHeader.toLowerCase();
72 if (!lower.startsWith("bearer ")) return null;
73 const token = authHeader.slice(7).trim();
74 if (!token.startsWith("glc_")) return null;
75 try {
76 const hash = await sha256Hex(token);
77 const [row] = await db
78 .select()
79 .from(apiTokens)
80 .where(eq(apiTokens.tokenHash, hash))
81 .limit(1);
82 if (!row) return null;
83 if (row.expiresAt && new Date(row.expiresAt) < new Date()) return null;
84 const [user] = await db
85 .select()
86 .from(users)
87 .where(eq(users.id, row.userId))
88 .limit(1);
89 if (!user) return null;
90 const scopes = row.scopes
91 ? row.scopes.split(/[,\s]+/).filter(Boolean)
92 : [];
93 return { user, scopes };
94 } catch (err) {
95 console.error("[workflow-artifacts] resolveBearer:", err);
96 return null;
97 }
98}
99
100/** Scope checks. Our PATs use names like `repo` / `user` / `admin`. We also
101 * accept the literal names from the spec (`read` / `write` / `admin`) so the
102 * action runner and CI clients can pick whichever feels natural. */
103function hasReadScope(scopes: string[]): boolean {
104 return (
105 scopes.includes("repo") ||
106 scopes.includes("read") ||
107 scopes.includes("write") ||
108 scopes.includes("admin")
109 );
110}
111function hasWriteScope(scopes: string[]): boolean {
112 return (
113 scopes.includes("repo") ||
114 scopes.includes("write") ||
115 scopes.includes("admin")
116 );
117}
118function hasAdminScope(scopes: string[]): boolean {
119 return scopes.includes("admin");
120}
121
122async function loadRepoOwner(repositoryId: string): Promise<
123 | { id: string; ownerId: string; isPrivate: boolean }
124 | null
125> {
126 try {
127 const [row] = await db
128 .select({
129 id: repositories.id,
130 ownerId: repositories.ownerId,
131 isPrivate: repositories.isPrivate,
132 })
133 .from(repositories)
134 .where(eq(repositories.id, repositoryId))
135 .limit(1);
136 return row || null;
137 } catch (err) {
138 console.error("[workflow-artifacts] loadRepoOwner:", err);
139 return null;
140 }
141}
142
143// ---------------------------------------------------------------------------
144// POST /api/v1/runs/:runId/artifacts — upload
145// ---------------------------------------------------------------------------
146
147app.post("/api/v1/runs/:runId/artifacts", async (c) => {
148 const runId = c.req.param("runId");
149
150 const bearer = await resolveBearer(c.req.header("authorization"));
151 if (!bearer) {
152 return c.json({ error: "authentication required" }, 401);
153 }
154 if (!hasWriteScope(bearer.scopes)) {
155 return c.json({ error: "token missing write scope" }, 403);
156 }
157
158 const repositoryId = await getRunRepositoryId(runId);
159 if (!repositoryId) {
160 return c.json({ error: "run not found" }, 404);
161 }
162 const repo = await loadRepoOwner(repositoryId);
163 if (!repo) {
164 return c.json({ error: "repository not found" }, 404);
165 }
166 if (repo.ownerId !== bearer.user.id) {
167 return c.json({ error: "forbidden" }, 403);
168 }
169
170 // Parse body — either multipart/form-data or JSON with base64 `content`.
171 const ctype = (c.req.header("content-type") || "").toLowerCase();
172 let name: string | undefined;
173 let jobId: string | undefined;
174 let contentType: string | undefined;
175 let content: Buffer | undefined;
176
177 try {
178 if (ctype.startsWith("application/json")) {
179 const body = await c.req.json<{
180 name?: string;
181 jobId?: string;
182 contentType?: string;
183 content?: string; // base64
184 }>();
185 name = body.name;
186 jobId = body.jobId;
187 contentType = body.contentType;
188 if (typeof body.content === "string") {
189 content = Buffer.from(body.content, "base64");
190 }
191 } else {
192 // Treat everything else (multipart, x-www-form-urlencoded) as form data.
193 const form = await c.req.parseBody({ all: false });
194 const n = form["name"];
195 const j = form["jobId"];
196 const ct = form["contentType"];
197 const f = form["content"];
198 if (typeof n === "string") name = n;
199 if (typeof j === "string") jobId = j;
200 if (typeof ct === "string") contentType = ct;
201 if (f instanceof File) {
202 const ab = await f.arrayBuffer();
203 content = Buffer.from(ab);
204 if (!contentType) contentType = f.type || undefined;
205 if (!name) name = f.name;
206 } else if (typeof f === "string") {
207 // Fallback: raw text content in a form field.
208 content = Buffer.from(f, "utf8");
209 }
210 }
211 } catch (err) {
212 console.error("[workflow-artifacts] parse body:", err);
213 return c.json({ error: "invalid request body" }, 400);
214 }
215
216 if (!name) return c.json({ error: "name is required" }, 400);
217 if (!jobId) return c.json({ error: "jobId is required" }, 400);
218 if (!content) return c.json({ error: "content is required" }, 400);
219
220 if (content.byteLength > MAX_ARTIFACT_BYTES) {
221 return c.json({ error: "payload exceeds 100MB limit" }, 413);
222 }
223
224 const result = await uploadArtifact({
225 runId,
226 jobId,
227 name,
228 content,
229 contentType,
230 });
231 if (!result.ok) {
232 // Treat validation errors as 400; other helper errors as 500.
233 const msg = result.error;
234 const status =
235 msg.startsWith("name ") ||
236 msg.includes("exceeds") ||
237 msg.includes("required")
238 ? 400
239 : 500;
240 return c.json({ error: msg }, status);
241 }
242
243 return c.json(
244 {
245 id: result.artifactId,
246 name,
247 size: content.byteLength,
248 contentType: contentType || "application/octet-stream",
249 downloadUrl: `/api/v1/artifacts/${result.artifactId}/download`,
250 },
251 201
252 );
253});
254
255// ---------------------------------------------------------------------------
256// GET /api/v1/runs/:runId/artifacts — list
257// ---------------------------------------------------------------------------
258
259app.get("/api/v1/runs/:runId/artifacts", async (c) => {
260 const runId = c.req.param("runId");
261 const repositoryId = await getRunRepositoryId(runId);
262 if (!repositoryId) return c.json({ error: "run not found" }, 404);
263 const repo = await loadRepoOwner(repositoryId);
264 if (!repo) return c.json({ error: "repository not found" }, 404);
265
266 const bearer = await resolveBearer(c.req.header("authorization"));
267 const cookieUser = c.get("user");
268
269 // Auth logic:
270 // - public repo → anyone with a valid bearer OR cookie session reads.
271 // Also allow totally-anonymous reads (matches packages + web UI style
272 // for public resources).
273 // - private repo → require bearer with read scope OR cookie session,
274 // AND caller must be the repo owner.
275 if (repo.isPrivate) {
276 let userId: string | null = null;
277 if (bearer) {
278 if (!hasReadScope(bearer.scopes)) {
279 return c.json({ error: "token missing read scope" }, 403);
280 }
281 userId = bearer.user.id;
282 } else if (cookieUser) {
283 userId = cookieUser.id;
284 } else {
285 return c.json({ error: "authentication required" }, 401);
286 }
287 if (userId !== repo.ownerId) {
288 return c.json({ error: "forbidden" }, 403);
289 }
290 } else if (bearer && !hasReadScope(bearer.scopes)) {
291 // Public repo but caller presented a weird-scoped token. Don't silently
292 // upgrade — reject.
293 return c.json({ error: "token missing read scope" }, 403);
294 }
295
296 const result = await listArtifacts(runId);
297 if (!result.ok) return c.json({ error: result.error }, 500);
298 return c.json({ artifacts: result.artifacts });
299});
300
301// ---------------------------------------------------------------------------
302// GET /api/v1/artifacts/:artifactId/download — binary download
303// ---------------------------------------------------------------------------
304
305app.get("/api/v1/artifacts/:artifactId/download", async (c) => {
306 const artifactId = c.req.param("artifactId");
307
308 const runId = await getArtifactRunId(artifactId);
309 if (!runId) return c.json({ error: "not found" }, 404);
310 const repositoryId = await getRunRepositoryId(runId);
311 if (!repositoryId) return c.json({ error: "not found" }, 404);
312 const repo = await loadRepoOwner(repositoryId);
313 if (!repo) return c.json({ error: "not found" }, 404);
314
315 const bearer = await resolveBearer(c.req.header("authorization"));
316 const cookieUser = c.get("user");
317
318 if (repo.isPrivate) {
319 let userId: string | null = null;
320 if (bearer) {
321 if (!hasReadScope(bearer.scopes)) {
322 return c.json({ error: "token missing read scope" }, 403);
323 }
324 userId = bearer.user.id;
325 } else if (cookieUser) {
326 userId = cookieUser.id;
327 } else {
328 return c.json({ error: "authentication required" }, 401);
329 }
330 if (userId !== repo.ownerId) {
331 return c.json({ error: "forbidden" }, 403);
332 }
333 } else if (bearer && !hasReadScope(bearer.scopes)) {
334 return c.json({ error: "token missing read scope" }, 403);
335 }
336
337 const result = await downloadArtifact(artifactId);
338 if (!result.ok) {
339 const status = result.error === "not found" ? 404 : 500;
340 return c.json({ error: result.error }, status);
341 }
342
343 // Sanitize filename for Content-Disposition. Artifact name regex is
344 // already `[A-Za-z0-9._-]+` so nothing dangerous can slip in, but we still
345 // strip any stray quotes/newlines defensively.
346 const safeName = result.name.replace(/["\r\n]/g, "");
347
348 return new Response(result.content as BodyInit, {
349 status: 200,
350 headers: {
351 "Content-Type": result.contentType,
352 "Content-Disposition": `attachment; filename="${safeName}"`,
353 "Content-Length": String(result.content.byteLength),
354 "Cache-Control": "private, max-age=0, no-store",
355 },
356 });
357});
358
359// ---------------------------------------------------------------------------
360// DELETE /api/v1/artifacts/:artifactId
361// ---------------------------------------------------------------------------
362
363app.delete("/api/v1/artifacts/:artifactId", async (c) => {
364 const artifactId = c.req.param("artifactId");
365
366 const bearer = await resolveBearer(c.req.header("authorization"));
367 if (!bearer) return c.json({ error: "authentication required" }, 401);
368 if (!hasAdminScope(bearer.scopes)) {
369 return c.json({ error: "admin scope required" }, 403);
370 }
371
372 const runId = await getArtifactRunId(artifactId);
373 if (!runId) return c.body(null, 404);
374 const repositoryId = await getRunRepositoryId(runId);
375 if (!repositoryId) return c.body(null, 404);
376 const repo = await loadRepoOwner(repositoryId);
377 if (!repo) return c.body(null, 404);
378 if (repo.ownerId !== bearer.user.id) {
379 return c.json({ error: "forbidden" }, 403);
380 }
381
382 const result = await deleteArtifact(artifactId);
383 if (!result.ok) {
384 const status = result.error === "not found" ? 404 : 500;
385 return c.json({ error: result.error }, status);
386 }
387 return c.body(null, 204);
388});
389
390export default app;