@antasphere/chassis-server 1.0.2 → 1.2.0

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 (189) hide show
  1. package/dist/api/apikeys.d.ts +4 -1
  2. package/dist/api/apikeys.d.ts.map +1 -1
  3. package/dist/api/apikeys.js +11 -1
  4. package/dist/api/apikeys.js.map +1 -1
  5. package/dist/api/cli-package.d.ts +58 -0
  6. package/dist/api/cli-package.d.ts.map +1 -0
  7. package/dist/api/cli-package.js +83 -0
  8. package/dist/api/cli-package.js.map +1 -0
  9. package/dist/api/create-api.d.ts +3 -0
  10. package/dist/api/create-api.d.ts.map +1 -1
  11. package/dist/api/create-api.js +33 -4
  12. package/dist/api/create-api.js.map +1 -1
  13. package/dist/api/index.d.ts +1 -0
  14. package/dist/api/index.d.ts.map +1 -1
  15. package/dist/api/index.js +1 -0
  16. package/dist/api/index.js.map +1 -1
  17. package/dist/api/invitations.d.ts.map +1 -1
  18. package/dist/api/invitations.js +6 -0
  19. package/dist/api/invitations.js.map +1 -1
  20. package/dist/api/members.d.ts +5 -2
  21. package/dist/api/members.d.ts.map +1 -1
  22. package/dist/api/members.js +1 -0
  23. package/dist/api/members.js.map +1 -1
  24. package/dist/api/projects.d.ts +3 -0
  25. package/dist/api/projects.d.ts.map +1 -1
  26. package/dist/api/projects.js +13 -1
  27. package/dist/api/projects.js.map +1 -1
  28. package/dist/api/webhooks.d.ts +24 -0
  29. package/dist/api/webhooks.d.ts.map +1 -0
  30. package/dist/api/webhooks.js +170 -0
  31. package/dist/api/webhooks.js.map +1 -0
  32. package/dist/boot.d.ts.map +1 -1
  33. package/dist/boot.js +47 -4
  34. package/dist/boot.js.map +1 -1
  35. package/dist/cli-package/index.d.ts +44 -0
  36. package/dist/cli-package/index.d.ts.map +1 -0
  37. package/dist/cli-package/index.js +60 -0
  38. package/dist/cli-package/index.js.map +1 -0
  39. package/dist/email/index.d.ts +1 -0
  40. package/dist/email/index.d.ts.map +1 -1
  41. package/dist/email/index.js +1 -0
  42. package/dist/email/index.js.map +1 -1
  43. package/dist/env.d.ts +11 -0
  44. package/dist/env.d.ts.map +1 -1
  45. package/dist/env.js +24 -0
  46. package/dist/env.js.map +1 -1
  47. package/dist/identity/demo-pass-rules.d.ts.map +1 -1
  48. package/dist/identity/demo-pass-rules.js +6 -1
  49. package/dist/identity/demo-pass-rules.js.map +1 -1
  50. package/dist/identity/hub-reconcile.d.ts +2 -2
  51. package/dist/identity/hub-reconcile.d.ts.map +1 -1
  52. package/dist/identity/hub-reconcile.js.map +1 -1
  53. package/dist/index.d.ts +2 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/jobs/index.d.ts +1 -1
  56. package/dist/jobs/index.d.ts.map +1 -1
  57. package/dist/jobs/index.js.map +1 -1
  58. package/dist/jobs/pgboss.d.ts +34 -0
  59. package/dist/jobs/pgboss.d.ts.map +1 -1
  60. package/dist/jobs/pgboss.js +32 -9
  61. package/dist/jobs/pgboss.js.map +1 -1
  62. package/dist/mcp/index.d.ts +1 -0
  63. package/dist/mcp/index.d.ts.map +1 -1
  64. package/dist/mcp/index.js +1 -0
  65. package/dist/mcp/index.js.map +1 -1
  66. package/dist/mcp/server.d.ts +1 -1
  67. package/dist/mcp/server.d.ts.map +1 -1
  68. package/dist/mcp/server.js +11 -2
  69. package/dist/mcp/server.js.map +1 -1
  70. package/dist/mcp/tool-kit.d.ts +2 -0
  71. package/dist/mcp/tool-kit.d.ts.map +1 -1
  72. package/dist/mcp/tool-kit.js +5 -1
  73. package/dist/mcp/tool-kit.js.map +1 -1
  74. package/dist/mcp/webhooks.d.ts +16 -0
  75. package/dist/mcp/webhooks.d.ts.map +1 -0
  76. package/dist/mcp/webhooks.js +226 -0
  77. package/dist/mcp/webhooks.js.map +1 -0
  78. package/dist/members/removal.d.ts +10 -2
  79. package/dist/members/removal.d.ts.map +1 -1
  80. package/dist/members/removal.js +3 -0
  81. package/dist/members/removal.js.map +1 -1
  82. package/dist/middleware/idempotency.d.ts.map +1 -1
  83. package/dist/middleware/idempotency.js +3 -1
  84. package/dist/middleware/idempotency.js.map +1 -1
  85. package/dist/middleware/scopes.d.ts +9 -1
  86. package/dist/middleware/scopes.d.ts.map +1 -1
  87. package/dist/middleware/scopes.js +8 -1
  88. package/dist/middleware/scopes.js.map +1 -1
  89. package/dist/secret-box.d.ts +10 -0
  90. package/dist/secret-box.d.ts.map +1 -0
  91. package/dist/secret-box.js +87 -0
  92. package/dist/secret-box.js.map +1 -0
  93. package/dist/testing/helpers.d.ts +8 -0
  94. package/dist/testing/helpers.d.ts.map +1 -1
  95. package/dist/testing/helpers.js.map +1 -1
  96. package/dist/tool-definition.d.ts +24 -0
  97. package/dist/tool-definition.d.ts.map +1 -1
  98. package/dist/tool-definition.js.map +1 -1
  99. package/dist/webhooks/catalog.d.ts +80 -0
  100. package/dist/webhooks/catalog.d.ts.map +1 -0
  101. package/dist/webhooks/catalog.js +134 -0
  102. package/dist/webhooks/catalog.js.map +1 -0
  103. package/dist/webhooks/index.d.ts +9 -0
  104. package/dist/webhooks/index.d.ts.map +1 -0
  105. package/dist/webhooks/index.js +9 -0
  106. package/dist/webhooks/index.js.map +1 -0
  107. package/dist/webhooks/jobs.d.ts +12 -0
  108. package/dist/webhooks/jobs.d.ts.map +1 -0
  109. package/dist/webhooks/jobs.js +77 -0
  110. package/dist/webhooks/jobs.js.map +1 -0
  111. package/dist/webhooks/mail.d.ts +24 -0
  112. package/dist/webhooks/mail.d.ts.map +1 -0
  113. package/dist/webhooks/mail.js +21 -0
  114. package/dist/webhooks/mail.js.map +1 -0
  115. package/dist/webhooks/match.d.ts +22 -0
  116. package/dist/webhooks/match.d.ts.map +1 -0
  117. package/dist/webhooks/match.js +40 -0
  118. package/dist/webhooks/match.js.map +1 -0
  119. package/dist/webhooks/safe-fetch.d.ts +94 -0
  120. package/dist/webhooks/safe-fetch.d.ts.map +1 -0
  121. package/dist/webhooks/safe-fetch.js +263 -0
  122. package/dist/webhooks/safe-fetch.js.map +1 -0
  123. package/dist/webhooks/service.d.ts +245 -0
  124. package/dist/webhooks/service.d.ts.map +1 -0
  125. package/dist/webhooks/service.js +861 -0
  126. package/dist/webhooks/service.js.map +1 -0
  127. package/dist/webhooks/signing.d.ts +40 -0
  128. package/dist/webhooks/signing.d.ts.map +1 -0
  129. package/dist/webhooks/signing.js +108 -0
  130. package/dist/webhooks/signing.js.map +1 -0
  131. package/dist/webhooks/tx-send.d.ts +31 -0
  132. package/dist/webhooks/tx-send.d.ts.map +1 -0
  133. package/dist/webhooks/tx-send.js +22 -0
  134. package/dist/webhooks/tx-send.js.map +1 -0
  135. package/package.json +11 -3
  136. package/src/api/apikeys.ts +12 -1
  137. package/src/api/cli-package.ts +151 -0
  138. package/src/api/create-api.ts +42 -4
  139. package/src/api/index.ts +1 -0
  140. package/src/api/invitations.ts +6 -0
  141. package/src/api/members.ts +6 -2
  142. package/src/api/projects.ts +16 -1
  143. package/src/api/webhooks.ts +205 -0
  144. package/src/boot.ts +48 -4
  145. package/src/cli-package/index.ts +88 -0
  146. package/src/email/index.ts +1 -0
  147. package/src/env.ts +25 -0
  148. package/src/identity/demo-pass-rules.ts +6 -1
  149. package/src/identity/hub-reconcile.ts +2 -2
  150. package/src/index.ts +13 -1
  151. package/src/jobs/index.ts +1 -0
  152. package/src/jobs/pgboss.ts +71 -9
  153. package/src/mcp/index.ts +1 -0
  154. package/src/mcp/server.ts +10 -2
  155. package/src/mcp/tool-kit.ts +7 -1
  156. package/src/mcp/webhooks.ts +370 -0
  157. package/src/members/removal.ts +21 -1
  158. package/src/middleware/idempotency.ts +3 -1
  159. package/src/middleware/scopes.ts +14 -0
  160. package/src/secret-box.ts +93 -0
  161. package/src/testing/helpers.ts +8 -0
  162. package/src/tool-definition.ts +29 -0
  163. package/src/webhooks/catalog.ts +205 -0
  164. package/src/webhooks/index.ts +58 -0
  165. package/src/webhooks/jobs.ts +92 -0
  166. package/src/webhooks/mail.ts +48 -0
  167. package/src/webhooks/match.ts +52 -0
  168. package/src/webhooks/safe-fetch.ts +335 -0
  169. package/src/webhooks/service.ts +1089 -0
  170. package/src/webhooks/signing.ts +132 -0
  171. package/src/webhooks/tx-send.ts +53 -0
  172. package/test/host/drizzle/0002_webhooks.sql +49 -0
  173. package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
  174. package/test/host/drizzle/meta/_journal.json +8 -1
  175. package/test/host/identity.ts +6 -1
  176. package/test/host/minimal-host.ts +2 -1
  177. package/test/host/minimal-tool.ts +32 -4
  178. package/test/integration/cli-package.test.ts +403 -0
  179. package/test/integration/mcp-webhooks.test.ts +360 -0
  180. package/test/integration/webhooks.test.ts +1684 -0
  181. package/test/unit/cli-package.test.ts +93 -0
  182. package/test/unit/secret-box.test.ts +124 -0
  183. package/test/unit/tx-send.test.ts +23 -0
  184. package/test/unit/webhook-catalog.test.ts +151 -0
  185. package/test/unit/webhook-env.test.ts +60 -0
  186. package/test/unit/webhook-jobs.test.ts +92 -0
  187. package/test/unit/webhook-match.test.ts +81 -0
  188. package/test/unit/webhook-safe-fetch.test.ts +330 -0
  189. package/test/unit/webhook-signing.test.ts +169 -0
