@capacms/sdk 1.0.0-next.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.
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withCache = withCache;
4
+ exports.tagsFor = tagsFor;
5
+ exports.revalidateFromWebhook = revalidateFromWebhook;
6
+ exports.draftClient = draftClient;
7
+ exports.routeOf = routeOf;
8
+ exports.preview = preview;
9
+ exports.pagesFor = pagesFor;
10
+ const next_1 = require("../next");
11
+ /** Add Next.js fetch-cache options without importing `next/*`. */
12
+ function withCache(fetchImpl, options) {
13
+ return (async (input, init = {}) => {
14
+ const previous = init.next ?? {};
15
+ const tags = [...new Set([...(previous.tags ?? []), ...(options.tags ?? [])])];
16
+ const next = { ...previous };
17
+ if (tags.length > 0)
18
+ next.tags = tags;
19
+ if (options.revalidate !== undefined)
20
+ next.revalidate = options.revalidate;
21
+ return fetchImpl(input, { ...init, next });
22
+ });
23
+ }
24
+ function tagsFor(input) {
25
+ const tags = [];
26
+ if (input.model)
27
+ tags.push(`m:${input.model}`);
28
+ if (input.entry)
29
+ tags.push(`e:${input.entry}`);
30
+ if (input.key)
31
+ tags.push(`k:${input.key}`);
32
+ if (input.tenant)
33
+ tags.push(`t:${input.tenant}`);
34
+ return tags;
35
+ }
36
+ /** Revalidate the concrete entry and model identities carried by a webhook. */
37
+ async function revalidateFromWebhook(input) {
38
+ const data = input.payload?.data ?? input.payload;
39
+ const tags = [];
40
+ const entryId = data?.instanceId ?? data?.entryId;
41
+ if (typeof entryId === "string" && entryId)
42
+ tags.push(`e:${entryId}`);
43
+ if (typeof data?.modelId === "string" && data.modelId)
44
+ tags.push(`m:${data.modelId}`);
45
+ const unique = [...new Set(tags)];
46
+ for (const tag of unique)
47
+ await input.revalidateTag(tag);
48
+ return unique;
49
+ }
50
+ /** Select a client using server-only draft state supplied by the caller. */
51
+ async function draftClient(input) {
52
+ return (0, next_1.createClient)((await input.isDraft()) ? input.draft : input.production);
53
+ }
54
+ // ------------------------------------------------------------------ pages ---
55
+ /**
56
+ * The page a Next.js route file renders, as a Capa page identity.
57
+ *
58
+ * `routeOf(import.meta.url)` in a route, or `routeOf(__filename)`, turns the
59
+ * file's own path into the string to send as `Capa-Page`. Writing the route out
60
+ * by hand works too; this exists so that moving a folder cannot silently split
61
+ * one page's telemetry into two.
62
+ *
63
+ * /abs/app/blog/[slug]/page.tsx -> /blog/[slug]
64
+ * src/app/(marketing)/pricing/page.tsx -> /pricing
65
+ * pages/blog/[slug].tsx -> /blog/[slug]
66
+ * app/page.tsx -> /
67
+ *
68
+ * WHAT IS DROPPED, and why each one:
69
+ *
70
+ * everything before `app/` or `pages/` the route starts at the router root,
71
+ * so an absolute path and a relative
72
+ * one give the same answer
73
+ * route groups `(marketing)` Next does not put them in the URL
74
+ * parallel slots `@modal` the same
75
+ * the `(.)` intercept marker an intercepting route at the SAME
76
+ * level renders the segment beside it
77
+ * `page.*`, `layout.*`, `route.*`, leaf files, not segments
78
+ * `default.*`, `template.*`
79
+ * `index` in the pages router the folder IS the route
80
+ * the extension never in a URL
81
+ *
82
+ * THROWS rather than guessing in three cases, because a page identity that is
83
+ * quietly wrong is worse than one that is missing: a path with no `app/` or
84
+ * `pages/` directory in it; a file inside a private `_folder`, which Next does
85
+ * not route at all; and a `(..)` or `(...)` intercepting route, whose URL is a
86
+ * segment somewhere ABOVE the file and is not derivable from the path. In each
87
+ * case the message says to pass the page string yourself.
88
+ */
89
+ const ROUTER_ROOTS = new Set(["app", "pages"]);
90
+ const LEAF_FILES = new Set(["page", "layout", "route", "default", "template"]);
91
+ /** `(..)photo`, `(..)(..)photo`, `(...)photo`: the URL is not here. */
92
+ const OUTER_INTERCEPT = /^(\(\.\.\.\)|(\(\.\.\))+)/;
93
+ function routeOf(file) {
94
+ if (typeof file !== "string" || file === "") {
95
+ throw new TypeError("@capacms/sdk/nextjs: routeOf needs a file path.");
96
+ }
97
+ // `import.meta.url` is a file: URL, and a Windows path uses backslashes.
98
+ const withoutScheme = file.startsWith("file://") ? file.slice("file://".length) : file;
99
+ const normalised = withoutScheme.replace(/\\/g, "/").split("?")[0];
100
+ const parts = normalised.split("/").filter((part) => part !== "" && part !== ".");
101
+ // The LAST router root wins, so a project with its own `app/` inside
102
+ // `packages/site/app/...` resolves against the one nearest the route.
103
+ let rootIndex = -1;
104
+ for (let i = parts.length - 1; i >= 0; i--) {
105
+ if (ROUTER_ROOTS.has(parts[i])) {
106
+ rootIndex = i;
107
+ break;
108
+ }
109
+ }
110
+ if (rootIndex === -1) {
111
+ throw new TypeError(`@capacms/sdk/nextjs: ${file} is not inside an app/ or pages/ directory, so it names no route. Pass the page string yourself.`);
112
+ }
113
+ const segments = [];
114
+ for (let i = rootIndex + 1; i < parts.length; i++) {
115
+ const isLast = i === parts.length - 1;
116
+ let part = parts[i];
117
+ if (isLast) {
118
+ const dot = part.lastIndexOf(".");
119
+ if (dot > 0)
120
+ part = part.slice(0, dot);
121
+ if (LEAF_FILES.has(part))
122
+ continue;
123
+ // The pages router: `pages/blog/index.tsx` is `/blog`.
124
+ if (part === "index")
125
+ continue;
126
+ }
127
+ if (part.startsWith("_")) {
128
+ throw new TypeError(`@capacms/sdk/nextjs: ${file} is inside the private folder ${part}, which Next does not route. Pass the page string yourself.`);
129
+ }
130
+ if (OUTER_INTERCEPT.test(part)) {
131
+ throw new TypeError(`@capacms/sdk/nextjs: ${file} intercepts a route above it (${part}), so its URL is not in its path. Pass the page string yourself.`);
132
+ }
133
+ // Route groups and parallel slots are organisation, not URL.
134
+ if (part.startsWith("(") && part.endsWith(")"))
135
+ continue;
136
+ if (part.startsWith("@"))
137
+ continue;
138
+ // `(.)photo` intercepts the sibling `photo`, so the marker goes and the
139
+ // name stays.
140
+ if (part.startsWith("(.)"))
141
+ part = part.slice("(.)".length);
142
+ segments.push(part);
143
+ }
144
+ return segments.length === 0 ? "/" : `/${segments.join("/")}`;
145
+ }
146
+ /**
147
+ * Verify a preview token from a Next route handler.
148
+ *
149
+ * A thin pass-through to `client.preview`, here so that a preview route reads
150
+ * as one import from `@capacms/sdk/nextjs` alongside the rest of the Next
151
+ * helpers. Null means "do not enable draft mode".
152
+ */
153
+ async function preview(token, client) {
154
+ return client.preview(token);
155
+ }
156
+ /**
157
+ * The page list, as a standalone value.
158
+ *
159
+ * `pagesFor(client)` is `client.pages` and exists for symmetry with the other
160
+ * helpers in this module: a build script that only wants the page list can take
161
+ * this and never hold the whole client.
162
+ */
163
+ function pagesFor(client) {
164
+ return client.pages;
165
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * webhook-signature.ts — verifying a Capa webhook, with no client and no key.
3
+ *
4
+ * This is the one thing in the SDK a receiver needs before it has anything
5
+ * else. It takes the raw request body, the `Capa-Signature` header and the
6
+ * endpoint's secret, and answers a boolean. It does not construct a client, it
7
+ * does not read configuration and it makes no network call, so it can be
8
+ * imported into a route handler that has nothing but the request in hand.
9
+ *
10
+ * WEBCRYPTO, NOT `node:crypto`, AND THAT IS WHY IT IS ASYNC.
11
+ * `globalThis.crypto.subtle` exists in Node 18+, Bun, Deno, Cloudflare Workers,
12
+ * Vercel's edge runtime and every browser. `node:crypto` exists in exactly one
13
+ * of those, and a bare `import crypto from "node:crypto"` at the top of a file
14
+ * that a bundler pulls into an edge or browser build breaks the build outright
15
+ * rather than at the call. WebCrypto's HMAC is promise-based, so this function
16
+ * is `async`. That is the whole reason, and it is worth the `await`.
17
+ *
18
+ * THE BODY MUST BE THE BYTES THAT ARRIVED.
19
+ * The signature covers `"<t>.<body>"` where `<body>` is the exact request body
20
+ * Capa sent. A body that has been through `JSON.parse` and `JSON.stringify`
21
+ * again is a different string (key order, whitespace, unicode escapes) and will
22
+ * not verify. In Express use `express.raw({ type: "application/json" })`, in
23
+ * Fastify add a raw-body parser for the route, in Next's app router use
24
+ * `await request.text()`. Pass the `Uint8Array` or `Buffer` straight in when
25
+ * you have one: this function signs the bytes you give it without a round trip
26
+ * through a string.
27
+ */
28
+ /** The header Capa signs with. */
29
+ export declare const WEBHOOK_SIGNATURE_HEADER = "Capa-Signature";
30
+ /** Five minutes, the tolerance in the publishing spec (4.4). */
31
+ export declare const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
32
+ export interface VerifyWebhookSignatureInput {
33
+ /**
34
+ * The exact request body. A string is encoded as UTF-8; a `Uint8Array` or
35
+ * `Buffer` is signed as given.
36
+ */
37
+ payload: string | Uint8Array;
38
+ /** The `Capa-Signature` header value, `t=<unix>,v1=<hex>[,v1=<hex>]`. */
39
+ header: string;
40
+ /** The endpoint's secret, `whsec_…`, including the prefix. */
41
+ secret: string;
42
+ /**
43
+ * How far `t` may be from now, in seconds, in either direction. Always
44
+ * enforced: `0` means the timestamp must be the current second, not that the
45
+ * check is skipped.
46
+ */
47
+ toleranceSeconds?: number;
48
+ /** Injected in tests. Unix seconds, or a Date. Defaults to the clock. */
49
+ now?: number | Date;
50
+ }
51
+ export interface ParsedWebhookSignature {
52
+ /** Unix seconds the sender signed at. */
53
+ t: number;
54
+ /** Every `v1=` entry, newest first, in header order. */
55
+ v1: string[];
56
+ }
57
+ /**
58
+ * Split a `Capa-Signature` header. `null` when it carries no usable `t` or no
59
+ * `v1` at all, which is the same thing as "do not trust this request".
60
+ */
61
+ export declare function parseWebhookSignatureHeader(header: string): ParsedWebhookSignature | null;
62
+ /** The lowercase hex HMAC-SHA256 of `"<t>.<payload>"` under `secret`. */
63
+ export declare function signWebhookPayload(payload: string | Uint8Array, secret: string, t: number): Promise<string>;
64
+ /**
65
+ * Is this request really from Capa, and recent?
66
+ *
67
+ * ```ts
68
+ * import { verifyWebhookSignature } from "@capacms/sdk";
69
+ *
70
+ * const body = await request.text();
71
+ * const ok = await verifyWebhookSignature({
72
+ * payload: body,
73
+ * header: request.headers.get("capa-signature") ?? "",
74
+ * secret: process.env.CAPA_WEBHOOK_SECRET!,
75
+ * });
76
+ * if (!ok) return new Response("bad signature", { status: 400 });
77
+ * ```
78
+ *
79
+ * `true` only when the header parses, `t` is inside the tolerance, and at least
80
+ * one `v1` entry equals the HMAC of `"<t>.<payload>"` under `secret`. During
81
+ * the 24 hours after a rotation Capa sends TWO `v1` entries, the new secret
82
+ * first, so a receiver that has only ever seen one of the two still verifies
83
+ * and you can change the stored secret whenever you like inside that window.
84
+ *
85
+ * Every `v1` is compared even after one matches, so the answer takes the same
86
+ * time whichever entry was the right one.
87
+ */
88
+ export declare function verifyWebhookSignature(input: VerifyWebhookSignatureInput): Promise<boolean>;
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ /**
3
+ * webhook-signature.ts — verifying a Capa webhook, with no client and no key.
4
+ *
5
+ * This is the one thing in the SDK a receiver needs before it has anything
6
+ * else. It takes the raw request body, the `Capa-Signature` header and the
7
+ * endpoint's secret, and answers a boolean. It does not construct a client, it
8
+ * does not read configuration and it makes no network call, so it can be
9
+ * imported into a route handler that has nothing but the request in hand.
10
+ *
11
+ * WEBCRYPTO, NOT `node:crypto`, AND THAT IS WHY IT IS ASYNC.
12
+ * `globalThis.crypto.subtle` exists in Node 18+, Bun, Deno, Cloudflare Workers,
13
+ * Vercel's edge runtime and every browser. `node:crypto` exists in exactly one
14
+ * of those, and a bare `import crypto from "node:crypto"` at the top of a file
15
+ * that a bundler pulls into an edge or browser build breaks the build outright
16
+ * rather than at the call. WebCrypto's HMAC is promise-based, so this function
17
+ * is `async`. That is the whole reason, and it is worth the `await`.
18
+ *
19
+ * THE BODY MUST BE THE BYTES THAT ARRIVED.
20
+ * The signature covers `"<t>.<body>"` where `<body>` is the exact request body
21
+ * Capa sent. A body that has been through `JSON.parse` and `JSON.stringify`
22
+ * again is a different string (key order, whitespace, unicode escapes) and will
23
+ * not verify. In Express use `express.raw({ type: "application/json" })`, in
24
+ * Fastify add a raw-body parser for the route, in Next's app router use
25
+ * `await request.text()`. Pass the `Uint8Array` or `Buffer` straight in when
26
+ * you have one: this function signs the bytes you give it without a round trip
27
+ * through a string.
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.DEFAULT_WEBHOOK_TOLERANCE_SECONDS = exports.WEBHOOK_SIGNATURE_HEADER = void 0;
31
+ exports.parseWebhookSignatureHeader = parseWebhookSignatureHeader;
32
+ exports.signWebhookPayload = signWebhookPayload;
33
+ exports.verifyWebhookSignature = verifyWebhookSignature;
34
+ /** The header Capa signs with. */
35
+ exports.WEBHOOK_SIGNATURE_HEADER = "Capa-Signature";
36
+ /** Five minutes, the tolerance in the publishing spec (4.4). */
37
+ exports.DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
38
+ /**
39
+ * Split a `Capa-Signature` header. `null` when it carries no usable `t` or no
40
+ * `v1` at all, which is the same thing as "do not trust this request".
41
+ */
42
+ function parseWebhookSignatureHeader(header) {
43
+ if (typeof header !== "string" || !header)
44
+ return null;
45
+ let t = null;
46
+ const v1 = [];
47
+ for (const part of header.split(",")) {
48
+ const eq = part.indexOf("=");
49
+ if (eq < 1)
50
+ continue;
51
+ const key = part.slice(0, eq).trim();
52
+ const value = part.slice(eq + 1).trim();
53
+ if (key === "t") {
54
+ // The first `t` wins. A header with two is malformed, and picking the
55
+ // last one would let an attacker append a fresh timestamp to a replay.
56
+ if (t === null && /^\d+$/.test(value))
57
+ t = Number(value);
58
+ }
59
+ else if (key === "v1") {
60
+ if (value)
61
+ v1.push(value);
62
+ }
63
+ }
64
+ if (t === null || !v1.length)
65
+ return null;
66
+ return { t, v1 };
67
+ }
68
+ /** The bytes that get signed: `"<t>."` followed by the body. */
69
+ function signedPayloadBytes(t, payload) {
70
+ const prefix = new TextEncoder().encode(`${t}.`);
71
+ const body = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
72
+ const out = new Uint8Array(prefix.length + body.length);
73
+ out.set(prefix, 0);
74
+ out.set(body, prefix.length);
75
+ return out;
76
+ }
77
+ function toHex(buffer) {
78
+ const bytes = new Uint8Array(buffer);
79
+ let hex = "";
80
+ for (let i = 0; i < bytes.length; i++)
81
+ hex += bytes[i].toString(16).padStart(2, "0");
82
+ return hex;
83
+ }
84
+ /**
85
+ * Constant time over the digest's own length.
86
+ *
87
+ * Both sides are a fixed-length lowercase hex SHA-256 digest, so a length
88
+ * mismatch is a malformed header rather than a near miss and leaks nothing
89
+ * worth having.
90
+ */
91
+ function timingSafeEqualHex(a, b) {
92
+ if (a.length !== b.length)
93
+ return false;
94
+ let diff = 0;
95
+ for (let i = 0; i < a.length; i++)
96
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
97
+ return diff === 0;
98
+ }
99
+ function subtle() {
100
+ const c = globalThis.crypto;
101
+ if (!c?.subtle) {
102
+ throw new Error("@capacms/sdk: verifyWebhookSignature needs WebCrypto (globalThis.crypto.subtle). " +
103
+ "It is present in Node 18+, Bun, Deno, Workers and browsers. On an older Node, " +
104
+ "run with --experimental-global-webcrypto or upgrade.");
105
+ }
106
+ return c.subtle;
107
+ }
108
+ /** The lowercase hex HMAC-SHA256 of `"<t>.<payload>"` under `secret`. */
109
+ async function signWebhookPayload(payload, secret, t) {
110
+ const key = await subtle().importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
111
+ return toHex(await subtle().sign("HMAC", key, signedPayloadBytes(t, payload)));
112
+ }
113
+ /**
114
+ * Is this request really from Capa, and recent?
115
+ *
116
+ * ```ts
117
+ * import { verifyWebhookSignature } from "@capacms/sdk";
118
+ *
119
+ * const body = await request.text();
120
+ * const ok = await verifyWebhookSignature({
121
+ * payload: body,
122
+ * header: request.headers.get("capa-signature") ?? "",
123
+ * secret: process.env.CAPA_WEBHOOK_SECRET!,
124
+ * });
125
+ * if (!ok) return new Response("bad signature", { status: 400 });
126
+ * ```
127
+ *
128
+ * `true` only when the header parses, `t` is inside the tolerance, and at least
129
+ * one `v1` entry equals the HMAC of `"<t>.<payload>"` under `secret`. During
130
+ * the 24 hours after a rotation Capa sends TWO `v1` entries, the new secret
131
+ * first, so a receiver that has only ever seen one of the two still verifies
132
+ * and you can change the stored secret whenever you like inside that window.
133
+ *
134
+ * Every `v1` is compared even after one matches, so the answer takes the same
135
+ * time whichever entry was the right one.
136
+ */
137
+ async function verifyWebhookSignature(input) {
138
+ const { payload, header, secret } = input ?? {};
139
+ if (typeof secret !== "string" || !secret)
140
+ return false;
141
+ if (payload === undefined || payload === null)
142
+ return false;
143
+ const parsed = parseWebhookSignatureHeader(header);
144
+ if (!parsed)
145
+ return false;
146
+ const tolerance = typeof input.toleranceSeconds === "number" && Number.isFinite(input.toleranceSeconds)
147
+ ? Math.abs(input.toleranceSeconds)
148
+ : exports.DEFAULT_WEBHOOK_TOLERANCE_SECONDS;
149
+ const nowSeconds = input.now instanceof Date
150
+ ? Math.floor(input.now.getTime() / 1000)
151
+ : typeof input.now === "number"
152
+ ? Math.floor(input.now)
153
+ : Math.floor(Date.now() / 1000);
154
+ if (Math.abs(nowSeconds - parsed.t) > tolerance)
155
+ return false;
156
+ const expected = await signWebhookPayload(payload, secret, parsed.t);
157
+ let ok = false;
158
+ for (const candidate of parsed.v1)
159
+ ok = timingSafeEqualHex(candidate, expected) || ok;
160
+ return ok;
161
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * webhooks.ts — managing endpoints and reading deliveries.
3
+ *
4
+ * WHY THIS ONE RESOURCE NEEDS A SESSION TOKEN
5
+ * Every other method on the client is an API key call. These are not, and the
6
+ * reason is a deliberate decision rather than an oversight: publishing spec D8
7
+ * says webhook endpoints have no agent mount and no API key bundle, because a
8
+ * key that can add an endpoint can quietly forward every content change in the
9
+ * tenant to a URL of its choosing, and a key is a string in a config file that
10
+ * nobody rotates. So `/v2/webhooks/*` is session-only: `authenticate`,
11
+ * `validateTenant`, `webhook:read` or `webhook:*` on a real person.
12
+ *
13
+ * The SDK therefore takes an `accessToken` (from `POST /v2/user/login`) and
14
+ * refuses locally when it is absent, rather than sending an `x-api-key` the
15
+ * route will never look at and returning the 401 that follows.
16
+ *
17
+ * READING IS THE COMMON CASE. Most callers want `deliveries()` and
18
+ * `deliveries.get()` to build a "why did my site not update" screen of their
19
+ * own. Those still need the token, because the route does.
20
+ */
21
+ import type { ResolvedConfig } from "./config";
22
+ /** `disabledReason` on an endpoint: who or what switched it off. */
23
+ export type WebhookDisabledReason = "paused" | "failing" | "ssrf_refused";
24
+ /**
25
+ * An endpoint as every route returns it.
26
+ *
27
+ * `headers` are MASKED here (`"••••ab"`). The stored value is sent to your URL
28
+ * in full; the API never reads one back out. `secretPrefix` is the first 10
29
+ * characters of the secret (`"whsec_3f9a"`), which is enough to tell two
30
+ * endpoints apart in a log and useless to anyone who finds it.
31
+ */
32
+ export interface WebhookEndpoint {
33
+ id: string;
34
+ tenantId: string;
35
+ name: string;
36
+ url: string;
37
+ description: string | null;
38
+ secretPrefix: string;
39
+ /** Exact types, or `"<resource>.*"` wildcards. */
40
+ events: string[];
41
+ enabled: boolean;
42
+ /** Sends the published entry in `data` on `instance.published`. */
43
+ includeData: boolean;
44
+ headers: Record<string, string>;
45
+ consecutiveFailures: number;
46
+ /** When the current failing streak started. Cleared by any success. */
47
+ failingSince: string | null;
48
+ lastDeliveryAt: string | null;
49
+ /**
50
+ * The HTTP status as a string when the endpoint answered at all (`"200"`,
51
+ * `"301"`, `"500"`), or `"timeout"` / `"network"` when it did not. An SSRF
52
+ * refusal is recorded in `disabledReason`, not here, so a refused endpoint
53
+ * still shows whatever its last real answer was.
54
+ */
55
+ lastDeliveryStatus: string | null;
56
+ disabledAt: string | null;
57
+ disabledReason: WebhookDisabledReason | null;
58
+ createdBy: string | null;
59
+ createdAt: string;
60
+ updatedAt: string;
61
+ }
62
+ /**
63
+ * What `create` and `rotateSecret` answer with. The ONLY two responses that
64
+ * ever carry `secret`; every later read carries `secretPrefix` alone, and the
65
+ * full value comes back only from `revealSecret`, which is audited.
66
+ */
67
+ export interface WebhookEndpointWithSecret extends WebhookEndpoint {
68
+ secret: string;
69
+ /** Set by `rotateSecret`: the old secret keeps signing until this instant. */
70
+ previousSecretExpiresAt?: string | null;
71
+ }
72
+ /** `get` adds the count the endpoint page puts on its Redeliver button. */
73
+ export interface WebhookEndpointDetail extends WebhookEndpoint {
74
+ failedDeliveries: number;
75
+ }
76
+ export type WebhookDeliveryStatus = "pending" | "succeeded" | "failed" | "exhausted" | "cancelled";
77
+ /**
78
+ * One attempt at one endpoint for one event.
79
+ *
80
+ * `eventId` is the same on a redelivery as on the original, and it is what goes
81
+ * out as `Idempotency-Key`, so a receiver that stores it can recognise a repeat
82
+ * without comparing bodies. `redeliveredFromId` points at the delivery this one
83
+ * was made from, and is `null` on an original.
84
+ */
85
+ export interface WebhookDelivery {
86
+ id: string;
87
+ tenantId: string;
88
+ endpointId: string;
89
+ eventId: string;
90
+ eventType: string;
91
+ status: WebhookDeliveryStatus;
92
+ attempts: number;
93
+ /** Always 6. The ladder is 1m, 5m, 30m, 2h, 6h after failures 1 to 5. */
94
+ maxAttempts: number;
95
+ /**
96
+ * Non-null only on a `pending` row that has already failed at least once. It
97
+ * is `null` everywhere else, including on a succeeded row, whose stored
98
+ * column would otherwise read as "another attempt is coming".
99
+ */
100
+ nextAttemptAt: string | null;
101
+ lastAttemptAt: string | null;
102
+ responseStatus: number | null;
103
+ responseMs: number | null;
104
+ /**
105
+ * `"timeout"`, `"network"`, `"redirect"`, `"ssrf_refused"`,
106
+ * `"endpoint_disabled"`, `"invalid_url"` (terminal: the stored URL no longer
107
+ * parses) or `"secret_unavailable"` (also terminal: the endpoint row carries
108
+ * no usable signing secret, so no retry could ever sign it — fix the
109
+ * endpoint and redeliver). An ordinary non-2xx answer leaves this null,
110
+ * because `responseStatus` says it all.
111
+ *
112
+ * A secret that could not be DECRYPTED right now — `CAPA_SECRET_KEY`
113
+ * mid-change — is the other case and never reaches this column: the delivery
114
+ * is released unattempted and stays `pending`.
115
+ */
116
+ error: string | null;
117
+ redeliveredFromId: string | null;
118
+ createdAt: string;
119
+ completedAt: string | null;
120
+ }
121
+ /** The single-delivery read, which adds the bodies the list leaves out. */
122
+ export interface WebhookDeliveryDetail extends WebhookDelivery {
123
+ /** The exact bytes that were signed and sent. */
124
+ requestBody: string;
125
+ /** The first 4 KB of what came back, or `null`. */
126
+ responseBody: string | null;
127
+ }
128
+ export type WebhookEventGroup = "Content" | "Models" | "Media" | "Publishing" | "Test";
129
+ /** One row of `webhooks.events()`: what to show in a subscription picker. */
130
+ export interface WebhookEventCatalogueEntry {
131
+ group: WebhookEventGroup;
132
+ type: string;
133
+ description: string;
134
+ sample: Record<string, unknown>;
135
+ /** Checked when a person adds an endpoint and changes nothing. */
136
+ defaultChecked: boolean;
137
+ }
138
+ export interface WebhookPagination {
139
+ total: number;
140
+ page: number;
141
+ limit: number;
142
+ totalPages: number;
143
+ hasMore: boolean;
144
+ }
145
+ export interface WebhookDeliveriesPage {
146
+ deliveries: WebhookDelivery[];
147
+ pagination: WebhookPagination;
148
+ }
149
+ export interface CreateWebhookEndpointInput {
150
+ name: string;
151
+ /** `https://…`. `http://` only for localhost, and only outside production. */
152
+ url: string;
153
+ description?: string;
154
+ /** At least one. Exact types or `"<resource>.*"`. */
155
+ events: string[];
156
+ includeData?: boolean;
157
+ /** At most 10. Sent with every request; they cannot override a `Capa-*` header. */
158
+ headers?: Record<string, string>;
159
+ }
160
+ /**
161
+ * A patch. `headers` REPLACES the whole map, so send the ones you want kept.
162
+ * A value left at its mask (`"••••ab"`) keeps the stored value, which is how
163
+ * the admin can round-trip a form it was never shown the real values for.
164
+ */
165
+ export type UpdateWebhookEndpointInput = Partial<CreateWebhookEndpointInput>;
166
+ export interface ResumeWebhookEndpointOptions {
167
+ /**
168
+ * `false` (the default) enables the endpoint and COUNTS what it missed
169
+ * without queueing any of it, so a caller can offer the number to a person
170
+ * first. `true` enables it and queues the same set.
171
+ */
172
+ backfill?: boolean;
173
+ /** ISO. Defaults to the instant the endpoint was disabled. */
174
+ since?: string;
175
+ }
176
+ export interface ResumeWebhookEndpointResult {
177
+ endpoint: WebhookEndpoint;
178
+ backfill: {
179
+ since: string | null;
180
+ /** How many events match, whether or not they were queued. */
181
+ count: number;
182
+ /** How many deliveries were actually created. `0` without `backfill: true`. */
183
+ inserted: number;
184
+ };
185
+ }
186
+ export interface ListWebhookDeliveriesOptions {
187
+ status?: WebhookDeliveryStatus[];
188
+ eventType?: string;
189
+ page?: number;
190
+ /** Default 50, max 200. */
191
+ limit?: number;
192
+ }
193
+ export interface WebhooksResource {
194
+ /** The event catalogue: every type, grouped, with a sample payload. */
195
+ events(): Promise<WebhookEventCatalogueEntry[]>;
196
+ endpoints: WebhookEndpointsResource;
197
+ deliveries: WebhookDeliveriesResource;
198
+ }
199
+ export interface WebhookEndpointsResource {
200
+ list(): Promise<WebhookEndpoint[]>;
201
+ /** The response carries `secret` once and never again. Store it now. */
202
+ create(input: CreateWebhookEndpointInput): Promise<WebhookEndpointWithSecret>;
203
+ /** `null` when no such endpoint belongs to this tenant. */
204
+ get(id: string): Promise<WebhookEndpointDetail | null>;
205
+ update(id: string, patch: UpdateWebhookEndpointInput): Promise<WebhookEndpoint>;
206
+ delete(id: string): Promise<boolean>;
207
+ /** Stops delivery and cancels everything already queued for it. */
208
+ pause(id: string): Promise<WebhookEndpoint>;
209
+ resume(id: string, options?: ResumeWebhookEndpointOptions): Promise<ResumeWebhookEndpointResult>;
210
+ /** Audited on the server, every time, including the refusals. */
211
+ revealSecret(id: string): Promise<{
212
+ secret: string;
213
+ secretPrefix: string;
214
+ }>;
215
+ /** The old secret keeps signing for 24 hours. See `WEBHOOKS.md`. */
216
+ rotateSecret(id: string): Promise<WebhookEndpointWithSecret>;
217
+ /**
218
+ * Queues one `webhook.test` event for this endpoint alone.
219
+ *
220
+ * The returned `eventId` is an OPAQUE request id, not the id of the event
221
+ * that gets delivered. The envelope's `id`, the `Capa-Event-Id` header and
222
+ * the delivery row's `eventId` are all `evt_` plus the outbox row's uuid,
223
+ * which is a different string, so this value correlates with nothing. To find
224
+ * what the test produced, read `deliveries(id)` and take the newest
225
+ * `webhook.test` row.
226
+ */
227
+ test(id: string): Promise<{
228
+ eventId: string;
229
+ }>;
230
+ deliveries(id: string, options?: ListWebhookDeliveriesOptions): Promise<WebhookDeliveriesPage>;
231
+ redeliverFailed(id: string, options?: {
232
+ since?: string;
233
+ }): Promise<{
234
+ created: number;
235
+ }>;
236
+ }
237
+ export interface WebhookDeliveriesResource {
238
+ /** `null` on a miss. Carries `requestBody` and `responseBody`. */
239
+ get(id: string): Promise<WebhookDeliveryDetail | null>;
240
+ /** A NEW delivery with the same `eventId`, pointing back at this one. */
241
+ redeliver(id: string): Promise<WebhookDelivery>;
242
+ }
243
+ export declare const WEBHOOKS_NEED_ACCESS_TOKEN = "@capacms/sdk: webhooks need accessToken; API keys cannot manage endpoints.";
244
+ export declare function createWebhooks(resolved: ResolvedConfig): WebhooksResource;