File
Blob: src/worker/db/d1/dal/tokens.ts
| 1 | import { and, desc, eq, inArray, isNull } from "drizzle-orm"; |
| 2 | |
| 3 | import type { Db } from "@/worker/db/d1/client"; |
| 4 | import { namespaces } from "@/worker/db/d1/schema/namespaces"; |
| 5 | import { |
| 6 | type NewPatNamespaceGrantRow, |
| 7 | type PatGrantLevel, |
| 8 | type PatNamespaceGrantRow, |
| 9 | patNamespaceGrants, |
| 10 | } from "@/worker/db/d1/schema/patNamespaceGrants"; |
| 11 | import { |
| 12 | type NewPatRepoGrantRow, |
| 13 | type PatRepoGrantRow, |
| 14 | patRepoGrants, |
| 15 | } from "@/worker/db/d1/schema/patRepoGrants"; |
| 16 | import { |
| 17 | type NewPersonalAccessTokenRow, |
| 18 | type PersonalAccessTokenRow, |
| 19 | personalAccessTokens, |
| 20 | } from "@/worker/db/d1/schema/personalAccessTokens"; |
| 21 | import { 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. |
| 25 | export type { |
| 26 | NewPersonalAccessTokenRow, |
| 27 | PersonalAccessTokenRow, |
| 28 | } from "@/worker/db/d1/schema/personalAccessTokens"; |
| 29 | export 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. |
| 35 | export 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 | |
| 57 | export 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 | |
| 69 | export 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 | |
| 77 | export type PatNamespaceGrantSummary = { |
| 78 | patId: string; |
| 79 | namespaceSlug: string; |
| 80 | level: PatGrantLevel; |
| 81 | }; |
| 82 | |
| 83 | export 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. |
| 94 | export 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 | |
| 127 | export 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 | |
| 140 | export 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`). |
| 156 | export 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 | |
| 163 | export 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). |
| 170 | export 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 | } |