@antasphere/chassis-server 1.1.0 → 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 (172) 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/boot.d.ts.map +1 -1
  25. package/dist/boot.js +47 -4
  26. package/dist/boot.js.map +1 -1
  27. package/dist/email/index.d.ts +1 -0
  28. package/dist/email/index.d.ts.map +1 -1
  29. package/dist/email/index.js +1 -0
  30. package/dist/email/index.js.map +1 -1
  31. package/dist/env.d.ts +9 -0
  32. package/dist/env.d.ts.map +1 -1
  33. package/dist/env.js +11 -0
  34. package/dist/env.js.map +1 -1
  35. package/dist/identity/demo-pass-rules.d.ts.map +1 -1
  36. package/dist/identity/demo-pass-rules.js +6 -1
  37. package/dist/identity/demo-pass-rules.js.map +1 -1
  38. package/dist/identity/hub-reconcile.d.ts +2 -2
  39. package/dist/identity/hub-reconcile.d.ts.map +1 -1
  40. package/dist/identity/hub-reconcile.js.map +1 -1
  41. package/dist/index.d.ts +2 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/jobs/index.d.ts +1 -1
  44. package/dist/jobs/index.d.ts.map +1 -1
  45. package/dist/jobs/index.js.map +1 -1
  46. package/dist/jobs/pgboss.d.ts +34 -0
  47. package/dist/jobs/pgboss.d.ts.map +1 -1
  48. package/dist/jobs/pgboss.js +32 -9
  49. package/dist/jobs/pgboss.js.map +1 -1
  50. package/dist/mcp/index.d.ts +1 -0
  51. package/dist/mcp/index.d.ts.map +1 -1
  52. package/dist/mcp/index.js +1 -0
  53. package/dist/mcp/index.js.map +1 -1
  54. package/dist/mcp/server.d.ts +1 -1
  55. package/dist/mcp/server.d.ts.map +1 -1
  56. package/dist/mcp/server.js +11 -2
  57. package/dist/mcp/server.js.map +1 -1
  58. package/dist/mcp/tool-kit.d.ts +2 -0
  59. package/dist/mcp/tool-kit.d.ts.map +1 -1
  60. package/dist/mcp/tool-kit.js +5 -1
  61. package/dist/mcp/tool-kit.js.map +1 -1
  62. package/dist/mcp/webhooks.d.ts +16 -0
  63. package/dist/mcp/webhooks.d.ts.map +1 -0
  64. package/dist/mcp/webhooks.js +226 -0
  65. package/dist/mcp/webhooks.js.map +1 -0
  66. package/dist/members/removal.d.ts +10 -2
  67. package/dist/members/removal.d.ts.map +1 -1
  68. package/dist/members/removal.js +3 -0
  69. package/dist/members/removal.js.map +1 -1
  70. package/dist/middleware/idempotency.d.ts.map +1 -1
  71. package/dist/middleware/idempotency.js +3 -1
  72. package/dist/middleware/idempotency.js.map +1 -1
  73. package/dist/middleware/scopes.d.ts +9 -1
  74. package/dist/middleware/scopes.d.ts.map +1 -1
  75. package/dist/middleware/scopes.js +8 -1
  76. package/dist/middleware/scopes.js.map +1 -1
  77. package/dist/secret-box.d.ts +10 -0
  78. package/dist/secret-box.d.ts.map +1 -0
  79. package/dist/secret-box.js +87 -0
  80. package/dist/secret-box.js.map +1 -0
  81. package/dist/testing/helpers.d.ts +8 -0
  82. package/dist/testing/helpers.d.ts.map +1 -1
  83. package/dist/testing/helpers.js.map +1 -1
  84. package/dist/tool-definition.d.ts +24 -0
  85. package/dist/tool-definition.d.ts.map +1 -1
  86. package/dist/tool-definition.js.map +1 -1
  87. package/dist/webhooks/catalog.d.ts +80 -0
  88. package/dist/webhooks/catalog.d.ts.map +1 -0
  89. package/dist/webhooks/catalog.js +134 -0
  90. package/dist/webhooks/catalog.js.map +1 -0
  91. package/dist/webhooks/index.d.ts +9 -0
  92. package/dist/webhooks/index.d.ts.map +1 -0
  93. package/dist/webhooks/index.js +9 -0
  94. package/dist/webhooks/index.js.map +1 -0
  95. package/dist/webhooks/jobs.d.ts +12 -0
  96. package/dist/webhooks/jobs.d.ts.map +1 -0
  97. package/dist/webhooks/jobs.js +77 -0
  98. package/dist/webhooks/jobs.js.map +1 -0
  99. package/dist/webhooks/mail.d.ts +24 -0
  100. package/dist/webhooks/mail.d.ts.map +1 -0
  101. package/dist/webhooks/mail.js +21 -0
  102. package/dist/webhooks/mail.js.map +1 -0
  103. package/dist/webhooks/match.d.ts +22 -0
  104. package/dist/webhooks/match.d.ts.map +1 -0
  105. package/dist/webhooks/match.js +40 -0
  106. package/dist/webhooks/match.js.map +1 -0
  107. package/dist/webhooks/safe-fetch.d.ts +94 -0
  108. package/dist/webhooks/safe-fetch.d.ts.map +1 -0
  109. package/dist/webhooks/safe-fetch.js +263 -0
  110. package/dist/webhooks/safe-fetch.js.map +1 -0
  111. package/dist/webhooks/service.d.ts +245 -0
  112. package/dist/webhooks/service.d.ts.map +1 -0
  113. package/dist/webhooks/service.js +861 -0
  114. package/dist/webhooks/service.js.map +1 -0
  115. package/dist/webhooks/signing.d.ts +40 -0
  116. package/dist/webhooks/signing.d.ts.map +1 -0
  117. package/dist/webhooks/signing.js +108 -0
  118. package/dist/webhooks/signing.js.map +1 -0
  119. package/dist/webhooks/tx-send.d.ts +31 -0
  120. package/dist/webhooks/tx-send.d.ts.map +1 -0
  121. package/dist/webhooks/tx-send.js +22 -0
  122. package/dist/webhooks/tx-send.js.map +1 -0
  123. package/package.json +7 -3
  124. package/src/api/apikeys.ts +12 -1
  125. package/src/api/create-api.ts +17 -3
  126. package/src/api/invitations.ts +6 -0
  127. package/src/api/members.ts +6 -2
  128. package/src/api/projects.ts +16 -1
  129. package/src/api/webhooks.ts +205 -0
  130. package/src/boot.ts +48 -4
  131. package/src/email/index.ts +1 -0
  132. package/src/env.ts +11 -0
  133. package/src/identity/demo-pass-rules.ts +6 -1
  134. package/src/identity/hub-reconcile.ts +2 -2
  135. package/src/index.ts +13 -1
  136. package/src/jobs/index.ts +1 -0
  137. package/src/jobs/pgboss.ts +71 -9
  138. package/src/mcp/index.ts +1 -0
  139. package/src/mcp/server.ts +10 -2
  140. package/src/mcp/tool-kit.ts +7 -1
  141. package/src/mcp/webhooks.ts +370 -0
  142. package/src/members/removal.ts +21 -1
  143. package/src/middleware/idempotency.ts +3 -1
  144. package/src/middleware/scopes.ts +14 -0
  145. package/src/secret-box.ts +93 -0
  146. package/src/testing/helpers.ts +8 -0
  147. package/src/tool-definition.ts +29 -0
  148. package/src/webhooks/catalog.ts +205 -0
  149. package/src/webhooks/index.ts +58 -0
  150. package/src/webhooks/jobs.ts +92 -0
  151. package/src/webhooks/mail.ts +48 -0
  152. package/src/webhooks/match.ts +52 -0
  153. package/src/webhooks/safe-fetch.ts +335 -0
  154. package/src/webhooks/service.ts +1089 -0
  155. package/src/webhooks/signing.ts +132 -0
  156. package/src/webhooks/tx-send.ts +53 -0
  157. package/test/host/drizzle/0002_webhooks.sql +49 -0
  158. package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
  159. package/test/host/drizzle/meta/_journal.json +8 -1
  160. package/test/host/identity.ts +6 -1
  161. package/test/host/minimal-host.ts +2 -1
  162. package/test/host/minimal-tool.ts +32 -4
  163. package/test/integration/mcp-webhooks.test.ts +360 -0
  164. package/test/integration/webhooks.test.ts +1684 -0
  165. package/test/unit/secret-box.test.ts +124 -0
  166. package/test/unit/tx-send.test.ts +23 -0
  167. package/test/unit/webhook-catalog.test.ts +151 -0
  168. package/test/unit/webhook-env.test.ts +60 -0
  169. package/test/unit/webhook-jobs.test.ts +92 -0
  170. package/test/unit/webhook-match.test.ts +81 -0
  171. package/test/unit/webhook-safe-fetch.test.ts +330 -0
  172. package/test/unit/webhook-signing.test.ts +169 -0
