@antasphere/chassis-server 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/cli-package.d.ts +58 -0
- package/dist/api/cli-package.d.ts.map +1 -0
- package/dist/api/cli-package.js +83 -0
- package/dist/api/cli-package.js.map +1 -0
- 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 +33 -4
- package/dist/api/create-api.js.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1 -0
- package/dist/api/index.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/cli-package/index.d.ts +44 -0
- package/dist/cli-package/index.d.ts.map +1 -0
- package/dist/cli-package/index.js +60 -0
- package/dist/cli-package/index.js.map +1 -0
- 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 +11 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +24 -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 +11 -3
- package/src/api/apikeys.ts +12 -1
- package/src/api/cli-package.ts +151 -0
- package/src/api/create-api.ts +42 -4
- package/src/api/index.ts +1 -0
- 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/cli-package/index.ts +88 -0
- package/src/email/index.ts +1 -0
- package/src/env.ts +25 -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/cli-package.test.ts +403 -0
- package/test/integration/mcp-webhooks.test.ts +360 -0
- package/test/integration/webhooks.test.ts +1684 -0
- package/test/unit/cli-package.test.ts +93 -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
package/src/env.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { parseApiKeyPeppers } from './apikeys/peppers.js';
|
|
3
3
|
import { parseSuperadminEmails } from './accounts/superadmin.js';
|
|
4
4
|
import { isDemoSignInHost, parseDemoEmailDomains, parseDemoHosts } from './identity/demo-pass-rules.js';
|
|
5
|
+
import { loadCliPackage } from './cli-package/index.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The single entry point for configuration. Every env var the app reads is
|
|
@@ -198,6 +199,19 @@ const chassisEnvShape = (version: string) => ({
|
|
|
198
199
|
HUB_HINT_COOKIE_DOMAIN: optionalString(z.string().min(1)),
|
|
199
200
|
/** The restricted bit on the tool itself (PRDCT-2947): `true` on the cloud edition makes this instance refuse to create an organization (`POST /workspaces` answers 403 `restricted_tool`, `/me.canCreateWorkspace` is false) and advertise `restricted: true` in discovery, so a tool that Antasphere opens to organizations one by one does not rest on the hub registry's flag alone. No effect on the self-hosted edition, where workspaces are local. */
|
|
200
201
|
TOOL_RESTRICTED: booleanish.default(false),
|
|
202
|
+
/** The tool's own CLI package as the release packed it (PRDCT-3312): a folder holding `package.tgz` (the output of `pnpm pack` of `packages/cli`) beside a copy of that package's `package.json`. The image sets it. Cloud edition: the instance serves the tarball on `POST /cli/package` to a member the hub lets into the tool, and discovery carries `cli { name, version }` so the `team` command knows what it installs and whether to update. Unset = the instance serves no CLI: discovery carries no `cli` and the route answers 404 `cli_not_packaged`. A set value whose folder does not hold both files, or whose `package.json` has no string `name` and `version`, refuses the boot with the path and what is missing: a misconfiguration is proven at the image, never discovered by a person's install. */
|
|
203
|
+
CLI_PACKAGE_DIR: optionalString(
|
|
204
|
+
z
|
|
205
|
+
.string()
|
|
206
|
+
.min(1)
|
|
207
|
+
.superRefine((dir, ctx) => {
|
|
208
|
+
try {
|
|
209
|
+
loadCliPackage(dir);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
ctx.addIssue({ code: 'custom', message: err instanceof Error ? err.message : String(err) });
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
),
|
|
201
215
|
/** R7 escape hatch (internal/federation.md): acknowledge an EDITION change on an already-set-up instance. Without it, boot refuses an EDITION that differs from the one stamped at setup — flipping editions under existing users/workspaces changes identity semantics and must be a conscious operator act. */
|
|
202
216
|
EDITION_CHANGE_ALLOWED: booleanish.default(false),
|
|
203
217
|
/** Reported version. Defaults to the code's own baked-in package version (PRDCT-1844) — the image knows its version intrinsically, CI injects nothing. The env var remains as a deliberate operator override only. */
|
|
@@ -252,6 +266,10 @@ const chassisEnvShape = (version: string) => ({
|
|
|
252
266
|
METRICS_TOKEN: optionalString(z.string().min(8)),
|
|
253
267
|
/** Days of audit_log to keep (nightly purge at 03:00). 0 = keep forever. */
|
|
254
268
|
AUDIT_RETENTION_DAYS: numeric(z.coerce.number().int().min(0).default(365)),
|
|
269
|
+
/** Days of webhook deliveries (the per-endpoint log) to keep (nightly purge at 03:00). 0 = keep forever. */
|
|
270
|
+
WEBHOOK_DELIVERY_RETENTION_DAYS: numeric(z.coerce.number().int().min(0).default(30)),
|
|
271
|
+
/** Let webhook endpoints target private, loopback and link-local addresses (a self-hoster's own n8n on the LAN). Default false: a webhook never reaches the instance's own network. Refused on EDITION=cloud. */
|
|
272
|
+
WEBHOOKS_ALLOW_PRIVATE_NETWORKS: booleanish.default(false),
|
|
255
273
|
/** Grace period for orphaned users (accounts with ZERO workspace memberships, e.g. setup-race losers): the nightly 03:00 sweep deletes them once older than this many hours. A user with ANY membership row — even deactivated — is never touched. 0 = sweep disabled. */
|
|
256
274
|
ORPHAN_USER_RETENTION_HOURS: numeric(z.coerce.number().int().min(0).default(72)),
|
|
257
275
|
/** pino level. */
|
|
@@ -358,6 +376,13 @@ export function buildEnvSchema<TShape extends z.ZodRawShape = {}>(
|
|
|
358
376
|
extension?.refine?.(env, ctx);
|
|
359
377
|
demoSignInRefusal(env, ctx);
|
|
360
378
|
if (env.EDITION !== 'cloud') return;
|
|
379
|
+
if (env.WEBHOOKS_ALLOW_PRIVATE_NETWORKS) {
|
|
380
|
+
ctx.addIssue({
|
|
381
|
+
code: 'custom',
|
|
382
|
+
path: ['WEBHOOKS_ALLOW_PRIVATE_NETWORKS'],
|
|
383
|
+
message: 'never on EDITION=cloud: a webhook of the cloud edition reaches public addresses only'
|
|
384
|
+
});
|
|
385
|
+
}
|
|
361
386
|
for (const key of HUB_REQUIRED_VARS) {
|
|
362
387
|
if (env[key] === undefined) {
|
|
363
388
|
ctx.addIssue({
|
|
@@ -220,7 +220,12 @@ export const DEMO_SESSION_REFUSED_API_ROUTES: readonly DemoSessionRefusedApiRout
|
|
|
220
220
|
{ method: 'ALL', path: '/demo/passes', does: 'manage demo links' },
|
|
221
221
|
{ method: 'ALL', path: '/demo/passes/*', does: 'manage demo links' },
|
|
222
222
|
{ method: 'POST', path: '/workspaces', does: 'create a workspace' },
|
|
223
|
-
{ method: 'PUT', path: '/me/default-workspace', does: 'change your default workspace' }
|
|
223
|
+
{ method: 'PUT', path: '/me/default-workspace', does: 'change your default workspace' },
|
|
224
|
+
// A webhook is something the workspace keeps, and its secret is a credential (PRDCT-3482).
|
|
225
|
+
{ method: 'POST', path: '/webhooks', does: 'register a webhook' },
|
|
226
|
+
{ method: 'PATCH', path: '/webhooks/*', does: 'change a webhook' },
|
|
227
|
+
{ method: 'DELETE', path: '/webhooks/*', does: 'delete a webhook' },
|
|
228
|
+
{ method: 'POST', path: '/webhooks/*', does: 'act on a webhook' }
|
|
224
229
|
];
|
|
225
230
|
|
|
226
231
|
/** The refusal's sentence for one entry of `DEMO_SESSION_REFUSED_API_ROUTES`. */
|
|
@@ -3,7 +3,7 @@ import { and, eq, inArray, isNotNull, ne, notInArray, sql } from 'drizzle-orm';
|
|
|
3
3
|
import { workspaceMembers, workspaces, type Db } from '@antasphere/chassis-db';
|
|
4
4
|
import type { AuditService } from '../audit/service.js';
|
|
5
5
|
import type { Logger } from '../logger.js';
|
|
6
|
-
import { deleteMembershipGrants, type
|
|
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
|
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
webhookDeliveryStatusSchema,
|
|
4
|
+
webhookEventPatternSchema,
|
|
5
|
+
webhookFiltersSchema,
|
|
6
|
+
webhookUrlSchema
|
|
7
|
+
} from '@antasphere/chassis-contract';
|
|
8
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { mcpInputs } from './inputs.js';
|
|
10
|
+
import { deny, jsonText, wrapToolErrors, type ErrorHints, type ToolTextResult } from './errors.js';
|
|
11
|
+
import {
|
|
12
|
+
callApi,
|
|
13
|
+
forWorkspace,
|
|
14
|
+
pageQuery,
|
|
15
|
+
type McpScopes,
|
|
16
|
+
type McpToolContext,
|
|
17
|
+
type ScopeCheck
|
|
18
|
+
} from './tool-kit.js';
|
|
19
|
+
import type { McpIdentity } from './server.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The eleven WEBHOOK tools (PRDCT-3482), registered by the chassis right after
|
|
23
|
+
* the team reads and only when the tool declares a webhooks scope: a webhook
|
|
24
|
+
* is a chassis concept (a URL a workspace registers to be told, by a signed
|
|
25
|
+
* POST, when one of the tool's events happens), so the tools over it are the
|
|
26
|
+
* chassis'. Thin shims over the instance's own /api/v1/webhooks called
|
|
27
|
+
* IN-PROCESS with the caller's bearer forwarded verbatim: the API's
|
|
28
|
+
* fail-closed scope allowlist, its owner-or-admin rule and its 404 for an
|
|
29
|
+
* endpoint of another workspace apply unchanged.
|
|
30
|
+
*
|
|
31
|
+
* Every tool pre-checks the tool's webhooks scope, except the catalog, which
|
|
32
|
+
* is a read (every non-guest member's, under the read scope). Nothing here
|
|
33
|
+
* names a tool, a brand or a domain: the prefix arrives as
|
|
34
|
+
* `identity.toolPrefix`, the event types as the instance's catalog.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** What a webhook IS, opening the descriptions. */
|
|
38
|
+
const WHAT_IS_A_WEBHOOK =
|
|
39
|
+
'A webhook is a URL an organization registers to be told, by a signed HTTP POST, when something ' +
|
|
40
|
+
'happens (for example `thing.created`).';
|
|
41
|
+
|
|
42
|
+
/** Who manages them. */
|
|
43
|
+
const WHO_MANAGES = 'Only an owner or an admin of the organization manages webhooks, reads included.';
|
|
44
|
+
|
|
45
|
+
/** How the `events` list of an endpoint reads. */
|
|
46
|
+
const EVENT_PATTERNS =
|
|
47
|
+
'Each entry of `events` is an exact event type (`thing.created`), a family (`thing.*`) or ' +
|
|
48
|
+
'everything (`*`).';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The webhook code only the webhook routes answer, merged into the instance's
|
|
52
|
+
* hint table by `composeErrorHints` like the project hints. The codes the
|
|
53
|
+
* webhook routes SHARE with the rest of the instance (`forbidden`,
|
|
54
|
+
* `demo_session`, `plan_required`) are worded for webhooks on the webhook
|
|
55
|
+
* tools alone ({@link webhookToolHints}): in the instance's table they would
|
|
56
|
+
* misread every other route's refusal.
|
|
57
|
+
*/
|
|
58
|
+
export function webhookErrorHints(toolPrefix: string): ErrorHints {
|
|
59
|
+
return {
|
|
60
|
+
url_refused:
|
|
61
|
+
'This instance does not deliver to that address. Refused: a scheme other than http or https ' +
|
|
62
|
+
'(https only on the cloud), a URL carrying a user name or password, a port other than the ' +
|
|
63
|
+
'scheme’s default, and an address that is not on the public internet (localhost, a private or ' +
|
|
64
|
+
'link-local network), unless the operator of a self-hosted instance opened private networks. ' +
|
|
65
|
+
`Use a public address of the receiver, then call ${toolPrefix}create_webhook or ` +
|
|
66
|
+
`${toolPrefix}update_webhook again.`,
|
|
67
|
+
unknown_event:
|
|
68
|
+
'One of the `events` entries is not an event type or family this instance delivers. ' +
|
|
69
|
+
`Read ${toolPrefix}webhook_events for the exact types; a family is its first word plus \`.*\`.`,
|
|
70
|
+
unknown_subject:
|
|
71
|
+
'One of the `filters` keys is not a subject this instance declares. ' +
|
|
72
|
+
`The subject keys are in ${toolPrefix}webhook_events (for example \`project\`).`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The shared codes, worded for the webhook tools only (layered over the instance's table there). */
|
|
77
|
+
function webhookToolHints(toolPrefix: string): ErrorHints {
|
|
78
|
+
return {
|
|
79
|
+
forbidden:
|
|
80
|
+
'Only an owner or an admin of the organization manages webhooks, reads included. Ask one of ' +
|
|
81
|
+
`them to make the change, or to read the endpoints (${toolPrefix}webhook_events stays open to ` +
|
|
82
|
+
'every member).',
|
|
83
|
+
demo_session:
|
|
84
|
+
'This connection was opened by a demonstration link, which may not set up webhooks. A person ' +
|
|
85
|
+
'signed in with their own account must do it.',
|
|
86
|
+
plan_required:
|
|
87
|
+
'The organization’s plan does not include this — the upgrade link in this message is where a ' +
|
|
88
|
+
'human raises it; nothing was created.'
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const webhookIdInput = z
|
|
93
|
+
.uuid()
|
|
94
|
+
.describe('The webhook id, from the webhook list or the create answer (never its URL).');
|
|
95
|
+
|
|
96
|
+
const deliveryIdInput = z.uuid().describe('The delivery id, from the delivery list of this webhook.');
|
|
97
|
+
|
|
98
|
+
const urlInput = webhookUrlSchema.describe(
|
|
99
|
+
'Where the deliveries go: a public http or https URL without credentials (https only on the cloud).'
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const eventsInput = z
|
|
103
|
+
.array(webhookEventPatternSchema)
|
|
104
|
+
.min(1)
|
|
105
|
+
.max(50)
|
|
106
|
+
.describe(`What the endpoint subscribes to. ${EVENT_PATTERNS}`);
|
|
107
|
+
|
|
108
|
+
const descriptionInput = z.string().max(200).describe('A short note on what the endpoint is for.');
|
|
109
|
+
|
|
110
|
+
const filtersInput = webhookFiltersSchema.describe(
|
|
111
|
+
'Narrows the subscription by subject: a subject key of the catalog (its `subjects[].key`) mapped to ' +
|
|
112
|
+
'the ids it accepts, AND across keys, OR within a key; an event that does not carry a filtered ' +
|
|
113
|
+
'subject does not match. An empty object filters nothing.'
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const enabledInput = z.boolean().describe('false pauses the deliveries without deleting the endpoint.');
|
|
117
|
+
|
|
118
|
+
/** Register the eleven webhook tools on a server, after the team reads. */
|
|
119
|
+
export function registerWebhookTools(
|
|
120
|
+
server: McpServer,
|
|
121
|
+
ctx: McpToolContext,
|
|
122
|
+
identity: McpIdentity,
|
|
123
|
+
scopes: McpScopes,
|
|
124
|
+
checkScope: ScopeCheck<string>,
|
|
125
|
+
hints: ErrorHints
|
|
126
|
+
): void {
|
|
127
|
+
const webhooksScope = scopes.webhooks;
|
|
128
|
+
if (!webhooksScope) return;
|
|
129
|
+
const { workspaceInput, cursorInput, limitInput } = mcpInputs(identity);
|
|
130
|
+
const prefix = identity.toolPrefix;
|
|
131
|
+
const t = (name: string) => `${prefix}${name}`;
|
|
132
|
+
const toolHints: ErrorHints = { ...hints, ...webhookToolHints(prefix) };
|
|
133
|
+
|
|
134
|
+
const run = (
|
|
135
|
+
scope: string,
|
|
136
|
+
workspace: string | undefined,
|
|
137
|
+
body: (c: McpToolContext) => Promise<unknown>
|
|
138
|
+
): Promise<ToolTextResult> => {
|
|
139
|
+
const denied = checkScope(ctx.principal, scope);
|
|
140
|
+
if (denied) return Promise.resolve(denied);
|
|
141
|
+
const c = forWorkspace(ctx, workspace);
|
|
142
|
+
return wrapToolErrors(async () => jsonText(await body(c)), toolHints);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const send = (method: string, value?: unknown): RequestInit =>
|
|
146
|
+
value === undefined
|
|
147
|
+
? { method }
|
|
148
|
+
: { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) };
|
|
149
|
+
|
|
150
|
+
const base = '/api/v1/webhooks';
|
|
151
|
+
|
|
152
|
+
// ── The catalog: a read ──────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
server.registerTool(
|
|
155
|
+
t('webhook_events'),
|
|
156
|
+
{
|
|
157
|
+
description:
|
|
158
|
+
`The event catalog of this instance: every event type it delivers to webhooks. ${WHAT_IS_A_WEBHOOK} ` +
|
|
159
|
+
'Returns { subjects: [{ key, field, description }], events: [{ type, description, subjects, ' +
|
|
160
|
+
'schema }] } — `subjects` are what an event is about (the key a filter uses, and the field of ' +
|
|
161
|
+
'the envelope’s `subjects` its value is written under: one id or a list of ids), `schema` is ' +
|
|
162
|
+
'the JSON Schema of the event’s `data`. ' +
|
|
163
|
+
`${EVENT_PATTERNS} Read this before ${t('create_webhook')}. Every member may read it.`,
|
|
164
|
+
inputSchema: { workspace: workspaceInput },
|
|
165
|
+
annotations: { readOnlyHint: true }
|
|
166
|
+
},
|
|
167
|
+
async ({ workspace }) => run(scopes.read, workspace, (c) => callApi(c, `${base}/events`))
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// ── Endpoints ────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
server.registerTool(
|
|
173
|
+
t('list_webhooks'),
|
|
174
|
+
{
|
|
175
|
+
description:
|
|
176
|
+
`List the organization’s webhooks, newest first. ${WHAT_IS_A_WEBHOOK} ${WHO_MANAGES} Returns ` +
|
|
177
|
+
'{ webhooks: [{ id, url, description, events, filters, enabled, createdBy, createdAt, ' +
|
|
178
|
+
'updatedAt, disabledAt, disabledReason, consecutiveFailures, lastDeliveryAt, ' +
|
|
179
|
+
'lastDeliveryStatus, previousSecretExpiresAt }], nextCursor } — never a signing secret. ' +
|
|
180
|
+
'`disabledAt` is set when the instance switched the endpoint off after failing too long. When ' +
|
|
181
|
+
'nextCursor is non-null, call again with cursor set to it.',
|
|
182
|
+
inputSchema: { workspace: workspaceInput, limit: limitInput, cursor: cursorInput },
|
|
183
|
+
annotations: { readOnlyHint: true }
|
|
184
|
+
},
|
|
185
|
+
async ({ workspace, limit, cursor }) =>
|
|
186
|
+
run(webhooksScope, workspace, (c) => callApi(c, pageQuery(base, { cursor, limit })))
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
server.registerTool(
|
|
190
|
+
t('create_webhook'),
|
|
191
|
+
{
|
|
192
|
+
description:
|
|
193
|
+
`Register a webhook — confirm with the user first (URL and events included). ${WHAT_IS_A_WEBHOOK} ` +
|
|
194
|
+
`${WHO_MANAGES} ${EVENT_PATTERNS} The types are checked against ${t('webhook_events')}, and ` +
|
|
195
|
+
'the keys of `filters` are the catalog’s subject keys. The answer is the endpoint plus its ' +
|
|
196
|
+
'signing `secret` (`whsec_…`), shown ONCE and never again: give it to the receiver, which ' +
|
|
197
|
+
'verifies every delivery’s Standard Webhooks signature with it, and never store it anywhere ' +
|
|
198
|
+
`else. A lost secret is replaced by ${t('rotate_webhook_secret')}.`,
|
|
199
|
+
inputSchema: {
|
|
200
|
+
workspace: workspaceInput,
|
|
201
|
+
url: urlInput,
|
|
202
|
+
events: eventsInput,
|
|
203
|
+
description: descriptionInput.optional(),
|
|
204
|
+
filters: filtersInput.optional(),
|
|
205
|
+
enabled: enabledInput.optional()
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
async ({ workspace, url, events, description, filters, enabled }) =>
|
|
209
|
+
run(webhooksScope, workspace, (c) =>
|
|
210
|
+
callApi(
|
|
211
|
+
c,
|
|
212
|
+
base,
|
|
213
|
+
send('POST', {
|
|
214
|
+
url,
|
|
215
|
+
events,
|
|
216
|
+
...(description !== undefined ? { description } : {}),
|
|
217
|
+
...(filters !== undefined ? { filters } : {}),
|
|
218
|
+
...(enabled !== undefined ? { enabled } : {})
|
|
219
|
+
})
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
server.registerTool(
|
|
225
|
+
t('get_webhook'),
|
|
226
|
+
{
|
|
227
|
+
description:
|
|
228
|
+
`Read one webhook, without its secret. ${WHO_MANAGES} Returns the endpoint as ` +
|
|
229
|
+
`${t('list_webhooks')} shows it. A webhook of another organization answers not found.`,
|
|
230
|
+
inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput },
|
|
231
|
+
annotations: { readOnlyHint: true }
|
|
232
|
+
},
|
|
233
|
+
async ({ workspace, webhookId }) =>
|
|
234
|
+
run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}`))
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
server.registerTool(
|
|
238
|
+
t('update_webhook'),
|
|
239
|
+
{
|
|
240
|
+
description:
|
|
241
|
+
'Change a webhook’s URL, description, events, filters or enabled switch — confirm with the ' +
|
|
242
|
+
`user first. ${WHO_MANAGES} Send only the fields you are changing; \`events\` and \`filters\` ` +
|
|
243
|
+
`REPLACE the whole list or object. ${EVENT_PATTERNS} Setting enabled back to true resumes an ` +
|
|
244
|
+
'endpoint the instance switched off. The secret is unchanged. Returns the endpoint as it now is.',
|
|
245
|
+
inputSchema: {
|
|
246
|
+
workspace: workspaceInput,
|
|
247
|
+
webhookId: webhookIdInput,
|
|
248
|
+
url: urlInput.optional(),
|
|
249
|
+
description: descriptionInput.optional(),
|
|
250
|
+
events: eventsInput.optional(),
|
|
251
|
+
filters: filtersInput.optional(),
|
|
252
|
+
enabled: enabledInput.optional()
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
async ({ workspace, webhookId, url, description, events, filters, enabled }) => {
|
|
256
|
+
const patch = { url, description, events, filters, enabled };
|
|
257
|
+
if (Object.values(patch).every((v) => v === undefined)) {
|
|
258
|
+
return deny(
|
|
259
|
+
'Nothing to change: pass at least one of url, description, events, filters or enabled. ' +
|
|
260
|
+
`Read the webhook first (${t('get_webhook')}) if you need its current values.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const body = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
|
|
264
|
+
return run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}`, send('PATCH', body)));
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
server.registerTool(
|
|
269
|
+
t('delete_webhook'),
|
|
270
|
+
{
|
|
271
|
+
description:
|
|
272
|
+
'Delete a webhook and its whole delivery history — confirm with the user first; nothing more ' +
|
|
273
|
+
`is sent to it and this cannot be undone. ${WHO_MANAGES} To pause it instead, call ` +
|
|
274
|
+
`${t('update_webhook')} with enabled set to false. Returns a last snapshot of the deleted endpoint.`,
|
|
275
|
+
inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput },
|
|
276
|
+
annotations: { destructiveHint: true }
|
|
277
|
+
},
|
|
278
|
+
async ({ workspace, webhookId }) =>
|
|
279
|
+
run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}`, send('DELETE')))
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
server.registerTool(
|
|
283
|
+
t('rotate_webhook_secret'),
|
|
284
|
+
{
|
|
285
|
+
description:
|
|
286
|
+
'Replace a webhook’s signing secret — confirm with the user first. ' +
|
|
287
|
+
`${WHO_MANAGES} The answer is the endpoint plus its new \`secret\` (\`whsec_…\`), shown ONCE: ` +
|
|
288
|
+
'give it to the receiver and never store it anywhere else. The previous secret keeps signing ' +
|
|
289
|
+
'until `previousSecretExpiresAt`, so the receiver can switch without a gap.',
|
|
290
|
+
inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput }
|
|
291
|
+
},
|
|
292
|
+
async ({ workspace, webhookId }) =>
|
|
293
|
+
run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}/rotate-secret`, send('POST')))
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
server.registerTool(
|
|
297
|
+
t('test_webhook'),
|
|
298
|
+
{
|
|
299
|
+
description:
|
|
300
|
+
'Send a signed `webhook.ping` to a webhook now, once, and answer how it went. ' +
|
|
301
|
+
`${WHO_MANAGES} Returns { delivery: { id, eventId, eventType, status, attempt, ` +
|
|
302
|
+
'lastStatusCode, lastError, … } } — `status` is `delivered` when the receiver answered 2xx, ' +
|
|
303
|
+
'`failed` otherwise, with `lastStatusCode` and `lastError` saying why. A ping is never retried.',
|
|
304
|
+
inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput }
|
|
305
|
+
},
|
|
306
|
+
async ({ workspace, webhookId }) =>
|
|
307
|
+
run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}/test`, send('POST')))
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// ── Deliveries ───────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
server.registerTool(
|
|
313
|
+
t('list_webhook_deliveries'),
|
|
314
|
+
{
|
|
315
|
+
description:
|
|
316
|
+
`List a webhook’s deliveries, newest first, optionally by status. ${WHO_MANAGES} A delivery ` +
|
|
317
|
+
'is one event sent to this endpoint, with its attempts. Returns { deliveries: [{ id, ' +
|
|
318
|
+
'endpointId, eventId, eventType, status, attempt, lastStatusCode, lastError, nextAttemptAt, ' +
|
|
319
|
+
'deliveredAt, createdAt, updatedAt, payload }], nextCursor } — `eventId` is the receiver’s ' +
|
|
320
|
+
'dedupe key, `lastStatusCode` and `lastError` describe the latest attempt only. When ' +
|
|
321
|
+
'nextCursor is non-null, call again with cursor set to it.',
|
|
322
|
+
inputSchema: {
|
|
323
|
+
workspace: workspaceInput,
|
|
324
|
+
webhookId: webhookIdInput,
|
|
325
|
+
status: webhookDeliveryStatusSchema
|
|
326
|
+
.optional()
|
|
327
|
+
.describe(
|
|
328
|
+
'`pending` (waiting for an attempt or a retry), `delivered`, `failed`, or `held` (out of ' +
|
|
329
|
+
'retries, kept for a redelivery).'
|
|
330
|
+
),
|
|
331
|
+
limit: limitInput,
|
|
332
|
+
cursor: cursorInput
|
|
333
|
+
},
|
|
334
|
+
annotations: { readOnlyHint: true }
|
|
335
|
+
},
|
|
336
|
+
async ({ workspace, webhookId, status, limit, cursor }) =>
|
|
337
|
+
run(webhooksScope, workspace, (c) =>
|
|
338
|
+
callApi(c, pageQuery(`${base}/${webhookId}/deliveries`, { cursor, limit }, { status }))
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
server.registerTool(
|
|
343
|
+
t('get_webhook_delivery'),
|
|
344
|
+
{
|
|
345
|
+
description:
|
|
346
|
+
`Read one delivery of a webhook, with the payload as sent and its latest attempt. ${WHO_MANAGES} ` +
|
|
347
|
+
`Returns the delivery as ${t('list_webhook_deliveries')} shows it.`,
|
|
348
|
+
inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput, deliveryId: deliveryIdInput },
|
|
349
|
+
annotations: { readOnlyHint: true }
|
|
350
|
+
},
|
|
351
|
+
async ({ workspace, webhookId, deliveryId }) =>
|
|
352
|
+
run(webhooksScope, workspace, (c) => callApi(c, `${base}/${webhookId}/deliveries/${deliveryId}`))
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
server.registerTool(
|
|
356
|
+
t('redeliver_webhook'),
|
|
357
|
+
{
|
|
358
|
+
description:
|
|
359
|
+
'Send one delivery’s event to its webhook again, as a new delivery — confirm with the user ' +
|
|
360
|
+
`first. ${WHO_MANAGES} The new delivery carries the same \`eventId\` (the receiver’s dedupe key) ` +
|
|
361
|
+
'and the same payload, and is queued: read its outcome later with ' +
|
|
362
|
+
`${t('get_webhook_delivery')}. Returns the new delivery.`,
|
|
363
|
+
inputSchema: { workspace: workspaceInput, webhookId: webhookIdInput, deliveryId: deliveryIdInput }
|
|
364
|
+
},
|
|
365
|
+
async ({ workspace, webhookId, deliveryId }) =>
|
|
366
|
+
run(webhooksScope, workspace, (c) =>
|
|
367
|
+
callApi(c, `${base}/${webhookId}/deliveries/${deliveryId}/redeliver`, send('POST'))
|
|
368
|
+
)
|
|
369
|
+
);
|
|
370
|
+
}
|