@antasphere/chassis-server 1.1.0 → 1.3.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 (187) 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/create-api.d.ts +3 -0
  6. package/dist/api/create-api.d.ts.map +1 -1
  7. package/dist/api/create-api.js +8 -3
  8. package/dist/api/create-api.js.map +1 -1
  9. package/dist/api/invitations.d.ts.map +1 -1
  10. package/dist/api/invitations.js +6 -0
  11. package/dist/api/invitations.js.map +1 -1
  12. package/dist/api/members.d.ts +5 -2
  13. package/dist/api/members.d.ts.map +1 -1
  14. package/dist/api/members.js +1 -0
  15. package/dist/api/members.js.map +1 -1
  16. package/dist/api/projects.d.ts +3 -0
  17. package/dist/api/projects.d.ts.map +1 -1
  18. package/dist/api/projects.js +13 -1
  19. package/dist/api/projects.js.map +1 -1
  20. package/dist/api/webhooks.d.ts +24 -0
  21. package/dist/api/webhooks.d.ts.map +1 -0
  22. package/dist/api/webhooks.js +170 -0
  23. package/dist/api/webhooks.js.map +1 -0
  24. package/dist/app.d.ts.map +1 -1
  25. package/dist/app.js +11 -5
  26. package/dist/app.js.map +1 -1
  27. package/dist/boot.d.ts.map +1 -1
  28. package/dist/boot.js +47 -4
  29. package/dist/boot.js.map +1 -1
  30. package/dist/email/index.d.ts +1 -0
  31. package/dist/email/index.d.ts.map +1 -1
  32. package/dist/email/index.js +1 -0
  33. package/dist/email/index.js.map +1 -1
  34. package/dist/env.d.ts +9 -0
  35. package/dist/env.d.ts.map +1 -1
  36. package/dist/env.js +11 -0
  37. package/dist/env.js.map +1 -1
  38. package/dist/identity/demo-pass-rules.d.ts.map +1 -1
  39. package/dist/identity/demo-pass-rules.js +6 -1
  40. package/dist/identity/demo-pass-rules.js.map +1 -1
  41. package/dist/identity/hub-reconcile.d.ts +2 -2
  42. package/dist/identity/hub-reconcile.d.ts.map +1 -1
  43. package/dist/identity/hub-reconcile.js.map +1 -1
  44. package/dist/index.d.ts +2 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/jobs/index.d.ts +1 -1
  47. package/dist/jobs/index.d.ts.map +1 -1
  48. package/dist/jobs/index.js.map +1 -1
  49. package/dist/jobs/pgboss.d.ts +34 -0
  50. package/dist/jobs/pgboss.d.ts.map +1 -1
  51. package/dist/jobs/pgboss.js +32 -9
  52. package/dist/jobs/pgboss.js.map +1 -1
  53. package/dist/mcp/index.d.ts +1 -0
  54. package/dist/mcp/index.d.ts.map +1 -1
  55. package/dist/mcp/index.js +1 -0
  56. package/dist/mcp/index.js.map +1 -1
  57. package/dist/mcp/server.d.ts +1 -1
  58. package/dist/mcp/server.d.ts.map +1 -1
  59. package/dist/mcp/server.js +11 -2
  60. package/dist/mcp/server.js.map +1 -1
  61. package/dist/mcp/tool-kit.d.ts +2 -0
  62. package/dist/mcp/tool-kit.d.ts.map +1 -1
  63. package/dist/mcp/tool-kit.js +5 -1
  64. package/dist/mcp/tool-kit.js.map +1 -1
  65. package/dist/mcp/webhooks.d.ts +16 -0
  66. package/dist/mcp/webhooks.d.ts.map +1 -0
  67. package/dist/mcp/webhooks.js +226 -0
  68. package/dist/mcp/webhooks.js.map +1 -0
  69. package/dist/members/removal.d.ts +10 -2
  70. package/dist/members/removal.d.ts.map +1 -1
  71. package/dist/members/removal.js +3 -0
  72. package/dist/members/removal.js.map +1 -1
  73. package/dist/middleware/idempotency.d.ts.map +1 -1
  74. package/dist/middleware/idempotency.js +3 -1
  75. package/dist/middleware/idempotency.js.map +1 -1
  76. package/dist/middleware/index.d.ts +1 -0
  77. package/dist/middleware/index.d.ts.map +1 -1
  78. package/dist/middleware/index.js +1 -0
  79. package/dist/middleware/index.js.map +1 -1
  80. package/dist/middleware/scopes.d.ts +9 -1
  81. package/dist/middleware/scopes.d.ts.map +1 -1
  82. package/dist/middleware/scopes.js +8 -1
  83. package/dist/middleware/scopes.js.map +1 -1
  84. package/dist/middleware/static-assets.d.ts +63 -0
  85. package/dist/middleware/static-assets.d.ts.map +1 -0
  86. package/dist/middleware/static-assets.js +125 -0
  87. package/dist/middleware/static-assets.js.map +1 -0
  88. package/dist/secret-box.d.ts +10 -0
  89. package/dist/secret-box.d.ts.map +1 -0
  90. package/dist/secret-box.js +87 -0
  91. package/dist/secret-box.js.map +1 -0
  92. package/dist/testing/helpers.d.ts +8 -0
  93. package/dist/testing/helpers.d.ts.map +1 -1
  94. package/dist/testing/helpers.js.map +1 -1
  95. package/dist/tool-definition.d.ts +24 -0
  96. package/dist/tool-definition.d.ts.map +1 -1
  97. package/dist/tool-definition.js.map +1 -1
  98. package/dist/webhooks/catalog.d.ts +80 -0
  99. package/dist/webhooks/catalog.d.ts.map +1 -0
  100. package/dist/webhooks/catalog.js +134 -0
  101. package/dist/webhooks/catalog.js.map +1 -0
  102. package/dist/webhooks/index.d.ts +9 -0
  103. package/dist/webhooks/index.d.ts.map +1 -0
  104. package/dist/webhooks/index.js +9 -0
  105. package/dist/webhooks/index.js.map +1 -0
  106. package/dist/webhooks/jobs.d.ts +12 -0
  107. package/dist/webhooks/jobs.d.ts.map +1 -0
  108. package/dist/webhooks/jobs.js +77 -0
  109. package/dist/webhooks/jobs.js.map +1 -0
  110. package/dist/webhooks/mail.d.ts +24 -0
  111. package/dist/webhooks/mail.d.ts.map +1 -0
  112. package/dist/webhooks/mail.js +21 -0
  113. package/dist/webhooks/mail.js.map +1 -0
  114. package/dist/webhooks/match.d.ts +22 -0
  115. package/dist/webhooks/match.d.ts.map +1 -0
  116. package/dist/webhooks/match.js +40 -0
  117. package/dist/webhooks/match.js.map +1 -0
  118. package/dist/webhooks/safe-fetch.d.ts +94 -0
  119. package/dist/webhooks/safe-fetch.d.ts.map +1 -0
  120. package/dist/webhooks/safe-fetch.js +263 -0
  121. package/dist/webhooks/safe-fetch.js.map +1 -0
  122. package/dist/webhooks/service.d.ts +245 -0
  123. package/dist/webhooks/service.d.ts.map +1 -0
  124. package/dist/webhooks/service.js +861 -0
  125. package/dist/webhooks/service.js.map +1 -0
  126. package/dist/webhooks/signing.d.ts +40 -0
  127. package/dist/webhooks/signing.d.ts.map +1 -0
  128. package/dist/webhooks/signing.js +108 -0
  129. package/dist/webhooks/signing.js.map +1 -0
  130. package/dist/webhooks/tx-send.d.ts +31 -0
  131. package/dist/webhooks/tx-send.d.ts.map +1 -0
  132. package/dist/webhooks/tx-send.js +22 -0
  133. package/dist/webhooks/tx-send.js.map +1 -0
  134. package/package.json +7 -3
  135. package/src/api/apikeys.ts +12 -1
  136. package/src/api/create-api.ts +17 -3
  137. package/src/api/invitations.ts +6 -0
  138. package/src/api/members.ts +6 -2
  139. package/src/api/projects.ts +16 -1
  140. package/src/api/webhooks.ts +205 -0
  141. package/src/app.ts +17 -4
  142. package/src/boot.ts +48 -4
  143. package/src/email/index.ts +1 -0
  144. package/src/env.ts +11 -0
  145. package/src/identity/demo-pass-rules.ts +6 -1
  146. package/src/identity/hub-reconcile.ts +2 -2
  147. package/src/index.ts +13 -1
  148. package/src/jobs/index.ts +1 -0
  149. package/src/jobs/pgboss.ts +71 -9
  150. package/src/mcp/index.ts +1 -0
  151. package/src/mcp/server.ts +10 -2
  152. package/src/mcp/tool-kit.ts +7 -1
  153. package/src/mcp/webhooks.ts +370 -0
  154. package/src/members/removal.ts +21 -1
  155. package/src/middleware/idempotency.ts +3 -1
  156. package/src/middleware/index.ts +11 -0
  157. package/src/middleware/scopes.ts +14 -0
  158. package/src/middleware/static-assets.ts +144 -0
  159. package/src/secret-box.ts +93 -0
  160. package/src/testing/helpers.ts +8 -0
  161. package/src/tool-definition.ts +29 -0
  162. package/src/webhooks/catalog.ts +205 -0
  163. package/src/webhooks/index.ts +58 -0
  164. package/src/webhooks/jobs.ts +92 -0
  165. package/src/webhooks/mail.ts +48 -0
  166. package/src/webhooks/match.ts +52 -0
  167. package/src/webhooks/safe-fetch.ts +335 -0
  168. package/src/webhooks/service.ts +1089 -0
  169. package/src/webhooks/signing.ts +132 -0
  170. package/src/webhooks/tx-send.ts +53 -0
  171. package/test/host/drizzle/0002_webhooks.sql +49 -0
  172. package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
  173. package/test/host/drizzle/meta/_journal.json +8 -1
  174. package/test/host/identity.ts +6 -1
  175. package/test/host/minimal-host.ts +2 -1
  176. package/test/host/minimal-tool.ts +32 -4
  177. package/test/integration/mcp-webhooks.test.ts +360 -0
  178. package/test/integration/webhooks.test.ts +1684 -0
  179. package/test/unit/secret-box.test.ts +124 -0
  180. package/test/unit/static-assets.test.ts +227 -0
  181. package/test/unit/tx-send.test.ts +23 -0
  182. package/test/unit/webhook-catalog.test.ts +151 -0
  183. package/test/unit/webhook-env.test.ts +60 -0
  184. package/test/unit/webhook-jobs.test.ts +92 -0
  185. package/test/unit/webhook-match.test.ts +81 -0
  186. package/test/unit/webhook-safe-fetch.test.ts +330 -0
  187. package/test/unit/webhook-signing.test.ts +169 -0