@@ -0,0 +1,370 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ webhookDeliveryStatusSchema,
4
+ webhookEventPatternSchema,
5
+ webhookFiltersSchema,
6
+ webhookUrlSchema
7
+ } from '@antasphere/chassis-contract';
8
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
+ import { mcpInputs } from './inputs.js';
10
+ import { deny, jsonText, wrapToolErrors, type ErrorHints, type ToolTextResult } from './errors.js';
11
+ import {
12
+ callApi,
13
+ forWorkspace,
14
+ pageQuery,
15
+ type McpScopes,
16
+ type McpToolContext,
17
+ type ScopeCheck
18
+ } from './tool-kit.js';
19
+ import type { McpIdentity } from './server.js';
20
+
21
+ /**
22
+ * The eleven WEBHOOK tools (PRDCT-3482), registered by the chassis right after
23
+ * the team reads and only when the tool declares a webhooks scope: a webhook
24
+ * is a chassis concept (a URL a workspace registers to be told, by a signed
25
+ * POST, when one of the tool's events happens), so the tools over it are the
26
+ * chassis'. Thin shims over the instance's own /api/v1/webhooks called
27
+ * IN-PROCESS with the caller's bearer forwarded verbatim: the API's
28
+ * fail-closed scope allowlist, its owner-or-admin rule and its 404 for an
29
+ * endpoint of another workspace apply unchanged.
30
+ *
31
+ * Every tool pre-checks the tool's webhooks scope, except the catalog, which
32
+ * is a read (every non-guest member's, under the read scope). Nothing here
33
+ * names a tool, a brand or a domain: the prefix arrives as
34
+ * `identity.toolPrefix`, the event types as the instance's catalog.
35
+ */
36
+
37
+ /** What a webhook IS, opening the descriptions. */
38
+ const WHAT_IS_A_WEBHOOK =
39
+ 'A webhook is a URL an organization registers to be told, by a signed HTTP POST, when something ' +
40
+ 'happens (for example `thing.created`).';
41
+
42
+ /** Who manages them. */
43
+ const WHO_MANAGES = 'Only an owner or an admin of the organization manages webhooks, reads included.';
44
+
45
+ /** How the `events` list of an endpoint reads. */
46
+ const EVENT_PATTERNS =
47
+ 'Each entry of `events` is an exact event type (`thing.created`), a family (`thing.*`) or ' +
48
+ 'everything (`*`).';
49
+
50
+ /**
51
+ * The webhook code only the webhook routes answer, merged into the instance's
52
+ * hint table by `composeErrorHints` like the project hints. The codes the
53
+ * webhook routes SHARE with the rest of the instance (`forbidden`,
54
+ * `demo_session`, `plan_required`) are worded for webhooks on the webhook
55
+ * tools alone ({@link webhookToolHints}): in the instance's table they would
56
+ * misread every other route's refusal.
57
+ */
58
+ export function webhookErrorHints(toolPrefix: string): ErrorHints {
59
+ return {
60
+ url_refused:
61
+ 'This instance does not deliver to that address. Refused: a scheme other than http or https ' +
62
+ '(https only on the cloud), a URL carrying a user name or password, a port other than the ' +
63
+ 'scheme’s default, and an address that is not on the public internet (localhost, a private or ' +
64
+ 'link-local network), unless the operator of a self-hosted instance opened private networks. ' +
65
+ `Use a public address of the receiver, then call ${toolPrefix}create_webhook or ` +
66
+ `${toolPrefix}update_webhook again.`,
67
+ unknown_event:
68
+ 'One of the `events` entries is not an event type or family this instance delivers. ' +
69
+ `Read ${toolPrefix}webhook_events for the exact types; a family is its first word plus \`.*\`.`,
70
+ unknown_subject:
71
+ 'One of the `filters` keys is not a subject this instance declares. ' +
72
+ `The subject keys are in ${toolPrefix}webhook_events (for example \`project\`).`
73
+ };
74
+ }
75
+
76
+ /** The shared codes, worded for the webhook tools only (layered over the instance's table there). */
77
+ function webhookToolHints(toolPrefix: string): ErrorHints {
78
+ return {
79
+ forbidden:
80
+ 'Only an owner or an admin of the organization manages webhooks, reads included. Ask one of ' +
81
+ `them to make the change, or to read the endpoints (${toolPrefix}webhook_events stays open to ` +
82
+ 'every member).',
83
+ demo_session:
84
+ 'This connection was opened by a demonstration link, which may not set up webhooks. A person ' +
85
+ 'signed in with their own account must do it.',
86
+ plan_required:
87
+ 'The organization’s plan does not include this — the upgrade link in this message is where a ' +
88
+ 'human raises it; nothing was created.'
89
+ };
90
+ }
91
+
92
+ const webhookIdInput = z
93
+ .uuid()
94
+ .describe('The webhook id, from the webhook list or the create answer (never its URL).');
95
+
96
+ const deliveryIdInput = z.uuid().describe('The delivery id, from the delivery list of this webhook.');
97
+
98
+ const urlInput = webhookUrlSchema.describe(
99
+ 'Where the deliveries go: a public http or https URL without credentials (https only on the cloud).'
100
+ );
101
+
102
+ const eventsInput = z
103
+ .array(webhookEventPatternSchema)
104
+ .min(1)
105
+ .max(50)
106
+ .describe(`What the endpoint subscribes to. ${EVENT_PATTERNS}`);
107
+
108
+ const descriptionInput = z.string().max(200).describe('A short note on what the endpoint is for.');
109
+
110
+ const filtersInput = webhookFiltersSchema.describe(
111
+ 'Narrows the subscription by subject: a subject key of the catalog (its `subjects[].key`) mapped to ' +
112
+ 'the ids it accepts, AND across keys, OR within a key; an event that does not carry a filtered ' +
113
+ 'subject does not match. An empty object filters nothing.'
114
+ );
115
+
116
+ const enabledInput = z.boolean().describe('false pauses the deliveries without deleting the endpoint.');
117
+
118
+ /** Register the eleven webhook tools on a server, after the team reads. */
119
+ export function registerWebhookTools(
120
+ server: McpServer,
121
+ ctx: McpToolContext,
122
+ identity: McpIdentity,
123
+ scopes: McpScopes,
124
+ checkScope: ScopeCheck<string>,
125
+ hints: ErrorHints
126
+ ): void {
127
+ const webhooksScope = scopes.webhooks;
128
+ if (!webhooksScope) return;
129
+ const { workspaceInput, cursorInput, limitInput } = mcpInputs(identity);
130
+ const prefix = identity.toolPrefix;
131
+ const t = (name: string) => `${prefix}${name}`;
132
+ const toolHints: ErrorHints = { ...hints, ...webhookToolHints(prefix) };
133
+
134
+ const run = (
135
+ scope: string,
136
+ workspace: string | undefined,
137
+ body: (c: McpToolContext) => Promise<unknown>
138
+ ): Promise<ToolTextResult> => {
139
+ const denied = checkScope(ctx.principal, scope);
140
+ if (denied) return Promise.resolve(denied);
141
+ const c = forWorkspace(ctx, workspace);
142
+ return wrapToolErrors(async () => jsonText(await body(c)), toolHints);
143
+ };
144
+
145
+ const send = (method: string, value?: unknown): RequestInit =>
146
+ value === undefined
147
+ ? { method }
148
+ : { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) };
149
+
150
+ const base = '/api/v1/webhooks';
151
+
152
+ // ── The catalog: a read ──────────────────────────────────────────────────
153
+
154
+ server.registerTool(
155
+ t('webhook_events'),
156
+ {
157
+ description:
158
+ `The event catalog of this instance: every event type it delivers to webhooks. ${WHAT_IS_A_WEBHOOK} ` +
159
+ 'Returns { subjects: [{ key, field, description }], events: [{ type, description, subjects, ' +
160
+ 'schema }] } — `subjects` are what an event is about (the key a filter uses, and the field of ' +
161
+ 'the envelope’s `subjects` its value is written under: one id or a list of ids), `schema` is ' +
162
+ 'the JSON Schema of the event’s `data`. ' +
163
+ `${EVENT_PATTERNS} Read this before ${t('create_webhook')}. Every member may read it.`,
164
+ inputSchema: { workspace: workspaceInput },
165
+ annotations: { readOnlyHint: true }
166
+ },
167
+ async ({ workspace }) => run(scopes.read, workspace, (c) => callApi(c, `${base}/events`))
168
+ );
169
+
170
+ // ── Endpoints ────────────────────────────────────────────────────────────
171
+
172
+ server.registerTool(
173
+ t('list_webhooks'),
174
+ {
175
+ description:
176
+ `List the organization’s webhooks, newest first. ${WHAT_IS_A_WEBHOOK} ${WHO_MANAGES} Returns ` +
177
+ '{ webhooks: [{ id, url, description, events, filters, enabled, createdBy, createdAt, ' +
178
+ 'updatedAt, disabledAt, disabledReason, consecutiveFailures, lastDeliveryAt, ' +
179
+ 'lastDeliveryStatus, previousSecretExpiresAt }], nextCursor } — never a signing secret. ' +
180
+ '`disabledAt` is set when the instance switched the endpoint off after failing too long. When ' +
181
+ 'nextCursor is non-null, call again with cursor set to it.',
182
+ inputSchema: { workspace: workspaceInput, limit: limitInput, cursor: cursorInput },
183
+ annotations: { readOnlyHint: true }
184
+ },
185
+ async ({ workspace, limit, cursor }) =>
186
+ run(webhooksScope, workspace, (c) => callApi(c, pageQuery(base, { cursor, limit })))
187
+ );
188
+
189
+ server.registerTool(
190
+ t('create_webhook'),
191
+ {
192
+ description:
193
+ `Register a webhook — confirm with the user first (URL and events included). ${WHAT_IS_A_WEBHOOK} ` +
194
+ `${WHO_MANAGES} ${EVENT_PATTERNS} The types are checked against ${t('webhook_events')}, and ` +
195
+ 'the keys of `filters` are the catalog’s subject keys. The answer is the endpoint plus its ' +
196
+ 'signing `secret` (`whsec_…`), shown ONCE and never again: give it to the receiver, which ' +
197
+ 'verifies every delivery’s Standard Webhooks signature with it, and never store it anywhere ' +
198
+ `else. A lost secret is replaced by ${t('rotate_webhook_secret')}.`,
199
+ inputSchema: {
200
+ workspace: workspaceInput,
201
+ url: urlInput,
202
+ events: eventsInput,
203
+ description: descriptionInput.optional(),
204
+ filters: filtersInput.optional(),
205
+ enabled: enabledInput.optional()
206
+ }
207
+ },
208
+ async ({ workspace, url, events, description, filters, enabled }) =>
209
+ run(webhooksScope, workspace, (c) =>
210
+ callApi(
211
+ c,
212
+ base,
213
+ send('POST', {
214
+ url,
215
+ events,
216
+ ...(description !== undefined ? { description } : {}),
217
+ ...(filters !== undefined ? { filters } : {}),
218
+ ...(enabled !== undefined ? { enabled } : {})
219
+ })
220
+ )
221
+ )
222
+ );
223
+
224
+ server.registerTool(
225
+ t('get_webhook'),
226
+ {
227
+ description:
228
+ `Read one webhook, without its secret. ${WHO_MANAGES} Returns the endpoint as ` +
229
+ `${t('list_webhooks')} shows it. A webhook of another organization answers not found.`,
230
+ inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput },
231
+ annotations: { readOnlyHint: true }
232
+ },
233
+ async ({ workspace, webhookId }) =>
234
+ run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}`))
235
+ );
236
+
237
+ server.registerTool(
238
+ t('update_webhook'),
239
+ {
240
+ description:
241
+ 'Change a webhook’s URL, description, events, filters or enabled switch — confirm with the ' +
242
+ `user first. ${WHO_MANAGES} Send only the fields you are changing; \`events\` and \`filters\` ` +
243
+ `REPLACE the whole list or object. ${EVENT_PATTERNS} Setting enabled back to true resumes an ` +
244
+ 'endpoint the instance switched off. The secret is unchanged. Returns the endpoint as it now is.',
245
+ inputSchema: {
246
+ workspace: workspaceInput,
247
+ webhookId: webhookIdInput,
248
+ url: urlInput.optional(),
249
+ description: descriptionInput.optional(),
250
+ events: eventsInput.optional(),
251
+ filters: filtersInput.optional(),
252
+ enabled: enabledInput.optional()
253
+ }
254
+ },
255
+ async ({ workspace, webhookId, url, description, events, filters, enabled }) => {
256
+ const patch = { url, description, events, filters, enabled };
257
+ if (Object.values(patch).every((v) => v === undefined)) {
258
+ return deny(
259
+ 'Nothing to change: pass at least one of url, description, events, filters or enabled. ' +
260
+ `Read the webhook first (${t('get_webhook')}) if you need its current values.`
261
+ );
262
+ }
263
+ const body = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
264
+ return run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}`, send('PATCH', body)));
265
+ }
266
+ );
267
+
268
+ server.registerTool(
269
+ t('delete_webhook'),
270
+ {
271
+ description:
272
+ 'Delete a webhook and its whole delivery history — confirm with the user first; nothing more ' +
273
+ `is sent to it and this cannot be undone. ${WHO_MANAGES} To pause it instead, call ` +
274
+ `${t('update_webhook')} with enabled set to false. Returns a last snapshot of the deleted endpoint.`,
275
+ inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput },
276
+ annotations: { destructiveHint: true }
277
+ },
278
+ async ({ workspace, webhookId }) =>
279
+ run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}`, send('DELETE')))
280
+ );
281
+
282
+ server.registerTool(
283
+ t('rotate_webhook_secret'),
284
+ {
285
+ description:
286
+ 'Replace a webhook’s signing secret — confirm with the user first. ' +
287
+ `${WHO_MANAGES} The answer is the endpoint plus its new \`secret\` (\`whsec_…\`), shown ONCE: ` +
288
+ 'give it to the receiver and never store it anywhere else. The previous secret keeps signing ' +
289
+ 'until `previousSecretExpiresAt`, so the receiver can switch without a gap.',
290
+ inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput }
291
+ },
292
+ async ({ workspace, webhookId }) =>
293
+ run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}/rotate-secret`, send('POST')))
294
+ );
295
+
296
+ server.registerTool(
297
+ t('test_webhook'),
298
+ {
299
+ description:
300
+ 'Send a signed `webhook.ping` to a webhook now, once, and answer how it went. ' +
301
+ `${WHO_MANAGES} Returns { delivery: { id, eventId, eventType, status, attempt, ` +
302
+ 'lastStatusCode, lastError, … } } — `status` is `delivered` when the receiver answered 2xx, ' +
303
+ '`failed` otherwise, with `lastStatusCode` and `lastError` saying why. A ping is never retried.',
304
+ inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput }
305
+ },
306
+ async ({ workspace, webhookId }) =>
307
+ run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}/test`, send('POST')))
308
+ );
309
+
310
+ // ── Deliveries ───────────────────────────────────────────────────────────
311
+
312
+ server.registerTool(
313
+ t('list_webhook_deliveries'),
314
+ {
315
+ description:
316
+ `List a webhook’s deliveries, newest first, optionally by status. ${WHO_MANAGES} A delivery ` +
317
+ 'is one event sent to this endpoint, with its attempts. Returns { deliveries: [{ id, ' +
318
+ 'endpointId, eventId, eventType, status, attempt, lastStatusCode, lastError, nextAttemptAt, ' +
319
+ 'deliveredAt, createdAt, updatedAt, payload }], nextCursor } — `eventId` is the receiver’s ' +
320
+ 'dedupe key, `lastStatusCode` and `lastError` describe the latest attempt only. When ' +
321
+ 'nextCursor is non-null, call again with cursor set to it.',
322
+ inputSchema: {
323
+ workspace: workspaceInput,
324
+ webhookId: webhookIdInput,
325
+ status: webhookDeliveryStatusSchema
326
+ .optional()
327
+ .describe(
328
+ '`pending` (waiting for an attempt or a retry), `delivered`, `failed`, or `held` (out of ' +
329
+ 'retries, kept for a redelivery).'
330
+ ),
331
+ limit: limitInput,
332
+ cursor: cursorInput
333
+ },
334
+ annotations: { readOnlyHint: true }
335
+ },
336
+ async ({ workspace, webhookId, status, limit, cursor }) =>
337
+ run(webhooksScope, workspace, (c) =>
338
+ callApi(c, pageQuery(`${base}/${webhookId}/deliveries`, { cursor, limit }, { status }))
339
+ )
340
+ );
341
+
342
+ server.registerTool(
343
+ t('get_webhook_delivery'),
344
+ {
345
+ description:
346
+ `Read one delivery of a webhook, with the payload as sent and its latest attempt. ${WHO_MANAGES} ` +
347
+ `Returns the delivery as ${t('list_webhook_deliveries')} shows it.`,
348
+ inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput, deliveryId: deliveryIdInput },
349
+ annotations: { readOnlyHint: true }
350
+ },
351
+ async ({ workspace, webhookId, deliveryId }) =>
352
+ run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}/deliveries/${deliveryId}`))
353
+ );
354
+
355
+ server.registerTool(
356
+ t('redeliver_webhook'),
357
+ {
358
+ description:
359
+ 'Send one delivery’s event to its webhook again, as a new delivery — confirm with the user ' +
360
+ `first. ${WHO_MANAGES} The new delivery carries the same \`eventId\` (the receiver’s dedupe key) ` +
361
+ 'and the same payload, and is queued: read its outcome later with ' +
362
+ `${t('get_webhook_delivery')}. Returns the new delivery.`,
363
+ inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput, deliveryId: deliveryIdInput }
364
+ },
365
+ async ({ workspace, webhookId, deliveryId }) =>
366
+ run(webhooksScope, workspace, (c) =>
367
+ callApi(c, `${base}/${webhookId}/deliveries/${deliveryId}/redeliver`, send('POST'))
368
+ )
369
+ );
370
+ }
@@ -12,6 +12,7 @@ import {
12
12
  workspaceTeamMembers,
13
13
  type DbConn
14
14
  } from '@antasphere/chassis-db';
