@intx/hub-api 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +29 -0
  2. package/package.json +28 -0
  3. package/src/app.test.ts +225 -0
  4. package/src/app.ts +382 -0
  5. package/src/auth.ts +21 -0
  6. package/src/context.ts +38 -0
  7. package/src/format.ts +9 -0
  8. package/src/git-http/advertise-refs.test.ts +459 -0
  9. package/src/git-http/advertise-refs.ts +226 -0
  10. package/src/git-http/pkt-line.test.ts +220 -0
  11. package/src/git-http/pkt-line.ts +235 -0
  12. package/src/git-http/receive-pack.test.ts +397 -0
  13. package/src/git-http/receive-pack.ts +261 -0
  14. package/src/git-http/side-band-64k.test.ts +181 -0
  15. package/src/git-http/side-band-64k.ts +134 -0
  16. package/src/git-http/upload-pack.test.ts +545 -0
  17. package/src/git-http/upload-pack.ts +396 -0
  18. package/src/index.ts +23 -0
  19. package/src/middleware/git-token-auth.test.ts +587 -0
  20. package/src/middleware/git-token-auth.ts +315 -0
  21. package/src/middleware/grant.ts +106 -0
  22. package/src/middleware/session.ts +13 -0
  23. package/src/middleware/tenant.test.ts +192 -0
  24. package/src/middleware/tenant.ts +101 -0
  25. package/src/openapi.ts +66 -0
  26. package/src/pagination.ts +117 -0
  27. package/src/routes/agent-data.ts +179 -0
  28. package/src/routes/agent-state-git.ts +562 -0
  29. package/src/routes/agents.test.ts +337 -0
  30. package/src/routes/agents.ts +704 -0
  31. package/src/routes/approvals.ts +130 -0
  32. package/src/routes/assets.test.ts +567 -0
  33. package/src/routes/assets.ts +592 -0
  34. package/src/routes/credentials.ts +435 -0
  35. package/src/routes/git-tokens.test.ts +709 -0
  36. package/src/routes/git-tokens.ts +771 -0
  37. package/src/routes/grants.ts +509 -0
  38. package/src/routes/instances.test.ts +1103 -0
  39. package/src/routes/instances.ts +1797 -0
  40. package/src/routes/me.ts +405 -0
  41. package/src/routes/oauth-clients.ts +349 -0
  42. package/src/routes/observability.ts +146 -0
  43. package/src/routes/offerings.ts +382 -0
  44. package/src/routes/principals.ts +515 -0
  45. package/src/routes/providers.ts +351 -0
  46. package/src/routes/roles.ts +452 -0
  47. package/src/routes/sidecars.ts +221 -0
  48. package/src/routes/tenant-federation.ts +225 -0
  49. package/src/routes/tenants.ts +369 -0
  50. package/src/routes/wallets.ts +370 -0
  51. package/src/session.ts +44 -0
  52. package/src/timeline-reconstruction.test.ts +786 -0
  53. package/src/timeline-reconstruction.ts +383 -0
  54. package/tsconfig.json +4 -0
  55. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,771 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { and, eq } from "drizzle-orm";