@@ -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/app.ts CHANGED
@@ -2,13 +2,19 @@ import { Hono } from 'hono';
2
2
  import type { MiddlewareHandler } from 'hono';
3
3
  import { HTTPException } from 'hono/http-exception';
4
4
  import type { OpenAPIHono } from '@hono/zod-openapi';
5
- import { serveStatic } from '@hono/node-server/serve-static';
6
5
  import { readFile } from 'node:fs/promises';
7
6
  import { join } from 'node:path';
8
7
  import type { Logger } from './logger.js';
9
8
  import type { RuntimeState } from './util/index.js';
10
9
  import { requestId } from './middleware/index.js';
11
- import { buildCsp, inlineScriptHashes, securityHeaders } from './middleware/index.js';
10
+ import {
11
+ REVALIDATE_CACHE_CONTROL,
12
+ buildCsp,
13
+ inlineScriptHashes,
14
+ loadSpaFallback,
15
+ securityHeaders,
16
+ staticAssets
17
+ } from './middleware/index.js';
12
18
  import { healthRoutes } from './routes/index.js';
13
19
 
14
20
  export interface AppDeps {
@@ -214,10 +220,17 @@ export async function createApp({
214
220
  // static assets and the SPA fallback, after every credentialed surface.
215
221
  if (publicRoutes) app.route('/', publicRoutes);
216
222
 
217
- app.use('*', serveStatic({ root: publicDir }));
223
+ // The dashboard build: hashed assets cached for a year, everything else
224
+ // revalidated, precompressed siblings served when accepted (PRDCT-3584).
225
+ app.use('*', staticAssets({ publicDir }));
218
226
 
227
+ // The SPA fallback is the application's HTML under any route: kept by the
228
+ // browser but revalidated on every use (a strong ETag, a 304 when it still
229
+ // holds), precompressed when the build wrote the sibling.
230
+ const fallback = indexHtml ? await loadSpaFallback(publicDir, indexHtml) : null;
219
231
  app.get('*', (c) => {
220
- if (indexHtml) return c.html(indexHtml);
232
+ if (fallback) return fallback.respond(c);
233
+ c.header('Cache-Control', REVALIDATE_CACHE_CONTROL);
221
234
  return c.html(
222
235
  `<!doctype html><title>${displayName}</title><h1>${displayName} API is running</h1>` +
223
236
  '<p>No dashboard build found. API health: <a href="/healthz">/healthz</a></p>'
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
  }
@@ -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';
package/src/env.ts CHANGED
@@ -266,6 +266,10 @@ const chassisEnvShape = (version: string) => ({
266
266
  METRICS_TOKEN: optionalString(z.string().min(8)),
267
267
  /** Days of audit_log to keep (nightly purge at 03:00). 0 = keep forever. */
268
268
  AUDIT_RETENTION_DAYS: numeric(z.coerce.number().int().min(0).default(365)),
269
+ /** Days of webhook deliveries (the per-endpoint log) to keep (nightly purge at 03:00). 0 = keep forever. */
270
+ WEBHOOK_DELIVERY_RETENTION_DAYS: numeric(z.coerce.number().int().min(0).default(30)),
271
+ /** Let webhook endpoints target private, loopback and link-local addresses (a self-hoster's own n8n on the LAN). Default false: a webhook never reaches the instance's own network. Refused on EDITION=cloud. */
272
+ WEBHOOKS_ALLOW_PRIVATE_NETWORKS: booleanish.default(false),
269
273
  /** Grace period for orphaned users (accounts with ZERO workspace memberships, e.g. setup-race losers): the nightly 03:00 sweep deletes them once older than this many hours. A user with ANY membership row — even deactivated — is never touched. 0 = sweep disabled. */
270
274
  ORPHAN_USER_RETENTION_HOURS: numeric(z.coerce.number().int().min(0).default(72)),
271
275
  /** pino level. */
@@ -372,6 +376,13 @@ export function buildEnvSchema<TShape extends z.ZodRawShape = {}>(
372
376
  extension?.refine?.(env, ctx);
373
377
  demoSignInRefusal(env, ctx);
374
378
  if (env.EDITION !== 'cloud') return;
379
+ if (env.WEBHOOKS_ALLOW_PRIVATE_NETWORKS) {
380
+ ctx.addIssue({
381
+ code: 'custom',
382
+ path: ['WEBHOOKS_ALLOW_PRIVATE_NETWORKS'],
383
+ message: 'never on EDITION=cloud: a webhook of the cloud edition reaches public addresses only'
384
+ });
385
+ }
375
386
  for (const key of HUB_REQUIRED_VARS) {
376
387
  if (env[key] === undefined) {
377
388
  ctx.addIssue({
@@ -220,7 +220,12 @@ export const DEMO_SESSION_REFUSED_API_ROUTES: readonly DemoSessionRefusedApiRout
220
220
  { method: 'ALL', path: '/demo/passes', does: 'manage demo links' },
221
221
  { method: 'ALL', path: '/demo/passes/*', does: 'manage demo links' },
222
222
  { method: 'POST', path: '/workspaces', does: 'create a workspace' },
223
- { method: 'PUT', path: '/me/default-workspace', does: 'change your default workspace' }
223
+ { method: 'PUT', path: '/me/default-workspace', does: 'change your default workspace' },
224
+ // A webhook is something the workspace keeps, and its secret is a credential (PRDCT-3482).
225
+ { method: 'POST', path: '/webhooks', does: 'register a webhook' },
226
+ { method: 'PATCH', path: '/webhooks/*', does: 'change a webhook' },
227
+ { method: 'DELETE', path: '/webhooks/*', does: 'delete a webhook' },
228
+ { method: 'POST', path: '/webhooks/*', does: 'act on a webhook' }
224
229
  ];
225
230
 
226
231
  /** The refusal's sentence for one entry of `DEMO_SESSION_REFUSED_API_ROUTES`. */
@@ -3,7 +3,7 @@ import { and, eq, inArray, isNotNull, ne, notInArray, sql } from 'drizzle-orm';
3
3
  import { workspaceMembers, workspaces, type Db } from '@antasphere/chassis-db';
4
4
  import type { AuditService } from '../audit/service.js';
5
5
  import type { Logger } from '../logger.js';
6
- import { deleteMembershipGrants, type MembershipRemovalHook } from '../members/removal.js';
6
+ import { deleteMembershipGrants, type BoundMembershipRemoval } from '../members/removal.js';
7
7
  import { projectOrgMembership, projectOrgTeams, projectTeamSeats } from './hub-projection.js';
8
8
  import type { HubDeniedOrg, HubUserClient, LoginAccessToken } from './hub-user-client.js';
9
9
 
@@ -130,7 +130,7 @@ export interface HubOrgReconcilerDeps {
130
130
  logger: Logger;
131
131
  dials: HubFederationDials;
132
132
  /** The tool's half of a removal (the `membershipRemoval` slot), run in the sweep's transaction. */
133
- onMembershipRemoval?: MembershipRemovalHook | undefined;
133
+ onMembershipRemoval?: BoundMembershipRemoval | undefined;
134
134
  /** Test seam. */
135
135
  now?: () => number;
136
136
  }
package/src/index.ts CHANGED
@@ -62,4 +62,16 @@ export function createPlatform<
62
62
  ): Platform<TEnvShape, TDomain, TEvents, TToolOverrides> {
63
63
  return { boot: (source, overrides) => bootPlatform(tool, source, overrides) };
64
64
  }
65
- export type { MembershipRemovalHook, RemovedMembership, RemovalCounts } from './members/removal.js';
65
+ export type {
66
+ MembershipRemovalContext,
67
+ MembershipRemovalHook,
68
+ RemovedMembership,
69
+ RemovalCounts
70
+ } from './members/removal.js';
71
+ export type {
72
+ WebhookCatalogDeclaration,
73
+ WebhookEmitter,
74
+ WebhookEventDeclaration,
75
+ WebhookSubjectDeclaration,
76
+ WebhookSubjectValue
77
+ } from './webhooks/index.js';
package/src/jobs/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export {
2
2
  type JobDeclaration,
3
+ type QueueOptions,
3
4
  type Jobs,
4
5
  type UsageRetry,
5
6
  DEFAULT_USAGE_RETRY,
@@ -69,9 +69,55 @@ export const ORPHAN_USER_PURGE_QUEUE = 'orphan-user-purge';
69
69
  export interface JobDeclaration<TData extends object = object> {
70
70
  queue: string;
71
71
  schedule?: { cron: string; enabled?: boolean };
72
+ /**
73
+ * The queue's own settings (PRDCT-3482), applied at install on every
74
+ * worker boot: the retry budget of the jobs sent to it and its dead
75
+ * letter, the queue a job goes to when the budget runs out. The dead
76
+ * letter is named ON THE QUEUE, never on a send (pg-boss stamps it on
77
+ * each job row at insert, and an api-role replica sends with no DDL), so
78
+ * it must be declared BEFORE the queue that names it.
79
+ */
80
+ options?: QueueOptions;
81
+ /**
82
+ * How many pollers this replica runs on the queue (default 1). pg-boss's
83
+ * worker is a serial loop, one job at a time; a queue whose handler waits
84
+ * on the outside (a webhook delivery, up to its timeout) declares more, so
85
+ * one silent receiver never holds every other job of the instance behind
86
+ * it (verifier round 1, MJ1). Each poller fetches with SKIP LOCKED, so the
87
+ * pollers never share a job.
88
+ */
89
+ concurrency?: number;
90
+ /**
91
+ * How often each poller asks for work, in seconds (pg-boss's default is
92
+ * two). pg-boss's worker sleeps this long after EVERY loop, jobs found or
93
+ * not, so a queue that must drain a burst declares a short one (verifier
94
+ * round 2, MJ-R2-1).
95
+ */
96
+ pollingIntervalSeconds?: number;
72
97
  handler(data: TData | null, job: TimerJob): Promise<void>;
73
98
  }
74
99
 
100
+ /** A queue's settings, as a declaration names them. */
101
+ export interface QueueOptions {
102
+ retryLimit?: number;
103
+ retryDelaySeconds?: number;
104
+ retryBackoff?: boolean;
105
+ /** The queue a job moves to when its retries run out; declared earlier in the list. */
106
+ deadLetter?: string;
107
+ expireInSeconds?: number;
108
+ }
109
+
110
+ function pgBossQueueOptions(name: string, options: QueueOptions): PgBoss.Queue {
111
+ return {
112
+ name,
113
+ ...(options.retryLimit !== undefined ? { retryLimit: options.retryLimit } : {}),
114
+ ...(options.retryDelaySeconds !== undefined ? { retryDelay: options.retryDelaySeconds } : {}),
115
+ ...(options.retryBackoff !== undefined ? { retryBackoff: options.retryBackoff } : {}),
116
+ ...(options.deadLetter !== undefined ? { deadLetter: options.deadLetter } : {}),
117
+ ...(options.expireInSeconds !== undefined ? { expireInSeconds: options.expireInSeconds } : {})
118
+ };
119
+ }
120
+
75
121
  /**
76
122
  * Advisory-lock key serializing pg-boss's install DDL across replicas.
77
123
  *
@@ -219,6 +265,10 @@ export async function createJobs(
219
265
  // The tool's queues: same install section, same lock, declaration order.
220
266
  for (const job of toolJobs) {
221
267
  await boss.createQueue(job.queue);
268
+ // The settings are applied on every boot (`createQueue` leaves an
269
+ // existing queue as it is), so a changed budget reaches an installed
270
+ // instance at its next worker boot.
271
+ if (job.options) await boss.updateQueue(job.queue, pgBossQueueOptions(job.queue, job.options));
222
272
  if (!job.schedule) continue;
223
273
  if (job.schedule.enabled !== false) {
224
274
  await boss.schedule(job.queue, job.schedule.cron);
@@ -372,15 +422,27 @@ export async function createJobs(
372
422
  // default batch), its payload and its key handed to the handler: a timer
373
423
  // (`./timers.ts`) rides the same poller as a cron.
374
424
  for (const job of toolJobs) {
375
- await boss.work<object>(job.queue, { includeMetadata: true }, async (jobs) => {
376
- for (const fetched of jobs) {
377
- await job.handler(fetched.data ?? null, {
378
- id: fetched.id,
379
- queue: job.queue,
380
- key: fetched.singletonKey ?? null
381
- });
382
- }
383
- });
425
+ const pollers = Math.max(1, Math.floor(job.concurrency ?? 1));
426
+ for (let i = 0; i < pollers; i++) {
427
+ await boss.work<object>(
428
+ job.queue,
429
+ {
430
+ includeMetadata: true,
431
+ ...(job.pollingIntervalSeconds !== undefined
432
+ ? { pollingIntervalSeconds: job.pollingIntervalSeconds }
433
+ : {})
434
+ },
435
+ async (jobs) => {
436
+ for (const fetched of jobs) {
437
+ await job.handler(fetched.data ?? null, {
438
+ id: fetched.id,
439
+ queue: job.queue,
440
+ key: fetched.singletonKey ?? null
441
+ });
442
+ }
443
+ }
444
+ );
445
+ }
384
446
  }
385
447
 
386
448
  // Orphaned-user GC: setup-race losers, fail-closed SSO login strands
package/src/mcp/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  export { mcpRoutes } from './http.js';
11
11
  export { projectErrorHints, registerProjectTools } from './projects.js';
12
12
  export { registerTeamTools } from './teams.js';
13
+ export { registerWebhookTools, webhookErrorHints } from './webhooks.js';
13
14
  export { type McpInputs, mcpInputs } from './inputs.js';
14
15
  export {
15
16
  type McpIdentity,
package/src/mcp/server.ts CHANGED
@@ -4,6 +4,7 @@ import { mcpInputs } from './inputs.js';
4
4
  import { jsonText, mergeErrorHints, wrapToolErrors, type ErrorHints } from './errors.js';
5
5
  import { projectErrorHints, registerProjectTools } from './projects.js';
6
6
  import { registerTeamTools } from './teams.js';
7
+ import { registerWebhookTools, webhookErrorHints } from './webhooks.js';
7
8
  import {
8
9
  callApi,
9
10
  createScopeCheck,
@@ -57,13 +58,17 @@ export type McpIdentity = ToolIdentity['mcp'];
57
58
 
58
59
  /**
59
60
  * The ONE composition of an instance's hint table: the domain hints, then
60
- * every chassis group (the projects today), then the tool's own last, so a
61
+ * every chassis group (the projects, the webhooks), then the tool's own last, so a
61
62
  * tool may re-word a chassis code and never the reverse. A tool set that
62
63
  * wraps its own errors reads the same composition (never a copy), so a
63
64
  * chassis group added here reaches the tool's tools without a second edit.
64
65
  */
65
66
  export function composeErrorHints(toolHints: ErrorHints, toolPrefix: string): ErrorHints {
66
- return mergeErrorHints({ ...projectErrorHints(toolPrefix), ...toolHints });
67
+ return mergeErrorHints({
68
+ ...projectErrorHints(toolPrefix),
69
+ ...webhookErrorHints(toolPrefix),
70
+ ...toolHints
71
+ });
67
72
  }
68
73
 
69
74
  export function buildMcpServer(
@@ -166,6 +171,9 @@ export function buildMcpServer(
166
171
  // Teams (PRDCT-2813): a chassis concept too, read-only over MCP, right after
167
172
  // the projects they can be members of.
168
173
  registerTeamTools(server, ctx, identity, tool.scopes, checkScope, hints);
174
+ // Webhooks (PRDCT-3482): a chassis concept, registered only when the tool
175
+ // declares the webhooks scope (a tool without one serves no webhook tool).
176
+ if (tool.scopes.webhooks) registerWebhookTools(server, ctx, identity, tool.scopes, checkScope, hints);
169
177
 
170
178
  // The product surface: the tool's own set, after the chassis tools.
171
179
  tool.registerTools(server, ctx);
@@ -62,6 +62,8 @@ export type ScopeCheck<TScope extends string> = (
62
62
  export interface McpScopes<TRead extends string = string, TWrite extends string = string> {
63
63
  read: TRead;
64
64
  write: TWrite;
65
+ /** The webhooks' opt-in scope (PRDCT-3482); absent, the chassis registers no webhook tool. */
66
+ webhooks?: string | undefined;
65
67
  }
66
68
 
67
69
  export function createScopeCheck<TRead extends string, TWrite extends string>(
@@ -71,7 +73,11 @@ export function createScopeCheck<TRead extends string, TWrite extends string>(
71
73
  if (principal.scopes && !principal.scopes.has(scope)) {
72
74
  return deny(
73
75
  `Missing scope "${scope}": this connection was not granted ` +
74
- (scope === scopes.write ? 'permission to create or edit data.' : 'permission to read data.') +
76
+ (scope === scopes.write
77
+ ? 'permission to create or edit data.'
78
+ : scope === scopes.webhooks
79
+ ? 'permission to manage webhooks.'
80
+ : 'permission to read data.') +
75
81
  ' Reconnect the MCP server and approve the permission on the consent screen.'
76
82
  );
77
83
  }