@@ -7,7 +7,7 @@ import { registerOpenApiDoc } from './index.js';
7
7
  import { ulid } from 'ulid';
8
8
  import { and, asc, desc, eq } from 'drizzle-orm';
9
9
  import { ACTIVE_WORKSPACE_HEADER, type Principal } from '@antasphere/chassis-contract';
10
- import { instanceRoute, setupRoute } from '@antasphere/chassis-contract/routes';
10
+ import { cliPackageRoute, instanceRoute, setupRoute } from '@antasphere/chassis-contract/routes';
11
11
  import {
12
12
  account,
13
13
  instanceSettings,
@@ -76,11 +76,16 @@ import {
76
76
  type WorkspaceCloudDeps
77
77
  } from './index.js';
78
78
  import { registerSsoConnectRoutes } from './index.js';
79
+ import { registerCliPackageRoutes } from './index.js';
80
+ import { loadCliPackage } from '../cli-package/index.js';
79
81
  import { registerSsoLogoutRoutes } from './index.js';
80
82
  import { registerMemberRoutes } from './index.js';
81
83
  import { demoSessionJudge, demoSignInOn, registerDemoPassRoutes } from './index.js';
82
84
  import { registerProjectRoutes } from './index.js';
83
85
  import { registerTeamRoutes } from './index.js';
86
+ import { registerWebhookRoutes } from './webhooks.js';
87
+ import type { WebhookService } from '../webhooks/index.js';
88
+ import { bindMembershipRemoval } from '../members/removal.js';
84
89
  import { registerApiKeyRoutes } from './index.js';
85
90
  import { registerInvitationRoutes } from './index.js';
86
91
  import { registerAuditRoutes } from './index.js';
@@ -307,6 +312,8 @@ export interface ApiDeps<
307
312
  * the gate compares limits against their oss values and emits nothing.
308
313
  */
309
314
  entitlementCloud?: EntitlementCloud | undefined;
315
+ /** The outbound webhooks (PRDCT-3482): the endpoint routes and the catalog. */
316
+ webhooks: WebhookService;
310
317
  }