15
+ import type { WebhookEmitter } from '../webhooks/service.js';
15
16
 
16
17
  /**
17
18
  * The membership an ADD hangs a right on, re-read inside the add's own
@@ -67,10 +68,29 @@ export interface RemovedMembership {
67
68
  * audit record.
68
69
  */
69
70
  export type MembershipRemovalHook = (
71
+ tx: DbConn,
72
+ removed: readonly RemovedMembership[],
73
+ ctx: MembershipRemovalContext
74
+ ) => Promise<Record<string, number>>;
75
+
76
+ /** What the tool's hook receives beside the rows: the webhooks' emitter, to report what it ended inside the same transaction (PRDCT-3483). */
77
+ export interface MembershipRemovalContext {
78
+ webhooks: WebhookEmitter;
79
+ }
80
+
81
+ /** The hook as the chassis calls it, the context bound at boot (`bindMembershipRemoval`). */
82
+ export type BoundMembershipRemoval = (
70
83
  tx: DbConn,
71
84
  removed: readonly RemovedMembership[]
72
85
  ) => Promise<Record<string, number>>;
73
86
 
87
+ export function bindMembershipRemoval(
88
+ hook: MembershipRemovalHook | undefined,
89
+ ctx: MembershipRemovalContext
90
+ ): BoundMembershipRemoval | undefined {
91
+ return hook ? (tx, removed) => hook(tx, removed, ctx) : undefined;
92
+ }
93
+
74
94
  export interface RemovalCounts {
75
95
  projectGrants: number;
76
96
  teamSeats: number;
@@ -127,7 +147,7 @@ export interface RemovalCounts {
127
147
  export async function deleteMembershipGrants(
128
148
  tx: DbConn,
129
149
  removed: readonly RemovedMembership[],
130
- toolHook?: MembershipRemovalHook
150
+ toolHook?: BoundMembershipRemoval
131
151
  ): Promise<RemovalCounts> {
132
152
  if (removed.length === 0) {
133
153
  return { projectGrants: 0, teamSeats: 0, invitations: 0, demoPasses: 0, tool: {} };
@@ -69,7 +69,9 @@ const TARGET_PATHS = new Set([
69
69
  '/api/v1/api-keys',
70
70
  '/api/v1/invitations',
71
71
  '/api/v1/workspaces',
72
- '/api/v1/projects'
72
+ '/api/v1/projects',
73
+ // A retried registration answers the first one's secret, never a second endpoint (PRDCT-3482).
74
+ '/api/v1/webhooks'
73
75
  ]);
74
76
  // The two member routes that MINT a credential for another user. Both are
75
77
  // covered (FUZZ-7): a replayed mint hands out a second live secret, and the
@@ -30,6 +30,14 @@ export interface ScopeAllowlistOptions<S extends string> {
30
30
  write: S;
31
31
  /** The tool's dedicated opt-in scope for the full-workspace export. */
32
32
  dataExport: S;
33
+ /**
34
+ * The tool's dedicated opt-in scope for the webhooks family (PRDCT-3482):
35
+ * registering an endpoint is a standing read of the workspace to a URL of
36
+ * the caller's choice, so no read or write key opens it. Absent: no
37
+ * machine principal reaches `/webhooks` at all (the catalog read stays
38
+ * under the read scope).
39
+ */
40
+ webhooks?: S | undefined;
33
41
  /** The tool's own rules, asked in order AFTER the chassis rules. */
34
42
  rules: ReadonlyArray<ScopeRule<S>>;
35
43
  }
@@ -89,6 +97,7 @@ export function createScopeAllowlist<S extends string>({
89
97
  read,
90
98
  write,
91
99
  dataExport,
100
+ webhooks,
92
101
  rules
93
102
  }: ScopeAllowlistOptions<S>): (path: string, method: string) => S | null {
94
103
  return function requiredScopeFor(path: string, method: string): S | null {
@@ -130,6 +139,11 @@ export function createScopeAllowlist<S extends string>({
130
139
  // Teams: the same discipline (above).
131
140
  const team = teamAccess(path, method);
132
141
  if (team !== null) return team === 'read' ? read : write;
142
+ // Webhooks (PRDCT-3482): the catalog is a read any key may make; the
143
+ // family itself, reads included, needs the tool's dedicated scope, and
144
+ // closes entirely when the tool declares none.
145
+ if (path === '/api/v1/webhooks/events' && isRead) return read;
146
+ if (path === '/api/v1/webhooks' || path.startsWith('/api/v1/webhooks/')) return webhooks ?? null;
133
147
  for (const rule of rules) {
134
148
  const needed = rule(path, method, isRead);
135
149
  if (needed !== null) return needed;
@@ -0,0 +1,93 @@
1
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
2
+
3
+ /**
4
+ * A box for secrets the instance must read back (a signing secret it hands to
5
+ * a receiver, a refresh token it presents later): AES-256-GCM under a key
6
+ * derived from the instance's auth secret by HKDF-SHA256 with a caller-given
7
+ * `info`, so the database never holds them in the clear and a copy of the
8
+ * database alone opens nothing. A one-way secret (a password, an API key the
9
+ * instance only compares) never goes in here: it is hashed, not sealed.
10
+ *
11
+ * `info` separates the boxes: two boxes of one instance with different infos
12
+ * derive different keys, so a value sealed for one purpose never opens under
13
+ * another. Name it `<area>:<purpose>` and never change it once values are
14
+ * stored under it (they would no longer open).
15
+ *
16
+ * A sealed value reads `v1.<iv>.<tag>.<ciphertext>`, each part base64url: a
17
+ * fresh 12-byte IV per seal (two seals of one value differ), the 16-byte GCM
18
+ * tag, which `open` checks before it answers anything (a changed byte
19
+ * anywhere is refused, never decrypted to garbage). The version prefix lets a
20
+ * later key or cipher change read the old rows. Every refusal, a malformed
21
+ * string included, is a `SecretBoxError` and nothing else.
22
+ */
23
+ const VERSION = 'v1';
24
+ const IV_BYTES = 12;
25
+ const TAG_BYTES = 16;
26
+ const KEY_BYTES = 32;
27
+ const BASE64URL = /^[A-Za-z0-9_-]*$/;
28
+
29
+ export class SecretBoxError extends Error {
30
+ constructor(message: string) {
31
+ super(message);
32
+ this.name = 'SecretBoxError';
33
+ }
34
+ }
35
+
36
+ export interface SecretBox {
37
+ seal(plain: string): string;
38
+ open(sealed: string): string;
39
+ }
40
+
41
+ /** `info` separates the boxes: two boxes of one instance with different infos never open each other's values. */
42
+ export function createSecretBox(authSecret: string, info: string): SecretBox {
43
+ if (typeof authSecret !== 'string' || authSecret.length === 0) {
44
+ throw new SecretBoxError('the secret box needs the auth secret');
45
+ }
46
+ if (typeof info !== 'string' || info.length === 0) {
47
+ throw new SecretBoxError('the secret box needs an info string that names its purpose');
48
+ }
49
+ const key = Buffer.from(hkdfSync('sha256', authSecret, Buffer.alloc(0), info, KEY_BYTES));
50
+
51
+ return {
52
+ seal(plain: string): string {
53
+ if (typeof plain !== 'string') throw new SecretBoxError('the secret box seals strings only');
54
+ const iv = randomBytes(IV_BYTES);
55
+ const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES });
56
+ const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
57
+ const tag = cipher.getAuthTag();
58
+ return [
59
+ VERSION,
60
+ iv.toString('base64url'),
61
+ tag.toString('base64url'),
62
+ ciphertext.toString('base64url')
63
+ ].join('.');
64
+ },
65
+
66
+ open(sealed: string): string {
67
+ if (typeof sealed !== 'string') throw new SecretBoxError('not a sealed value');
68
+ const parts = sealed.split('.');
69
+ if (parts.length !== 4 || parts[0] !== VERSION) {
70
+ throw new SecretBoxError('not a sealed value of this version');
71
+ }
72
+ const [, ivPart, tagPart, ciphertextPart] = parts as [string, string, string, string];
73
+ // Buffer.from skips characters outside the alphabet silently: refuse them
74
+ // here, so only the one spelling of a sealed value opens.
75
+ if (!BASE64URL.test(ivPart) || !BASE64URL.test(tagPart) || !BASE64URL.test(ciphertextPart)) {
76
+ throw new SecretBoxError('a sealed value with a part that is not base64url');
77
+ }
78
+ const iv = Buffer.from(ivPart, 'base64url');
79
+ const tag = Buffer.from(tagPart, 'base64url');
80
+ const ciphertext = Buffer.from(ciphertextPart, 'base64url');
81
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
82
+ throw new SecretBoxError('a sealed value with a malformed iv or tag');
83
+ }
84
+ try {
85
+ const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES });
86
+ decipher.setAuthTag(tag);
87
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
88
+ } catch {
89
+ throw new SecretBoxError('the sealed value does not open under this key');
90
+ }
91
+ }
92
+ };
93
+ }
@@ -93,6 +93,14 @@ export interface ChassisTestHost<TResult extends BootResultLike, TOverrides> {
93
93
  * scope) that answers 200 to any principal the gates let through.
94
94
  */
95
95
  probeRoute: string;
96
+ /**
97
+ * The host's tool is the chassis's minimal test tool, which declares the
98
+ * `thing` subject and the `thing.created` / `thing.deleted` events the
99
+ * webhooks suite emits (PRDCT-3482). A real tool leaves it unset: the
100
+ * suite's delivery scenarios skip under its host, and the tool proves its
101
+ * own events in its own suite.
102
+ */
103
+ webhooks?: boolean;
96
104
  }
97
105
 
98
106
  /** Postgres `admin_shutdown`: what a backend answers a still-connected client when the server stops. */
@@ -29,6 +29,13 @@ import type { McpToolDefinition } from './mcp/server.js';
29
29
  import type { BucketDeclaration, ClientIpFn, RateLimiters } from './middleware/rate-limit.js';
30
30
  import type { Otel } from './observability/otel.js';
31
31
  import type { EventBus } from './platform/events.js';
32
+ import type {
33
+ WebhookCatalogDeclaration,
34
+ WebhookDials,
35
+ WebhookEmitter,
36
+ WebhookService
37
+ } from './webhooks/index.js';
38
+ import type { SafeFetchOptions } from './webhooks/safe-fetch.js';
32
39
  import type { PlatformRegistry } from './platform/registry.js';
33
40
  import type { RuntimeState } from './state.js';
34
41
  import type { StorageDriver } from './storage/driver.js';
@@ -66,6 +73,13 @@ export interface ServiceCore<TEnvShape extends z.ZodRawShape, TEvents, TToolOver
66
73
  authSecret: string;
67
74
  /** One-off timers on the queues the `jobs` slot declared (`jobs/timers.ts`): a service arms and withdraws them. */
68
75
  timers: Timers;
76
+ /**
77
+ * The outbound webhooks' emitter (PRDCT-3482): a service calls
78
+ * `webhooks.emit(tx, …)` INSIDE the transaction of the write it reports,
79
+ * with a type the tool's `webhooks` slot declared. Costs one lookup when
80
+ * no endpoint of the workspace listens.
81
+ */
82
+ webhooks: WebhookEmitter;
69
83
  /** The tool's own test seams (`BootOverrides.tool`); undefined in production. */
70
84
  overrides: TToolOverrides | undefined;
71
85
  }
@@ -328,6 +342,15 @@ export interface ToolDefinition<
328
342
  * Absent = nothing declared: no route gated, empty lists in discovery.
329
343
  */
330
344
  entitlements?: (env: ToolEnv<TEnvShape>, ctx: EntitlementsContext<TDomain>) => ToolEntitlementDeclaration;
345
+ /**
346
+ * Slot 25: the tool's event catalog for outbound webhooks (PRDCT-3482):
347
+ * the subjects an endpoint may filter on (each with the envelope field it
348
+ * is written under) and the event types the tool emits, each with the zod
349
+ * schema of its `data`. Checked at boot (`buildWebhookCatalog`), merged
350
+ * with the chassis's own events, served on `GET /webhooks/events`. Absent
351
+ * = the chassis events alone; the routes exist on every instance.
352
+ */
353
+ webhooks?: WebhookCatalogDeclaration;
331
354
  }
332
355
 
333
356
  /**
@@ -393,6 +416,10 @@ export interface BootOverrides<TToolOverrides = never> {
393
416
  hubCreateOrg?: HubOrgCreator;
394
417
  /** The tool's own test seams, handed to its `services` slot as `core.overrides`. */
395
418
  tool?: TToolOverrides;
419
+ /** Shrinks the webhooks' retry budget, hold, auto-disable streak, listener cache and overlap for the tests. */
420
+ webhookDials?: Partial<WebhookDials> | undefined;
421
+ /** The guarded fetch's seams (a lookup, a transport) so a suite delivers to a loopback receiver. */
422
+ webhookFetch?: SafeFetchOptions | undefined;
396
423
  }
397
424
 
398
425
  export interface BootResult<
@@ -414,6 +441,8 @@ export interface BootResult<
414
441
  email: EmailDriver;
415
442
  otel: Otel;
416
443
  authSecret: string;
444
+ /** The outbound webhooks (PRDCT-3482): the emitter, the fan-out and the delivery, for a suite to drive directly. */
445
+ webhooks: WebhookService;
417
446
  }
418
447
 
419
448
  /**