@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.
- 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/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/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/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/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/scopes.ts +14 -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/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,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSecretBox, SecretBoxError } from '../../src/secret-box.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The readable-secret box: AES-256-GCM under an HKDF key per (auth secret,
|
|
6
|
+
* info). Every refusal is a SecretBoxError, a malformed string included, and
|
|
7
|
+
* nothing that was changed ever opens to garbage.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const AUTH_SECRET = 'unit-test-secret-0123456789abcdef0123456789abcdef';
|
|
11
|
+
const INFO = 'webhooks:signing-secret';
|
|
12
|
+
const PLAIN = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw';
|
|
13
|
+
|
|
14
|
+
const box = createSecretBox(AUTH_SECRET, INFO);
|
|
15
|
+
|
|
16
|
+
/** Flip one bit of the decoded bytes of part `index` (1 = iv, 2 = tag, 3 = ciphertext). */
|
|
17
|
+
function flipByte(sealed: string, index: 1 | 2 | 3): string {
|
|
18
|
+
const parts = sealed.split('.');
|
|
19
|
+
const bytes = Buffer.from(parts[index]!, 'base64url');
|
|
20
|
+
bytes[0] = bytes[0]! ^ 0x01;
|
|
21
|
+
parts[index] = bytes.toString('base64url');
|
|
22
|
+
return parts.join('.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expectRefused(open: () => string): void {
|
|
26
|
+
let thrown: unknown;
|
|
27
|
+
try {
|
|
28
|
+
open();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
thrown = error;
|
|
31
|
+
}
|
|
32
|
+
expect(thrown).toBeInstanceOf(SecretBoxError);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('createSecretBox', () => {
|
|
36
|
+
it('round-trips a value', () => {
|
|
37
|
+
expect(box.open(box.seal(PLAIN))).toBe(PLAIN);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('round-trips the empty string', () => {
|
|
41
|
+
expect(box.open(box.seal(''))).toBe('');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('spells v1.<iv>.<tag>.<ciphertext> in base64url without the plaintext', () => {
|
|
45
|
+
const sealed = box.seal(PLAIN);
|
|
46
|
+
const parts = sealed.split('.');
|
|
47
|
+
expect(parts).toHaveLength(4);
|
|
48
|
+
expect(parts[0]).toBe('v1');
|
|
49
|
+
expect(Buffer.from(parts[1]!, 'base64url')).toHaveLength(12);
|
|
50
|
+
expect(Buffer.from(parts[2]!, 'base64url')).toHaveLength(16);
|
|
51
|
+
for (const part of parts.slice(1)) expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
52
|
+
expect(sealed).not.toContain('MfKQ9r8');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('seals one value twice to two different strings, both of which open', () => {
|
|
56
|
+
const first = box.seal(PLAIN);
|
|
57
|
+
const second = box.seal(PLAIN);
|
|
58
|
+
expect(first).not.toBe(second);
|
|
59
|
+
expect(first.split('.')[1]).not.toBe(second.split('.')[1]);
|
|
60
|
+
expect(box.open(first)).toBe(PLAIN);
|
|
61
|
+
expect(box.open(second)).toBe(PLAIN);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it.each([
|
|
65
|
+
['the iv', 1],
|
|
66
|
+
['the tag', 2],
|
|
67
|
+
['the ciphertext', 3]
|
|
68
|
+
] as const)('refuses a flipped byte in %s', (_name, index) => {
|
|
69
|
+
const sealed = box.seal(PLAIN);
|
|
70
|
+
expectRefused(() => box.open(flipByte(sealed, index)));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('refuses a value sealed under another info', () => {
|
|
74
|
+
const other = createSecretBox(AUTH_SECRET, 'webhooks:other-purpose');
|
|
75
|
+
expectRefused(() => other.open(box.seal(PLAIN)));
|
|
76
|
+
expectRefused(() => box.open(other.seal(PLAIN)));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('refuses a value sealed under another auth secret', () => {
|
|
80
|
+
const other = createSecretBox('another-auth-secret-0123456789abcdef0123456789', INFO);
|
|
81
|
+
expectRefused(() => other.open(box.seal(PLAIN)));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('opens a value sealed by another box of the same auth secret and info', () => {
|
|
85
|
+
const twin = createSecretBox(AUTH_SECRET, INFO);
|
|
86
|
+
expect(twin.open(box.seal(PLAIN))).toBe(PLAIN);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('keeps unicode plaintext intact', () => {
|
|
90
|
+
const unicode = 'clé secrète: 🔐 — 秘密 — Ωμέγα\u0000end';
|
|
91
|
+
expect(box.open(box.seal(unicode))).toBe(unicode);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('a malformed string is a SecretBoxError and nothing else', () => {
|
|
95
|
+
const sealed = box.seal(PLAIN);
|
|
96
|
+
const [, iv, tag, ciphertext] = sealed.split('.') as [string, string, string, string];
|
|
97
|
+
const cases: Array<[string, unknown]> = [
|
|
98
|
+
['the empty string', ''],
|
|
99
|
+
['no separators', 'garbage'],
|
|
100
|
+
['three parts', `v1.${iv}.${tag}`],
|
|
101
|
+
['five parts', `${sealed}.extra`],
|
|
102
|
+
['an unknown version', `v2.${iv}.${tag}.${ciphertext}`],
|
|
103
|
+
['an empty version', `.${iv}.${tag}.${ciphertext}`],
|
|
104
|
+
['a short iv', `v1.${iv.slice(0, 8)}.${tag}.${ciphertext}`],
|
|
105
|
+
['a long iv', `v1.${iv}AAAA.${tag}.${ciphertext}`],
|
|
106
|
+
['an empty iv', `v1..${tag}.${ciphertext}`],
|
|
107
|
+
['a short tag', `v1.${iv}.${tag.slice(0, 10)}.${ciphertext}`],
|
|
108
|
+
['an empty tag', `v1.${iv}..${ciphertext}`],
|
|
109
|
+
['an empty ciphertext', `v1.${iv}.${tag}.`],
|
|
110
|
+
['a character outside base64url', `v1.${iv}.${tag}.${ciphertext}*`],
|
|
111
|
+
['standard base64 characters', `v1.${iv}.${tag}.+/${ciphertext}`],
|
|
112
|
+
['a non-string', null],
|
|
113
|
+
['a number', 42]
|
|
114
|
+
];
|
|
115
|
+
it.each(cases)('refuses %s', (_name, input) => {
|
|
116
|
+
expectRefused(() => box.open(input as string));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('refuses an empty auth secret or an empty info', () => {
|
|
121
|
+
expect(() => createSecretBox('', INFO)).toThrow(SecretBoxError);
|
|
122
|
+
expect(() => createSecretBox(AUTH_SECRET, '')).toThrow(SecretBoxError);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
4
|
+
import { connectionOf } from '../../src/webhooks/tx-send.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The transactional send reaches the connection behind a drizzle handle
|
|
8
|
+
* through the driver binding's session (`session.client`): this pins the
|
|
9
|
+
* shape against the installed drizzle, so a bump that moves it fails here
|
|
10
|
+
* and not in a delivery.
|
|
11
|
+
*/
|
|
12
|
+
describe('tx-send: the connection behind a drizzle handle', () => {
|
|
13
|
+
it('a plain handle exposes its pool, with query', () => {
|
|
14
|
+
const pool = new pg.Pool({ connectionString: 'postgres://nobody@127.0.0.1:1/none' });
|
|
15
|
+
const db = drizzle(pool);
|
|
16
|
+
expect(connectionOf(db)).toBe(pool);
|
|
17
|
+
expect(typeof connectionOf(db).query).toBe('function');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('something that is not a drizzle handle is refused, naming the shape', () => {
|
|
21
|
+
expect(() => connectionOf({} as never)).toThrow(/session\.client/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
buildWebhookCatalog,
|
|
5
|
+
catalogToWire,
|
|
6
|
+
CHASSIS_WEBHOOK_EVENTS,
|
|
7
|
+
type WebhookCatalogDeclaration,
|
|
8
|
+
type WebhookEventDeclaration
|
|
9
|
+
} from '../../src/webhooks/catalog.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The boot check of the webhooks catalog (PRDCT-3482, verifier round 1, G7):
|
|
13
|
+
* the chassis's events merged with the tool's, every fault named at boot, and
|
|
14
|
+
* the wire form `GET /webhooks/events` answers.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const thingCreated: WebhookEventDeclaration = {
|
|
18
|
+
type: 'thing.created',
|
|
19
|
+
description: 'A thing was created.',
|
|
20
|
+
subjects: ['thing', 'project'],
|
|
21
|
+
data: z.object({ thingId: z.string(), name: z.string() })
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const tool = (over: Partial<WebhookCatalogDeclaration> = {}): WebhookCatalogDeclaration => ({
|
|
25
|
+
subjects: { thing: { field: 'thingId', description: 'A thing, by id' } },
|
|
26
|
+
events: [thingCreated],
|
|
27
|
+
...over
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('buildWebhookCatalog', () => {
|
|
31
|
+
it('merges the chassis events with the tool events', () => {
|
|
32
|
+
const catalog = buildWebhookCatalog(tool());
|
|
33
|
+
for (const e of CHASSIS_WEBHOOK_EVENTS) expect(catalog.events.has(e.type), e.type).toBe(true);
|
|
34
|
+
expect(catalog.events.get('thing.created')).toBe(thingCreated);
|
|
35
|
+
expect(Object.keys(catalog.subjects).sort()).toEqual(['project', 'thing']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('a tool with no webhooks slot gets the chassis catalog alone', () => {
|
|
39
|
+
const catalog = buildWebhookCatalog(undefined);
|
|
40
|
+
expect([...catalog.events.keys()].sort()).toEqual(CHASSIS_WEBHOOK_EVENTS.map((e) => e.type).sort());
|
|
41
|
+
expect(catalog.subjects.project).toMatchObject({ field: 'projectId' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('refuses a type declared twice', () => {
|
|
45
|
+
expect(() => buildWebhookCatalog(tool({ events: [thingCreated, { ...thingCreated }] }))).toThrow(
|
|
46
|
+
/"thing\.created" is declared twice/
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('refuses a tool re-declaring a chassis type', () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
buildWebhookCatalog(tool({ events: [{ ...thingCreated, type: 'project.created' }] }))
|
|
53
|
+
).toThrow(/"project\.created" is declared twice/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('refuses an event carrying a subject nobody declared', () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
buildWebhookCatalog(tool({ events: [{ ...thingCreated, subjects: ['thing', 'widget'] }] }))
|
|
59
|
+
).toThrow(/carries the subject "widget", which webhooks\.subjects does not declare/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each(['Thing.created', 'thing', 'thing.created.now', 'thing-x.created', '1thing.created', 'thing.'])(
|
|
63
|
+
'refuses the malformed type %s',
|
|
64
|
+
(type) => {
|
|
65
|
+
expect(() => buildWebhookCatalog(tool({ events: [{ ...thingCreated, type }] }))).toThrow(
|
|
66
|
+
/must be family\.verb/
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
it.each(['*', 'thing.*'])('refuses the pattern %s as a type', (type) => {
|
|
72
|
+
expect(() => buildWebhookCatalog(tool({ events: [{ ...thingCreated, type }] }))).toThrow(
|
|
73
|
+
new RegExp(`"${type.replace(/[.*]/g, '\\$&')}"`)
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('refuses an event without a description', () => {
|
|
78
|
+
expect(() => buildWebhookCatalog(tool({ events: [{ ...thingCreated, description: '' }] }))).toThrow(
|
|
79
|
+
/"thing\.created" needs a description/
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('refuses a malformed subject key and a subject without a field', () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
buildWebhookCatalog(tool({ subjects: { 'Bad-Key': { field: 'x', description: 'x' } } }))
|
|
86
|
+
).toThrow(/webhooks\.subjects key "Bad-Key"/);
|
|
87
|
+
expect(() => buildWebhookCatalog(tool({ subjects: { thing: { field: '', description: 'x' } } }))).toThrow(
|
|
88
|
+
/webhooks\.subjects\.thing\.field is required/
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('accepts a tool renaming the project subject’s field (a list under projectIds)', () => {
|
|
93
|
+
const catalog = buildWebhookCatalog(
|
|
94
|
+
tool({
|
|
95
|
+
subjects: {
|
|
96
|
+
thing: { field: 'thingId', description: 'A thing, by id' },
|
|
97
|
+
project: { field: 'projectIds', description: 'The projects of a thing', list: true }
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
expect(catalog.subjects.project).toEqual({
|
|
102
|
+
field: 'projectIds',
|
|
103
|
+
description: 'The projects of a thing',
|
|
104
|
+
list: true
|
|
105
|
+
});
|
|
106
|
+
// The chassis's own project events now carry the tool's declaration.
|
|
107
|
+
expect(catalog.events.get('project.created')?.subjects).toEqual(['project']);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('catalogToWire', () => {
|
|
112
|
+
it('carries list: true for a list subject and false otherwise, and the JSON Schema of every event', () => {
|
|
113
|
+
const wire = catalogToWire(
|
|
114
|
+
buildWebhookCatalog(
|
|
115
|
+
tool({
|
|
116
|
+
subjects: {
|
|
117
|
+
thing: { field: 'thingId', description: 'A thing, by id' },
|
|
118
|
+
project: { field: 'projectIds', description: 'The projects of a thing', list: true }
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
expect(wire.subjects).toEqual(
|
|
124
|
+
expect.arrayContaining([
|
|
125
|
+
{ key: 'thing', field: 'thingId', description: 'A thing, by id', list: false },
|
|
126
|
+
{ key: 'project', field: 'projectIds', description: 'The projects of a thing', list: true }
|
|
127
|
+
])
|
|
128
|
+
);
|
|
129
|
+
expect(wire.subjects).toHaveLength(2);
|
|
130
|
+
const created = wire.events.find((e) => e.type === 'thing.created');
|
|
131
|
+
expect(created).toMatchObject({
|
|
132
|
+
description: 'A thing was created.',
|
|
133
|
+
subjects: ['thing', 'project'],
|
|
134
|
+
schema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: { thingId: { type: 'string' }, name: { type: 'string' } },
|
|
137
|
+
required: ['thingId', 'name']
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
expect(wire.events.map((e) => e.type)).toEqual(
|
|
141
|
+
expect.arrayContaining(['webhook.ping', 'project.created', 'thing.created'])
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('the chassis’s own project subject reads list: false', () => {
|
|
146
|
+
const wire = catalogToWire(buildWebhookCatalog(undefined));
|
|
147
|
+
expect(wire.subjects).toEqual([
|
|
148
|
+
{ key: 'project', field: 'projectId', description: expect.any(String), list: false }
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildEnvSchema } from '../../src/env.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The webhooks' environment (PRDCT-3482, verifier round 1, G8): the switch
|
|
6
|
+
* that opens private networks is refused on the cloud edition, by name, and
|
|
7
|
+
* the delivery log's retention defaults to thirty days.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const schema = buildEnvSchema({ version: '0.0.0-test' });
|
|
11
|
+
const BASE = { DATABASE_URL: 'postgres://u:p@localhost:5432/db' };
|
|
12
|
+
const CLOUD = {
|
|
13
|
+
...BASE,
|
|
14
|
+
EDITION: 'cloud',
|
|
15
|
+
HUB_ISSUER_URL: 'https://account.example.com',
|
|
16
|
+
HUB_CLIENT_ID: 'tool-client',
|
|
17
|
+
HUB_CLIENT_SECRET: 'a-hub-client-secret-of-length'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const issuesOf = (source: Record<string, string>) => {
|
|
21
|
+
const result = schema.safeParse(source);
|
|
22
|
+
return result.success
|
|
23
|
+
? []
|
|
24
|
+
: result.error.issues.map((i) => ({ key: i.path.join('.'), message: i.message }));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('WEBHOOKS_ALLOW_PRIVATE_NETWORKS', () => {
|
|
28
|
+
it('is refused on EDITION=cloud, naming the key', () => {
|
|
29
|
+
const issues = issuesOf({ ...CLOUD, WEBHOOKS_ALLOW_PRIVATE_NETWORKS: 'true' });
|
|
30
|
+
expect(issues).toEqual([
|
|
31
|
+
{ key: 'WEBHOOKS_ALLOW_PRIVATE_NETWORKS', message: expect.stringMatching(/never on EDITION=cloud/) }
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('a cloud env without it parses (the refusal is the switch, not the edition)', () => {
|
|
36
|
+
expect(issuesOf(CLOUD)).toEqual([]);
|
|
37
|
+
expect(
|
|
38
|
+
schema.parse({ ...CLOUD, WEBHOOKS_ALLOW_PRIVATE_NETWORKS: 'false' }).WEBHOOKS_ALLOW_PRIVATE_NETWORKS
|
|
39
|
+
).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('parses true on oss, and defaults to false', () => {
|
|
43
|
+
expect(
|
|
44
|
+
schema.parse({ ...BASE, WEBHOOKS_ALLOW_PRIVATE_NETWORKS: 'true' }).WEBHOOKS_ALLOW_PRIVATE_NETWORKS
|
|
45
|
+
).toBe(true);
|
|
46
|
+
expect(schema.parse(BASE).WEBHOOKS_ALLOW_PRIVATE_NETWORKS).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('WEBHOOK_DELIVERY_RETENTION_DAYS', () => {
|
|
51
|
+
it('defaults to 30, takes 0 (keep forever), refuses a negative', () => {
|
|
52
|
+
expect(schema.parse(BASE).WEBHOOK_DELIVERY_RETENTION_DAYS).toBe(30);
|
|
53
|
+
expect(
|
|
54
|
+
schema.parse({ ...BASE, WEBHOOK_DELIVERY_RETENTION_DAYS: '0' }).WEBHOOK_DELIVERY_RETENTION_DAYS
|
|
55
|
+
).toBe(0);
|
|
56
|
+
expect(issuesOf({ ...BASE, WEBHOOK_DELIVERY_RETENTION_DAYS: '-1' }).map((i) => i.key)).toEqual([
|
|
57
|
+
'WEBHOOK_DELIVERY_RETENTION_DAYS'
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Logger } from '../../src/logger.js';
|
|
3
|
+
import { webhookJobs } from '../../src/webhooks/jobs.js';
|
|
4
|
+
import {
|
|
5
|
+
MAX_IN_FLIGHT_PER_ENDPOINT,
|
|
6
|
+
WEBHOOK_DELIVERIES_HELD_QUEUE,
|
|
7
|
+
WEBHOOK_DELIVERIES_QUEUE,
|
|
8
|
+
WEBHOOK_EVENTS_QUEUE,
|
|
9
|
+
WEBHOOK_PURGE_QUEUE,
|
|
10
|
+
WebhookDeliveryDeferred,
|
|
11
|
+
WebhookDeliveryFailed,
|
|
12
|
+
type WebhookService
|
|
13
|
+
} from '../../src/webhooks/service.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The webhooks' queue declarations (verifier round 2, MJ-R2-1): the two
|
|
17
|
+
* queues that must drain a burst poll every half second, the delivery queue
|
|
18
|
+
* runs eight pollers, and an attempt the per-endpoint bound defers goes back
|
|
19
|
+
* in the queue with its retry budget untouched (the job completes), while a
|
|
20
|
+
* failed attempt still throws so pg-boss retries it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as Logger;
|
|
24
|
+
|
|
25
|
+
function fakeService(attempt: (id: string) => Promise<unknown>) {
|
|
26
|
+
const requeue = vi.fn(async (_id: string, _inSeconds: number) => undefined);
|
|
27
|
+
const service = {
|
|
28
|
+
dials: { retryLimit: 2, retryDelaySeconds: 1 },
|
|
29
|
+
attempt: vi.fn(async (id: string) => attempt(id)),
|
|
30
|
+
requeue,
|
|
31
|
+
fanOut: vi.fn(async () => 0),
|
|
32
|
+
hold: vi.fn(async () => undefined),
|
|
33
|
+
purge: vi.fn(async () => 0)
|
|
34
|
+
} as unknown as WebhookService;
|
|
35
|
+
return { service, requeue };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const jobOf = (service: WebhookService, queue: string) => {
|
|
39
|
+
const job = webhookJobs(service, logger, 30).find((j) => j.queue === queue);
|
|
40
|
+
expect(job, `no declaration for ${queue}`).toBeDefined();
|
|
41
|
+
return job!;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const META = { id: 'job-1', queue: WEBHOOK_DELIVERIES_QUEUE, key: null };
|
|
45
|
+
|
|
46
|
+
describe('webhookJobs', () => {
|
|
47
|
+
it('the fan-out and the delivery queues poll every half second; the held queue and the purge keep the default', () => {
|
|
48
|
+
const { service } = fakeService(async () => null);
|
|
49
|
+
const byQueue = new Map(webhookJobs(service, logger, 30).map((j) => [j.queue, j]));
|
|
50
|
+
expect(byQueue.get(WEBHOOK_EVENTS_QUEUE)).toMatchObject({ concurrency: 2, pollingIntervalSeconds: 0.5 });
|
|
51
|
+
expect(byQueue.get(WEBHOOK_DELIVERIES_QUEUE)).toMatchObject({
|
|
52
|
+
concurrency: 8,
|
|
53
|
+
pollingIntervalSeconds: 0.5
|
|
54
|
+
});
|
|
55
|
+
expect(byQueue.get(WEBHOOK_DELIVERIES_HELD_QUEUE)?.pollingIntervalSeconds).toBeUndefined();
|
|
56
|
+
expect(byQueue.get(WEBHOOK_PURGE_QUEUE)?.pollingIntervalSeconds).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('the bound leaves room for other endpoints: two per endpoint of the eight pollers', () => {
|
|
60
|
+
expect(MAX_IN_FLIGHT_PER_ENDPOINT).toBe(2);
|
|
61
|
+
const { service } = fakeService(async () => null);
|
|
62
|
+
expect(jobOf(service, WEBHOOK_DELIVERIES_QUEUE).concurrency).toBeGreaterThan(MAX_IN_FLIGHT_PER_ENDPOINT);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('a deferred attempt is put back in the queue two seconds later and the job completes', async () => {
|
|
66
|
+
const { service, requeue } = fakeService(async (id) => {
|
|
67
|
+
throw new WebhookDeliveryDeferred(id);
|
|
68
|
+
});
|
|
69
|
+
const job = jobOf(service, WEBHOOK_DELIVERIES_QUEUE);
|
|
70
|
+
vi.mocked(logger.warn).mockClear();
|
|
71
|
+
await expect(job.handler({ deliveryId: 'd-1' }, META)).resolves.toBeUndefined();
|
|
72
|
+
expect(requeue).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(requeue).toHaveBeenCalledWith('d-1', 2);
|
|
74
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('a failed attempt throws so pg-boss retries it, and is never requeued by hand', async () => {
|
|
78
|
+
const { service, requeue } = fakeService(async (id) => {
|
|
79
|
+
throw new WebhookDeliveryFailed(id, 'HTTP 500');
|
|
80
|
+
});
|
|
81
|
+
const job = jobOf(service, WEBHOOK_DELIVERIES_QUEUE);
|
|
82
|
+
await expect(job.handler({ deliveryId: 'd-2' }, META)).rejects.toBeInstanceOf(WebhookDeliveryFailed);
|
|
83
|
+
expect(requeue).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('a delivered attempt neither throws nor requeues', async () => {
|
|
87
|
+
const { service, requeue } = fakeService(async () => ({ status: 'delivered' }));
|
|
88
|
+
const job = jobOf(service, WEBHOOK_DELIVERIES_QUEUE);
|
|
89
|
+
await expect(job.handler({ deliveryId: 'd-3' }, META)).resolves.toBeUndefined();
|
|
90
|
+
expect(requeue).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { endpointWants, matchesEventType, matchesFilters } from '../../src/webhooks/match.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The subscription rule (PRDCT-3482, verifier round 1, G7): an endpoint's
|
|
6
|
+
* event patterns against a type, its filters against the event's subjects.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
describe('matchesEventType', () => {
|
|
10
|
+
it('`*` matches every type', () => {
|
|
11
|
+
expect(matchesEventType(['*'], 'thing.created')).toBe(true);
|
|
12
|
+
expect(matchesEventType(['*'], 'webhook.ping')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('a family matches its own types only', () => {
|
|
16
|
+
expect(matchesEventType(['thing.*'], 'thing.created')).toBe(true);
|
|
17
|
+
expect(matchesEventType(['thing.*'], 'thing.deleted')).toBe(true);
|
|
18
|
+
expect(matchesEventType(['thing.*'], 'project.created')).toBe(false);
|
|
19
|
+
// A family is the whole segment before the dot, never a prefix of it.
|
|
20
|
+
expect(matchesEventType(['thing.*'], 'things.created')).toBe(false);
|
|
21
|
+
expect(matchesEventType(['thin.*'], 'thing.created')).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('an exact type matches itself only', () => {
|
|
25
|
+
expect(matchesEventType(['thing.created'], 'thing.created')).toBe(true);
|
|
26
|
+
expect(matchesEventType(['thing.created'], 'thing.deleted')).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('any of several patterns; none is no match', () => {
|
|
30
|
+
expect(matchesEventType(['project.created', 'thing.deleted'], 'thing.deleted')).toBe(true);
|
|
31
|
+
expect(matchesEventType(['project.created', 'thing.deleted'], 'thing.created')).toBe(false);
|
|
32
|
+
expect(matchesEventType([], 'thing.created')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('matchesFilters', () => {
|
|
37
|
+
it('an empty filter matches everything, an event with no subject included', () => {
|
|
38
|
+
expect(matchesFilters({}, {})).toBe(true);
|
|
39
|
+
expect(matchesFilters({}, { thing: 't1' })).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('a key with no ids is no constraint', () => {
|
|
43
|
+
expect(matchesFilters({ project: [] }, { thing: 't1' })).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('OR within one key: any id matches, on a scalar or a list', () => {
|
|
47
|
+
expect(matchesFilters({ thing: ['t1', 't2'] }, { thing: 't2' })).toBe(true);
|
|
48
|
+
expect(matchesFilters({ thing: ['t1', 't2'] }, { thing: 't3' })).toBe(false);
|
|
49
|
+
expect(matchesFilters({ project: ['p9'] }, { project: ['p1', 'p9'] })).toBe(true);
|
|
50
|
+
expect(matchesFilters({ project: ['p9'] }, { project: ['p1', 'p2'] })).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('AND across keys: every filtered key must match', () => {
|
|
54
|
+
const filters = { thing: ['t-a'], project: ['p-a'] };
|
|
55
|
+
expect(matchesFilters(filters, { thing: 't-a', project: ['p-a'] })).toBe(true);
|
|
56
|
+
expect(matchesFilters(filters, { thing: 't-a', project: ['p-b'] })).toBe(false);
|
|
57
|
+
expect(matchesFilters(filters, { thing: 't-b', project: ['p-a'] })).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('an event missing a filtered subject does not match', () => {
|
|
61
|
+
expect(matchesFilters({ project: ['p1'] }, { thing: 't1' })).toBe(false);
|
|
62
|
+
expect(matchesFilters({ thing: ['t1'], project: ['p1'] }, { thing: 't1' })).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('endpointWants', () => {
|
|
67
|
+
const endpoint = { enabled: true, events: ['thing.*'], filters: { thing: ['t1'] } };
|
|
68
|
+
|
|
69
|
+
it('enabled, the type subscribed and the filters satisfied', () => {
|
|
70
|
+
expect(endpointWants(endpoint, 'thing.created', { thing: 't1' })).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a disabled endpoint wants nothing', () => {
|
|
74
|
+
expect(endpointWants({ ...endpoint, enabled: false }, 'thing.created', { thing: 't1' })).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('the type unsubscribed, or the filters unmet, is no', () => {
|
|
78
|
+
expect(endpointWants(endpoint, 'project.created', { thing: 't1' })).toBe(false);
|
|
79
|
+
expect(endpointWants(endpoint, 'thing.created', { thing: 't2' })).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|