@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,452 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import { Hono } from "hono";
3
+ import { describeRoute, resolver, validator } from "hono-openapi";
4
+
5
+ import { role, principalRole, principal } from "@intx/db/schema";
6
+ import type { DB } from "@intx/db";
7
+ import {
8
+ CreateRole,
9
+ UpdateRole,
10
+ RoleResponse,
11
+ ErrorResponse,
12
+ paginatedSchema,
13
+ } from "@intx/types";
14
+
15
+ import type { TenantEnv } from "../context";
16
+ import { first, ts } from "../format";
17
+ import { generateId } from "@intx/hub-common";
18
+ import { idResource } from "../middleware/grant";
19
+ import type { RequireGrant } from "../middleware/grant";
20
+ import {
21
+ parsePageParams,
22
+ cursorCondition,
23
+ pageOrder,
24
+ paginatedResponse,
25
+ pageParameters,
26
+ } from "../pagination";
27
+
28
+ function formatRole(row: typeof role.$inferSelect) {
29
+ return {
30
+ id: row.id,
31
+ tenantId: row.tenantId,
32
+ name: row.name,
33
+ description: row.description ?? null,
34
+ isSystem: row.isSystem,
35
+ createdAt: ts(row.createdAt),
36
+ updatedAt: ts(row.updatedAt),
37
+ };
38
+ }
39
+
40
+ export type CreateRoleRoutesDeps = {
41
+ db: DB["db"];
42
+ requireGrant: RequireGrant;
43
+ };
44
+
45
+ export function createRoleRoutes({
46
+ db,
47
+ requireGrant,
48
+ }: CreateRoleRoutesDeps): Hono<TenantEnv> {
49
+ const app = new Hono<TenantEnv>();
50
+
51
+ app.get(
52
+ "/",
53
+ requireGrant("role:*", "read"),
54
+ describeRoute({
55
+ tags: ["Roles"],
56
+ summary: "List roles in the tenant",
57
+ description:
58
+ "Lists both system roles (owner, admin, member) and custom roles.",
59
+ parameters: [...pageParameters],
60
+ responses: {
61
+ 200: {
62
+ description: "List of roles",
63
+ content: {
64
+ "application/json": {
65
+ schema: resolver(paginatedSchema(RoleResponse)),
66
+ },
67
+ },
68
+ },
69
+ },
70
+ }),
71
+ async (c) => {
72
+ const tenantCtx = c.get("tenant");
73
+ const { limit, cursor } = parsePageParams({
74
+ cursor: c.req.query("cursor"),
75
+ limit: c.req.query("limit"),
76
+ });
77
+
78
+ const conditions = [eq(role.tenantId, tenantCtx.id)];
79
+ if (cursor) {
80
+ conditions.push(cursorCondition(role.createdAt, role.id, cursor));
81
+ }
82
+
83
+ const rows = await db.query.role.findMany({
84
+ where: and(...conditions),
85
+ orderBy: pageOrder(role.createdAt, role.id),
86
+ limit,
87
+ });
88
+
89
+ return c.json(paginatedResponse(rows.map(formatRole), rows, limit));
90
+ },
91
+ );
92
+
93
+ app.post(
94
+ "/",
95
+ requireGrant("role:*", "create"),
96
+ describeRoute({
97
+ tags: ["Roles"],
98
+ summary: "Create a custom role",
99
+ responses: {
100
+ 201: {
101
+ description: "Role created",
102
+ content: {
103
+ "application/json": { schema: resolver(RoleResponse) },
104
+ },
105
+ },
106
+ 400: {
107
+ description: "Validation error",
108
+ content: {
109
+ "application/json": { schema: resolver(ErrorResponse) },
110
+ },
111
+ },
112
+ },
113
+ }),
114
+ validator("json", CreateRole),
115
+ async (c) => {
116
+ const tenantCtx = c.get("tenant");
117
+ const body = c.req.valid("json");
118
+
119
+ const now = new Date();
120
+ const row = first(
121
+ await db
122
+ .insert(role)
123
+ .values({
124
+ id: generateId("role"),
125
+ tenantId: tenantCtx.id,
126
+ name: body.name,
127
+ description: body.description ?? null,
128
+ isSystem: false,
129
+ createdAt: now,
130
+ updatedAt: now,
131
+ })
132
+ .returning(),
133
+ );
134
+
135
+ return c.json(formatRole(row), 201);
136
+ },
137
+ );
138
+
139
+ app.get(
140
+ "/:roleId",
141
+ requireGrant(idResource("role", "roleId"), "read"),
142
+ describeRoute({
143
+ tags: ["Roles"],
144
+ summary: "Get role details",
145
+ description: "Returns role details including attached grants.",
146
+ responses: {
147
+ 200: {
148
+ description: "Role details",
149
+ content: {
150
+ "application/json": { schema: resolver(RoleResponse) },
151
+ },
152
+ },
153
+ 404: {
154
+ description: "Role not found",
155
+ content: {
156
+ "application/json": { schema: resolver(ErrorResponse) },
157
+ },
158
+ },
159
+ },
160
+ }),
161
+ async (c) => {
162
+ const tenantCtx = c.get("tenant");
163
+ const roleId = c.req.param("roleId");
164
+
165
+ const row = await db.query.role.findFirst({
166
+ where: and(eq(role.id, roleId), eq(role.tenantId, tenantCtx.id)),
167
+ });
168
+
169
+ if (!row) {
170
+ return c.json(
171
+ { error: { code: "not_found", message: "Role not found" } },
172
+ 404,
173
+ );
174
+ }
175
+
176
+ return c.json(formatRole(row));
177
+ },
178
+ );
179
+
180
+ app.patch(
181
+ "/:roleId",
182
+ requireGrant(idResource("role", "roleId"), "manage"),
183
+ describeRoute({
184
+ tags: ["Roles"],
185
+ summary: "Update a role",
186
+ description:
187
+ "Update name or description. System roles cannot be modified.",
188
+ responses: {
189
+ 200: {
190
+ description: "Role updated",
191
+ content: {
192
+ "application/json": { schema: resolver(RoleResponse) },
193
+ },
194
+ },
195
+ 403: {
196
+ description: "Cannot modify system role",
197
+ content: {
198
+ "application/json": { schema: resolver(ErrorResponse) },
199
+ },
200
+ },
201
+ },
202
+ }),
203
+ validator("json", UpdateRole),
204
+ async (c) => {
205
+ const tenantCtx = c.get("tenant");
206
+ const roleId = c.req.param("roleId");
207
+ const body = c.req.valid("json");
208
+
209
+ const existing = await db.query.role.findFirst({
210
+ where: and(eq(role.id, roleId), eq(role.tenantId, tenantCtx.id)),
211
+ });
212
+
213
+ if (!existing) {
214
+ return c.json(
215
+ { error: { code: "not_found", message: "Role not found" } },
216
+ 404,
217
+ );
218
+ }
219
+
220
+ if (existing.isSystem) {
221
+ return c.json(
222
+ {
223
+ error: {
224
+ code: "forbidden",
225
+ message: "Cannot modify system roles",
226
+ },
227
+ },
228
+ 403,
229
+ );
230
+ }
231
+
232
+ const updates: Record<string, unknown> = { updatedAt: new Date() };
233
+ if (body.name !== undefined) updates["name"] = body.name;
234
+ if (body.description !== undefined)
235
+ updates["description"] = body.description;
236
+
237
+ const updated = first(
238
+ await db
239
+ .update(role)
240
+ .set(updates)
241
+ .where(eq(role.id, roleId))
242
+ .returning(),
243
+ );
244
+
245
+ return c.json(formatRole(updated));
246
+ },
247
+ );
248
+
249
+ app.delete(
250
+ "/:roleId",
251
+ requireGrant(idResource("role", "roleId"), "manage"),
252
+ describeRoute({
253
+ tags: ["Roles"],
254
+ summary: "Delete a custom role",
255
+ description:
256
+ "Deletes a custom role. Fails if principals are currently assigned to it. System roles cannot be deleted.",
257
+ responses: {
258
+ 204: {
259
+ description: "Role deleted",
260
+ },
261
+ 400: {
262
+ description: "Role still assigned to principals",
263
+ content: {
264
+ "application/json": { schema: resolver(ErrorResponse) },
265
+ },
266
+ },
267
+ 403: {
268
+ description: "Cannot delete system role",
269
+ content: {
270
+ "application/json": { schema: resolver(ErrorResponse) },
271
+ },
272
+ },
273
+ },
274
+ }),
275
+ async (c) => {
276
+ const tenantCtx = c.get("tenant");
277
+ const roleId = c.req.param("roleId");
278
+
279
+ const existing = await db.query.role.findFirst({
280
+ where: and(eq(role.id, roleId), eq(role.tenantId, tenantCtx.id)),
281
+ });
282
+
283
+ if (!existing) {
284
+ return c.json(
285
+ { error: { code: "not_found", message: "Role not found" } },
286
+ 404,
287
+ );
288
+ }
289
+
290
+ if (existing.isSystem) {
291
+ return c.json(
292
+ {
293
+ error: {
294
+ code: "forbidden",
295
+ message: "Cannot delete system roles",
296
+ },
297
+ },
298
+ 403,
299
+ );
300
+ }
301
+
302
+ const assignments = await db.query.principalRole.findMany({
303
+ where: eq(principalRole.roleId, roleId),
304
+ });
305
+
306
+ if (assignments.length > 0) {
307
+ return c.json(
308
+ {
309
+ error: {
310
+ code: "bad_request",
311
+ message: `Role is still assigned to ${assignments.length} principal(s)`,
312
+ },
313
+ },
314
+ 400,
315
+ );
316
+ }
317
+
318
+ await db.delete(role).where(eq(role.id, roleId));
319
+
320
+ return c.body(null, 204);
321
+ },
322
+ );
323
+
324
+ return app;
325
+ }
326
+
327
+ // Role assignment routes are mounted under principals
328
+ export type CreateRoleAssignRoutesDeps = {
329
+ db: DB["db"];
330
+ requireGrant: RequireGrant;
331
+ };
332
+
333
+ export function createRoleAssignRoutes({
334
+ db,
335
+ requireGrant,
336
+ }: CreateRoleAssignRoutesDeps): Hono<TenantEnv> {
337
+ const assignApp = new Hono<TenantEnv>();
338
+
339
+ assignApp.post(
340
+ "/:roleId",
341
+ requireGrant("role:*", "manage"),
342
+ describeRoute({
343
+ tags: ["Roles"],
344
+ summary: "Assign a role to a principal",
345
+ description:
346
+ "Assigns a role to a user or agent principal within the tenant.",
347
+ responses: {
348
+ 204: {
349
+ description: "Role assigned",
350
+ },
351
+ 404: {
352
+ description: "Principal or role not found",
353
+ content: {
354
+ "application/json": { schema: resolver(ErrorResponse) },
355
+ },
356
+ },
357
+ },
358
+ }),
359
+ async (c) => {
360
+ const tenantCtx = c.get("tenant");
361
+ const principalId = c.req.param("principalId") ?? "";
362
+ const roleId = c.req.param("roleId") ?? "";
363
+
364
+ const principalRow = await db.query.principal.findFirst({
365
+ where: and(
366
+ eq(principal.id, principalId),
367
+ eq(principal.tenantId, tenantCtx.id),
368
+ ),
369
+ });
370
+ if (!principalRow) {
371
+ return c.json(
372
+ { error: { code: "not_found", message: "Principal not found" } },
373
+ 404,
374
+ );
375
+ }
376
+
377
+ const roleRow = await db.query.role.findFirst({
378
+ where: and(eq(role.id, roleId), eq(role.tenantId, tenantCtx.id)),
379
+ });
380
+ if (!roleRow) {
381
+ return c.json(
382
+ { error: { code: "not_found", message: "Role not found" } },
383
+ 404,
384
+ );
385
+ }
386
+
387
+ const existing = await db.query.principalRole.findFirst({
388
+ where: and(
389
+ eq(principalRole.principalId, principalId),
390
+ eq(principalRole.roleId, roleId),
391
+ ),
392
+ });
393
+
394
+ if (!existing) {
395
+ await db.insert(principalRole).values({
396
+ principalId,
397
+ roleId,
398
+ createdAt: new Date(),
399
+ });
400
+ }
401
+
402
+ return c.body(null, 204);
403
+ },
404
+ );
405
+
406
+ assignApp.delete(
407
+ "/:roleId",
408
+ requireGrant("role:*", "manage"),
409
+ describeRoute({
410
+ tags: ["Roles"],
411
+ summary: "Remove a role from a principal",
412
+ responses: {
413
+ 204: {
414
+ description: "Role removed",
415
+ },
416
+ 404: {
417
+ description: "Assignment not found",
418
+ content: {
419
+ "application/json": { schema: resolver(ErrorResponse) },
420
+ },
421
+ },
422
+ },
423
+ }),
424
+ async (c) => {
425
+ const principalId = c.req.param("principalId") ?? "";
426
+ const roleId = c.req.param("roleId") ?? "";
427
+
428
+ const deleted = await db
429
+ .delete(principalRole)
430
+ .where(
431
+ and(
432
+ eq(principalRole.principalId, principalId),
433
+ eq(principalRole.roleId, roleId),
434
+ ),
435
+ )
436
+ .returning();
437
+
438
+ if (deleted.length === 0) {
439
+ return c.json(
440
+ {
441
+ error: { code: "not_found", message: "Assignment not found" },
442
+ },
443
+ 404,
444
+ );
445
+ }
446
+
447
+ return c.body(null, 204);
448
+ },
449
+ );
450
+
451
+ return assignApp;
452
+ }
@@ -0,0 +1,221 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { Hono } from "hono";
3
+ import { describeRoute, resolver, validator } from "hono-openapi";
4
+ import type { Handler } from "hono";
5
+
6
+ import { sidecar } from "@intx/db/schema";
7
+ import { parseSidecarStatus } from "@intx/db";
8
+ import type { DB } from "@intx/db";
9
+ import { CreateSidecar, SidecarResponse, ErrorResponse } from "@intx/types";
10
+
11
+ import type { AppEnv } from "../context";
12
+ import { first, ts } from "../format";
13
+
14
+ function formatSidecar(row: typeof sidecar.$inferSelect) {
15
+ return {
16
+ id: row.id,
17
+ url: row.url,
18
+ status: parseSidecarStatus(row.status),
19
+ lastHeartbeat: row.lastHeartbeat ? ts(row.lastHeartbeat) : null,
20
+ createdAt: ts(row.createdAt),
21
+ updatedAt: ts(row.updatedAt),
22
+ };
23
+ }
24
+
25
+ export type CreateSidecarRoutesDeps = {
26
+ db: DB["db"];
27
+ wsHandler?: Handler<AppEnv>;
28
+ };
29
+
30
+ // Sidecar management routes are system-level (not tenant-scoped) and
31
+ // authenticated by the sidecar's registration token over the WebSocket
32
+ // channel. The REST endpoints here are for internal tooling and are not
33
+ // exposed through tenant authorization grants.
34
+ export function createSidecarRoutes({
35
+ db,
36
+ wsHandler,
37
+ }: CreateSidecarRoutesDeps): Hono<AppEnv> {
38
+ const app = new Hono<AppEnv>();
39
+
40
+ if (wsHandler) {
41
+ app.get("/ws", wsHandler);
42
+ }
43
+
44
+ app.post(
45
+ "/",
46
+ describeRoute({
47
+ tags: ["Sidecars"],
48
+ summary: "Register or update a sidecar",
49
+ description:
50
+ "Upserts a sidecar record. If an id is provided and already exists, the record is updated. Used for idempotent sidecar registration by a known stable identifier.",
51
+ responses: {
52
+ 201: {
53
+ description: "Sidecar registered",
54
+ content: {
55
+ "application/json": { schema: resolver(SidecarResponse) },
56
+ },
57
+ },
58
+ },
59
+ }),
60
+ validator("json", CreateSidecar),
61
+ async (c) => {
62
+ const body = c.req.valid("json");
63
+
64
+ const resolvedStatus = body.status ?? "online";
65
+ const created = first(
66
+ await db
67
+ .insert(sidecar)
68
+ .values({
69
+ id: body.id || crypto.randomUUID(),
70
+ url: body.url,
71
+ status: resolvedStatus,
72
+ lastHeartbeat: new Date(),
73
+ })
74
+ .onConflictDoUpdate({
75
+ target: sidecar.id,
76
+ set: {
77
+ url: body.url,
78
+ status: resolvedStatus,
79
+ lastHeartbeat: new Date(),
80
+ updatedAt: new Date(),
81
+ },
82
+ })
83
+ .returning(),
84
+ );
85
+
86
+ return c.json(formatSidecar(created), 201);
87
+ },
88
+ );
89
+
90
+ app.get(
91
+ "/",
92
+ describeRoute({
93
+ tags: ["Sidecars"],
94
+ summary: "List all sidecars",
95
+ responses: {
96
+ 200: {
97
+ description: "List of sidecars",
98
+ content: {
99
+ "application/json": {
100
+ schema: resolver(SidecarResponse.array()),
101
+ },
102
+ },
103
+ },
104
+ },
105
+ }),
106
+ async (c) => {
107
+ const sidecars = await db.select().from(sidecar);
108
+ return c.json(sidecars.map(formatSidecar));
109
+ },
110
+ );
111
+
112
+ app.get(
113
+ "/:id",
114
+ describeRoute({
115
+ tags: ["Sidecars"],
116
+ summary: "Get a sidecar by ID",
117
+ responses: {
118
+ 200: {
119
+ description: "Sidecar detail",
120
+ content: {
121
+ "application/json": { schema: resolver(SidecarResponse) },
122
+ },
123
+ },
124
+ 404: {
125
+ description: "Sidecar not found",
126
+ content: {
127
+ "application/json": { schema: resolver(ErrorResponse) },
128
+ },
129
+ },
130
+ },
131
+ }),
132
+ async (c) => {
133
+ const id = c.req.param("id");
134
+ const [sc] = await db.select().from(sidecar).where(eq(sidecar.id, id));
135
+
136
+ if (!sc) {
137
+ return c.json(
138
+ { error: { code: "not_found", message: "Sidecar not found" } },
139
+ 404,
140
+ );
141
+ }
142
+
143
+ return c.json(formatSidecar(sc));
144
+ },
145
+ );
146
+
147
+ app.delete(
148
+ "/:id",
149
+ describeRoute({
150
+ tags: ["Sidecars"],
151
+ summary: "Deregister a sidecar",
152
+ responses: {
153
+ 204: { description: "Sidecar deregistered" },
154
+ 404: {
155
+ description: "Sidecar not found",
156
+ content: {
157
+ "application/json": { schema: resolver(ErrorResponse) },
158
+ },
159
+ },
160
+ },
161
+ }),
162
+ async (c) => {
163
+ const id = c.req.param("id");
164
+ const deleted = await db
165
+ .delete(sidecar)
166
+ .where(eq(sidecar.id, id))
167
+ .returning();
168
+
169
+ if (deleted.length === 0) {
170
+ return c.json(
171
+ { error: { code: "not_found", message: "Sidecar not found" } },
172
+ 404,
173
+ );
174
+ }
175
+
176
+ return c.body(null, 204);
177
+ },
178
+ );
179
+
180
+ app.post(
181
+ "/:id/heartbeat",
182
+ describeRoute({
183
+ tags: ["Sidecars"],
184
+ summary: "Record a sidecar heartbeat",
185
+ description:
186
+ "Updates the sidecar's last heartbeat timestamp and sets status to online.",
187
+ responses: {
188
+ 204: { description: "Heartbeat recorded" },
189
+ 404: {
190
+ description: "Sidecar not found",
191
+ content: {
192
+ "application/json": { schema: resolver(ErrorResponse) },
193
+ },
194
+ },
195
+ },
196
+ }),
197
+ async (c) => {
198
+ const id = c.req.param("id");
199
+ const updated = await db
200
+ .update(sidecar)
201
+ .set({
202
+ lastHeartbeat: new Date(),
203
+ status: "online",
204
+ updatedAt: new Date(),
205
+ })
206
+ .where(eq(sidecar.id, id))
207
+ .returning();
208
+
209
+ if (updated.length === 0) {
210
+ return c.json(
211
+ { error: { code: "not_found", message: "Sidecar not found" } },
212
+ 404,
213
+ );
214
+ }
215
+
216
+ return c.body(null, 204);
217
+ },
218
+ );
219
+
220
+ return app;
221
+ }