@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.
- package/README.md +29 -0
- package/package.json +28 -0
- package/src/app.test.ts +225 -0
- package/src/app.ts +382 -0
- package/src/auth.ts +21 -0
- package/src/context.ts +38 -0
- package/src/format.ts +9 -0
- package/src/git-http/advertise-refs.test.ts +459 -0
- package/src/git-http/advertise-refs.ts +226 -0
- package/src/git-http/pkt-line.test.ts +220 -0
- package/src/git-http/pkt-line.ts +235 -0
- package/src/git-http/receive-pack.test.ts +397 -0
- package/src/git-http/receive-pack.ts +261 -0
- package/src/git-http/side-band-64k.test.ts +181 -0
- package/src/git-http/side-band-64k.ts +134 -0
- package/src/git-http/upload-pack.test.ts +545 -0
- package/src/git-http/upload-pack.ts +396 -0
- package/src/index.ts +23 -0
- package/src/middleware/git-token-auth.test.ts +587 -0
- package/src/middleware/git-token-auth.ts +315 -0
- package/src/middleware/grant.ts +106 -0
- package/src/middleware/session.ts +13 -0
- package/src/middleware/tenant.test.ts +192 -0
- package/src/middleware/tenant.ts +101 -0
- package/src/openapi.ts +66 -0
- package/src/pagination.ts +117 -0
- package/src/routes/agent-data.ts +179 -0
- package/src/routes/agent-state-git.ts +562 -0
- package/src/routes/agents.test.ts +337 -0
- package/src/routes/agents.ts +704 -0
- package/src/routes/approvals.ts +130 -0
- package/src/routes/assets.test.ts +567 -0
- package/src/routes/assets.ts +592 -0
- package/src/routes/credentials.ts +435 -0
- package/src/routes/git-tokens.test.ts +709 -0
- package/src/routes/git-tokens.ts +771 -0
- package/src/routes/grants.ts +509 -0
- package/src/routes/instances.test.ts +1103 -0
- package/src/routes/instances.ts +1797 -0
- package/src/routes/me.ts +405 -0
- package/src/routes/oauth-clients.ts +349 -0
- package/src/routes/observability.ts +146 -0
- package/src/routes/offerings.ts +382 -0
- package/src/routes/principals.ts +515 -0
- package/src/routes/providers.ts +351 -0
- package/src/routes/roles.ts +452 -0
- package/src/routes/sidecars.ts +221 -0
- package/src/routes/tenant-federation.ts +225 -0
- package/src/routes/tenants.ts +369 -0
- package/src/routes/wallets.ts +370 -0
- package/src/session.ts +44 -0
- package/src/timeline-reconstruction.test.ts +786 -0
- package/src/timeline-reconstruction.ts +383 -0
- package/tsconfig.json +4 -0
- 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
|
+
}
|