311
318
 
312
319
  /** Control-flow marker: the singleton claim lost (instance already set up). */
@@ -357,6 +364,13 @@ export function createApiApp<
357
364
  // Demo sign-in (the demo pass spec): DEMO_SIGN_IN on the self-hosted
358
365
  // edition only. Off, no demo route, wall or discovery key exists.
359
366
  const demoSignIn = demoSignInOn(env, hub, logger);
367
+ // The tool's own CLI package (PRDCT-3312), read once here. parseEnv has
368
+ // already refused a CLI_PACKAGE_DIR that does not hold the pair, so a
369
+ // throw from here (the folder emptied between the two reads) still
370
+ // refuses the boot, it is never a person's failed install. Served (and
371
+ // advertised) only where its route is registered: the cloud block below.
372
+ const cliPackage = loadCliPackage(env.CLI_PACKAGE_DIR);
373
+ const cliServed = deps.hubSso && deps.hubGrant ? cliPackage : null;
360
374
 
361
375
  const api = new OpenAPIHono({
362
376
  // Validation failures use the same wire shape as every other error.
@@ -573,6 +587,9 @@ export function createApiApp<
573
587
  // route itself: oss mounts zero hub surface.
574
588
  if (deps.hubSso) {
575
589
  api.use('/sso/cli-connect', rateLimit(limiters.cliConnect, clientIp));
590
+ // The CLI package download (cloud only, api/cli-package.ts) presents the
591
+ // same one-time hub token at the same cost: the same wall.
592
+ api.use('/cli/package', rateLimit(limiters.cliConnect, clientIp));
576
593
  }
577
594
 
578
595
  // ── JSON pre-validation in front of the Better Auth mount (AF-1 + the
@@ -832,6 +849,10 @@ export function createApiApp<
832
849
  // Present only on a cloud instance whose operator restricted the tool
833
850
  // (PRDCT-2947): organizations are opened by Antasphere, not created here.
834
851
  ...(hub && env.TOOL_RESTRICTED ? { restricted: true } : {}),
852
+ // Present only on a cloud instance whose release packed the tool's
853
+ // CLI (PRDCT-3312): what `POST /cli/package` serves, for the `team`
854
+ // command to install. An oss boot has no route, so no key either.
855
+ ...(cliServed ? { cli: { name: cliServed.name, version: cliServed.version } } : {}),
835
856
  // What this version declares for the billing rail (§7): the hub seeds
836
857
  // from it, staff read it, a client reads the limit it is held to.
837
858
  entitlements: {
@@ -1290,6 +1311,14 @@ export function createApiApp<
1290
1311
  cliKeyScopes: tool.scopes.cliKey,
1291
1312
  routes: { ssoCliConnectRoute }
1292
1313
  });
1314
+ // The tool's own CLI package (PRDCT-3312): cloud-only for the same
1315
+ // reason as /sso/cli-connect (it verifies the same hub exchange token
1316
+ // and burns it in the same ledger), so an oss boot leaves the path to
1317
+ // the JSON 404 terminator. Deliberately UNLISTED in the machine scope
1318
+ // allowlist and absent from PUBLIC_API_PATHS: the token rides in the
1319
+ // body, a request without a credential reaches the handler, and an API
1320
+ // key or an OAuth bearer meets 403 endpoint_not_allowed before it.
1321
+ registerCliPackageRoutes(api, { db, hubSso, cliPackage, logger, routes: { cliPackageRoute } });
1293
1322
  }
