@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,509 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { Hono } from "hono";
3
+ import { describeRoute, resolver, validator } from "hono-openapi";
4
+
5
+ import { authorize } from "@intx/authz";
6
+ import { grant, principal } from "@intx/db/schema";
7
+ import { parseGrantRow } from "@intx/db";
8
+ import type { DB } from "@intx/db";
9
+ import type { ConditionRegistry, GrantStore } from "@intx/types/authz";
10
+ import {
11
+ CreateGrant,
12
+ UpdateGrant,
13
+ GrantResponse,
14
+ EvaluateRequest,
15
+ EvaluateResult,
16
+ ErrorResponse,
17
+ paginatedSchema,
18
+ } from "@intx/types";
19
+
20
+ import type { TenantEnv } from "../context";
21
+ import { first, ts } from "../format";
22
+ import { generateId } from "@intx/hub-common";
23
+ import { idResource } from "../middleware/grant";
24
+ import type { RequireGrant } from "../middleware/grant";
25
+ import {
26
+ parsePageParams,
27
+ cursorCondition,
28
+ pageOrder,
29
+ paginatedResponse,
30
+ pageParameters,
31
+ } from "../pagination";
32
+
33
+ type ResolvedNames = {
34
+ roleNames: Map<string, string>;
35
+ principalNames: Map<string, string>;
36
+ };
37
+
38
+ function formatGrant(row: typeof grant.$inferSelect, names?: ResolvedNames) {
39
+ const parsed = parseGrantRow(row);
40
+ return {
41
+ id: parsed.id,
42
+ tenantId: parsed.tenantId,
43
+ roleId: parsed.roleId ?? null,
44
+ roleName: (parsed.roleId && names?.roleNames.get(parsed.roleId)) ?? null,
45
+ principalId: parsed.principalId ?? null,
46
+ principalName:
47
+ (parsed.principalId && names?.principalNames.get(parsed.principalId)) ??
48
+ null,
49
+ resource: parsed.resource,
50
+ action: parsed.action,
51
+ effect: parsed.effect,
52
+ conditions: parsed.conditions,
53
+ origin: parsed.origin,
54
+ expiresAt: parsed.expiresAt ? ts(parsed.expiresAt) : null,
55
+ createdAt: ts(parsed.createdAt),
56
+ updatedAt: ts(parsed.updatedAt),
57
+ };
58
+ }
59
+
60
+ async function resolveGrantNames(
61
+ db: DB["db"],
62
+ grants: (typeof grant.$inferSelect)[],
63
+ ): Promise<ResolvedNames> {
64
+ const roleIds = [
65
+ ...new Set(
66
+ grants.map((g) => g.roleId).filter((id): id is string => id !== null),
67
+ ),
68
+ ];
69
+ const principalIds = [
70
+ ...new Set(
71
+ grants
72
+ .map((g) => g.principalId)
73
+ .filter((id): id is string => id !== null),
74
+ ),
75
+ ];
76
+
77
+ const roleNames = new Map<string, string>();
78
+ if (roleIds.length > 0) {
79
+ const roles = await db.query.role.findMany({
80
+ where: (r, { inArray }) => inArray(r.id, roleIds),
81
+ });
82
+ for (const r of roles) {
83
+ roleNames.set(r.id, r.name);
84
+ }
85
+ }
86
+
87
+ const principalNames = new Map<string, string>();
88
+ if (principalIds.length > 0) {
89
+ const principals = await db.query.principal.findMany({
90
+ where: (p, { inArray }) => inArray(p.id, principalIds),
91
+ });
92
+
93
+ const userRefIds = principals
94
+ .filter((p) => p.kind === "user")
95
+ .map((p) => p.refId);
96
+ const agentRefIds = principals
97
+ .filter((p) => p.kind === "agent")
98
+ .map((p) => p.refId);
99
+
100
+ const refToName = new Map<string, string>();
101
+
102
+ if (userRefIds.length > 0) {
103
+ const users = await db.query.user.findMany({
104
+ where: (u, { inArray }) => inArray(u.id, userRefIds),
105
+ });
106
+ for (const u of users) {
107
+ refToName.set(u.id, u.name);
108
+ }
109
+ }
110
+
111
+ if (agentRefIds.length > 0) {
112
+ const agents = await db.query.agent.findMany({
113
+ where: (a, { inArray }) => inArray(a.id, agentRefIds),
114
+ });
115
+ for (const a of agents) {
116
+ refToName.set(a.id, a.name);
117
+ }
118
+
119
+ // Resolve instance principals (refId = agentInstance.id)
120
+ const unresolvedRefIds = agentRefIds.filter((id) => !refToName.has(id));
121
+ if (unresolvedRefIds.length > 0) {
122
+ const instances = await db.query.agentInstance.findMany({
123
+ where: (i, { inArray }) => inArray(i.id, unresolvedRefIds),
124
+ });
125
+ const definitionIds = [...new Set(instances.map((i) => i.agentId))];
126
+ const definitions =
127
+ definitionIds.length > 0
128
+ ? await db.query.agent.findMany({
129
+ where: (a, { inArray }) => inArray(a.id, definitionIds),
130
+ })
131
+ : [];
132
+ const defNames = new Map(definitions.map((d) => [d.id, d.name]));
133
+ for (const inst of instances) {
134
+ const name = defNames.get(inst.agentId);
135
+ if (name) {
136
+ refToName.set(inst.id, `${name} (instance)`);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ for (const p of principals) {
143
+ const name = refToName.get(p.refId);
144
+ if (name) principalNames.set(p.id, name);
145
+ }
146
+ }
147
+
148
+ return { roleNames, principalNames };
149
+ }
150
+
151
+ export type CreateGrantRoutesDeps = {
152
+ db: DB["db"];
153
+ requireGrant: RequireGrant;
154
+ };
155
+
156
+ export function createGrantRoutes({
157
+ db,
158
+ requireGrant,
159
+ }: CreateGrantRoutesDeps): Hono<TenantEnv> {
160
+ const app = new Hono<TenantEnv>();
161
+
162
+ app.get(
163
+ "/",
164
+ requireGrant("grant:*", "read"),
165
+ describeRoute({
166
+ tags: ["Grants"],
167
+ summary: "List grants in the tenant",
168
+ description:
169
+ "Lists all grants. Filterable by principalId, roleId, resource pattern, and effect.",
170
+ parameters: [
171
+ { name: "principalId", in: "query", schema: { type: "string" } },
172
+ { name: "roleId", in: "query", schema: { type: "string" } },
173
+ { name: "resource", in: "query", schema: { type: "string" } },
174
+ {
175
+ name: "effect",
176
+ in: "query",
177
+ schema: { type: "string", enum: ["allow", "deny", "ask"] },
178
+ },
179
+ ...pageParameters,
180
+ ],
181
+ responses: {
182
+ 200: {
183
+ description: "List of grants",
184
+ content: {
185
+ "application/json": {
186
+ schema: resolver(paginatedSchema(GrantResponse)),
187
+ },
188
+ },
189
+ },
190
+ },
191
+ }),
192
+ async (c) => {
193
+ const tenantCtx = c.get("tenant");
194
+
195
+ const principalId = c.req.query("principalId");
196
+ const roleId = c.req.query("roleId");
197
+ const resource = c.req.query("resource");
198
+ const effect = c.req.query("effect");
199
+ const { limit, cursor } = parsePageParams({
200
+ cursor: c.req.query("cursor"),
201
+ limit: c.req.query("limit"),
202
+ });
203
+
204
+ const conditions = [eq(grant.tenantId, tenantCtx.id)];
205
+ if (principalId) conditions.push(eq(grant.principalId, principalId));
206
+ if (roleId) conditions.push(eq(grant.roleId, roleId));
207
+ if (resource) conditions.push(eq(grant.resource, resource));
208
+ if (effect === "allow" || effect === "deny" || effect === "ask") {
209
+ conditions.push(eq(grant.effect, effect));
210
+ }
211
+ if (cursor) {
212
+ conditions.push(cursorCondition(grant.createdAt, grant.id, cursor));
213
+ }
214
+
215
+ const rows = await db.query.grant.findMany({
216
+ where: and(...conditions),
217
+ orderBy: pageOrder(grant.createdAt, grant.id),
218
+ limit,
219
+ });
220
+
221
+ const names = await resolveGrantNames(db, rows);
222
+ return c.json(
223
+ paginatedResponse(
224
+ rows.map((g) => formatGrant(g, names)),
225
+ rows,
226
+ limit,
227
+ ),
228
+ );
229
+ },
230
+ );
231
+
232
+ app.post(
233
+ "/",
234
+ requireGrant("grant:*", "create"),
235
+ describeRoute({
236
+ tags: ["Grants"],
237
+ summary: "Create a grant",
238
+ description:
239
+ "Creates a grant targeting either a role or a principal directly. Exactly one of roleId or principalId must be provided.",
240
+ responses: {
241
+ 201: {
242
+ description: "Grant created",
243
+ content: {
244
+ "application/json": { schema: resolver(GrantResponse) },
245
+ },
246
+ },
247
+ 400: {
248
+ description: "Validation error",
249
+ content: {
250
+ "application/json": { schema: resolver(ErrorResponse) },
251
+ },
252
+ },
253
+ },
254
+ }),
255
+ validator("json", CreateGrant),
256
+ async (c) => {
257
+ const tenantCtx = c.get("tenant");
258
+ const body = c.req.valid("json");
259
+
260
+ if (!body.roleId && !body.principalId) {
261
+ return c.json(
262
+ {
263
+ error: {
264
+ code: "bad_request",
265
+ message: "Either roleId or principalId must be provided",
266
+ },
267
+ },
268
+ 400,
269
+ );
270
+ }
271
+
272
+ const now = new Date();
273
+ const row = first(
274
+ await db
275
+ .insert(grant)
276
+ .values({
277
+ id: generateId("grant"),
278
+ tenantId: tenantCtx.id,
279
+ roleId: body.roleId ?? null,
280
+ principalId: body.principalId ?? null,
281
+ resource: body.resource,
282
+ action: body.action,
283
+ effect: body.effect,
284
+ conditions: body.conditions ?? null,
285
+ origin: body.origin,
286
+ expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
287
+ createdAt: now,
288
+ updatedAt: now,
289
+ })
290
+ .returning(),
291
+ );
292
+
293
+ return c.json(formatGrant(row), 201);
294
+ },
295
+ );
296
+
297
+ app.get(
298
+ "/:grantId",
299
+ requireGrant(idResource("grant", "grantId"), "read"),
300
+ describeRoute({
301
+ tags: ["Grants"],
302
+ summary: "Get grant details",
303
+ responses: {
304
+ 200: {
305
+ description: "Grant details",
306
+ content: {
307
+ "application/json": { schema: resolver(GrantResponse) },
308
+ },
309
+ },
310
+ 404: {
311
+ description: "Grant not found",
312
+ content: {
313
+ "application/json": { schema: resolver(ErrorResponse) },
314
+ },
315
+ },
316
+ },
317
+ }),
318
+ async (c) => {
319
+ const tenantCtx = c.get("tenant");
320
+ const grantId = c.req.param("grantId");
321
+
322
+ const row = await db.query.grant.findFirst({
323
+ where: and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id)),
324
+ });
325
+
326
+ if (!row) {
327
+ return c.json(
328
+ { error: { code: "not_found", message: "Grant not found" } },
329
+ 404,
330
+ );
331
+ }
332
+
333
+ return c.json(formatGrant(row));
334
+ },
335
+ );
336
+
337
+ app.patch(
338
+ "/:grantId",
339
+ requireGrant(idResource("grant", "grantId"), "manage"),
340
+ describeRoute({
341
+ tags: ["Grants"],
342
+ summary: "Update a grant",
343
+ description: "Update effect, conditions, or expiry on an existing grant.",
344
+ responses: {
345
+ 200: {
346
+ description: "Grant updated",
347
+ content: {
348
+ "application/json": { schema: resolver(GrantResponse) },
349
+ },
350
+ },
351
+ 404: {
352
+ description: "Grant not found",
353
+ content: {
354
+ "application/json": { schema: resolver(ErrorResponse) },
355
+ },
356
+ },
357
+ },
358
+ }),
359
+ validator("json", UpdateGrant),
360
+ async (c) => {
361
+ const tenantCtx = c.get("tenant");
362
+ const grantId = c.req.param("grantId");
363
+ const body = c.req.valid("json");
364
+
365
+ const updates: Record<string, unknown> = { updatedAt: new Date() };
366
+ if (body.effect !== undefined) updates["effect"] = body.effect;
367
+ if (body.conditions !== undefined)
368
+ updates["conditions"] = body.conditions;
369
+ if (body.expiresAt !== undefined) {
370
+ updates["expiresAt"] = body.expiresAt ? new Date(body.expiresAt) : null;
371
+ }
372
+
373
+ const [updated] = await db
374
+ .update(grant)
375
+ .set(updates)
376
+ .where(and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id)))
377
+ .returning();
378
+
379
+ if (!updated) {
380
+ return c.json(
381
+ { error: { code: "not_found", message: "Grant not found" } },
382
+ 404,
383
+ );
384
+ }
385
+
386
+ return c.json(formatGrant(updated));
387
+ },
388
+ );
389
+
390
+ app.delete(
391
+ "/:grantId",
392
+ requireGrant(idResource("grant", "grantId"), "manage"),
393
+ describeRoute({
394
+ tags: ["Grants"],
395
+ summary: "Revoke a grant",
396
+ responses: {
397
+ 204: {
398
+ description: "Grant revoked",
399
+ },
400
+ 404: {
401
+ description: "Grant not found",
402
+ content: {
403
+ "application/json": { schema: resolver(ErrorResponse) },
404
+ },
405
+ },
406
+ },
407
+ }),
408
+ async (c) => {
409
+ const tenantCtx = c.get("tenant");
410
+ const grantId = c.req.param("grantId");
411
+
412
+ const deleted = await db
413
+ .delete(grant)
414
+ .where(and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id)))
415
+ .returning();
416
+
417
+ if (deleted.length === 0) {
418
+ return c.json(
419
+ { error: { code: "not_found", message: "Grant not found" } },
420
+ 404,
421
+ );
422
+ }
423
+
424
+ return c.body(null, 204);
425
+ },
426
+ );
427
+
428
+ return app;
429
+ }
430
+
431
+ // Evaluate endpoint is mounted under principals
432
+ export type CreateEvaluateRoutesDeps = {
433
+ db: DB["db"];
434
+ grantStore: GrantStore;
435
+ conditionRegistry: ConditionRegistry;
436
+ };
437
+
438
+ export function createEvaluateRoutes({
439
+ db,
440
+ grantStore,
441
+ conditionRegistry,
442
+ }: CreateEvaluateRoutesDeps): Hono<TenantEnv> {
443
+ const evaluateApp = new Hono<TenantEnv>();
444
+
445
+ evaluateApp.post(
446
+ "/",
447
+ describeRoute({
448
+ tags: ["Grants"],
449
+ summary: "Evaluate grants for a principal",
450
+ description:
451
+ "Evaluates what would happen if a principal attempted an operation. Returns the resolved effect and all matching grants. Useful for debugging authorization.",
452
+ responses: {
453
+ 200: {
454
+ description: "Evaluation result",
455
+ content: {
456
+ "application/json": { schema: resolver(EvaluateResult) },
457
+ },
458
+ },
459
+ 404: {
460
+ description: "Principal not found",
461
+ content: {
462
+ "application/json": { schema: resolver(ErrorResponse) },
463
+ },
464
+ },
465
+ },
466
+ }),
467
+ validator("json", EvaluateRequest),
468
+ async (c) => {
469
+ const tenantCtx = c.get("tenant");
470
+ const principalId = c.req.param("principalId") ?? "";
471
+ const body = c.req.valid("json");
472
+ const principalRow = await db.query.principal.findFirst({
473
+ where: and(
474
+ eq(principal.id, principalId),
475
+ eq(principal.tenantId, tenantCtx.id),
476
+ ),
477
+ });
478
+
479
+ if (!principalRow) {
480
+ return c.json(
481
+ { error: { code: "not_found", message: "Principal not found" } },
482
+ 404,
483
+ );
484
+ }
485
+
486
+ const result = await authorize(
487
+ grantStore,
488
+ principalId,
489
+ tenantCtx.id,
490
+ body.resource,
491
+ body.action,
492
+ conditionRegistry,
493
+ );
494
+
495
+ return c.json({
496
+ effect: result.effect ?? "deny",
497
+ matchingGrants: result.matchingGrants.map((g) => ({
498
+ id: g.id,
499
+ resource: g.resource,
500
+ action: g.action,
501
+ effect: g.effect,
502
+ origin: g.origin,
503
+ })),
504
+ });
505
+ },
506
+ );
507
+
508
+ return evaluateApp;
509
+ }