Skip to content
File

Blob: src/worker/db/d1/dal/tokens.ts

typescript191 lines
1import { and, desc, eq, inArray, isNull } from "drizzle-orm";
2 
3import type { Db } from "@/worker/db/d1/client";
4import { namespaces } from "@/worker/db/d1/schema/namespaces";
5import {
6 type NewPatNamespaceGrantRow,
7 type PatGrantLevel,
8 type PatNamespaceGrantRow,
9 patNamespaceGrants,
10} from "@/worker/db/d1/schema/patNamespaceGrants";
11import {
12 type NewPatRepoGrantRow,
13 type PatRepoGrantRow,
14 patRepoGrants,
15} from "@/worker/db/d1/schema/patRepoGrants";
16import {
17 type NewPersonalAccessTokenRow,
18 type PersonalAccessTokenRow,
19 personalAccessTokens,
20} from "@/worker/db/d1/schema/personalAccessTokens";
21import { repositories } from "@/worker/db/d1/schema/repositories";
22 
23// Re-exporting the schema row types from the DAL keeps callers (route
24// handlers, tests) on a single import surface.
25export type {
26 NewPersonalAccessTokenRow,
27 PersonalAccessTokenRow,
28} from "@/worker/db/d1/schema/personalAccessTokens";
29export type { PatGrantLevel } from "@/worker/db/d1/schema/patNamespaceGrants";
30 
31// Persist a fresh PAT and its grants in one D1 batch. D1's `batch()` runs
32// the statements as a single SQL transaction, so a failure on any grant
33// insert rolls back the PAT row too โ€” the table never carries an unusable
34// PAT without its scopes.
35export async function insertPatWithGrants(
36 db: Db,
37 args: {
38 pat: NewPersonalAccessTokenRow;
39 namespaceGrants: NewPatNamespaceGrantRow[];
40 repoGrants: NewPatRepoGrantRow[];
41 }
42): Promise<void> {
43 const statements = [
44 db.insert(personalAccessTokens).values(args.pat),
45 ...args.namespaceGrants.map((row) => db.insert(patNamespaceGrants).values(row)),
46 ...args.repoGrants.map((row) => db.insert(patRepoGrants).values(row)),
47 ];
48 if (statements.length === 1) {
49 await statements[0];
50 return;
51 }
52 // Drizzle's batch typing requires a non-empty tuple; we always have at
53 // least the PAT insert, plus zero or more grant inserts.
54 await db.batch(statements as [(typeof statements)[0], ...typeof statements]);
55}
56 
57export async function findPatByPrefix(
58 db: Db,
59 prefix: string
60): Promise<PersonalAccessTokenRow | undefined> {
61 const rows = await db
62 .select()
63 .from(personalAccessTokens)
64 .where(eq(personalAccessTokens.prefix, prefix))
65 .limit(1);
66 return rows[0];
67}
68 
69export async function listPatsForUser(db: Db, userId: string): Promise<PersonalAccessTokenRow[]> {
70 return await db
71 .select()
72 .from(personalAccessTokens)
73 .where(eq(personalAccessTokens.userId, userId))
74 .orderBy(desc(personalAccessTokens.createdAt));
75}
76 
77export type PatNamespaceGrantSummary = {
78 patId: string;
79 namespaceSlug: string;
80 level: PatGrantLevel;
81};
82 
83export type PatRepoGrantSummary = {
84 patId: string;
85 namespaceSlug: string;
86 repoSlug: string;
87 level: PatGrantLevel;
88};
89 
90// Fetch grant graphs for a set of PAT ids in two queries โ€” one per grant
91// table โ€” joined to namespace/repo rows so the management UI can render
92// permissions without further round trips. Returns empty arrays when
93// `patIds` is empty so callers skip a network round trip.
94export async function listPatGrantsByIds(
95 db: Db,
96 patIds: string[]
97): Promise<{
98 namespaceGrants: PatNamespaceGrantSummary[];
99 repoGrants: PatRepoGrantSummary[];
100}> {
101 if (patIds.length === 0) {
102 return { namespaceGrants: [], repoGrants: [] };
103 }
104 const nsRows = await db
105 .select({
106 patId: patNamespaceGrants.patId,
107 namespaceSlug: namespaces.slug,
108 level: patNamespaceGrants.level,
109 })
110 .from(patNamespaceGrants)
111 .innerJoin(namespaces, eq(patNamespaceGrants.namespaceId, namespaces.id))
112 .where(inArray(patNamespaceGrants.patId, patIds));
113 const repoRows = await db
114 .select({
115 patId: patRepoGrants.patId,
116 namespaceSlug: namespaces.slug,
117 repoSlug: repositories.slug,
118 level: patRepoGrants.level,
119 })
120 .from(patRepoGrants)
121 .innerJoin(repositories, eq(patRepoGrants.repoId, repositories.id))
122 .innerJoin(namespaces, eq(repositories.namespaceId, namespaces.id))
123 .where(inArray(patRepoGrants.patId, patIds));
124 return { namespaceGrants: nsRows, repoGrants: repoRows };
125}
126 
127export async function findPatGrantForRepo(
128 db: Db,
129 patId: string,
130 repoId: string
131): Promise<PatRepoGrantRow | undefined> {
132 const rows = await db
133 .select()
134 .from(patRepoGrants)
135 .where(and(eq(patRepoGrants.patId, patId), eq(patRepoGrants.repoId, repoId)))
136 .limit(1);
137 return rows[0];
138}
139 
140export async function findPatGrantForNamespace(
141 db: Db,
142 patId: string,
143 namespaceId: string
144): Promise<PatNamespaceGrantRow | undefined> {
145 const rows = await db
146 .select()
147 .from(patNamespaceGrants)
148 .where(
149 and(eq(patNamespaceGrants.patId, patId), eq(patNamespaceGrants.namespaceId, namespaceId))
150 )
151 .limit(1);
152 return rows[0];
153}
154 
155// Caller decides whether to call (throttle policy lives in `gitAuth.ts`).
156export async function updatePatLastUsedAt(db: Db, patId: string, now: number): Promise<void> {
157 await db
158 .update(personalAccessTokens)
159 .set({ lastUsedAt: now })
160 .where(eq(personalAccessTokens.id, patId));
161}
162 
163export type RevokePatResult =
164 | { ok: true }
165 | { ok: false; reason: "not-found" | "not-owner" | "already-revoked" };
166 
167// Revoke a PAT but only when the caller owns it. Returning a tagged union
168// keeps the route handler in charge of HTTP status mapping (404 vs 403 vs
169// 200 idempotent).
170export async function revokePatById(
171 db: Db,
172 patId: string,
173 userId: string,
174 now: number
175): Promise<RevokePatResult> {
176 const rows = await db
177 .select()
178 .from(personalAccessTokens)
179 .where(eq(personalAccessTokens.id, patId))
180 .limit(1);
181 const existing = rows[0];
182 if (!existing) return { ok: false, reason: "not-found" };
183 if (existing.userId !== userId) return { ok: false, reason: "not-owner" };
184 if (existing.revokedAt !== null) return { ok: false, reason: "already-revoked" };
185 await db
186 .update(personalAccessTokens)
187 .set({ revokedAt: now })
188 .where(and(eq(personalAccessTokens.id, patId), isNull(personalAccessTokens.revokedAt)));
189 return { ok: true };
190}