4
+ import { Hono } from "hono";
5
+ import { describeRoute, resolver, validator } from "hono-openapi";
6
+ import { type } from "arktype";
7
+
8
+ import { gitToken } from "@intx/db/schema";
9
+ import { parseGitTokenRow } from "@intx/db";
10
+ import type { DB } from "@intx/db";
11
+ import {
12
+ expandRepoActionAlias,
13
+ generateId,
14
+ glob,
15
+ PAT_PREFIX,
16
+ RepoActionAliases,
17
+ SVC_PREFIX,
18
+ } from "@intx/hub-common";
19
+ import { getLogger } from "@intx/log";
20
+ import type { RepoAction } from "@intx/types/sidecar";
21
+ import { ErrorResponse, paginatedSchema } from "@intx/types";
22
+
23
+ import type { AppEnv, TenantEnv } from "../context";
24
+ import { ts } from "../format";
25
+ import type { RequireGrant } from "../middleware/grant";
26
+ import {
27
+ cursorCondition,
28
+ pageOrder,
29
+ pageParameters,
30
+ paginatedResponse,
31
+ parsePageParams,
32
+ } from "../pagination";
33
+
34
+ const log = getLogger(["hub", "git-token"]);
35
+
36
+ const SECRET_BYTES = 32;
37
+
38
+ /**
39
+ * Minimum lifetime of a freshly minted token. The mint endpoint
40
+ * rejects `expiresAt` values that fall within this window so callers
41
+ * cannot accidentally issue a token whose effective lifetime is so
42
+ * short that it cannot be used.
43
+ */
44
+ const MIN_LIFETIME_MS = 60_000;
45
+
46
+ /**
47
+ * Full `RepoAction` vocabulary as used by the substrate. Returned to
48
+ * callers in the mint response and list payloads. The mint INPUT
49
+ * surface is narrower (see `MintableRepoActionType` below); `init`
50
+ * and `writeTree` are hub-internal actions and cannot be minted by
51
+ * user-facing API.
52
+ */
53
+ const RepoActionType = type.enumerated(
54
+ "init",
55
+ "writeTree",
56
+ "receivePack",
57
+ "createPack",
58
+ "resolveRef",
59
+ );
60
+
61
+ const MintableRepoActionType = type.enumerated(
62
+ "receivePack",
63
+ "createPack",
64
+ "resolveRef",
65
+ );
66
+
67
+ const RepoActionAliasName = type.enumerated("can_read", "can_push");
68
+
69
+ // Compile-time check that every alias literal accepted by
70
+ // `RepoActionAliasName` is a real key of `RepoActionAliases`. Does
71
+ // NOT enforce the reverse direction; do not rely on this guard
72
+ // alone if `RepoActionAliases` grows new entries.
73
+ const _aliasNameCoverage: Record<keyof typeof RepoActionAliases, true> = {
74
+ can_read: true,
75
+ can_push: true,
76
+ };
77
+ void _aliasNameCoverage;
78
+
79
+ const ActionInput = MintableRepoActionType.or(RepoActionAliasName);
80
+
81
+ const CreateTenantGitToken = type({
82
+ name: "string",
83
+ resource: "string",
84
+ refPattern: "string",
85
+ actions: ActionInput.array(),
86
+ expiresAt: "string",
87
+ });
88
+
89
+ const CreateMeGitToken = type({
90
+ name: "string",
91
+ resource: "string",
92
+ refPattern: "string",
93
+ actions: ActionInput.array(),
94
+ expiresAt: "string",
95
+ "tenantId?": "string",
96
+ });
97
+
98
+ const GitTokenSummary = type({
99
+ id: "string",
100
+ userId: "string",
101
+ "principalId?": "string | null",
102
+ "tenantId?": "string | null",
103
+ name: "string",
104
+ kind: type.enumerated("pat", "svc"),
105
+ resource: "string",
106
+ refPattern: "string",
107
+ actions: RepoActionType.array(),
108
+ expiresAt: "string",
109
+ "revokedAt?": "string | null",
110
+ createdAt: "string",
111
+ });
112
+
113
+ const GitTokenMintResponse = type({
114
+ id: "string",
115
+ secret: "string",
116
+ name: "string",
117
+ kind: type.enumerated("pat", "svc"),
118
+ claims: {
119
+ resource: "string",
120
+ refPattern: "string",
121
+ actions: RepoActionType.array(),
122
+ expiresAt: "string",
123
+ },
124
+ });
125
+
126
+ type MintInput = {
127
+ kind: "pat" | "svc";
128
+ userId: string;
129
+ principalId: string | null;
130
+ tenantId: string | null;
131
+ name: string;
132
+ resource: string;
133
+ refPattern: string;
134
+ rawActions: string[];
135
+ expiresAt: Date;
136
+ now: Date;
137
+ };
138
+
139
+ type MintResult = {
140
+ id: string;
141
+ secret: string;
142
+ name: string;
143
+ kind: "pat" | "svc";
144
+ resource: string;
145
+ refPattern: string;
146
+ actions: RepoAction[];
147
+ expiresAt: Date;
148
+ };
149
+
150
+ /**
151
+ * Error raised by `mintGitToken` when an input fails validation.
152
+ * Each `code` corresponds to a distinct REST error response so the
153
+ * HTTP layer can translate without rebuilding the validation chain.
154
+ */
155
+ class MintValidationError extends Error {
156
+ constructor(
157
+ readonly code:
158
+ | "invalid_ref_pattern"
159
+ | "invalid_action"
160
+ | "invalid_expires_at",
161
+ message: string,
162
+ ) {
163
+ super(message);
164
+ }
165
+ }
166
+
167
+ function generateSecret(kind: "pat" | "svc"): string {
168
+ const prefix = kind === "pat" ? PAT_PREFIX : SVC_PREFIX;
169
+ const bytes = new Uint8Array(SECRET_BYTES);
170
+ // `crypto.getRandomValues` is the Web Crypto API and is exposed on
171
+ // the global `crypto` object in both Node and Bun.
172
+ crypto.getRandomValues(bytes);
173
+ return `${prefix}${Buffer.from(bytes).toString("base64url")}`;
174
+ }
175
+
176
+ function sha256(input: string): Uint8Array {
177
+ return new Uint8Array(createHash("sha256").update(input, "utf8").digest());
178
+ }
179
+
180
+ /**
181
+ * Validate that a ref pattern is a syntactically acceptable glob.
182
+ * The simple-glob compiler does not throw on any input, so the only
183
+ * shape concerns to catch at this layer are the patterns that would
184
+ * never match anything useful: empty strings, and patterns
185
+ * containing characters that cannot appear in a git ref name.
186
+ */
187
+ function validateRefPattern(pattern: string): void {
188
+ if (pattern.length === 0) {
189
+ throw new MintValidationError(
190
+ "invalid_ref_pattern",
191
+ "refPattern must not be empty",
192
+ );
193
+ }
194
+ // Probe the compiler against a benign sample so callers see the
195
+ // matcher run; any future enrichment of the compiler that adds
196
+ // throw-on-malformed behaviour surfaces here.
197
+ glob.match(pattern, "refs/heads/main");
198
+ }
199
+
200
+ function resolveActions(raw: string[]): RepoAction[] {
201
+ if (raw.length === 0) {
202
+ throw new MintValidationError(
203
+ "invalid_action",
204
+ "at least one action or alias is required",
205
+ );
206
+ }
207
+ const seen = new Set<RepoAction>();
208
+ for (const name of raw) {
209
+ let expanded: RepoAction[];
210
+ try {
211
+ expanded = expandRepoActionAlias(name);
212
+ } catch {
213
+ throw new MintValidationError(
214
+ "invalid_action",
215
+ `unknown action or alias: ${name}`,
216
+ );
217
+ }
218
+ for (const a of expanded) seen.add(a);
219
+ }
220
+ return [...seen];
221
+ }
222
+
223
+ function validateExpiresAt(expiresAt: Date, now: Date): void {
224
+ if (Number.isNaN(expiresAt.getTime())) {
225
+ throw new MintValidationError(
226
+ "invalid_expires_at",
227
+ "expiresAt must be an ISO-8601 timestamp",
228
+ );
229
+ }
230
+ if (expiresAt.getTime() - now.getTime() < MIN_LIFETIME_MS) {
231
+ throw new MintValidationError(
232
+ "invalid_expires_at",
233
+ `expiresAt must be at least ${MIN_LIFETIME_MS / 1000}s in the future`,
234
+ );
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Core mint primitive shared by both tenant-bound and personal
240
+ * endpoints. Generates a fresh secret, validates the inputs,
241
+ * inserts the row with the SHA-256 digest, and returns the resolved
242
+ * claims alongside the plaintext secret. The plaintext is never
243
+ * persisted; the caller is responsible for handing it back to the
244
+ * user exactly once.
245
+ */
246
+ export async function mintGitToken(
247
+ db: DB["db"],
248
+ input: MintInput,
249
+ ): Promise<MintResult> {
250
+ validateRefPattern(input.refPattern);
251
+ const actions = resolveActions(input.rawActions);
252
+ validateExpiresAt(input.expiresAt, input.now);
253
+ const expiresAt = input.expiresAt;
254
+
255
+ const id = generateId("gitToken");
256
+ const secret = generateSecret(input.kind);
257
+ const tokenHashSha256 = sha256(secret);
258
+
259
+ await db.insert(gitToken).values({
260
+ id,
261
+ userId: input.userId,
262
+ principalId: input.principalId,
263
+ tenantId: input.tenantId,
264
+ name: input.name,
265
+ kind: input.kind,
266
+ tokenHashSha256,
267
+ resource: input.resource,
268
+ refPattern: input.refPattern,
269
+ actions,
270
+ expiresAt,
271
+ createdAt: input.now,
272
+ });
273
+
274
+ return {
275
+ id,
276
+ secret,
277
+ name: input.name,
278
+ kind: input.kind,
279
+ resource: input.resource,
280
+ refPattern: input.refPattern,
281
+ actions,
282
+ expiresAt,
283
+ };
284
+ }
285
+
286
+ function mintErrorBody(err: MintValidationError) {
287
+ return { error: { code: err.code, message: err.message } } as const;
288
+ }
289
+
290
+ function formatGitTokenRow(row: typeof gitToken.$inferSelect) {
291
+ const parsed = parseGitTokenRow(row);
292
+ return {
293
+ id: parsed.id,
294
+ userId: parsed.userId,
295
+ principalId: parsed.principalId ?? null,
296
+ tenantId: parsed.tenantId ?? null,
297
+ name: parsed.name,
298
+ kind: parsed.kind,
299
+ resource: parsed.resource,
300
+ refPattern: parsed.refPattern,
301
+ actions: parsed.actions,
302
+ expiresAt: ts(parsed.expiresAt),
303
+ revokedAt: parsed.revokedAt ? ts(parsed.revokedAt) : null,
304
+ createdAt: ts(parsed.createdAt),
305
+ };
306
+ }
307
+
308
+ function formatMintResult(result: MintResult) {
309
+ return {
310
+ id: result.id,
311
+ secret: result.secret,
312
+ name: result.name,
313
+ kind: result.kind,
314
+ claims: {
315
+ resource: result.resource,
316
+ refPattern: result.refPattern,
317
+ actions: result.actions,
318
+ expiresAt: ts(result.expiresAt),
319
+ },
320
+ };
321
+ }
322
+
323
+ export type CreateTenantGitTokenRoutesDeps = {
324
+ db: DB["db"];
325
+ requireGrant: RequireGrant;
326
+ };
327
+
328
+ export function createTenantGitTokenRoutes({
329
+ db,
330
+ requireGrant,
331
+ }: CreateTenantGitTokenRoutesDeps): Hono<TenantEnv> {
332
+ const app = new Hono<TenantEnv>();
333
+
334
+ app.get(
335
+ "/",
336
+ requireGrant("git-token:*", "read"),
337
+ describeRoute({
338
+ tags: ["Git Tokens"],
339
+ summary: "List tenant git tokens",
340
+ description:
341
+ 'Lists service tokens (`kind: "svc"`) bound to this tenant. Secrets are never returned; the plaintext is shown only at mint time.',
342
+ parameters: [...pageParameters],
343
+ responses: {
344
+ 200: {
345
+ description: "List of git tokens",
346
+ content: {
347
+ "application/json": {
348
+ schema: resolver(paginatedSchema(GitTokenSummary)),
349
+ },
350
+ },
351
+ },
352
+ },
353
+ }),
354
+ async (c) => {
355
+ const tenantCtx = c.get("tenant");
356
+ const { limit, cursor } = parsePageParams({
357
+ cursor: c.req.query("cursor"),
358
+ limit: c.req.query("limit"),
359
+ });
360
+
361
+ const conditions = [eq(gitToken.tenantId, tenantCtx.id)];
362
+ if (cursor) {
363
+ conditions.push(
364
+ cursorCondition(gitToken.createdAt, gitToken.id, cursor),
365
+ );
366
+ }
367
+
368
+ const rows = await db.query.gitToken.findMany({
369
+ where: and(...conditions),
370
+ orderBy: pageOrder(gitToken.createdAt, gitToken.id),
371
+ limit,
372
+ });
373
+
374
+ log.info("tenant list {tenantId} count={count}", {
375
+ tenantId: tenantCtx.id,
376
+ count: rows.length,
377
+ });
378
+
379
+ return c.json(
380
+ paginatedResponse(rows.map(formatGitTokenRow), rows, limit),
381
+ );
382
+ },
383
+ );
384
+
385
+ app.post(
386
+ "/",
387
+ requireGrant("git-token:*", "create"),
388
+ describeRoute({
389
+ tags: ["Git Tokens"],
390
+ summary: "Mint a tenant-bound service git token",
391
+ description:
392
+ 'Mints a service token (`kind: "svc"`) bound to the requesting tenant. The plaintext secret is returned exactly once in the response and is never persisted in plaintext.',
393
+ responses: {
394
+ 201: {
395
+ description: "Token minted",
396
+ content: {
397
+ "application/json": { schema: resolver(GitTokenMintResponse) },
398
+ },
399
+ },
400
+ 400: {
401
+ description: "Validation error",
402
+ content: {
403
+ "application/json": { schema: resolver(ErrorResponse) },
404
+ },
405
+ },
406
+ },
407
+ }),
408
+ validator("json", CreateTenantGitToken),
409
+ async (c) => {
410
+ const tenantCtx = c.get("tenant");
411
+ const principalCtx = c.get("principal");
412
+ const user = c.get("user");
413
+ if (!user) {
414
+ return c.json(
415
+ {
416
+ error: { code: "unauthorized", message: "Authentication required" },
417
+ },
418
+ 401,
419
+ );
420
+ }
421
+ const body = c.req.valid("json");
422
+
423
+ const now = new Date();
424
+ let result: MintResult;
425
+ try {
426
+ result = await mintGitToken(db, {
427
+ kind: "svc",
428
+ userId: user.id,
429
+ principalId: principalCtx.id,
430
+ tenantId: tenantCtx.id,
431
+ name: body.name,
432
+ resource: body.resource,
433
+ refPattern: body.refPattern,
434
+ rawActions: body.actions,
435
+ expiresAt: new Date(body.expiresAt),
436
+ now,
437
+ });
438
+ } catch (err) {
439
+ if (err instanceof MintValidationError) {
440
+ log.info(
441
+ "tenant mint rejected {tenantId} principal={principalId} code={code}",
442
+ {
443
+ tenantId: tenantCtx.id,
444
+ principalId: principalCtx.id,
445
+ code: err.code,
446
+ },
447
+ );
448
+ return c.json(mintErrorBody(err), 400);
449
+ }
450
+ throw err;
451
+ }
452
+
453
+ log.info(
454
+ "tenant mint succeeded {tenantId} principal={principalId} tokenId={tokenId}",
455
+ {
456
+ tenantId: tenantCtx.id,
457
+ principalId: principalCtx.id,
458
+ tokenId: result.id,
459
+ },
460
+ );
461
+
462
+ return c.json(formatMintResult(result), 201);
463
+ },
464
+ );
465
+
466
+ app.delete(
467
+ "/:tokenId",
468
+ requireGrant("git-token:*", "manage"),
469
+ describeRoute({
470
+ tags: ["Git Tokens"],
471
+ summary: "Revoke a tenant git token",
472
+ description:
473
+ "Soft-revokes a tenant-bound git token by setting `revokedAt`. The row is retained for audit.",
474
+ responses: {
475
+ 204: { description: "Token revoked" },
476
+ 404: {
477
+ description: "Token not found",
478
+ content: {
479
+ "application/json": { schema: resolver(ErrorResponse) },
480
+ },
481
+ },
482
+ },
483
+ }),
484
+ async (c) => {
485
+ const tenantCtx = c.get("tenant");
486
+ const tokenId = c.req.param("tokenId");
487
+
488
+ const existing = await db.query.gitToken.findFirst({
489
+ where: and(
490
+ eq(gitToken.id, tokenId),
491
+ eq(gitToken.tenantId, tenantCtx.id),
492
+ ),
493
+ });
494
+ if (!existing) {
495
+ log.info("tenant revoke not found {tenantId} tokenId={tokenId}", {
496
+ tenantId: tenantCtx.id,
497
+ tokenId,
498
+ });
499
+ return c.json(
500
+ { error: { code: "not_found", message: "Token not found" } },
501
+ 404,
502
+ );
503
+ }
504
+
505
+ if (existing.revokedAt === null) {
506
+ await db
507
+ .update(gitToken)
508
+ .set({ revokedAt: new Date() })
509
+ .where(eq(gitToken.id, tokenId));
510
+ }
511
+
512
+ log.info("tenant revoke succeeded {tenantId} tokenId={tokenId}", {
513
+ tenantId: tenantCtx.id,
514
+ tokenId,
515
+ });
516
+
517
+ return c.body(null, 204);
518
+ },
519
+ );
520
+
521
+ return app;
522
+ }
523
+
524
+ export type CreateMeGitTokenRoutesDeps = {
525
+ db: DB["db"];
526
+ };
527
+
528
+ export function createMeGitTokenRoutes({
529
+ db,
530
+ }: CreateMeGitTokenRoutesDeps): Hono<AppEnv> {
531
+ const app = new Hono<AppEnv>();
532
+
533
+ app.get(
534
+ "/",
535
+ describeRoute({
536
+ tags: ["Git Tokens"],
537
+ summary: "List personal git tokens",
538
+ description:
539
+ 'Lists the authenticated user\'s personal access tokens (`kind: "pat"`). Secrets are never returned; the plaintext is shown only at mint time.',
540
+ parameters: [...pageParameters],
541
+ responses: {
542
+ 200: {
543
+ description: "List of git tokens",
544
+ content: {
545
+ "application/json": {
546
+ schema: resolver(paginatedSchema(GitTokenSummary)),
547
+ },
548
+ },
549
+ },
550
+ 401: {
551
+ description: "Not authenticated",
552
+ content: {
553
+ "application/json": { schema: resolver(ErrorResponse) },
554
+ },
555
+ },
556
+ },
557
+ }),
558
+ async (c) => {
559
+ const user = c.get("user");
560
+ if (!user) {
561
+ return c.json(
562
+ {
563
+ error: { code: "unauthorized", message: "Authentication required" },
564
+ },
565
+ 401,
566
+ );
567
+ }
568
+ const { limit, cursor } = parsePageParams({
569
+ cursor: c.req.query("cursor"),
570
+ limit: c.req.query("limit"),
571
+ });
572
+
573
+ const conditions = [
574
+ eq(gitToken.userId, user.id),
575
+ eq(gitToken.kind, "pat"),
576
+ ];
577
+ if (cursor) {
578
+ conditions.push(
579
+ cursorCondition(gitToken.createdAt, gitToken.id, cursor),
580
+ );
581
+ }
582
+
583
+ const rows = await db.query.gitToken.findMany({
584
+ where: and(...conditions),
585
+ orderBy: pageOrder(gitToken.createdAt, gitToken.id),
586
+ limit,
587
+ });
588
+
589
+ log.info("personal list userId={userId} count={count}", {
590
+ userId: user.id,
591
+ count: rows.length,
592
+ });
593
+
594
+ return c.json(
595
+ paginatedResponse(rows.map(formatGitTokenRow), rows, limit),
596
+ );
597
+ },
598
+ );
599
+
600
+ app.post(
601
+ "/",
602
+ describeRoute({
603
+ tags: ["Git Tokens"],
604
+ summary: "Mint a personal access git token",
605
+ description:
606
+ 'Mints a personal access token (`kind: "pat"`) for the authenticated user. The plaintext secret is returned exactly once in the response. An optional `tenantId` restricts the token to a single tenant.',
607
+ responses: {
608
+ 201: {
609
+ description: "Token minted",
610
+ content: {
611
+ "application/json": { schema: resolver(GitTokenMintResponse) },
612
+ },
613
+ },
614
+ 400: {
615
+ description: "Validation error",
616
+ content: {
617
+ "application/json": { schema: resolver(ErrorResponse) },
618
+ },
619
+ },
620
+ 401: {
621
+ description: "Not authenticated",
622
+ content: {
623
+ "application/json": { schema: resolver(ErrorResponse) },
624
+ },
625
+ },
626
+ },
627
+ }),
628
+ validator("json", CreateMeGitToken),
629
+ async (c) => {
630
+ const user = c.get("user");
631
+ if (!user) {
632
+ return c.json(
633
+ {
634
+ error: { code: "unauthorized", message: "Authentication required" },
635
+ },
636
+ 401,
637
+ );
638
+ }
639
+ const body = c.req.valid("json");
640
+
641
+ const now = new Date();
642
+ let result: MintResult;
643
+ try {
644
+ result = await mintGitToken(db, {
645
+ kind: "pat",
646
+ userId: user.id,
647
+ principalId: null,
648
+ tenantId: body.tenantId ?? null,
649
+ name: body.name,
650
+ resource: body.resource,
651
+ refPattern: body.refPattern,
652
+ rawActions: body.actions,
653
+ expiresAt: new Date(body.expiresAt),
654
+ now,
655
+ });
656
+ } catch (err) {
657
+ if (err instanceof MintValidationError) {
658
+ log.info("personal mint rejected userId={userId} code={code}", {
659
+ userId: user.id,
660
+ code: err.code,
661
+ });
662
+ return c.json(mintErrorBody(err), 400);
663
+ }
664
+ throw err;
665
+ }
666
+
667
+ log.info("personal mint succeeded userId={userId} tokenId={tokenId}", {
668
+ userId: user.id,
669
+ tokenId: result.id,
670
+ });
671
+
672
+ return c.json(formatMintResult(result), 201);
673
+ },
674
+ );
675
+
676
+ app.delete(
677
+ "/:tokenId",
678
+ describeRoute({
679
+ tags: ["Git Tokens"],
680
+ summary: "Revoke a personal git token",
681
+ description:
682
+ "Soft-revokes a personal access token by setting `revokedAt`. Only the owning user may revoke their own tokens.",
683
+ responses: {
684
+ 204: { description: "Token revoked" },
685
+ 401: {
686
+ description: "Not authenticated",
687
+ content: {
688
+ "application/json": { schema: resolver(ErrorResponse) },
689
+ },
690
+ },
691
+ 403: {
692
+ description: "Token not owned by the authenticated user",
693
+ content: {
694
+ "application/json": { schema: resolver(ErrorResponse) },
695
+ },
696
+ },
697
+ 404: {
698
+ description: "Token not found",
699
+ content: {
700
+ "application/json": { schema: resolver(ErrorResponse) },
701
+ },
702
+ },
703
+ },
704
+ }),
705
+ async (c) => {
706
+ const user = c.get("user");
707
+ if (!user) {
708
+ return c.json(
709
+ {
710
+ error: { code: "unauthorized", message: "Authentication required" },
711
+ },
712
+ 401,
713
+ );
714
+ }
715
+ const tokenId = c.req.param("tokenId");
716
+
717
+ const existing = await db.query.gitToken.findFirst({
718
+ where: eq(gitToken.id, tokenId),
719
+ });
720
+ if (!existing) {
721
+ log.info(
722
+ "personal revoke not found userId={userId} tokenId={tokenId}",
723
+ {
724
+ userId: user.id,
725
+ tokenId,
726
+ },
727
+ );
728
+ return c.json(
729
+ { error: { code: "not_found", message: "Token not found" } },
730
+ 404,
731
+ );
732
+ }
733
+
734
+ if (existing.userId !== user.id) {
735
+ log.info(
736
+ "personal revoke forbidden userId={userId} tokenId={tokenId} owner={ownerId}",
737
+ {
738
+ userId: user.id,
739
+ tokenId,
740
+ ownerId: existing.userId,
741
+ },
742
+ );
743
+ return c.json(
744
+ {
745
+ error: {
746
+ code: "forbidden",
747
+ message: "Token is owned by a different user",
748
+ },
749
+ },
750
+ 403,
751
+ );
752
+ }
753
+
754
+ if (existing.revokedAt === null) {
755
+ await db
756
+ .update(gitToken)
757
+ .set({ revokedAt: new Date() })
758
+ .where(eq(gitToken.id, tokenId));
759
+ }
760
+
761
+ log.info("personal revoke succeeded userId={userId} tokenId={tokenId}", {
762
+ userId: user.id,
763
+ tokenId,
764
+ });
765
+
766
+ return c.body(null, 204);
767
+ },
768
+ );
769
+
770
+ return app;
771
+ }