1294
1323
  // Single logout (SL-2): cloud-only like /sso/cli-connect — an oss boot
1295
1324
  // leaves POST /sso/logout to the JSON 404 terminator. Deliberately
@@ -1309,16 +1338,19 @@ export function createApiApp<
1309
1338
  }
1310
1339
  registerMemberRoutes(api, {
1311
1340
  db,
1341
+ events: chassisRegistry.events,
1312
1342
  auth,
1313
1343
  publicBaseUrl: env.PUBLIC_BASE_URL,
1314
1344
  accountDeletion: deps.accountDeletion,
1315
1345
  hubManaged,
1316
1346
  guestTargetMessage: tool.copy.guestTarget,
1317
- onMembershipRemoval: tool.membershipRemoval
1347
+ onMembershipRemoval: bindMembershipRemoval(tool.membershipRemoval, { webhooks: deps.webhooks })
1318
1348
  });
1319
1349
  // Teams: the tool's own on self-hosted (and in a cloud-local workspace), the
1320
1350
  // hub's projection in a hub-origin workspace, where the members' gate refuses the writes.
1321
1351
  registerTeamRoutes(api, { db, hubManaged });
1352
+ // Webhooks (PRDCT-3482): the tool's on both editions, never hub-managed (Romain's rule of 27 September 2026).
1353
+ registerWebhookRoutes(api, { service: deps.webhooks });
1322
1354
  if (demoSignIn) {
1323
1355
  registerDemoPassRoutes(api, {
1324
1356
  db,
@@ -1327,7 +1359,13 @@ export function createApiApp<
1327
1359
  guestTargetMessage: tool.copy.guestTarget
1328
1360
  });
1329
1361
  }
