@antasphere/chassis-server 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/apikeys.d.ts +4 -1
- package/dist/api/apikeys.d.ts.map +1 -1
- package/dist/api/apikeys.js +11 -1
- package/dist/api/apikeys.js.map +1 -1
- package/dist/api/create-api.d.ts +3 -0
- package/dist/api/create-api.d.ts.map +1 -1
- package/dist/api/create-api.js +8 -3
- package/dist/api/create-api.js.map +1 -1
- package/dist/api/invitations.d.ts.map +1 -1
- package/dist/api/invitations.js +6 -0
- package/dist/api/invitations.js.map +1 -1
- package/dist/api/members.d.ts +5 -2
- package/dist/api/members.d.ts.map +1 -1
- package/dist/api/members.js +1 -0
- package/dist/api/members.js.map +1 -1
- package/dist/api/projects.d.ts +3 -0
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +13 -1
- package/dist/api/projects.js.map +1 -1
- package/dist/api/webhooks.d.ts +24 -0
- package/dist/api/webhooks.d.ts.map +1 -0
- package/dist/api/webhooks.js +170 -0
- package/dist/api/webhooks.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +11 -5
- package/dist/app.js.map +1 -1
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +47 -4
- package/dist/boot.js.map +1 -1
- package/dist/email/index.d.ts +1 -0
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +1 -0
- package/dist/email/index.js.map +1 -1
- package/dist/env.d.ts +9 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +11 -0
- package/dist/env.js.map +1 -1
- package/dist/identity/demo-pass-rules.d.ts.map +1 -1
- package/dist/identity/demo-pass-rules.js +6 -1
- package/dist/identity/demo-pass-rules.js.map +1 -1
- package/dist/identity/hub-reconcile.d.ts +2 -2
- package/dist/identity/hub-reconcile.d.ts.map +1 -1
- package/dist/identity/hub-reconcile.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/pgboss.d.ts +34 -0
- package/dist/jobs/pgboss.d.ts.map +1 -1
- package/dist/jobs/pgboss.js +32 -9
- package/dist/jobs/pgboss.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +11 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-kit.d.ts +2 -0
- package/dist/mcp/tool-kit.d.ts.map +1 -1
- package/dist/mcp/tool-kit.js +5 -1
- package/dist/mcp/tool-kit.js.map +1 -1
- package/dist/mcp/webhooks.d.ts +16 -0
- package/dist/mcp/webhooks.d.ts.map +1 -0
- package/dist/mcp/webhooks.js +226 -0
- package/dist/mcp/webhooks.js.map +1 -0
- package/dist/members/removal.d.ts +10 -2
- package/dist/members/removal.d.ts.map +1 -1
- package/dist/members/removal.js +3 -0
- package/dist/members/removal.js.map +1 -1
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +3 -1
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/scopes.d.ts +9 -1
- package/dist/middleware/scopes.d.ts.map +1 -1
- package/dist/middleware/scopes.js +8 -1
- package/dist/middleware/scopes.js.map +1 -1
- package/dist/middleware/static-assets.d.ts +63 -0
- package/dist/middleware/static-assets.d.ts.map +1 -0
- package/dist/middleware/static-assets.js +125 -0
- package/dist/middleware/static-assets.js.map +1 -0
- package/dist/secret-box.d.ts +10 -0
- package/dist/secret-box.d.ts.map +1 -0
- package/dist/secret-box.js +87 -0
- package/dist/secret-box.js.map +1 -0
- package/dist/testing/helpers.d.ts +8 -0
- package/dist/testing/helpers.d.ts.map +1 -1
- package/dist/testing/helpers.js.map +1 -1
- package/dist/tool-definition.d.ts +24 -0
- package/dist/tool-definition.d.ts.map +1 -1
- package/dist/tool-definition.js.map +1 -1
- package/dist/webhooks/catalog.d.ts +80 -0
- package/dist/webhooks/catalog.d.ts.map +1 -0
- package/dist/webhooks/catalog.js +134 -0
- package/dist/webhooks/catalog.js.map +1 -0
- package/dist/webhooks/index.d.ts +9 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +9 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/jobs.d.ts +12 -0
- package/dist/webhooks/jobs.d.ts.map +1 -0
- package/dist/webhooks/jobs.js +77 -0
- package/dist/webhooks/jobs.js.map +1 -0
- package/dist/webhooks/mail.d.ts +24 -0
- package/dist/webhooks/mail.d.ts.map +1 -0
- package/dist/webhooks/mail.js +21 -0
- package/dist/webhooks/mail.js.map +1 -0
- package/dist/webhooks/match.d.ts +22 -0
- package/dist/webhooks/match.d.ts.map +1 -0
- package/dist/webhooks/match.js +40 -0
- package/dist/webhooks/match.js.map +1 -0
- package/dist/webhooks/safe-fetch.d.ts +94 -0
- package/dist/webhooks/safe-fetch.d.ts.map +1 -0
- package/dist/webhooks/safe-fetch.js +263 -0
- package/dist/webhooks/safe-fetch.js.map +1 -0
- package/dist/webhooks/service.d.ts +245 -0
- package/dist/webhooks/service.d.ts.map +1 -0
- package/dist/webhooks/service.js +861 -0
- package/dist/webhooks/service.js.map +1 -0
- package/dist/webhooks/signing.d.ts +40 -0
- package/dist/webhooks/signing.d.ts.map +1 -0
- package/dist/webhooks/signing.js +108 -0
- package/dist/webhooks/signing.js.map +1 -0
- package/dist/webhooks/tx-send.d.ts +31 -0
- package/dist/webhooks/tx-send.d.ts.map +1 -0
- package/dist/webhooks/tx-send.js +22 -0
- package/dist/webhooks/tx-send.js.map +1 -0
- package/package.json +7 -3
- package/src/api/apikeys.ts +12 -1
- package/src/api/create-api.ts +17 -3
- package/src/api/invitations.ts +6 -0
- package/src/api/members.ts +6 -2
- package/src/api/projects.ts +16 -1
- package/src/api/webhooks.ts +205 -0
- package/src/app.ts +17 -4
- package/src/boot.ts +48 -4
- package/src/email/index.ts +1 -0
- package/src/env.ts +11 -0
- package/src/identity/demo-pass-rules.ts +6 -1
- package/src/identity/hub-reconcile.ts +2 -2
- package/src/index.ts +13 -1
- package/src/jobs/index.ts +1 -0
- package/src/jobs/pgboss.ts +71 -9
- package/src/mcp/index.ts +1 -0
- package/src/mcp/server.ts +10 -2
- package/src/mcp/tool-kit.ts +7 -1
- package/src/mcp/webhooks.ts +370 -0
- package/src/members/removal.ts +21 -1
- package/src/middleware/idempotency.ts +3 -1
- package/src/middleware/index.ts +11 -0
- package/src/middleware/scopes.ts +14 -0
- package/src/middleware/static-assets.ts +144 -0
- package/src/secret-box.ts +93 -0
- package/src/testing/helpers.ts +8 -0
- package/src/tool-definition.ts +29 -0
- package/src/webhooks/catalog.ts +205 -0
- package/src/webhooks/index.ts +58 -0
- package/src/webhooks/jobs.ts +92 -0
- package/src/webhooks/mail.ts +48 -0
- package/src/webhooks/match.ts +52 -0
- package/src/webhooks/safe-fetch.ts +335 -0
- package/src/webhooks/service.ts +1089 -0
- package/src/webhooks/signing.ts +132 -0
- package/src/webhooks/tx-send.ts +53 -0
- package/test/host/drizzle/0002_webhooks.sql +49 -0
- package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
- package/test/host/drizzle/meta/_journal.json +8 -1
- package/test/host/identity.ts +6 -1
- package/test/host/minimal-host.ts +2 -1
- package/test/host/minimal-tool.ts +32 -4
- package/test/integration/mcp-webhooks.test.ts +360 -0
- package/test/integration/webhooks.test.ts +1684 -0
- package/test/unit/secret-box.test.ts +124 -0
- package/test/unit/static-assets.test.ts +227 -0
- package/test/unit/tx-send.test.ts +23 -0
- package/test/unit/webhook-catalog.test.ts +151 -0
- package/test/unit/webhook-env.test.ts +60 -0
- package/test/unit/webhook-jobs.test.ts +92 -0
- package/test/unit/webhook-match.test.ts +81 -0
- package/test/unit/webhook-safe-fetch.test.ts +330 -0
- package/test/unit/webhook-signing.test.ts +169 -0
|
@@ -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
|
+
}
|
package/src/testing/helpers.ts
CHANGED
|
@@ -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. */
|
package/src/tool-definition.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -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
|
+
}
|