Skip to content
File

Blob: src/worker/routes/api/pats.ts

typescript180 lines
1import { Hono } from "hono";
2import { describeRoute, validator } from "hono-openapi";
3 
4import { generatePat, normalizeScopes } from "@/worker/auth/pats";
5import { authObject } from "@/worker/auth/session-cookie";
6import { createControlPlaneDb } from "@/worker/db/d1/client";
7import type { PatProjectionRow } from "@/worker/db/d1/schema";
8import {
9 createPatProjection,
10 getPatProjection,
11 listPatProjections,
12 updatePatProjection,
13} from "@/worker/db/d1/repository";
14import {
15 PatCreateRequestSchema,
16 PatCreateResponseSchema,
17 PatPatchRequestSchema,
18 PatResponseSchema,
19} from "@/worker/openapi/schemas";
20import { describeJson, jsonErrorResponse } from "@/worker/routes/api/openapi";
21import { msToIso, requireSession } from "@/worker/routes/api/session";
22import type { AppEnv } from "@/worker/types";
23import { safeAudit } from "@/worker/util/audit";
24import { jsonError } from "@/worker/util/response";
25 
26function patResponse(row: PatProjectionRow) {
27 return {
28 id: row.id,
29 name: row.name,
30 scopes: row.scopes,
31 created_at: new Date(row.createdAtMs).toISOString(),
32 expires_at: msToIso(row.expiresAtMs),
33 revoked_at: msToIso(row.revokedAtMs),
34 last_used_at: msToIso(row.lastUsedAtMs),
35 };
36}
37 
38function parseExpiresAt(value: string | null | undefined): number | null {
39 if (value === null || value === undefined) return null;
40 const parsed = Date.parse(value);
41 if (!Number.isFinite(parsed)) throw new Error("Invalid expires_at");
42 return parsed;
43}
44 
45export function createPatApiRoutes(): Hono<AppEnv> {
46 const api = new Hono<AppEnv>();
47 
48 api.get("/", describeJson("List Personal Access Tokens", "pats", PatResponseSchema.array()), async (c) => {
49 const session = requireSession(c);
50 const rows = await listPatProjections(createControlPlaneDb(c.env.DAV_CONTROL_PLANE), session.subjectId);
51 return c.json(rows.map(patResponse));
52 });
53 
54 api.post(
55 "/",
56 describeJson("Create Personal Access Token", "pats", PatCreateResponseSchema, 201),
57 validator("json", PatCreateRequestSchema),
58 async (c) => {
59 const session = requireSession(c);
60 const body = c.req.valid("json");
61 const scopes = normalizeScopes(body.scopes);
62 if (!scopes || scopes.length === 0) return jsonError("invalid_pat_scopes", "PAT scopes are invalid.", 400);
63 
64 let expiresAtMs: number | null;
65 try {
66 expiresAtMs = parseExpiresAt(body.expires_at);
67 } catch {
68 return jsonError("invalid_expires_at", "expires_at must be an ISO datetime.", 400);
69 }
70 
71 const generated = await generatePat();
72 const nowMs = Date.now();
73 const pat = await authObject(c.env, session.storageId).createPat({
74 id: generated.id,
75 name: body.name,
76 tokenDigest: generated.tokenDigest,
77 scopes,
78 createdAtMs: nowMs,
79 expiresAtMs,
80 revokedAtMs: null,
81 lastUsedAtMs: null,
82 });
83 await createPatProjection(createControlPlaneDb(c.env.DAV_CONTROL_PLANE), {
84 id: pat.id,
85 subjectId: session.subjectId,
86 name: pat.name,
87 scopes: pat.scopes,
88 createdAtMs: pat.createdAtMs,
89 expiresAtMs: pat.expiresAtMs,
90 revokedAtMs: pat.revokedAtMs,
91 lastUsedAtMs: pat.lastUsedAtMs,
92 });
93 await safeAudit(c, {
94 subjectId: session.subjectId,
95 actorSubjectId: session.subjectId,
96 eventType: "pat.create",
97 data: { patId: pat.id, scopes: pat.scopes },
98 createdAtMs: nowMs,
99 });
100 
101 return c.json({ ...patResponse({ ...pat, subjectId: session.subjectId }), token: generated.token }, 201);
102 },
103 );
104 
105 api.get("/:pat_id", describeJson("Get Personal Access Token", "pats", PatResponseSchema), async (c) => {
106 const session = requireSession(c);
107 const row = await getPatProjection(
108 createControlPlaneDb(c.env.DAV_CONTROL_PLANE),
109 session.subjectId,
110 c.req.param("pat_id"),
111 );
112 if (!row) return jsonError("not_found", "PAT not found.", 404);
113 return c.json(patResponse(row));
114 });
115 
116 api.patch(
117 "/:pat_id",
118 describeJson("Update Personal Access Token", "pats", PatResponseSchema),
119 validator("json", PatPatchRequestSchema),
120 async (c) => {
121 const session = requireSession(c);
122 const patId = c.req.param("pat_id");
123 const existing = await getPatProjection(createControlPlaneDb(c.env.DAV_CONTROL_PLANE), session.subjectId, patId);
124 if (!existing) return jsonError("not_found", "PAT not found.", 404);
125 
126 let expiresAtMs: number | null | undefined;
127 try {
128 const body = c.req.valid("json");
129 expiresAtMs = body.expires_at === undefined ? undefined : parseExpiresAt(body.expires_at);
130 await authObject(c.env, session.storageId).updatePat({ patId, name: body.name, expiresAtMs });
131 await updatePatProjection(createControlPlaneDb(c.env.DAV_CONTROL_PLANE), session.subjectId, patId, {
132 name: body.name,
133 expiresAtMs,
134 });
135 await safeAudit(c, {
136 subjectId: session.subjectId,
137 actorSubjectId: session.subjectId,
138 eventType: "pat.update",
139 data: { patId },
140 });
141 } catch {
142 return jsonError("invalid_pat_update", "PAT update payload is invalid.", 400);
143 }
144 
145 const row = await getPatProjection(createControlPlaneDb(c.env.DAV_CONTROL_PLANE), session.subjectId, patId);
146 return c.json(patResponse(row ?? existing));
147 },
148 );
149 
150 api.delete(
151 "/:pat_id",
152 describeRoute({
153 summary: "Revoke Personal Access Token",
154 tags: ["pats"],
155 responses: { 204: { description: "PAT revoked" }, 404: jsonErrorResponse },
156 }),
157 async (c) => {
158 const session = requireSession(c);
159 const patId = c.req.param("pat_id");
160 const existing = await getPatProjection(createControlPlaneDb(c.env.DAV_CONTROL_PLANE), session.subjectId, patId);
161 if (!existing) return jsonError("not_found", "PAT not found.", 404);
162 const nowMs = Date.now();
163 await authObject(c.env, session.storageId).updatePat({ patId, revokedAtMs: nowMs });
164 await updatePatProjection(createControlPlaneDb(c.env.DAV_CONTROL_PLANE), session.subjectId, patId, {
165 revokedAtMs: nowMs,
166 });
167 await safeAudit(c, {
168 subjectId: session.subjectId,
169 actorSubjectId: session.subjectId,
170 eventType: "pat.revoke",
171 data: { patId },
172 createdAtMs: nowMs,
173 });
174 return new Response(null, { status: 204 });
175 },
176 );
177 
178 return api;
179}