1330
- registerApiKeyRoutes(api, db, apiKeyService, { apiKeysListRoute, apiKeyCreateRoute, apiKeyRevokeRoute });
1362
+ registerApiKeyRoutes(
1363
+ api,
1364
+ db,
1365
+ apiKeyService,
1366
+ { apiKeysListRoute, apiKeyCreateRoute, apiKeyRevokeRoute },
1367
+ chassisRegistry.events
1368
+ );
1331
1369
  registerInvitationRoutes(api, {
1332
1370
  db,
1333
1371
  env,
@@ -1346,7 +1384,7 @@ export function createApiApp<
1346
1384
  // Projects: a subgroup of the workspace with its own members. Deliberately
1347
1385
  // handed NO `hubManaged`: project membership is the tool's own on both
1348
1386
  // editions, picked among the workspace's existing members.
1349
- registerProjectRoutes(api, { db });
1387
+ registerProjectRoutes(api, { db, webhooks: deps.webhooks });
1350
1388
  registerAuditRoutes(api, db);
1351
1389
  registerExportRoutes(api, {
1352
1390
  db,
package/src/api/index.ts CHANGED
@@ -2,6 +2,7 @@ export { registerApiKeyRoutes } from './apikeys.js';
2
2
  export { registerAuditRoutes } from './audit.js';
3
3
  export { registerBreakGlassRoutes } from './break-glass.js';
4
4
  export { registerCliAuthRoutes } from './cli-auth.js';
5
+ export { registerCliPackageRoutes, type CliPackageRouteDeps } from './cli-package.js';
5
6
  export { demoSessionJudge, demoSignInOn, registerDemoPassRoutes } from './demo-passes.js';
6
7
  export {
7
8
  registerExportRoutes,
@@ -380,6 +380,12 @@ export function registerInvitationRoutes(api: OpenAPIHono, deps: InvitationRoute
380
380
  invitationId: invitation.id,
381
381
  userId
382
382
  });
383
+ // The person is a member now (the webhooks' `member.joined` rides the bus).
384
+ registry.events.emit('member.joined', {
385
+ workspaceId: invitation.workspaceId,
386
+ userId,
387
+ role: invitation.role
388
+ });
383
389
  // Public endpoint: no principal in context, write the audit row directly.
384
390
  await audit.write({
385
391
  workspaceId: invitation.workspaceId,
@@ -20,12 +20,15 @@ import { hubManagedMembershipGate } from '../middleware/hub-managed.js';
20
20
  import {
21
21
  deleteMembershipGrants,
22
22
  mergeRemovalCounts,
23
- type MembershipRemovalHook,
23
+ type BoundMembershipRemoval,
24
24
  type RemovalCounts
25
25
  } from '../members/removal.js';
26
+ import type { EventBus } from '../platform/events.js';
26
27
 
27
28
  export interface MemberRouteDeps {
28
29
  db: Db;
30
+ /** The bus, for `member.removed` after a removal commits (the webhooks bridge it). */
31
+ events?: EventBus | undefined;
29
32
  auth: Auth;
30
33
  publicBaseUrl: string;
31
34
  accountDeletion: AccountDeletionService;
@@ -38,7 +41,7 @@ export interface MemberRouteDeps {
38
41
  /** The tool's `guest_target` sentence (`copy.guestTarget`). */
39
42
  guestTargetMessage: string;
40
43
  /** The tool's half of a removal (the `membershipRemoval` slot), run in the removal's transaction. */
41
- onMembershipRemoval?: MembershipRemovalHook | undefined;
44
+ onMembershipRemoval?: BoundMembershipRemoval | undefined;
42
45
  }
43
46
 
44
47
  const err = (code: string, message: string) => ({ error: { code, message } });
@@ -425,6 +428,7 @@ export function registerMemberRoutes(api: OpenAPIHono, deps: MemberRouteDeps): v
425
428
  .where(eq(workspaceMembers.id, target.id))
426
429
  .limit(1);
427
430
 
431
+ deps.events?.emit('member.removed', { workspaceId: principal.workspaceId, userId: target.userId });
428
432
  c.set('audit', {
429
433
  action: 'member.remove',
430
434
  resourceType: 'member',
@@ -1,4 +1,5 @@
1
1
  import type { OpenAPIHono } from '@hono/zod-openapi';
2
+ import type { WebhookEmitter } from '../webhooks/service.js';
2
3
  import type { Context } from 'hono';
3
4
  import { and, desc, eq, isNotNull, isNull, sql } from 'drizzle-orm';
4
5
  import { projectRoleAtLeast, type Principal, type ProjectRole } from '@antasphere/chassis-contract';
@@ -62,6 +63,8 @@ import {
62
63
  */
63
64
  export interface ProjectRouteDeps {
64
65
  db: Db;
66
+ /** The outbound webhooks' emitter (PRDCT-3482): `project.created` inside the create's transaction, `project.archived` after the archive. */
67
+ webhooks: WebhookEmitter;
65
68
  }
66
69
 
67
70
  const err = (code: string, message: string) => ({ error: { code, message } });
@@ -210,7 +213,7 @@ interface MemberUnionRow {
210
213
  }
211
214
 
212
215
  export function registerProjectRoutes(api: OpenAPIHono, deps: ProjectRouteDeps): void {
213
- const { db } = deps;
216
+ const { db, webhooks } = deps;
214
217
 
215
218
  // Projects are a workspace-level surface: a guest membership exists for
216
219
  // principal resolution only (D2) and is refused the whole subtree, flat, on
@@ -362,6 +365,12 @@ export function registerProjectRoutes(api: OpenAPIHono, deps: ProjectRouteDeps):
362
365
  role: 'manager',
363
366
  addedBy: principal.userId
364
367
  });
368
+ await webhooks.emit(tx, {
369
+ workspaceId: principal.workspaceId,
370
+ type: 'project.created',
371
+ subjects: { project: project!.id },
372
+ data: { projectId: project!.id, name: body.name, createdBy: principal.userId }
373
+ });
365
374
  return readProject(tx, principal, project!.id);
366
375
  });
367
376
  // The principal resolved from an active membership a moment ago; losing
@@ -435,6 +444,12 @@ export function registerProjectRoutes(api: OpenAPIHono, deps: ProjectRouteDeps):
435
444
  .returning({ id: projects.id });
436
445
  if (updated.length === 0) return c.json(archived(), 409);
437
446
  const project = (await readProject(db, principal, id))!;
447
+ await webhooks.emit(db, {
448
+ workspaceId: principal.workspaceId,
449
+ type: 'project.archived',
450
+ subjects: { project: id },
451
+ data: { projectId: id, name: project.name }
452
+ });
438
453
  c.set('audit', { action: 'project.archive', resourceType: 'project', resourceId: id });
439
454
  return c.json(toWire(project), 200);
440
455
  });
@@ -0,0 +1,205 @@
1
+ import type { OpenAPIHono } from '@hono/zod-openapi';
2
+ import type { Principal } from '@antasphere/chassis-contract';
3
+ import {
4
+ webhookCatalogRoute,
5
+ webhookCreateRoute,
6
+ webhookDeleteRoute,
7
+ webhookDeliveriesListRoute,
8
+ webhookDeliveryGetRoute,
9
+ webhookGetRoute,
10
+ webhookRedeliverRoute,
11
+ webhookRotateSecretRoute,
12
+ webhooksListRoute,
13
+ webhookTestRoute,
14
+ webhookUpdateRoute
15
+ } from '@antasphere/chassis-contract/routes';
16
+ import { requireAuth, requireNonGuest } from '../middleware/auth-context.js';
17
+ import { catalogToWire, WebhookRefusedError, type WebhookService } from '../webhooks/index.js';
18
+
19
+ /**
20
+ * The webhook routes (PRDCT-3482), the same on both editions and never
21
+ * hub-managed: a webhook is the tool's, on a self-hosted instance and on
22
+ * the cloud alike (Romain's rule of 27 September 2026).
23
+ *
24
+ * A webhook is a standing read of the workspace to a URL of the caller's
25
+ * choice, so the whole family, reads included, is an OWNER's or an ADMIN's
26
+ * act, checked inside each handler (never a path-pattern `requireRole`, the
27
+ * `/thing/:id` trap: the catalog lives under the same prefix and is every
28
+ * non-guest member's). A guest is refused flat; a session a demo link
29
+ * opened is refused every write (`DEMO_SESSION_REFUSED_API_ROUTES`); a
30
+ * machine principal needs the tool's dedicated scope (`middleware/scopes.ts`).
31
+ * An endpoint of another workspace reads as absent: 404, never 403.
32
+ *
33
+ * The secret is answered ONCE, at creation and at rotation, and never
34
+ * written to the audit row.
35
+ */
36
+ export interface WebhookRouteDeps {
37
+ service: WebhookService;
38
+ }
39
+
40
+ const err = (code: string, message: string) => ({ error: { code, message } });
41
+ const FORBIDDEN = err('forbidden', 'Only an owner or an admin manages webhooks');
42
+ const NOT_FOUND = err('not_found', 'Webhook not found');
43
+ const DELIVERY_NOT_FOUND = err('not_found', 'Delivery not found');
44
+
45
+ const canManage = (principal: Principal) => principal.role === 'owner' || principal.role === 'admin';
46
+
47
+ export function registerWebhookRoutes(api: OpenAPIHono, deps: WebhookRouteDeps): void {
48
+ const { service } = deps;
49
+
50
+ for (const path of ['/webhooks', '/webhooks/*']) {
51
+ api.use(path, requireAuth());
52
+ api.use(path, requireNonGuest());
53
+ }
54
+
55
+ // The catalog BEFORE the `{id}` routes: `events` is not an id. Every
56
+ // non-guest member reads it (an agent discovers the types with a read key).
57
+ api.openapi(webhookCatalogRoute, async (c) => c.json(catalogToWire(service.catalog), 200));
58
+
59
+ api.openapi(webhooksListRoute, async (c) => {
60
+ const principal = c.get('principal')!;
61
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
62
+ const { cursor, limit } = c.req.valid('query');
63
+ return c.json(await service.list(principal.workspaceId, cursor, limit), 200);
64
+ });
65
+
66
+ api.openapi(webhookCreateRoute, async (c) => {
67
+ const principal = c.get('principal')!;
68
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
69
+ const body = c.req.valid('json');
70
+ let created: Awaited<ReturnType<WebhookService['create']>>;
71
+ try {
72
+ created = await service.create(principal.workspaceId, principal.userId, body);
73
+ } catch (e) {
74
+ if (e instanceof WebhookRefusedError && e.code !== 'not_found')
75
+ return c.json(err(e.code, e.message), 400);
76
+ throw e;
77
+ }
78
+ c.set('audit', {
79
+ action: 'webhook.create',
80
+ resourceType: 'webhook',
81
+ resourceId: created.endpoint.id,
82
+ metadata: {
83
+ url: created.endpoint.url,
84
+ events: created.endpoint.events,
85
+ filters: created.endpoint.filters
86
+ }
87
+ });
88
+ return c.json({ ...created.endpoint, secret: created.secret }, 201);
89
+ });
90
+
91
+ api.openapi(webhookGetRoute, async (c) => {
92
+ const principal = c.get('principal')!;
93
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
94
+ const { id } = c.req.valid('param');
95
+ const endpoint = await service.get(principal.workspaceId, id);
96
+ return endpoint ? c.json(endpoint, 200) : c.json(NOT_FOUND, 404);
97
+ });
98
+
99
+ api.openapi(webhookUpdateRoute, async (c) => {
100
+ const principal = c.get('principal')!;
101
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
102
+ const { id } = c.req.valid('param');
103
+ const body = c.req.valid('json');
104
+ let updated: Awaited<ReturnType<WebhookService['update']>>;
105
+ try {
106
+ updated = await service.update(principal.workspaceId, id, body);
107
+ } catch (e) {
108
+ if (e instanceof WebhookRefusedError && e.code !== 'not_found')
109
+ return c.json(err(e.code, e.message), 400);
110
+ throw e;
111
+ }
112
+ if (!updated) return c.json(NOT_FOUND, 404);
113
+ c.set('audit', {
114
+ action: 'webhook.update',
115
+ resourceType: 'webhook',
116
+ resourceId: id,
117
+ metadata: { fields: Object.keys(body) }
118
+ });
119
+ return c.json(updated, 200);
120
+ });
121
+
122
+ api.openapi(webhookDeleteRoute, async (c) => {
123
+ const principal = c.get('principal')!;
124
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
125
+ const { id } = c.req.valid('param');
126
+ const removed = await service.remove(principal.workspaceId, id);
127
+ if (!removed) return c.json(NOT_FOUND, 404);
128
+ c.set('audit', {
129
+ action: 'webhook.delete',
130
+ resourceType: 'webhook',
131
+ resourceId: id,
132
+ metadata: { url: removed.url }
133
+ });
134
+ return c.json(removed, 200);
135
+ });
136
+
137
+ api.openapi(webhookRotateSecretRoute, async (c) => {
138
+ const principal = c.get('principal')!;
139
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
140
+ const { id } = c.req.valid('param');
141
+ const rotated = await service.rotateSecret(principal.workspaceId, id);
142
+ if (!rotated) return c.json(NOT_FOUND, 404);
143
+ c.set('audit', {
144
+ action: 'webhook.rotate_secret',
145
+ resourceType: 'webhook',
146
+ resourceId: id,
147
+ metadata: { previousSecretExpiresAt: rotated.previousSecretExpiresAt }
148
+ });
149
+ return c.json(
150
+ {
151
+ ...rotated.endpoint,
152
+ secret: rotated.secret,
153
+ previousSecretExpiresAt: rotated.previousSecretExpiresAt
154
+ },
155
+ 200
156
+ );
157
+ });
158
+
159
+ api.openapi(webhookTestRoute, async (c) => {
160
+ const principal = c.get('principal')!;
161
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
162
+ const { id } = c.req.valid('param');
163
+ const delivery = await service.test(principal.workspaceId, id, principal.userId);
164
+ if (!delivery) return c.json(NOT_FOUND, 404);
165
+ c.set('audit', {
166
+ action: 'webhook.test',
167
+ resourceType: 'webhook',
168
+ resourceId: id,
169
+ metadata: { deliveryId: delivery.id, status: delivery.status, lastStatusCode: delivery.lastStatusCode }
170
+ });
171
+ return c.json({ delivery }, 200);
172
+ });
173
+
174
+ api.openapi(webhookDeliveriesListRoute, async (c) => {
175
+ const principal = c.get('principal')!;
176
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
177
+ const { id } = c.req.valid('param');
178
+ const { cursor, limit, status } = c.req.valid('query');
179
+ const page = await service.listDeliveries(principal.workspaceId, id, { cursor, limit, status });
180
+ return page ? c.json(page, 200) : c.json(NOT_FOUND, 404);
181
+ });
182
+
183
+ api.openapi(webhookDeliveryGetRoute, async (c) => {
184
+ const principal = c.get('principal')!;
185
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
186
+ const { id, deliveryId } = c.req.valid('param');
187
+ const delivery = await service.getDelivery(principal.workspaceId, id, deliveryId);
188
+ return delivery ? c.json(delivery, 200) : c.json(DELIVERY_NOT_FOUND, 404);
189
+ });
190
+
191
+ api.openapi(webhookRedeliverRoute, async (c) => {
192
+ const principal = c.get('principal')!;
193
+ if (!canManage(principal)) return c.json(FORBIDDEN, 403);
194
+ const { id, deliveryId } = c.req.valid('param');
195
+ const delivery = await service.redeliver(principal.workspaceId, id, deliveryId);
196
+ if (!delivery) return c.json(DELIVERY_NOT_FOUND, 404);
197
+ c.set('audit', {
198
+ action: 'webhook.redeliver',
199
+ resourceType: 'webhook',
200
+ resourceId: id,
201
+ metadata: { deliveryId: delivery.id, eventId: delivery.eventId, eventType: delivery.eventType }
202
+ });
203
+ return c.json(delivery, 201);
204
+ });
205
+ }
package/src/boot.ts CHANGED
@@ -52,6 +52,8 @@ import { createOtel } from './observability/index.js';
52
52
  import { bindEditionSeams } from './platform/index.js';
53
53
  import { AllowAllEntitlements } from './platform/index.js';
54
54
  import { EventBus } from './platform/index.js';
55
+ import { buildWebhookCatalog, WebhookService, webhookJobs } from './webhooks/index.js';
56
+ import { bindMembershipRemoval } from './members/removal.js';
55
57
  import { LocalIdentityProvider } from './platform/index.js';
56
58
  import { createRegistry } from './platform/index.js';
57
59
  import { NoopUsageSink } from './platform/index.js';
@@ -540,6 +542,26 @@ export async function bootPlatform<
540
542
  }
541
543
  }
542
544
 
545
+ // Outbound webhooks (PRDCT-3482): the catalog checked, the service built
546
+ // BEFORE the job runtime (its four queues are declared to it like a tool's
547
+ // own) and attached to it right after; a tool's `services` slot receives
548
+ // the emitter, the API app the service, the bus the chassis's own events.
549
+ const webhookCatalog = buildWebhookCatalog(tool.webhooks);
550
+ const webhooks = new WebhookService({
551
+ db: db.db,
552
+ logger,
553
+ authSecret,
554
+ identity: tool.identity,
555
+ version: tool.runtime.version,
556
+ policy: { allowPrivateNetworks: env.WEBHOOKS_ALLOW_PRIVATE_NETWORKS, httpsOnly: env.EDITION === 'cloud' },
557
+ email,
558
+ brand: { name: tool.identity.displayName, tagline: tool.copy.mail.tagline },
559
+ publicBaseUrl: env.PUBLIC_BASE_URL,
560
+ catalog: webhookCatalog,
561
+ dials: overrides.webhookDials,
562
+ fetchOptions: overrides.webhookFetch
563
+ });
564
+
543
565
  // The live user-scoped federation stack (cloud only, internal/federation.md):
544
566
  // each user's OWN hub grant (offline_access refresh token on the account
545
567
  // row) → as-the-user `GET /orgs` reads → the org reconciler that the
@@ -611,7 +633,7 @@ export async function bootPlatform<
611
633
  audit,
612
634
  logger,
613
635
  dials: hubDials,
614
- onMembershipRemoval: tool.membershipRemoval,
636
+ onMembershipRemoval: bindMembershipRemoval(tool.membershipRemoval, { webhooks }),
615
637
  ...(overrides.hubNow ? { now: overrides.hubNow } : {})
616
638
  })
617
639
  : undefined;
@@ -662,9 +684,14 @@ export async function bootPlatform<
662
684
  overrides.usageDownstream ?? hubUsagePoster ?? new NoopUsageSink(),
663
685
  auth,
664
686
  audit,
665
- tool.jobs?.({ env, db: db.db, logger, getTool: () => domainRef.current }) ?? [],
687
+ [
688
+ ...webhookJobs(webhooks, logger, env.WEBHOOK_DELIVERY_RETENTION_DAYS),
689
+ ...(tool.jobs?.({ env, db: db.db, logger, getTool: () => domainRef.current }) ?? [])
690
+ ],
666
691
  usageRetry
667
692
  );
