@antasphere/chassis-server 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/apikeys.d.ts +4 -1
- package/dist/api/apikeys.d.ts.map +1 -1
- package/dist/api/apikeys.js +11 -1
- package/dist/api/apikeys.js.map +1 -1
- package/dist/api/create-api.d.ts +3 -0
- package/dist/api/create-api.d.ts.map +1 -1
- package/dist/api/create-api.js +8 -3
- package/dist/api/create-api.js.map +1 -1
- package/dist/api/invitations.d.ts.map +1 -1
- package/dist/api/invitations.js +6 -0
- package/dist/api/invitations.js.map +1 -1
- package/dist/api/members.d.ts +5 -2
- package/dist/api/members.d.ts.map +1 -1
- package/dist/api/members.js +1 -0
- package/dist/api/members.js.map +1 -1
- package/dist/api/projects.d.ts +3 -0
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +13 -1
- package/dist/api/projects.js.map +1 -1
- package/dist/api/webhooks.d.ts +24 -0
- package/dist/api/webhooks.d.ts.map +1 -0
- package/dist/api/webhooks.js +170 -0
- package/dist/api/webhooks.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +11 -5
- package/dist/app.js.map +1 -1
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +47 -4
- package/dist/boot.js.map +1 -1
- package/dist/email/index.d.ts +1 -0
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +1 -0
- package/dist/email/index.js.map +1 -1
- package/dist/env.d.ts +9 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +11 -0
- package/dist/env.js.map +1 -1
- package/dist/identity/demo-pass-rules.d.ts.map +1 -1
- package/dist/identity/demo-pass-rules.js +6 -1
- package/dist/identity/demo-pass-rules.js.map +1 -1
- package/dist/identity/hub-reconcile.d.ts +2 -2
- package/dist/identity/hub-reconcile.d.ts.map +1 -1
- package/dist/identity/hub-reconcile.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/pgboss.d.ts +34 -0
- package/dist/jobs/pgboss.d.ts.map +1 -1
- package/dist/jobs/pgboss.js +32 -9
- package/dist/jobs/pgboss.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +11 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-kit.d.ts +2 -0
- package/dist/mcp/tool-kit.d.ts.map +1 -1
- package/dist/mcp/tool-kit.js +5 -1
- package/dist/mcp/tool-kit.js.map +1 -1
- package/dist/mcp/webhooks.d.ts +16 -0
- package/dist/mcp/webhooks.d.ts.map +1 -0
- package/dist/mcp/webhooks.js +226 -0
- package/dist/mcp/webhooks.js.map +1 -0
- package/dist/members/removal.d.ts +10 -2
- package/dist/members/removal.d.ts.map +1 -1
- package/dist/members/removal.js +3 -0
- package/dist/members/removal.js.map +1 -1
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +3 -1
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/scopes.d.ts +9 -1
- package/dist/middleware/scopes.d.ts.map +1 -1
- package/dist/middleware/scopes.js +8 -1
- package/dist/middleware/scopes.js.map +1 -1
- package/dist/middleware/static-assets.d.ts +63 -0
- package/dist/middleware/static-assets.d.ts.map +1 -0
- package/dist/middleware/static-assets.js +125 -0
- package/dist/middleware/static-assets.js.map +1 -0
- package/dist/secret-box.d.ts +10 -0
- package/dist/secret-box.d.ts.map +1 -0
- package/dist/secret-box.js +87 -0
- package/dist/secret-box.js.map +1 -0
- package/dist/testing/helpers.d.ts +8 -0
- package/dist/testing/helpers.d.ts.map +1 -1
- package/dist/testing/helpers.js.map +1 -1
- package/dist/tool-definition.d.ts +24 -0
- package/dist/tool-definition.d.ts.map +1 -1
- package/dist/tool-definition.js.map +1 -1
- package/dist/webhooks/catalog.d.ts +80 -0
- package/dist/webhooks/catalog.d.ts.map +1 -0
- package/dist/webhooks/catalog.js +134 -0
- package/dist/webhooks/catalog.js.map +1 -0
- package/dist/webhooks/index.d.ts +9 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +9 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/jobs.d.ts +12 -0
- package/dist/webhooks/jobs.d.ts.map +1 -0
- package/dist/webhooks/jobs.js +77 -0
- package/dist/webhooks/jobs.js.map +1 -0
- package/dist/webhooks/mail.d.ts +24 -0
- package/dist/webhooks/mail.d.ts.map +1 -0
- package/dist/webhooks/mail.js +21 -0
- package/dist/webhooks/mail.js.map +1 -0
- package/dist/webhooks/match.d.ts +22 -0
- package/dist/webhooks/match.d.ts.map +1 -0
- package/dist/webhooks/match.js +40 -0
- package/dist/webhooks/match.js.map +1 -0
- package/dist/webhooks/safe-fetch.d.ts +94 -0
- package/dist/webhooks/safe-fetch.d.ts.map +1 -0
- package/dist/webhooks/safe-fetch.js +263 -0
- package/dist/webhooks/safe-fetch.js.map +1 -0
- package/dist/webhooks/service.d.ts +245 -0
- package/dist/webhooks/service.d.ts.map +1 -0
- package/dist/webhooks/service.js +861 -0
- package/dist/webhooks/service.js.map +1 -0
- package/dist/webhooks/signing.d.ts +40 -0
- package/dist/webhooks/signing.d.ts.map +1 -0
- package/dist/webhooks/signing.js +108 -0
- package/dist/webhooks/signing.js.map +1 -0
- package/dist/webhooks/tx-send.d.ts +31 -0
- package/dist/webhooks/tx-send.d.ts.map +1 -0
- package/dist/webhooks/tx-send.js +22 -0
- package/dist/webhooks/tx-send.js.map +1 -0
- package/package.json +7 -3
- package/src/api/apikeys.ts +12 -1
- package/src/api/create-api.ts +17 -3
- package/src/api/invitations.ts +6 -0
- package/src/api/members.ts +6 -2
- package/src/api/projects.ts +16 -1
- package/src/api/webhooks.ts +205 -0
- package/src/app.ts +17 -4
- package/src/boot.ts +48 -4
- package/src/email/index.ts +1 -0
- package/src/env.ts +11 -0
- package/src/identity/demo-pass-rules.ts +6 -1
- package/src/identity/hub-reconcile.ts +2 -2
- package/src/index.ts +13 -1
- package/src/jobs/index.ts +1 -0
- package/src/jobs/pgboss.ts +71 -9
- package/src/mcp/index.ts +1 -0
- package/src/mcp/server.ts +10 -2
- package/src/mcp/tool-kit.ts +7 -1
- package/src/mcp/webhooks.ts +370 -0
- package/src/members/removal.ts +21 -1
- package/src/middleware/idempotency.ts +3 -1
- package/src/middleware/index.ts +11 -0
- package/src/middleware/scopes.ts +14 -0
- package/src/middleware/static-assets.ts +144 -0
- package/src/secret-box.ts +93 -0
- package/src/testing/helpers.ts +8 -0
- package/src/tool-definition.ts +29 -0
- package/src/webhooks/catalog.ts +205 -0
- package/src/webhooks/index.ts +58 -0
- package/src/webhooks/jobs.ts +92 -0
- package/src/webhooks/mail.ts +48 -0
- package/src/webhooks/match.ts +52 -0
- package/src/webhooks/safe-fetch.ts +335 -0
- package/src/webhooks/service.ts +1089 -0
- package/src/webhooks/signing.ts +132 -0
- package/src/webhooks/tx-send.ts +53 -0
- package/test/host/drizzle/0002_webhooks.sql +49 -0
- package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
- package/test/host/drizzle/meta/_journal.json +8 -1
- package/test/host/identity.ts +6 -1
- package/test/host/minimal-host.ts +2 -1
- package/test/host/minimal-tool.ts +32 -4
- package/test/integration/mcp-webhooks.test.ts +360 -0
- package/test/integration/webhooks.test.ts +1684 -0
- package/test/unit/secret-box.test.ts +124 -0
- package/test/unit/static-assets.test.ts +227 -0
- package/test/unit/tx-send.test.ts +23 -0
- package/test/unit/webhook-catalog.test.ts +151 -0
- package/test/unit/webhook-env.test.ts +60 -0
- package/test/unit/webhook-jobs.test.ts +92 -0
- package/test/unit/webhook-match.test.ts +81 -0
- package/test/unit/webhook-safe-fetch.test.ts +330 -0
- package/test/unit/webhook-signing.test.ts +169 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/members/removal.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
workspaceTeamMembers,
|
|
13
13
|
type DbConn
|
|
14
14
|
} from '@antasphere/chassis-db';
|
|
15
|
+
import type { WebhookEmitter } from '../webhooks/service.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* The membership an ADD hangs a right on, re-read inside the add's own
|
|
@@ -67,10 +68,29 @@ export interface RemovedMembership {
|
|
|
67
68
|
* audit record.
|
|
68
69
|
*/
|
|
69
70
|
export type MembershipRemovalHook = (
|
|
71
|
+
tx: DbConn,
|
|
72
|
+
removed: readonly RemovedMembership[],
|
|
73
|
+
ctx: MembershipRemovalContext
|
|
74
|
+
) => Promise<Record<string, number>>;
|
|
75
|
+
|
|
76
|
+
/** What the tool's hook receives beside the rows: the webhooks' emitter, to report what it ended inside the same transaction (PRDCT-3483). */
|
|
77
|
+
export interface MembershipRemovalContext {
|
|
78
|
+
webhooks: WebhookEmitter;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The hook as the chassis calls it, the context bound at boot (`bindMembershipRemoval`). */
|
|
82
|
+
export type BoundMembershipRemoval = (
|
|
70
83
|
tx: DbConn,
|
|
71
84
|
removed: readonly RemovedMembership[]
|
|
72
85
|
) => Promise<Record<string, number>>;
|
|
73
86
|
|
|
87
|
+
export function bindMembershipRemoval(
|
|
88
|
+
hook: MembershipRemovalHook | undefined,
|
|
89
|
+
ctx: MembershipRemovalContext
|
|
90
|
+
): BoundMembershipRemoval | undefined {
|
|
91
|
+
return hook ? (tx, removed) => hook(tx, removed, ctx) : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
export interface RemovalCounts {
|
|
75
95
|
projectGrants: number;
|
|
76
96
|
teamSeats: number;
|
|
@@ -127,7 +147,7 @@ export interface RemovalCounts {
|
|
|
127
147
|
export async function deleteMembershipGrants(
|
|
128
148
|
tx: DbConn,
|
|
129
149
|
removed: readonly RemovedMembership[],
|
|
130
|
-
toolHook?:
|
|
150
|
+
toolHook?: BoundMembershipRemoval
|
|
131
151
|
): Promise<RemovalCounts> {
|
|
132
152
|
if (removed.length === 0) {
|
|
133
153
|
return { projectGrants: 0, teamSeats: 0, invitations: 0, demoPasses: 0, tool: {} };
|
|
@@ -69,7 +69,9 @@ const TARGET_PATHS = new Set([
|
|
|
69
69
|
'/api/v1/api-keys',
|
|
70
70
|
'/api/v1/invitations',
|
|
71
71
|
'/api/v1/workspaces',
|
|
72
|
-
'/api/v1/projects'
|
|
72
|
+
'/api/v1/projects',
|
|
73
|
+
// A retried registration answers the first one's secret, never a second endpoint (PRDCT-3482).
|
|
74
|
+
'/api/v1/webhooks'
|
|
73
75
|
]);
|
|
74
76
|
// The two member routes that MINT a credential for another user. Both are
|
|
75
77
|
// covered (FUZZ-7): a replayed mint hands out a second live secret, and the
|
package/src/middleware/index.ts
CHANGED
|
@@ -31,3 +31,14 @@ export { MAX_JSON_DEPTH, exceedsJsonDepth, jsonDepthLimit } from './json-depth.j
|
|
|
31
31
|
export { noStoreAuthenticated } from './no-store.js';
|
|
32
32
|
export { isPublicOauthPath, oauthPublicEndpoints } from './oauth-public.js';
|
|
33
33
|
export { buildCsp, hstsValue, inlineScriptHashes, securityHeaders } from './security-headers.js';
|
|
34
|
+
export {
|
|
35
|
+
IMMUTABLE_ASSET_PREFIX,
|
|
36
|
+
IMMUTABLE_CACHE_CONTROL,
|
|
37
|
+
REVALIDATE_CACHE_CONTROL,
|
|
38
|
+
type SpaFallback,
|
|
39
|
+
type StaticAssetsOptions,
|
|
40
|
+
acceptedEncodings,
|
|
41
|
+
loadSpaFallback,
|
|
42
|
+
staticAssets,
|
|
43
|
+
staticCacheControl
|
|
44
|
+
} from './static-assets.js';
|
package/src/middleware/scopes.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface ScopeAllowlistOptions<S extends string> {
|
|
|
30
30
|
write: S;
|
|
31
31
|
/** The tool's dedicated opt-in scope for the full-workspace export. */
|
|
32
32
|
dataExport: S;
|
|
33
|
+
/**
|
|
34
|
+
* The tool's dedicated opt-in scope for the webhooks family (PRDCT-3482):
|
|
35
|
+
* registering an endpoint is a standing read of the workspace to a URL of
|
|
36
|
+
* the caller's choice, so no read or write key opens it. Absent: no
|
|
37
|
+
* machine principal reaches `/webhooks` at all (the catalog read stays
|
|
38
|
+
* under the read scope).
|
|
39
|
+
*/
|
|
40
|
+
webhooks?: S | undefined;
|
|
33
41
|
/** The tool's own rules, asked in order AFTER the chassis rules. */
|
|
34
42
|
rules: ReadonlyArray<ScopeRule<S>>;
|
|
35
43
|
}
|
|
@@ -89,6 +97,7 @@ export function createScopeAllowlist<S extends string>({
|
|
|
89
97
|
read,
|
|
90
98
|
write,
|
|
91
99
|
dataExport,
|
|
100
|
+
webhooks,
|
|
92
101
|
rules
|
|
93
102
|
}: ScopeAllowlistOptions<S>): (path: string, method: string) => S | null {
|
|
94
103
|
return function requiredScopeFor(path: string, method: string): S | null {
|
|
@@ -130,6 +139,11 @@ export function createScopeAllowlist<S extends string>({
|
|
|
130
139
|
// Teams: the same discipline (above).
|
|
131
140
|
const team = teamAccess(path, method);
|
|
132
141
|
if (team !== null) return team === 'read' ? read : write;
|
|
142
|
+
// Webhooks (PRDCT-3482): the catalog is a read any key may make; the
|
|
143
|
+
// family itself, reads included, needs the tool's dedicated scope, and
|
|
144
|
+
// closes entirely when the tool declares none.
|
|
145
|
+
if (path === '/api/v1/webhooks/events' && isRead) return read;
|
|
146
|
+
if (path === '/api/v1/webhooks' || path.startsWith('/api/v1/webhooks/')) return webhooks ?? null;
|
|
133
147
|
for (const rule of rules) {
|
|
134
148
|
const needed = rule(path, method, isRead);
|
|
135
149
|
if (needed !== null) return needed;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
2
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The dashboard build, served so the browser keeps it (PRDCT-3584).
|
|
9
|
+
*
|
|
10
|
+
* Before this middleware the SPA's files went out with no `Cache-Control`,
|
|
11
|
+
* no validator and no compression: every visit to a tool re-downloaded the
|
|
12
|
+
* whole dashboard (about 1.2 MB of JavaScript in a hundred files on the
|
|
13
|
+
* team stack, measured 2026-10-17), and the splash the person stared at
|
|
14
|
+
* was that download. Two rules fix it, both on the build's own shape:
|
|
15
|
+
*
|
|
16
|
+
* - `/_app/immutable/*` is CONTENT-HASHED by SvelteKit: a changed file has a
|
|
17
|
+
* new name, so the cached copy can never be stale. It is served with
|
|
18
|
+
* `public, max-age=31536000, immutable`: a second visit transfers none of
|
|
19
|
+
* it. Every other static path (the HTML, the icons, the manifest) is
|
|
20
|
+
* `no-cache`: kept, but revalidated on every use, so a release reaches
|
|
21
|
+
* the browser on its next load.
|
|
22
|
+
* - A `.br`, `.zst` or `.gz` sibling written at build time (the dashboard's
|
|
23
|
+
* adapter has `precompress: true`) is served in place of the file when
|
|
24
|
+
* the request accepts that encoding, with `Content-Encoding` and `Vary:
|
|
25
|
+
* Accept-Encoding`. The server compresses nothing at runtime, and the
|
|
26
|
+
* API is untouched: this middleware runs on the static files alone,
|
|
27
|
+
* after every credentialed route has answered.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here is a security input. `immutable` never reaches a response
|
|
30
|
+
* that is not a hashed asset, so no user-bearing answer can be cached; the
|
|
31
|
+
* authenticated JSON keeps its `no-store` (middleware/no-store.ts).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The content-hashed prefix of a SvelteKit build. */
|
|
35
|
+
export const IMMUTABLE_ASSET_PREFIX = '/_app/immutable/';
|
|
36
|
+
export const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
37
|
+
export const REVALIDATE_CACHE_CONTROL = 'no-cache';
|
|
38
|
+
|
|
39
|
+
/** The `Cache-Control` a static path of the build is served with. */
|
|
40
|
+
export function staticCacheControl(requestPath: string): string {
|
|
41
|
+
return requestPath.startsWith(IMMUTABLE_ASSET_PREFIX) ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StaticAssetsOptions {
|
|
45
|
+
/** The directory holding the built dashboard (`index.html`, `_app/`). */
|
|
46
|
+
publicDir: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Serve `publicDir` with the cache policy above and the precompressed
|
|
51
|
+
* siblings. Falls through (`next()`) on a path that is not a file there, so
|
|
52
|
+
* the SPA fallback behind it answers the application routes.
|
|
53
|
+
*/
|
|
54
|
+
export function staticAssets(options: StaticAssetsOptions): MiddlewareHandler {
|
|
55
|
+
const serve = serveStatic({ root: options.publicDir, precompressed: true });
|
|
56
|
+
return async (c, next) => {
|
|
57
|
+
// The application's HTML itself (`/`, `/index.html`) is the SPA
|
|
58
|
+
// fallback's to answer, with its validator and its sibling; the static
|
|
59
|
+
// server would hand it out with neither (verifier round 2).
|
|
60
|
+
if (c.req.path === '/' || c.req.path === '/index.html') return next();
|
|
61
|
+
const res = await serve(c, next);
|
|
62
|
+
// serveStatic answers a Response for a file it found and the result of
|
|
63
|
+
// `next()` otherwise; only the file gets the policy.
|
|
64
|
+
if (res instanceof Response) {
|
|
65
|
+
res.headers.set('Cache-Control', staticCacheControl(c.req.path));
|
|
66
|
+
if (!res.headers.has('Vary')) res.headers.set('Vary', 'Accept-Encoding');
|
|
67
|
+
}
|
|
68
|
+
return res;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── The SPA fallback ────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** The encodings a precompressed sibling may carry, in the order they are preferred. */
|
|
75
|
+
const FALLBACK_ENCODINGS = [
|
|
76
|
+
{ name: 'br', ext: '.br' },
|
|
77
|
+
{ name: 'gzip', ext: '.gz' }
|
|
78
|
+
] as const;
|
|
79
|
+
type FallbackEncoding = (typeof FALLBACK_ENCODINGS)[number]['name'];
|
|
80
|
+
|
|
81
|
+
/** The encodings a request accepts, read from `Accept-Encoding` with any `;q=` weight ignored. */
|
|
82
|
+
export function acceptedEncodings(header: string | undefined): Set<string> {
|
|
83
|
+
return new Set(
|
|
84
|
+
(header ?? '')
|
|
85
|
+
.split(',')
|
|
86
|
+
.map((part) => part.split(';')[0]!.trim().toLowerCase())
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SpaFallback {
|
|
92
|
+
/** The strong ETag of the plain HTML (the variants derive theirs from it). */
|
|
93
|
+
readonly etag: string;
|
|
94
|
+
/** Answer the application's HTML for a route the build has no file for. */
|
|
95
|
+
respond(c: Context): Response;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The application's HTML under any route the build has no file for, served
|
|
100
|
+
* with the two things the bare `c.html()` lacked (the tier 1 verifier's two
|
|
101
|
+
* caveats, PRDCT-3584): a strong `ETag` so `no-cache` means "revalidate"
|
|
102
|
+
* (a `304` costs no bytes), and the `.br` / `.gz` sibling of `index.html`
|
|
103
|
+
* when the build wrote one and the request accepts it, so a deep link costs
|
|
104
|
+
* the same as the root on a front that compresses nothing (Cloud Run, a
|
|
105
|
+
* self-hosted instance with no proxy). The HTML is read once at boot, like
|
|
106
|
+
* `indexHtml` itself.
|
|
107
|
+
*/
|
|
108
|
+
export async function loadSpaFallback(publicDir: string, html: string): Promise<SpaFallback> {
|
|
109
|
+
const plain = Buffer.from(html, 'utf8');
|
|
110
|
+
const etag = `"${createHash('sha256').update(plain).digest('base64url').slice(0, 27)}"`;
|
|
111
|
+
const variants = new Map<FallbackEncoding, Buffer>();
|
|
112
|
+
for (const { name, ext } of FALLBACK_ENCODINGS) {
|
|
113
|
+
try {
|
|
114
|
+
variants.set(name, await readFile(join(publicDir, `index.html${ext}`)));
|
|
115
|
+
} catch {
|
|
116
|
+
// no sibling of that encoding: the plain HTML answers those requests
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
etag,
|
|
121
|
+
respond(c) {
|
|
122
|
+
const accepted = acceptedEncodings(c.req.header('accept-encoding'));
|
|
123
|
+
const encoding = FALLBACK_ENCODINGS.find((e) => accepted.has(e.name) && variants.has(e.name))?.name;
|
|
124
|
+
// One validator per representation: a cached brotli body is never
|
|
125
|
+
// confirmed against a plain one.
|
|
126
|
+
const variantEtag = encoding ? etag.replace(/"$/, `-${encoding}"`) : etag;
|
|
127
|
+
// The type rides the 304 too: the node server would otherwise stamp a
|
|
128
|
+
// text/plain default on it (verifier round 2), and RFC 9110 allows the
|
|
129
|
+
// 304 to repeat what the 200 carries.
|
|
130
|
+
const headers: Record<string, string> = {
|
|
131
|
+
'Cache-Control': REVALIDATE_CACHE_CONTROL,
|
|
132
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
133
|
+
ETag: variantEtag,
|
|
134
|
+
Vary: 'Accept-Encoding'
|
|
135
|
+
};
|
|
136
|
+
if (c.req.header('if-none-match') === variantEtag) {
|
|
137
|
+
return c.body(null, 304, headers);
|
|
138
|
+
}
|
|
139
|
+
if (encoding) headers['Content-Encoding'] = encoding;
|
|
140
|
+
const body = encoding ? variants.get(encoding)! : plain;
|
|
141
|
+
return c.body(new Uint8Array(body), 200, headers);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|