@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
package/src/env.ts CHANGED
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { parseApiKeyPeppers } from './apikeys/peppers.js';
3
3
  import { parseSuperadminEmails } from './accounts/superadmin.js';
4
4
  import { isDemoSignInHost, parseDemoEmailDomains, parseDemoHosts } from './identity/demo-pass-rules.js';
5
+ import { loadCliPackage } from './cli-package/index.js';
5
6
 
6
7
  /**
7
8
  * The single entry point for configuration. Every env var the app reads is
@@ -198,6 +199,19 @@ const chassisEnvShape = (version: string) => ({
198
199
  HUB_HINT_COOKIE_DOMAIN: optionalString(z.string().min(1)),
199
200
  /** The restricted bit on the tool itself (PRDCT-2947): `true` on the cloud edition makes this instance refuse to create an organization (`POST /workspaces` answers 403 `restricted_tool`, `/me.canCreateWorkspace` is false) and advertise `restricted: true` in discovery, so a tool that Antasphere opens to organizations one by one does not rest on the hub registry's flag alone. No effect on the self-hosted edition, where workspaces are local. */
200
201
  TOOL_RESTRICTED: booleanish.default(false),
202
+ /** The tool's own CLI package as the release packed it (PRDCT-3312): a folder holding `package.tgz` (the output of `pnpm pack` of `packages/cli`) beside a copy of that package's `package.json`. The image sets it. Cloud edition: the instance serves the tarball on `POST /cli/package` to a member the hub lets into the tool, and discovery carries `cli { name, version }` so the `team` command knows what it installs and whether to update. Unset = the instance serves no CLI: discovery carries no `cli` and the route answers 404 `cli_not_packaged`. A set value whose folder does not hold both files, or whose `package.json` has no string `name` and `version`, refuses the boot with the path and what is missing: a misconfiguration is proven at the image, never discovered by a person's install. */
203
+ CLI_PACKAGE_DIR: optionalString(
204
+ z
205
+ .string()
206
+ .min(1)
207
+ .superRefine((dir, ctx) => {
208
+ try {
209
+ loadCliPackage(dir);
210
+ } catch (err) {
211
+ ctx.addIssue({ code: 'custom', message: err instanceof Error ? err.message : String(err) });
212
+ }
213
+ })
214
+ ),
201
215
  /** R7 escape hatch (internal/federation.md): acknowledge an EDITION change on an already-set-up instance. Without it, boot refuses an EDITION that differs from the one stamped at setup — flipping editions under existing users/workspaces changes identity semantics and must be a conscious operator act. */
202
216
  EDITION_CHANGE_ALLOWED: booleanish.default(false),
203
217
  /** Reported version. Defaults to the code's own baked-in package version (PRDCT-1844) — the image knows its version intrinsically, CI injects nothing. The env var remains as a deliberate operator override only. */
@@ -252,6 +266,10 @@ const chassisEnvShape = (version: string) => ({
252
266
  METRICS_TOKEN: optionalString(z.string().min(8)),
253
267
  /** Days of audit_log to keep (nightly purge at 03:00). 0 = keep forever. */
254
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),
255
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. */
256
274
  ORPHAN_USER_RETENTION_HOURS: numeric(z.coerce.number().int().min(0).default(72)),
257
275
  /** pino level. */
@@ -358,6 +376,13 @@ export function buildEnvSchema<TShape extends z.ZodRawShape = {}>(
358
376
  extension?.refine?.(env, ctx);
359
377
  demoSignInRefusal(env, ctx);
360
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
+ }
361
386
  for (const key of HUB_REQUIRED_VARS) {
362
387
  if (env[key] === undefined) {
363
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
  }
@@ -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
+ }