693
+ webhooks.attach(jobs.boss);
694
+ webhooks.bindBus(events.chassisView());
668
695
  // The tool's billing-rail declarations (slot 22), asserted once here: a
669
696
  // route that meters an action the price book does not know, or a limit no
670
697
  // tier values, stops the boot naming the route.
@@ -806,6 +833,7 @@ export async function bootPlatform<
806
833
  events,
807
834
  authSecret,
808
835
  timers: jobs.timers,
836
+ webhooks,
809
837
  overrides: overrides.tool
810
838
  };
811
839
  const domain = tool.services(serviceCore);
@@ -849,6 +877,7 @@ export async function bootPlatform<
849
877
  principalGate: seams.principalGate,
850
878
  entitlements,
851
879
  entitlementCloud,
880
+ webhooks,
852
881
  // Cloud only (PRDCT-2443): POST /workspaces creates the organization at
853
882
  // the hub AS THE CALLER, then forces their reconcile. undefined on oss —
854
883
  // the route creates locally there.
@@ -885,7 +914,8 @@ export async function bootPlatform<
885
914
  ...(hubGrant?.promMetrics ?? []),
886
915
  ...(hubUsagePoster?.promMetrics ?? []),
887
916
  ...(hubCreditCheck?.promMetrics ?? []),
