@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,205 @@
|
|
|
1
|
+
import type { OpenAPIHono } from '@hono/zod-openapi';
|
|
2
|
+
import type { Principal } from '@antasphere/chassis-contract';
|
|
3
|
+
import {
|
|
4
|
+
webhookCatalogRoute,
|
|
5
|
+
webhookCreateRoute,
|
|
6
|
+
webhookDeleteRoute,
|
|
7
|
+
webhookDeliveriesListRoute,
|
|
8
|
+
webhookDeliveryGetRoute,
|
|
9
|
+
webhookGetRoute,
|
|
10
|
+
webhookRedeliverRoute,
|
|
11
|
+
webhookRotateSecretRoute,
|
|
12
|
+
webhooksListRoute,
|
|
13
|
+
webhookTestRoute,
|
|
14
|
+
webhookUpdateRoute
|
|
15
|
+
} from '@antasphere/chassis-contract/routes';
|
|
16
|
+
import { requireAuth, requireNonGuest } from '../middleware/auth-context.js';
|
|
17
|
+
import { catalogToWire, WebhookRefusedError, type WebhookService } from '../webhooks/index.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The webhook routes (PRDCT-3482), the same on both editions and never
|
|
21
|
+
* hub-managed: a webhook is the tool's, on a self-hosted instance and on
|
|
22
|
+
* the cloud alike (Romain's rule of 27 September 2026).
|
|
23
|
+
*
|
|
24
|
+
* A webhook is a standing read of the workspace to a URL of the caller's
|
|
25
|
+
* choice, so the whole family, reads included, is an OWNER's or an ADMIN's
|
|
26
|
+
* act, checked inside each handler (never a path-pattern `requireRole`, the
|
|
27
|
+
* `/thing/:id` trap: the catalog lives under the same prefix and is every
|
|
28
|
+
* non-guest member's). A guest is refused flat; a session a demo link
|
|
29
|
+
* opened is refused every write (`DEMO_SESSION_REFUSED_API_ROUTES`); a
|
|
30
|
+
* machine principal needs the tool's dedicated scope (`middleware/scopes.ts`).
|
|
31
|
+
* An endpoint of another workspace reads as absent: 404, never 403.
|
|
32
|
+
*
|
|
33
|
+
* The secret is answered ONCE, at creation and at rotation, and never
|
|
34
|
+
* written to the audit row.
|
|
35
|
+
*/
|
|
36
|
+
export interface WebhookRouteDeps {
|
|
37
|
+
service: WebhookService;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const err = (code: string, message: string) => ({ error: { code, message } });
|
|
41
|
+
const FORBIDDEN = err('forbidden', 'Only an owner or an admin manages webhooks');
|
|
42
|
+
const NOT_FOUND = err('not_found', 'Webhook not found');
|
|
43
|
+
const DELIVERY_NOT_FOUND = err('not_found', 'Delivery not found');
|
|
44
|
+
|
|
45
|
+
const canManage = (principal: Principal) => principal.role === 'owner' || principal.role === 'admin';
|
|
46
|
+
|
|
47
|
+
export function registerWebhookRoutes(api: OpenAPIHono, deps: WebhookRouteDeps): void {
|
|
48
|
+
const { service } = deps;
|
|
49
|
+
|
|
50
|
+
for (const path of ['/webhooks', '/webhooks/*']) {
|
|
51
|
+
api.use(path, requireAuth());
|
|
52
|
+
api.use(path, requireNonGuest());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The catalog BEFORE the `{id}` routes: `events` is not an id. Every
|
|
56
|
+
// non-guest member reads it (an agent discovers the types with a read key).
|
|
57
|
+
api.openapi(webhookCatalogRoute, async (c) => c.json(catalogToWire(service.catalog), 200));
|
|
58
|
+
|
|
59
|
+
api.openapi(webhooksListRoute, async (c) => {
|
|
60
|
+
const principal = c.get('principal')!;
|
|
61
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
62
|
+
const { cursor, limit } = c.req.valid('query');
|
|
63
|
+
return c.json(await service.list(principal.workspaceId, cursor, limit), 200);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
api.openapi(webhookCreateRoute, async (c) => {
|
|
67
|
+
const principal = c.get('principal')!;
|
|
68
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
69
|
+
const body = c.req.valid('json');
|
|
70
|
+
let created: Awaited<ReturnType<WebhookService['create']>>;
|
|
71
|
+
try {
|
|
72
|
+
created = await service.create(principal.workspaceId, principal.userId, body);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e instanceof WebhookRefusedError && e.code !== 'not_found')
|
|
75
|
+
return c.json(err(e.code, e.message), 400);
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
c.set('audit', {
|
|
79
|
+
action: 'webhook.create',
|
|
80
|
+
resourceType: 'webhook',
|
|
81
|
+
resourceId: created.endpoint.id,
|
|
82
|
+
metadata: {
|
|
83
|
+
url: created.endpoint.url,
|
|
84
|
+
events: created.endpoint.events,
|
|
85
|
+
filters: created.endpoint.filters
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return c.json({ ...created.endpoint, secret: created.secret }, 201);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
api.openapi(webhookGetRoute, async (c) => {
|
|
92
|
+
const principal = c.get('principal')!;
|
|
93
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
94
|
+
const { id } = c.req.valid('param');
|
|
95
|
+
const endpoint = await service.get(principal.workspaceId, id);
|
|
96
|
+
return endpoint ? c.json(endpoint, 200) : c.json(NOT_FOUND, 404);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
api.openapi(webhookUpdateRoute, async (c) => {
|
|
100
|
+
const principal = c.get('principal')!;
|
|
101
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
102
|
+
const { id } = c.req.valid('param');
|
|
103
|
+
const body = c.req.valid('json');
|
|
104
|
+
let updated: Awaited<ReturnType<WebhookService['update']>>;
|
|
105
|
+
try {
|
|
106
|
+
updated = await service.update(principal.workspaceId, id, body);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e instanceof WebhookRefusedError && e.code !== 'not_found')
|
|
109
|
+
return c.json(err(e.code, e.message), 400);
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
if (!updated) return c.json(NOT_FOUND, 404);
|
|
113
|
+
c.set('audit', {
|
|
114
|
+
action: 'webhook.update',
|
|
115
|
+
resourceType: 'webhook',
|
|
116
|
+
resourceId: id,
|
|
117
|
+
metadata: { fields: Object.keys(body) }
|
|
118
|
+
});
|
|
119
|
+
return c.json(updated, 200);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
api.openapi(webhookDeleteRoute, async (c) => {
|
|
123
|
+
const principal = c.get('principal')!;
|
|
124
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
125
|
+
const { id } = c.req.valid('param');
|
|
126
|
+
const removed = await service.remove(principal.workspaceId, id);
|
|
127
|
+
if (!removed) return c.json(NOT_FOUND, 404);
|
|
128
|
+
c.set('audit', {
|
|
129
|
+
action: 'webhook.delete',
|
|
130
|
+
resourceType: 'webhook',
|
|
131
|
+
resourceId: id,
|
|
132
|
+
metadata: { url: removed.url }
|
|
133
|
+
});
|
|
134
|
+
return c.json(removed, 200);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
api.openapi(webhookRotateSecretRoute, async (c) => {
|
|
138
|
+
const principal = c.get('principal')!;
|
|
139
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
140
|
+
const { id } = c.req.valid('param');
|
|
141
|
+
const rotated = await service.rotateSecret(principal.workspaceId, id);
|
|
142
|
+
if (!rotated) return c.json(NOT_FOUND, 404);
|
|
143
|
+
c.set('audit', {
|
|
144
|
+
action: 'webhook.rotate_secret',
|
|
145
|
+
resourceType: 'webhook',
|
|
146
|
+
resourceId: id,
|
|
147
|
+
metadata: { previousSecretExpiresAt: rotated.previousSecretExpiresAt }
|
|
148
|
+
});
|
|
149
|
+
return c.json(
|
|
150
|
+
{
|
|
151
|
+
...rotated.endpoint,
|
|
152
|
+
secret: rotated.secret,
|
|
153
|
+
previousSecretExpiresAt: rotated.previousSecretExpiresAt
|
|
154
|
+
},
|
|
155
|
+
200
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
api.openapi(webhookTestRoute, async (c) => {
|
|
160
|
+
const principal = c.get('principal')!;
|
|
161
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
162
|
+
const { id } = c.req.valid('param');
|
|
163
|
+
const delivery = await service.test(principal.workspaceId, id, principal.userId);
|
|
164
|
+
if (!delivery) return c.json(NOT_FOUND, 404);
|
|
165
|
+
c.set('audit', {
|
|
166
|
+
action: 'webhook.test',
|
|
167
|
+
resourceType: 'webhook',
|
|
168
|
+
resourceId: id,
|
|
169
|
+
metadata: { deliveryId: delivery.id, status: delivery.status, lastStatusCode: delivery.lastStatusCode }
|
|
170
|
+
});
|
|
171
|
+
return c.json({ delivery }, 200);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
api.openapi(webhookDeliveriesListRoute, async (c) => {
|
|
175
|
+
const principal = c.get('principal')!;
|
|
176
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
177
|
+
const { id } = c.req.valid('param');
|
|
178
|
+
const { cursor, limit, status } = c.req.valid('query');
|
|
179
|
+
const page = await service.listDeliveries(principal.workspaceId, id, { cursor, limit, status });
|
|
180
|
+
return page ? c.json(page, 200) : c.json(NOT_FOUND, 404);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
api.openapi(webhookDeliveryGetRoute, async (c) => {
|
|
184
|
+
const principal = c.get('principal')!;
|
|
185
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
186
|
+
const { id, deliveryId } = c.req.valid('param');
|
|
187
|
+
const delivery = await service.getDelivery(principal.workspaceId, id, deliveryId);
|
|
188
|
+
return delivery ? c.json(delivery, 200) : c.json(DELIVERY_NOT_FOUND, 404);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
api.openapi(webhookRedeliverRoute, async (c) => {
|
|
192
|
+
const principal = c.get('principal')!;
|
|
193
|
+
if (!canManage(principal)) return c.json(FORBIDDEN, 403);
|
|
194
|
+
const { id, deliveryId } = c.req.valid('param');
|
|
195
|
+
const delivery = await service.redeliver(principal.workspaceId, id, deliveryId);
|
|
196
|
+
if (!delivery) return c.json(DELIVERY_NOT_FOUND, 404);
|
|
197
|
+
c.set('audit', {
|
|
198
|
+
action: 'webhook.redeliver',
|
|
199
|
+
resourceType: 'webhook',
|
|
200
|
+
resourceId: id,
|
|
201
|
+
metadata: { deliveryId: delivery.id, eventId: delivery.eventId, eventType: delivery.eventType }
|
|
202
|
+
});
|
|
203
|
+
return c.json(delivery, 201);
|
|
204
|
+
});
|
|
205
|
+
}
|
package/src/boot.ts
CHANGED
|
@@ -52,6 +52,8 @@ import { createOtel } from './observability/index.js';
|
|
|
52
52
|
import { bindEditionSeams } from './platform/index.js';
|
|
53
53
|
import { AllowAllEntitlements } from './platform/index.js';
|
|
54
54
|
import { EventBus } from './platform/index.js';
|
|
55
|
+
import { buildWebhookCatalog, WebhookService, webhookJobs } from './webhooks/index.js';
|
|
56
|
+
import { bindMembershipRemoval } from './members/removal.js';
|
|
55
57
|
import { LocalIdentityProvider } from './platform/index.js';
|
|
56
58
|
import { createRegistry } from './platform/index.js';
|
|
57
59
|
import { NoopUsageSink } from './platform/index.js';
|
|
@@ -540,6 +542,26 @@ export async function bootPlatform<
|
|
|
540
542
|
}
|
|
541
543
|
}
|
|
542
544
|
|
|
545
|
+
// Outbound webhooks (PRDCT-3482): the catalog checked, the service built
|
|
546
|
+
// BEFORE the job runtime (its four queues are declared to it like a tool's
|
|
547
|
+
// own) and attached to it right after; a tool's `services` slot receives
|
|
548
|
+
// the emitter, the API app the service, the bus the chassis's own events.
|
|
549
|
+
const webhookCatalog = buildWebhookCatalog(tool.webhooks);
|
|
550
|
+
const webhooks = new WebhookService({
|
|
551
|
+
db: db.db,
|
|
552
|
+
logger,
|
|
553
|
+
authSecret,
|
|
554
|
+
identity: tool.identity,
|
|
555
|
+
version: tool.runtime.version,
|
|
556
|
+
policy: { allowPrivateNetworks: env.WEBHOOKS_ALLOW_PRIVATE_NETWORKS, httpsOnly: env.EDITION === 'cloud' },
|
|
557
|
+
email,
|
|
558
|
+
brand: { name: tool.identity.displayName, tagline: tool.copy.mail.tagline },
|
|
559
|
+
publicBaseUrl: env.PUBLIC_BASE_URL,
|
|
560
|
+
catalog: webhookCatalog,
|
|
561
|
+
dials: overrides.webhookDials,
|
|
562
|
+
fetchOptions: overrides.webhookFetch
|
|
563
|
+
});
|
|
564
|
+
|
|
543
565
|
// The live user-scoped federation stack (cloud only, internal/federation.md):
|
|
544
566
|
// each user's OWN hub grant (offline_access refresh token on the account
|
|
545
567
|
// row) → as-the-user `GET /orgs` reads → the org reconciler that the
|
|
@@ -611,7 +633,7 @@ export async function bootPlatform<
|
|
|
611
633
|
audit,
|
|
612
634
|
logger,
|
|
613
635
|
dials: hubDials,
|
|
614
|
-
onMembershipRemoval: tool.membershipRemoval,
|
|
636
|
+
onMembershipRemoval: bindMembershipRemoval(tool.membershipRemoval, { webhooks }),
|
|
615
637
|
...(overrides.hubNow ? { now: overrides.hubNow } : {})
|
|
616
638
|
})
|
|
617
639
|
: undefined;
|
|
@@ -662,9 +684,14 @@ export async function bootPlatform<
|
|
|
662
684
|
overrides.usageDownstream ?? hubUsagePoster ?? new NoopUsageSink(),
|
|
663
685
|
auth,
|
|
664
686
|
audit,
|
|
665
|
-
|
|
687
|
+
[
|
|
688
|
+
...webhookJobs(webhooks, logger, env.WEBHOOK_DELIVERY_RETENTION_DAYS),
|
|
689
|
+
...(tool.jobs?.({ env, db: db.db, logger, getTool: () => domainRef.current }) ?? [])
|
|
690
|
+
],
|
|
666
691
|
usageRetry
|
|
667
692
|
);
|
|
693
|
+
webhooks.attach(jobs.boss);
|
|
694
|
+
webhooks.bindBus(events.chassisView());
|
|
668
695
|
// The tool's billing-rail declarations (slot 22), asserted once here: a
|
|
669
696
|
// route that meters an action the price book does not know, or a limit no
|
|
670
697
|
// tier values, stops the boot naming the route.
|
|
@@ -806,6 +833,7 @@ export async function bootPlatform<
|
|
|
806
833
|
events,
|
|
807
834
|
authSecret,
|
|
808
835
|
timers: jobs.timers,
|
|
836
|
+
webhooks,
|
|
809
837
|
overrides: overrides.tool
|
|
810
838
|
};
|
|
811
839
|
const domain = tool.services(serviceCore);
|
|
@@ -849,6 +877,7 @@ export async function bootPlatform<
|
|
|
849
877
|
principalGate: seams.principalGate,
|
|
850
878
|
entitlements,
|
|
851
879
|
entitlementCloud,
|
|
880
|
+
webhooks,
|
|
852
881
|
// Cloud only (PRDCT-2443): POST /workspaces creates the organization at
|
|
853
882
|
// the hub AS THE CALLER, then forces their reconcile. undefined on oss —
|
|
854
883
|
// the route creates locally there.
|
|
@@ -885,7 +914,8 @@ export async function bootPlatform<
|
|
|
885
914
|
...(hubGrant?.promMetrics ?? []),
|
|
886
915
|
...(hubUsagePoster?.promMetrics ?? []),
|
|
887
916
|
...(hubCreditCheck?.promMetrics ?? []),
|
|
888
|
-
...jobs.promMetrics
|
|
917
|
+
...jobs.promMetrics,
|
|
918
|
+
...webhooks.promMetrics
|
|
889
919
|
]) {
|
|
890
920
|
metrics.registry.registerMetric(metric);
|
|
891
921
|
}
|
|
@@ -952,5 +982,19 @@ export async function bootPlatform<
|
|
|
952
982
|
state.reason = readinessRefusal;
|
|
953
983
|
}
|
|
954
984
|
|
|
955
|
-
return {
|
|
985
|
+
return {
|
|
986
|
+
app,
|
|
987
|
+
env,
|
|
988
|
+
logger,
|
|
989
|
+
state,
|
|
990
|
+
db,
|
|
991
|
+
auth,
|
|
992
|
+
registry,
|
|
993
|
+
jobs,
|
|
994
|
+
email,
|
|
995
|
+
otel,
|
|
996
|
+
authSecret,
|
|
997
|
+
webhooks,
|
|
998
|
+
tool: domain
|
|
999
|
+
};
|
|
956
1000
|
}
|
package/src/email/index.ts
CHANGED
package/src/env.ts
CHANGED
|
@@ -266,6 +266,10 @@ const chassisEnvShape = (version: string) => ({
|
|
|
266
266
|
METRICS_TOKEN: optionalString(z.string().min(8)),
|
|
267
267
|
/** Days of audit_log to keep (nightly purge at 03:00). 0 = keep forever. */
|
|
268
268
|
AUDIT_RETENTION_DAYS: numeric(z.coerce.number().int().min(0).default(365)),
|
|
269
|
+
/** Days of webhook deliveries (the per-endpoint log) to keep (nightly purge at 03:00). 0 = keep forever. */
|
|
270
|
+
WEBHOOK_DELIVERY_RETENTION_DAYS: numeric(z.coerce.number().int().min(0).default(30)),
|
|
271
|
+
/** Let webhook endpoints target private, loopback and link-local addresses (a self-hoster's own n8n on the LAN). Default false: a webhook never reaches the instance's own network. Refused on EDITION=cloud. */
|
|
272
|
+
WEBHOOKS_ALLOW_PRIVATE_NETWORKS: booleanish.default(false),
|
|
269
273
|
/** Grace period for orphaned users (accounts with ZERO workspace memberships, e.g. setup-race losers): the nightly 03:00 sweep deletes them once older than this many hours. A user with ANY membership row — even deactivated — is never touched. 0 = sweep disabled. */
|
|
270
274
|
ORPHAN_USER_RETENTION_HOURS: numeric(z.coerce.number().int().min(0).default(72)),
|
|
271
275
|
/** pino level. */
|
|
@@ -372,6 +376,13 @@ export function buildEnvSchema<TShape extends z.ZodRawShape = {}>(
|
|
|
372
376
|
extension?.refine?.(env, ctx);
|
|
373
377
|
demoSignInRefusal(env, ctx);
|
|
374
378
|
if (env.EDITION !== 'cloud') return;
|
|
379
|
+
if (env.WEBHOOKS_ALLOW_PRIVATE_NETWORKS) {
|
|
380
|
+
ctx.addIssue({
|
|
381
|
+
code: 'custom',
|
|
382
|
+
path: ['WEBHOOKS_ALLOW_PRIVATE_NETWORKS'],
|
|
383
|
+
message: 'never on EDITION=cloud: a webhook of the cloud edition reaches public addresses only'
|
|
384
|
+
});
|
|
385
|
+
}
|
|
375
386
|
for (const key of HUB_REQUIRED_VARS) {
|
|
376
387
|
if (env[key] === undefined) {
|
|
377
388
|
ctx.addIssue({
|
|
@@ -220,7 +220,12 @@ export const DEMO_SESSION_REFUSED_API_ROUTES: readonly DemoSessionRefusedApiRout
|
|
|
220
220
|
{ method: 'ALL', path: '/demo/passes', does: 'manage demo links' },
|
|
221
221
|
{ method: 'ALL', path: '/demo/passes/*', does: 'manage demo links' },
|
|
222
222
|
{ method: 'POST', path: '/workspaces', does: 'create a workspace' },
|
|
223
|
-
{ method: 'PUT', path: '/me/default-workspace', does: 'change your default workspace' }
|
|
223
|
+
{ method: 'PUT', path: '/me/default-workspace', does: 'change your default workspace' },
|
|
224
|
+
// A webhook is something the workspace keeps, and its secret is a credential (PRDCT-3482).
|
|
225
|
+
{ method: 'POST', path: '/webhooks', does: 'register a webhook' },
|
|
226
|
+
{ method: 'PATCH', path: '/webhooks/*', does: 'change a webhook' },
|
|
227
|
+
{ method: 'DELETE', path: '/webhooks/*', does: 'delete a webhook' },
|
|
228
|
+
{ method: 'POST', path: '/webhooks/*', does: 'act on a webhook' }
|
|
224
229
|
];
|
|
225
230
|
|
|
226
231
|
/** The refusal's sentence for one entry of `DEMO_SESSION_REFUSED_API_ROUTES`. */
|
|
@@ -3,7 +3,7 @@ import { and, eq, inArray, isNotNull, ne, notInArray, sql } from 'drizzle-orm';
|
|
|
3
3
|
import { workspaceMembers, workspaces, type Db } from '@antasphere/chassis-db';
|
|
4
4
|
import type { AuditService } from '../audit/service.js';
|
|
5
5
|
import type { Logger } from '../logger.js';
|
|
6
|
-
import { deleteMembershipGrants, type
|
|
6
|
+
import { deleteMembershipGrants, type BoundMembershipRemoval } from '../members/removal.js';
|
|
7
7
|
import { projectOrgMembership, projectOrgTeams, projectTeamSeats } from './hub-projection.js';
|
|
8
8
|
import type { HubDeniedOrg, HubUserClient, LoginAccessToken } from './hub-user-client.js';
|
|
9
9
|
|
|
@@ -130,7 +130,7 @@ export interface HubOrgReconcilerDeps {
|
|
|
130
130
|
logger: Logger;
|
|
131
131
|
dials: HubFederationDials;
|
|
132
132
|
/** The tool's half of a removal (the `membershipRemoval` slot), run in the sweep's transaction. */
|
|
133
|
-
onMembershipRemoval?:
|
|
133
|
+
onMembershipRemoval?: BoundMembershipRemoval | undefined;
|
|
134
134
|
/** Test seam. */
|
|
135
135
|
now?: () => number;
|
|
136
136
|
}
|
package/src/index.ts
CHANGED
|
@@ -62,4 +62,16 @@ export function createPlatform<
|
|
|
62
62
|
): Platform<TEnvShape, TDomain, TEvents, TToolOverrides> {
|
|
63
63
|
return { boot: (source, overrides) => bootPlatform(tool, source, overrides) };
|
|
64
64
|
}
|
|
65
|
-
export type {
|
|
65
|
+
export type {
|
|
66
|
+
MembershipRemovalContext,
|
|
67
|
+
MembershipRemovalHook,
|
|
68
|
+
RemovedMembership,
|
|
69
|
+
RemovalCounts
|
|
70
|
+
} from './members/removal.js';
|
|
71
|
+
export type {
|
|
72
|
+
WebhookCatalogDeclaration,
|
|
73
|
+
WebhookEmitter,
|
|
74
|
+
WebhookEventDeclaration,
|
|
75
|
+
WebhookSubjectDeclaration,
|
|
76
|
+
WebhookSubjectValue
|
|
77
|
+
} from './webhooks/index.js';
|
package/src/jobs/index.ts
CHANGED
package/src/jobs/pgboss.ts
CHANGED
|
@@ -69,9 +69,55 @@ export const ORPHAN_USER_PURGE_QUEUE = 'orphan-user-purge';
|
|
|
69
69
|
export interface JobDeclaration<TData extends object = object> {
|
|
70
70
|
queue: string;
|
|
71
71
|
schedule?: { cron: string; enabled?: boolean };
|
|
72
|
+
/**
|
|
73
|
+
* The queue's own settings (PRDCT-3482), applied at install on every
|
|
74
|
+
* worker boot: the retry budget of the jobs sent to it and its dead
|
|
75
|
+
* letter, the queue a job goes to when the budget runs out. The dead
|
|
76
|
+
* letter is named ON THE QUEUE, never on a send (pg-boss stamps it on
|
|
77
|
+
* each job row at insert, and an api-role replica sends with no DDL), so
|
|
78
|
+
* it must be declared BEFORE the queue that names it.
|
|
79
|
+
*/
|
|
80
|
+
options?: QueueOptions;
|
|
81
|
+
/**
|
|
82
|
+
* How many pollers this replica runs on the queue (default 1). pg-boss's
|
|
83
|
+
* worker is a serial loop, one job at a time; a queue whose handler waits
|
|
84
|
+
* on the outside (a webhook delivery, up to its timeout) declares more, so
|
|
85
|
+
* one silent receiver never holds every other job of the instance behind
|
|
86
|
+
* it (verifier round 1, MJ1). Each poller fetches with SKIP LOCKED, so the
|
|
87
|
+
* pollers never share a job.
|
|
88
|
+
*/
|
|
89
|
+
concurrency?: number;
|
|
90
|
+
/**
|
|
91
|
+
* How often each poller asks for work, in seconds (pg-boss's default is
|
|
92
|
+
* two). pg-boss's worker sleeps this long after EVERY loop, jobs found or
|
|
93
|
+
* not, so a queue that must drain a burst declares a short one (verifier
|
|
94
|
+
* round 2, MJ-R2-1).
|
|
95
|
+
*/
|
|
96
|
+
pollingIntervalSeconds?: number;
|
|
72
97
|
handler(data: TData | null, job: TimerJob): Promise<void>;
|
|
73
98
|
}
|
|
74
99
|
|
|
100
|
+
/** A queue's settings, as a declaration names them. */
|
|
101
|
+
export interface QueueOptions {
|
|
102
|
+
retryLimit?: number;
|
|
103
|
+
retryDelaySeconds?: number;
|
|
104
|
+
retryBackoff?: boolean;
|
|
105
|
+
/** The queue a job moves to when its retries run out; declared earlier in the list. */
|
|
106
|
+
deadLetter?: string;
|
|
107
|
+
expireInSeconds?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pgBossQueueOptions(name: string, options: QueueOptions): PgBoss.Queue {
|
|
111
|
+
return {
|
|
112
|
+
name,
|
|
113
|
+
...(options.retryLimit !== undefined ? { retryLimit: options.retryLimit } : {}),
|
|
114
|
+
...(options.retryDelaySeconds !== undefined ? { retryDelay: options.retryDelaySeconds } : {}),
|
|
115
|
+
...(options.retryBackoff !== undefined ? { retryBackoff: options.retryBackoff } : {}),
|
|
116
|
+
...(options.deadLetter !== undefined ? { deadLetter: options.deadLetter } : {}),
|
|
117
|
+
...(options.expireInSeconds !== undefined ? { expireInSeconds: options.expireInSeconds } : {})
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
75
121
|
/**
|
|
76
122
|
* Advisory-lock key serializing pg-boss's install DDL across replicas.
|
|
77
123
|
*
|
|
@@ -219,6 +265,10 @@ export async function createJobs(
|
|
|
219
265
|
// The tool's queues: same install section, same lock, declaration order.
|
|
220
266
|
for (const job of toolJobs) {
|
|
221
267
|
await boss.createQueue(job.queue);
|
|
268
|
+
// The settings are applied on every boot (`createQueue` leaves an
|
|
269
|
+
// existing queue as it is), so a changed budget reaches an installed
|
|
270
|
+
// instance at its next worker boot.
|
|
271
|
+
if (job.options) await boss.updateQueue(job.queue, pgBossQueueOptions(job.queue, job.options));
|
|
222
272
|
if (!job.schedule) continue;
|
|
223
273
|
if (job.schedule.enabled !== false) {
|
|
224
274
|
await boss.schedule(job.queue, job.schedule.cron);
|
|
@@ -372,15 +422,27 @@ export async function createJobs(
|
|
|
372
422
|
// default batch), its payload and its key handed to the handler: a timer
|
|
373
423
|
// (`./timers.ts`) rides the same poller as a cron.
|
|
374
424
|
for (const job of toolJobs) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
425
|
+
const pollers = Math.max(1, Math.floor(job.concurrency ?? 1));
|
|
426
|
+
for (let i = 0; i < pollers; i++) {
|
|
427
|
+
await boss.work<object>(
|
|
428
|
+
job.queue,
|
|
429
|
+
{
|
|
430
|
+
includeMetadata: true,
|
|
431
|
+
...(job.pollingIntervalSeconds !== undefined
|
|
432
|
+
? { pollingIntervalSeconds: job.pollingIntervalSeconds }
|
|
433
|
+
: {})
|
|
434
|
+
},
|
|
435
|
+
async (jobs) => {
|
|
436
|
+
for (const fetched of jobs) {
|
|
437
|
+
await job.handler(fetched.data ?? null, {
|
|
438
|
+
id: fetched.id,
|
|
439
|
+
queue: job.queue,
|
|
440
|
+
key: fetched.singletonKey ?? null
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
);
|
|
445
|
+
}
|
|
384
446
|
}
|
|
385
447
|
|
|
386
448
|
// Orphaned-user GC: setup-race losers, fail-closed SSO login strands
|
package/src/mcp/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
export { mcpRoutes } from './http.js';
|
|
11
11
|
export { projectErrorHints, registerProjectTools } from './projects.js';
|
|
12
12
|
export { registerTeamTools } from './teams.js';
|
|
13
|
+
export { registerWebhookTools, webhookErrorHints } from './webhooks.js';
|
|
13
14
|
export { type McpInputs, mcpInputs } from './inputs.js';
|
|
14
15
|
export {
|
|
15
16
|
type McpIdentity,
|
package/src/mcp/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { mcpInputs } from './inputs.js';
|
|
|
4
4
|
import { jsonText, mergeErrorHints, wrapToolErrors, type ErrorHints } from './errors.js';
|
|
5
5
|
import { projectErrorHints, registerProjectTools } from './projects.js';
|
|
6
6
|
import { registerTeamTools } from './teams.js';
|
|
7
|
+
import { registerWebhookTools, webhookErrorHints } from './webhooks.js';
|
|
7
8
|
import {
|
|
8
9
|
callApi,
|
|
9
10
|
createScopeCheck,
|
|
@@ -57,13 +58,17 @@ export type McpIdentity = ToolIdentity['mcp'];
|
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* The ONE composition of an instance's hint table: the domain hints, then
|
|
60
|
-
* every chassis group (the projects
|
|
61
|
+
* every chassis group (the projects, the webhooks), then the tool's own last, so a
|
|
61
62
|
* tool may re-word a chassis code and never the reverse. A tool set that
|
|
62
63
|
* wraps its own errors reads the same composition (never a copy), so a
|
|
63
64
|
* chassis group added here reaches the tool's tools without a second edit.
|
|
64
65
|
*/
|
|
65
66
|
export function composeErrorHints(toolHints: ErrorHints, toolPrefix: string): ErrorHints {
|
|
66
|
-
return mergeErrorHints({
|
|
67
|
+
return mergeErrorHints({
|
|
68
|
+
...projectErrorHints(toolPrefix),
|
|
69
|
+
...webhookErrorHints(toolPrefix),
|
|
70
|
+
...toolHints
|
|
71
|
+
});
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
export function buildMcpServer(
|
|
@@ -166,6 +171,9 @@ export function buildMcpServer(
|
|
|
166
171
|
// Teams (PRDCT-2813): a chassis concept too, read-only over MCP, right after
|
|
167
172
|
// the projects they can be members of.
|
|
168
173
|
registerTeamTools(server, ctx, identity, tool.scopes, checkScope, hints);
|
|
174
|
+
// Webhooks (PRDCT-3482): a chassis concept, registered only when the tool
|
|
175
|
+
// declares the webhooks scope (a tool without one serves no webhook tool).
|
|
176
|
+
if (tool.scopes.webhooks) registerWebhookTools(server, ctx, identity, tool.scopes, checkScope, hints);
|
|
169
177
|
|
|
170
178
|
// The product surface: the tool's own set, after the chassis tools.
|
|
171
179
|
tool.registerTools(server, ctx);
|
package/src/mcp/tool-kit.ts
CHANGED
|
@@ -62,6 +62,8 @@ export type ScopeCheck<TScope extends string> = (
|
|
|
62
62
|
export interface McpScopes<TRead extends string = string, TWrite extends string = string> {
|
|
63
63
|
read: TRead;
|
|
64
64
|
write: TWrite;
|
|
65
|
+
/** The webhooks' opt-in scope (PRDCT-3482); absent, the chassis registers no webhook tool. */
|
|
66
|
+
webhooks?: string | undefined;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
export function createScopeCheck<TRead extends string, TWrite extends string>(
|
|
@@ -71,7 +73,11 @@ export function createScopeCheck<TRead extends string, TWrite extends string>(
|
|
|
71
73
|
if (principal.scopes && !principal.scopes.has(scope)) {
|
|
72
74
|
return deny(
|
|
73
75
|
`Missing scope "${scope}": this connection was not granted ` +
|
|
74
|
-
(scope === scopes.write
|
|
76
|
+
(scope === scopes.write
|
|
77
|
+
? 'permission to create or edit data.'
|
|
78
|
+
: scope === scopes.webhooks
|
|
79
|
+
? 'permission to manage webhooks.'
|
|
80
|
+
: 'permission to read data.') +
|
|
75
81
|
' Reconnect the MCP server and approve the permission on the consent screen.'
|
|
76
82
|
);
|
|
77
83
|
}
|