@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,205 @@
1
+ import { z } from 'zod';
2
+ import type { WebhookCatalog } from '@antasphere/chassis-contract';
3
+
4
+ /**
5
+ * The event catalog (PRDCT-3482): what an instance can deliver to a webhook
6
+ * endpoint. The TOOL declares its events through the `webhooks` slot of its
7
+ * definition (its subjects, its event types, the zod schema of each event's
8
+ * `data`); the chassis adds its own events, checks the whole at boot, matches
9
+ * subscriptions against it and serves it on `GET /webhooks/events` with the
10
+ * JSON Schema of every payload, so an agent discovers the types and the
11
+ * shapes without reading a page of documentation.
12
+ *
13
+ * A SUBJECT is a filterable key the tool exposes (`thing`, `project`): an
14
+ * endpoint narrows its subscription to a list of ids per subject, and the
15
+ * envelope carries each subject's value under the FIELD the tool names for it
16
+ * (`thingId`, `projectIds`). An event declares which subjects it carries. A
17
+ * subject's value in the envelope is one id, or always a list of ids when
18
+ * the subject is declared `list` (the shape never varies per event).
19
+ */
20
+
21
+ /** One filterable key of the tool, as the slot declares it. */
22
+ export interface WebhookSubjectDeclaration {
23
+ /** The envelope field the subject's value is written under (`thingId`, `projectIds`). */
24
+ field: string;
25
+ description: string;
26
+ /**
27
+ * The subject's value is a LIST of ids (a thing in several projects): the
28
+ * envelope always writes an array under the field, a single id included.
29
+ * Absent: one id, always a string; a list is refused at emit.
30
+ */
31
+ list?: boolean;
32
+ }
33
+
34
+ /** One event type of the tool, as the slot declares it. */
35
+ export interface WebhookEventDeclaration {
36
+ /** `thing.created`: a lowercase family, a dot, a lowercase verb. */
37
+ type: string;
38
+ description: string;
39
+ /** The subjects this event carries, by key; every one must be declared in `subjects`. */
40
+ subjects: readonly string[];
41
+ /** The schema of the event's `data`, validated at emit (a mismatch is a bug, logged and skipped). */
42
+ data: z.ZodType;
43
+ }
44
+
45
+ /** The `webhooks` slot of a tool definition. */
46
+ export interface WebhookCatalogDeclaration {
47
+ subjects: Readonly<Record<string, WebhookSubjectDeclaration>>;
48
+ events: readonly WebhookEventDeclaration[];
49
+ }
50
+
51
+ /** The value one emit gives a subject: one id, or a list (a thing in several projects). */
52
+ export type WebhookSubjectValue = string | readonly string[];
53
+
54
+ /** An event type: `family.verb`, lowercase, digits and underscores allowed after the first letter. */
55
+ export const WEBHOOK_EVENT_TYPE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
56
+ const SUBJECT_KEY = /^[a-z][a-z0-9_]*$/;
57
+
58
+ /** The test ping every endpoint can be sent (`POST /webhooks/{id}/test`). */
59
+ export const WEBHOOK_PING_EVENT = 'webhook.ping';
60
+
61
+ /**
62
+ * The chassis's own events: what the generic half knows happened, bridged
63
+ * from the in-process bus (member, workspace, key events) or emitted inside
64
+ * the write's transaction where the chassis owns it (projects, the ping).
65
+ * None names a tool. A tool may not re-declare one of these types.
66
+ */
67
+ export const CHASSIS_WEBHOOK_EVENTS: readonly WebhookEventDeclaration[] = [
68
+ {
69
+ type: WEBHOOK_PING_EVENT,
70
+ description: 'A test delivery, sent on request from the endpoint page or the API.',
71
+ subjects: [],
72
+ data: z.object({ endpointId: z.string(), sentBy: z.string().nullable() })
73
+ },
74
+ {
75
+ type: 'member.joined',
76
+ description: 'A person joined the workspace by accepting an invitation.',
77
+ subjects: [],
78
+ data: z.object({ userId: z.string(), role: z.string() })
79
+ },
80
+ {
81
+ type: 'member.removed',
82
+ description: 'A person was removed from the workspace by an owner or an admin.',
83
+ subjects: [],
84
+ data: z.object({ userId: z.string() })
85
+ },
86
+ {
87
+ type: 'workspace.created',
88
+ description: 'A workspace was created from inside the product.',
89
+ subjects: [],
90
+ data: z.object({ ownerUserId: z.string() })
91
+ },
92
+ {
93
+ type: 'apikey.created',
94
+ description:
95
+ 'An API key was created on the API keys page or through the API (not the command line’s own sign-in key).',
96
+ subjects: [],
97
+ data: z.object({ apiKeyId: z.string() })
98
+ },
99
+ {
100
+ type: 'apikey.revoked',
101
+ description:
102
+ 'An API key of the workspace was revoked from the API keys page or through the API (not a key revoking itself at logout).',
103
+ subjects: [],
104
+ data: z.object({ apiKeyId: z.string() })
105
+ },
106
+ {
107
+ type: 'project.created',
108
+ description: 'A project was created in the workspace.',
109
+ subjects: ['project'],
110
+ data: z.object({ projectId: z.string(), name: z.string(), createdBy: z.string() })
111
+ },
112
+ {
113
+ type: 'project.archived',
114
+ description: 'A project of the workspace was archived.',
115
+ subjects: ['project'],
116
+ data: z.object({ projectId: z.string(), name: z.string() })
117
+ }
118
+ ];
119
+
120
+ /**
121
+ * The chassis's own subject: a project, which every tool has (the chassis
122
+ * owns projects). A tool that declares `project` itself may name another
123
+ * field for it (`projectIds`, a list); the chassis's project events follow.
124
+ */
125
+ export const CHASSIS_WEBHOOK_SUBJECTS: Readonly<Record<string, WebhookSubjectDeclaration>> = {
126
+ project: { field: 'projectId', description: 'A project of the workspace, by id' }
127
+ };
128
+
129
+ /** The merged, checked catalog an instance runs on. */
130
+ export interface WebhookCatalogIndex {
131
+ subjects: Readonly<Record<string, WebhookSubjectDeclaration>>;
132
+ events: ReadonlyMap<string, WebhookEventDeclaration>;
133
+ }
134
+
135
+ /**
136
+ * Merge the chassis's events with the tool's and check the whole, at boot:
137
+ * a type that is not `family.verb`, a type declared twice (or re-declaring a
138
+ * chassis type), a subject key that is not a plain identifier, an event
139
+ * carrying a subject nobody declared, or the chassis's `project` subject
140
+ * declared under another field, stops the boot naming the fault.
141
+ */
142
+ export function buildWebhookCatalog(tool: WebhookCatalogDeclaration | undefined): WebhookCatalogIndex {
143
+ const subjects: Record<string, WebhookSubjectDeclaration> = { ...CHASSIS_WEBHOOK_SUBJECTS };
144
+ for (const [key, subject] of Object.entries(tool?.subjects ?? {})) {
145
+ if (!SUBJECT_KEY.test(key)) {
146
+ throw new Error(`tool definition: webhooks.subjects key "${key}" must match ${SUBJECT_KEY}`);
147
+ }
148
+ if (typeof subject.field !== 'string' || subject.field === '') {
149
+ throw new Error(`tool definition: webhooks.subjects.${key}.field is required (the envelope field)`);
150
+ }
151
+ // A tool may re-declare the chassis's `project` subject under its own
152
+ // field (a tool whose things belong to several projects writes a list
153
+ // under `projectIds`); the chassis's own project events then write
154
+ // their one id under that field too. A subject's value is one id or a
155
+ // list of ids, and the catalog says so.
156
+ subjects[key] = subject;
157
+ }
158
+ const events = new Map<string, WebhookEventDeclaration>();
159
+ for (const event of [...CHASSIS_WEBHOOK_EVENTS, ...(tool?.events ?? [])]) {
160
+ if (!WEBHOOK_EVENT_TYPE.test(event.type)) {
161
+ throw new Error(
162
+ `tool definition: webhooks event type "${event.type}" must be family.verb (${WEBHOOK_EVENT_TYPE})`
163
+ );
164
+ }
165
+ if (events.has(event.type)) {
166
+ throw new Error(`tool definition: webhooks event type "${event.type}" is declared twice`);
167
+ }
168
+ if (event.type === '*' || event.type.endsWith('.*')) {
169
+ throw new Error(`tool definition: webhooks event type "${event.type}" is a pattern, not a type`);
170
+ }
171
+ for (const key of event.subjects) {
172
+ if (!(key in subjects)) {
173
+ throw new Error(
174
+ `tool definition: webhooks event "${event.type}" carries the subject "${key}", which webhooks.subjects does not declare`
175
+ );
176
+ }
177
+ }
178
+ if (typeof event.description !== 'string' || event.description === '') {
179
+ throw new Error(`tool definition: webhooks event "${event.type}" needs a description`);
180
+ }
181
+ events.set(event.type, event);
182
+ }
183
+ return { subjects, events };
184
+ }
185
+
186
+ /** The catalog as `GET /webhooks/events` answers it: every event with the JSON Schema of its data. */
187
+ export function catalogToWire(catalog: WebhookCatalogIndex): WebhookCatalog {
188
+ return {
189
+ subjects: Object.entries(catalog.subjects).map(([key, s]) => ({
190
+ key,
191
+ field: s.field,
192
+ description: s.description,
193
+ list: s.list === true
194
+ })),
195
+ events: [...catalog.events.values()].map((e) => ({
196
+ type: e.type,
197
+ description: e.description,
198
+ subjects: [...e.subjects],
199
+ schema: z.toJSONSchema(e.data, { target: 'draft-2020-12', unrepresentable: 'any' }) as Record<
200
+ string,
201
+ unknown
202
+ >
203
+ }))
204
+ };
205
+ }
@@ -0,0 +1,58 @@
1
+ export {
2
+ buildWebhookCatalog,
3
+ catalogToWire,
4
+ CHASSIS_WEBHOOK_EVENTS,
5
+ CHASSIS_WEBHOOK_SUBJECTS,
6
+ WEBHOOK_EVENT_TYPE,
7
+ WEBHOOK_PING_EVENT,
8
+ type WebhookCatalogDeclaration,
9
+ type WebhookCatalogIndex,
10
+ type WebhookEventDeclaration,
11
+ type WebhookSubjectDeclaration,
12
+ type WebhookSubjectValue
13
+ } from './catalog.js';
14
+ export { endpointWants, matchesEventType, matchesFilters } from './match.js';
15
+ export {
16
+ checkWebhookUrl,
17
+ isPublicAddress,
18
+ safePost,
19
+ WebhookUrlRefusedError,
20
+ type SafeFetchOptions,
21
+ type SafeFetchPolicy,
22
+ type SafeFetchResult,
23
+ type SafeLookup,
24
+ type SafeTransport,
25
+ type WebhookUrlRefusal
26
+ } from './safe-fetch.js';
27
+ export {
28
+ generateWebhookSecret,
29
+ signWebhook,
30
+ SigningError,
31
+ verifyWebhookSignature,
32
+ WEBHOOK_SECRET_PREFIX,
33
+ webhookHeaders,
34
+ webhookSecretKey
35
+ } from './signing.js';
36
+ export {
37
+ DEFAULT_WEBHOOK_DIALS,
38
+ deliveryToWire,
39
+ endpointToWire,
40
+ WEBHOOK_DELIVERIES_HELD_QUEUE,
41
+ WEBHOOK_DELIVERIES_QUEUE,
42
+ WEBHOOK_EVENTS_QUEUE,
43
+ WEBHOOK_PURGE_QUEUE,
44
+ WEBHOOK_SECRET_BOX_INFO,
45
+ MAX_IN_FLIGHT_PER_ENDPOINT,
46
+ WebhookDeliveryDeferred,
47
+ WebhookDeliveryFailed,
48
+ WebhookRefusedError,
49
+ WebhookService,
50
+ type WebhookDials,
51
+ type WebhookEmit,
52
+ type WebhookEmitter,
53
+ type WebhookEventJob,
54
+ type WebhookServiceDeps
55
+ } from './service.js';
56
+ export { webhookJobs } from './jobs.js';
57
+ export { buildWebhookDisabledEmail, type WebhookDisabledEmailParams } from './mail.js';
58
+ export { connectionOf, drizzleExecutor, sendInTx } from './tx-send.js';
@@ -0,0 +1,92 @@
1
+ import type { JobDeclaration } from '../jobs/pgboss.js';
2
+ import type { Logger } from '../logger.js';
3
+ import {
4
+ WEBHOOK_DELIVERIES_HELD_QUEUE,
5
+ WEBHOOK_DELIVERIES_QUEUE,
6
+ WEBHOOK_EVENTS_QUEUE,
7
+ WEBHOOK_PURGE_QUEUE,
8
+ WebhookDeliveryDeferred,
9
+ WebhookDeliveryFailed,
10
+ type WebhookDeliveryJob,
11
+ type WebhookEventJob,
12
+ type WebhookService
13
+ } from './service.js';
14
+
15
+ /**
16
+ * The four queues of the webhooks, declared to the job runtime like a tool's
17
+ * own (`createJobs` installs them in the same lock section, in this order):
18
+ * the held queue FIRST so the delivery queue can name it as its dead letter
19
+ * (pg-boss's job row holds a foreign key on it), then the fan-out, the
20
+ * delivery with its retry budget, and the nightly purge.
21
+ */
22
+ export function webhookJobs(
23
+ service: WebhookService,
24
+ logger: Logger,
25
+ retentionDays: number
26
+ ): JobDeclaration[] {
27
+ const { dials } = service;
28
+ return [
29
+ {
30
+ queue: WEBHOOK_DELIVERIES_HELD_QUEUE,
31
+ options: { retryLimit: 10, retryDelaySeconds: 60, retryBackoff: true },
32
+ handler: async (data) => {
33
+ const job = data as WebhookDeliveryJob | null;
34
+ if (!job?.deliveryId) return;
35
+ await service.hold(job.deliveryId);
36
+ }
37
+ },
38
+ {
39
+ queue: WEBHOOK_EVENTS_QUEUE,
40
+ concurrency: 2,
41
+ pollingIntervalSeconds: 0.5,
42
+ handler: async (data) => {
43
+ const job = data as WebhookEventJob | null;
44
+ if (!job?.id) return;
45
+ const count = await service.fanOut(job);
46
+ logger.debug({ eventId: job.id, type: job.type, deliveries: count }, 'webhooks: event fanned out');
47
+ }
48
+ },
49
+ {
50
+ queue: WEBHOOK_DELIVERIES_QUEUE,
51
+ options: {
52
+ retryLimit: dials.retryLimit,
53
+ retryDelaySeconds: dials.retryDelaySeconds,
54
+ retryBackoff: true,
55
+ deadLetter: WEBHOOK_DELIVERIES_HELD_QUEUE
56
+ },
57
+ // Eight deliveries in flight per replica: a receiver that answers
58
+ // nothing holds one slot for its timeout, not the instance.
59
+ concurrency: 8,
60
+ pollingIntervalSeconds: 0.5,
61
+ handler: async (data) => {
62
+ const job = data as WebhookDeliveryJob | null;
63
+ if (!job?.deliveryId) return;
64
+ try {
65
+ await service.attempt(job.deliveryId, { retryable: true });
66
+ } catch (e) {
67
+ if (e instanceof WebhookDeliveryDeferred) {
68
+ // The endpoint already holds its share of the pollers: back in the
69
+ // queue in two seconds, its retry budget untouched.
70
+ await service.requeue(job.deliveryId, 2);
71
+ return;
72
+ }
73
+ if (e instanceof WebhookDeliveryFailed) {
74
+ logger.warn(
75
+ { deliveryId: e.deliveryId, reason: e.message },
76
+ 'webhooks: a delivery attempt failed, will retry'
77
+ );
78
+ }
79
+ throw e;
80
+ }
81
+ }
82
+ },
83
+ {
84
+ queue: WEBHOOK_PURGE_QUEUE,
85
+ schedule: { cron: '0 3 * * *', enabled: retentionDays > 0 },
86
+ handler: async () => {
87
+ const deleted = await service.purge(retentionDays);
88
+ logger.info({ retentionDays, deleted }, 'webhook deliveries retention purge ran');
89
+ }
90
+ }
91
+ ];
92
+ }
@@ -0,0 +1,48 @@
1
+ import { button, esc, fine, fmtDate, makeShell, para, spelledLink, type MailBrand } from '../email/shell.js';
2
+
3
+ /**
4
+ * The one mail of the webhooks: an endpoint was switched off after failing
5
+ * for days, sent once to the person who registered it. Env-free like the
6
+ * account mails (`email/templates.ts`): every value arrives as a parameter,
7
+ * so a tool's mail wall renders it standalone.
8
+ */
9
+ export interface WebhookDisabledEmailParams {
10
+ brand: MailBrand;
11
+ recipientName: string;
12
+ url: string;
13
+ description: string;
14
+ workspaceName: string;
15
+ consecutiveFailures: number;
16
+ firstFailureAt: Date;
17
+ /** The endpoint on its page in the dashboard: where it is re-enabled. */
18
+ manageUrl: string;
19
+ }
20
+
21
+ export function buildWebhookDisabledEmail(p: WebhookDisabledEmailParams): {
22
+ subject: string;
23
+ html: string;
24
+ text: string;
25
+ } {
26
+ const name = p.brand.name;
27
+ const what = p.description ? `${p.description} (${p.url})` : p.url;
28
+ const subject = `A webhook of ${p.workspaceName} was switched off`;
29
+ const html = makeShell(p.brand)({
30
+ preheader: `${what} has not accepted a delivery since ${fmtDate(p.firstFailureAt)}.`,
31
+ eyebrow: 'Webhooks',
32
+ title: 'A webhook was switched off',
33
+ body:
34
+ para(
35
+ `${esc(name)} has stopped delivering to <strong>${esc(what)}</strong>, a webhook of
36
+ <strong>${esc(p.workspaceName)}</strong>: every delivery since ${fmtDate(p.firstFailureAt)}
37
+ failed, ${p.consecutiveFailures} in a row.`
38
+ ) +
39
+ para(
40
+ 'The deliveries it missed are kept on its page. Once the receiver answers again, switch the webhook back on there and redeliver what matters.'
41
+ ) +
42
+ button(p.manageUrl, 'Open the webhook') +
43
+ fine(`You receive this because you registered the webhook, ${esc(p.recipientName)}.`) +
44
+ spelledLink(p.manageUrl)
45
+ });
46
+ const text = `${name} has stopped delivering to ${what}, a webhook of ${p.workspaceName}: every delivery since ${fmtDate(p.firstFailureAt)} failed, ${p.consecutiveFailures} in a row.\n\nOpen the webhook: ${p.manageUrl}\n\nThe deliveries it missed are kept on its page. Once the receiver answers again, switch the webhook back on there and redeliver what matters.`;
47
+ return { subject, html, text };
48
+ }
@@ -0,0 +1,52 @@
1
+ import type { WebhookSubjectValue } from './catalog.js';
2
+
3
+ /**
4
+ * The subscription rule, pure and unit-tested (`webhook-match.test.ts`): an
5
+ * endpoint's `events` are matched against an event's type, its `filters`
6
+ * against the event's subjects. The chassis never knows what a subject IS,
7
+ * only that it is a key with one or several ids.
8
+ */
9
+
10
+ /** `*` matches every type; `family.*` matches the family; a type matches itself. */
11
+ export function matchesEventType(patterns: readonly string[], type: string): boolean {
12
+ const family = type.slice(0, type.indexOf('.'));
13
+ for (const pattern of patterns) {
14
+ if (pattern === '*' || pattern === type) return true;
15
+ if (pattern.endsWith('.*') && pattern.slice(0, -2) === family) return true;
16
+ }
17
+ return false;
18
+ }
19
+
20
+ /**
21
+ * AND across the filter's keys, OR within a key's ids. An event that does not
22
+ * carry a filtered subject does not match (a filter on `project` excludes an
23
+ * event with no project). An empty filter matches everything.
24
+ */
25
+ export function matchesFilters(
26
+ filters: Readonly<Record<string, readonly string[]>>,
27
+ subjects: Readonly<Record<string, WebhookSubjectValue>>
28
+ ): boolean {
29
+ for (const [key, ids] of Object.entries(filters)) {
30
+ if (ids.length === 0) continue;
31
+ const value = subjects[key];
32
+ if (value === undefined) return false;
33
+ const values = typeof value === 'string' ? [value] : value;
34
+ if (!values.some((v) => ids.includes(v))) return false;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ /** Whether an endpoint takes this event: enabled, the type subscribed, the filters satisfied. */
40
+ export function endpointWants(
41
+ endpoint: {
42
+ enabled: boolean;
43
+ events: readonly string[];
44
+ filters: Readonly<Record<string, readonly string[]>>;
45
+ },
46
+ type: string,
47
+ subjects: Readonly<Record<string, WebhookSubjectValue>>
48
+ ): boolean {
49
+ return (
50
+ endpoint.enabled && matchesEventType(endpoint.events, type) && matchesFilters(endpoint.filters, subjects)
51
+ );
52
+ }