888
- ...jobs.promMetrics
917
+ ...jobs.promMetrics,
918
+ ...webhooks.promMetrics
889
919
  ]) {
890
920
  metrics.registry.registerMetric(metric);
891
921
  }
@@ -952,5 +982,19 @@ export async function bootPlatform<
952
982
  state.reason = readinessRefusal;
953
983
  }
954
984
 
955
- return { app, env, logger, state, db, auth, registry, jobs, email, otel, authSecret, tool: domain };
985
+ return {
986
+ app,
987
+ env,
988
+ logger,
989
+ state,
990
+ db,
991
+ auth,
992
+ registry,
993
+ jobs,
994
+ email,
995
+ otel,
996
+ authSecret,
997
+ webhooks,
998
+ tool: domain
999
+ };
956
1000
  }
@@ -0,0 +1,88 @@
1
+ import { readFileSync, statSync, type Stats } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The tool's own CLI package, as the release packed it (PRDCT-3312): the
6
+ * folder `CLI_PACKAGE_DIR` names holds `package.tgz` (the output of
7
+ * `pnpm pack` of the tool's `packages/cli`) beside a copy of that package's
8
+ * `package.json`. The instance reads the pair ONCE at boot and serves the
9
+ * tarball on `POST /cli/package` (api/cli-package.ts, cloud only) to a member
10
+ * the hub let into the tool; discovery advertises `cli { name, version }` so
11
+ * the `team` command knows what it installs and whether to update.
12
+ *
13
+ * A folder that is named but does not hold the pair, or whose `package.json`
14
+ * carries no string `name` and `version`, refuses the boot (env.ts): a
15
+ * misconfiguration is proven at the image, never discovered by a person's
16
+ * install. Pure: nothing runs at import; the reads are synchronous and happen
17
+ * when the boot asks.
18
+ */
19
+ export interface CliPackage {
20
+ /** The package's npm name, from its `package.json` (e.g. `@scope/tool`). */
21
+ name: string;
22
+ /** The package's version, from its `package.json`. */
23
+ version: string;
24
+ /** The tarball's path (the configured folder joined with `package.tgz`), what the route streams. */
25
+ path: string;
26
+ /** The tarball's size when the boot read it (the route stats the file again when it serves). */
27
+ sizeBytes: number;
28
+ /** npm's own pack name for the pair, the attachment's filename. */
29
+ filename: string;
30
+ }
31
+
32
+ /** The tarball's name inside the folder, whatever the package is called. */
33
+ export const CLI_PACKAGE_TARBALL = 'package.tgz';
34
+ /** The manifest copy beside it. */
35
+ export const CLI_PACKAGE_MANIFEST = 'package.json';
36
+
37
+ /**
38
+ * npm's own pack filename: the scope's `@` dropped and its `/` turned into
39
+ * `-` (`@scope/tool` + `1.2.3` → `scope-tool-1.2.3.tgz`); an unscoped name is
40
+ * kept as it is (`tool` → `tool-1.2.3.tgz`).
41
+ */
42
+ export function packFilename(name: string, version: string): string {
43
+ const base = name.startsWith('@') ? name.slice(1).replace('/', '-') : name;
44
+ return `${base}-${version}.tgz`;
45
+ }
46
+
47
+ /**
48
+ * Read the folder `CLI_PACKAGE_DIR` names. `undefined` (the variable unset)
49
+ * answers null: the instance serves no CLI. A named folder answers the
50
+ * package, or THROWS an Error naming the folder and the missing piece.
51
+ */
52
+ export function loadCliPackage(dir: string | undefined): CliPackage | null {
53
+ if (dir === undefined) return null;
54
+ const refuse = (what: string): never => {
55
+ throw new Error(`CLI_PACKAGE_DIR=${dir}: ${what}`);
56
+ };
57
+
58
+ const tarball = join(dir, CLI_PACKAGE_TARBALL);
59
+ let stat: Stats;
60
+ try {
61
+ stat = statSync(tarball);
62
+ } catch {
63
+ return refuse(`no ${CLI_PACKAGE_TARBALL} in the folder (the output of \`pnpm pack\` of the CLI package)`);
64
+ }
65
+ if (!stat.isFile()) return refuse(`${CLI_PACKAGE_TARBALL} is not a file`);
66
+
67
+ let raw: string;
68
+ try {
69
+ raw = readFileSync(join(dir, CLI_PACKAGE_MANIFEST), 'utf8');
70
+ } catch {
71
+ return refuse(`no ${CLI_PACKAGE_MANIFEST} in the folder (a copy of the CLI package's manifest)`);
72
+ }
73
+ let manifest: unknown;
74
+ try {
75
+ manifest = JSON.parse(raw);
76
+ } catch {
77
+ return refuse(`${CLI_PACKAGE_MANIFEST} is not valid JSON`);
78
+ }
79
+ const { name, version } = (manifest ?? {}) as { name?: unknown; version?: unknown };
80
+ if (typeof name !== 'string' || name.length === 0) {
81
+ return refuse(`${CLI_PACKAGE_MANIFEST} carries no string "name"`);
82
+ }
83
+ if (typeof version !== 'string' || version.length === 0) {
84
+ return refuse(`${CLI_PACKAGE_MANIFEST} carries no string "version"`);
85
+ }
86
+
87
+ return { name, version, path: tarball, sizeBytes: stat.size, filename: packFilename(name, version) };
88
+ }
@@ -24,3 +24,4 @@ export {
24
24
  buildPasswordResetEmail,
25
25
  buildVerifyEmailEmail
26
26
  } from './templates.js';
27
+ export { buildWebhookDisabledEmail, type WebhookDisabledEmailParams } from '../webhooks/mail.js';