@evinvest/kitstart 0.4.0 → 0.5.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/README.md +68 -5
- package/dist/core/accept.d.ts +9 -0
- package/dist/core/accept.js +8 -0
- package/dist/next/quote-route.d.ts +9 -1
- package/dist/next/quote-route.js +16 -0
- package/dist/server/env.d.ts +7 -1
- package/dist/server/env.js +15 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +3 -0
- package/dist/server/lead-webhook.d.ts +50 -0
- package/dist/server/lead-webhook.js +31 -0
- package/dist/server/webhook-outbox.d.ts +77 -0
- package/dist/server/webhook-outbox.js +257 -0
- package/dist/server/webhook-signature.d.ts +32 -0
- package/dist/server/webhook-signature.js +16 -0
- package/package.json +1 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/tests/e2e/quote-form.spec.ts +28 -9
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ stays in the brand.
|
|
|
14
14
|
| Import | Runtime | What |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| `@evinvest/kitstart` | anywhere (edge, client, server) | `defineSite`, places, routing (`createRouting`), the lead schema and funnel (`createAcceptLead`), antispam, JSON-LD / sitemap / robots builders, analytics events, the copy contract |
|
|
17
|
-
| `@evinvest/kitstart/server` | Node, `server-only` | `createServerEnv`, `createPlaceSource`, the lead store (`openLeadStore` by `LEADS_DB_URL`: `sqlite:` today, `postgres://` a stub that refuses at boot), `checkLeadStore`, `leadNotifier`, `sendMail`, `clientKey` |
|
|
17
|
+
| `@evinvest/kitstart/server` | Node, `server-only` | `createServerEnv`, `createPlaceSource`, the lead store (`openLeadStore` by `LEADS_DB_URL`: `sqlite:` today, `postgres://` a stub that refuses at boot), `checkLeadStore`, `leadNotifier`, `leadWebhook` (signed, outboxed), `sendMail`, `clientKey` |
|
|
18
18
|
| `@evinvest/kitstart/proxy` | edge | `createProxy(site)`, `PROXY_MATCHER` |
|
|
19
19
|
| `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `sitemapRoute`, `robotsRoute`, `ogRoute`, `healthRoute`, `createPlaceLoader`, `loadLocale`, `placeMetadata` / `brandMetadata` / `statusMetadata`, `metadataBase` |
|
|
20
20
|
| `@evinvest/kitstart/next/config` | `next.config.ts`, `vitest.config.ts` | `withLanding`, `buildEnv` and the `assets/` readers |
|
|
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
|
|
|
45
45
|
the whole of one:
|
|
46
46
|
|
|
47
47
|
```nix
|
|
48
|
-
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.
|
|
48
|
+
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.5.0"; # the version in package-lock.json
|
|
49
49
|
inputs.ev.inputs.v_flakes.follows = "v_flakes";
|
|
50
50
|
|
|
51
51
|
landing = ev.lib.mkLanding {
|
|
@@ -251,6 +251,60 @@ chooses per visitor answer `Cache-Control: private, no-store`.
|
|
|
251
251
|
network filesystem is not a place for it — that is what the Postgres port
|
|
252
252
|
is for.
|
|
253
253
|
|
|
254
|
+
## Lead webhook
|
|
255
|
+
|
|
256
|
+
A signed POST of each lead to a receiver the brand names, delivered through an
|
|
257
|
+
outbox in the leads file. The kit knows no receiver: the brand builds the body
|
|
258
|
+
and passes the signing scheme; the kit serialises, signs, stores and retries.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// shared/config/env.ts
|
|
262
|
+
import { createServerEnv, leadWebhook, type LeadWebhook } from "@evinvest/kitstart/server";
|
|
263
|
+
|
|
264
|
+
export const serverEnv = createServerEnv(site);
|
|
265
|
+
let hook: LeadWebhook | null | undefined;
|
|
266
|
+
export const webhook = (): LeadWebhook | null =>
|
|
267
|
+
(hook ??= leadWebhook(site, serverEnv(), {
|
|
268
|
+
// The receiver's scheme, all of it: the kit has no defaults.
|
|
269
|
+
signing: {
|
|
270
|
+
prefix: "sa-ingest/v1.",
|
|
271
|
+
headers: { keyId: "x-sa-key-id", timestamp: "x-sa-timestamp", signature: "x-sa-signature" },
|
|
272
|
+
},
|
|
273
|
+
buildBody: (lead, ctx) => ({ events: [/* the receiver's shape, from lead + ctx */] }),
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
// instrumentation.ts, in register(): resume the queue after a restart
|
|
277
|
+
webhook()?.start();
|
|
278
|
+
|
|
279
|
+
// app/quote/route.ts
|
|
280
|
+
export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavailable });
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
- **Off unless both halves are there.** No `LEAD_WEBHOOK_URL`, or no
|
|
284
|
+
`buildBody` → `leadWebhook` returns `null`. With the URL,
|
|
285
|
+
`LEAD_WEBHOOK_KEY_ID` and `LEAD_WEBHOOK_SECRET` are required, and the URL
|
|
286
|
+
must be `https:`, or `http:` to a `*.svc` / `*.svc.cluster.local` host or
|
|
287
|
+
loopback — the body carries PII. All checked at boot.
|
|
288
|
+
- **Queued before the 303, sent after it.** The body is built once, from the
|
|
289
|
+
lead and `ctx` (`leadId`, `brandId`, `locale`, `formId`, `at`, and a fresh
|
|
290
|
+
`idempotencyKey` for the receiver to deduplicate by), and written to the
|
|
291
|
+
`webhook_outbox` table before the visitor is thanked; the send runs in
|
|
292
|
+
`after`. A lead the notifier skips (honeypot, rate limit) is not queued.
|
|
293
|
+
- **Signature.** Three headers named by `signing.headers`: the key id, the
|
|
294
|
+
timestamp (unix seconds) and `hex(HMAC-SHA256(secret, prefix + timestamp +
|
|
295
|
+
"." + body))`, signed afresh on each attempt. Prefix and names are required.
|
|
296
|
+
- **At-least-once.** 2xx is delivered. A `207` is delivered too, and an item
|
|
297
|
+
it marks `rejected` is final — logged by index, not retried. 5xx, 408, 429
|
|
298
|
+
and network errors retry, doubling from 5 s up to an hour (with jitter, and
|
|
299
|
+
at least `Retry-After`), `WEBHOOK_MAX_ATTEMPTS` (12) times; any other status,
|
|
300
|
+
3xx included, is final. A final row stays as `dead` with its `last_error`.
|
|
301
|
+
- **PII stays in the body.** Logs name the row, the attempt and the status;
|
|
302
|
+
the receiver's own words (which may echo a field) go to the row's
|
|
303
|
+
`last_error`, never to the log.
|
|
304
|
+
- **Restarts.** Rows are in the leads file; `start()` ticks every
|
|
305
|
+
`WEBHOOK_TICK_MS` and a new process picks up what is due. Rows queued for a
|
|
306
|
+
previous URL stay in place and are counted at open.
|
|
307
|
+
|
|
254
308
|
## The site
|
|
255
309
|
|
|
256
310
|
```ts
|
|
@@ -286,9 +340,18 @@ page how to write its links through the route it rewrites to:
|
|
|
286
340
|
|
|
287
341
|
## Publishing
|
|
288
342
|
|
|
289
|
-
`
|
|
290
|
-
|
|
291
|
-
|
|
343
|
+
`template/` follows the release on its own: `nix run .#publish` rewrites a
|
|
344
|
+
template range that no longer admits a version it just published to
|
|
345
|
+
`^<version>`, and on a kitstart release moves the `@evinvest/kitstart-v…` pin in
|
|
346
|
+
`template/flake.nix` and this README — all in the release commit, so the tag
|
|
347
|
+
lands on a template that already points at it. kitstart publishes last in a
|
|
348
|
+
run, so its tarball carries the new ranges. A run without kitstart still
|
|
349
|
+
moves the template, but the kitstart tarball already on npm ships the old one,
|
|
350
|
+
so that run ends by printing the `--only @evinvest/kitstart` command that
|
|
351
|
+
releases it. A range the script cannot read, or a branch behind its upstream,
|
|
352
|
+
stops the run before anything is published. `npm test` fails whenever the
|
|
353
|
+
template stops admitting the workspace versions. kitstart's peers must be on
|
|
354
|
+
the registry first, so a release is two runs:
|
|
292
355
|
|
|
293
356
|
1. `nix run .#publish -- minor --npm-only --only @evinvest/uikit --only @evinvest/marketing`,
|
|
294
357
|
then wait until `npm view @evinvest/uikit version` answers the new one;
|
package/dist/core/accept.d.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface AcceptDeps {
|
|
|
27
27
|
/** Runs after the response is sent; see the route handler. */
|
|
28
28
|
defer: (task: () => Promise<void> | void) => void;
|
|
29
29
|
notify: (lead: Lead, id: number) => Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Queues the lead for a durable delivery (the webhook outbox) before the
|
|
32
|
+
* answer, where a crash cannot drop it; a lead the notifier skips is skipped
|
|
33
|
+
* here too. A failure logs and changes nothing — the lead is stored.
|
|
34
|
+
*/
|
|
35
|
+
enqueue?: (lead: Lead, id: number, meta: {
|
|
36
|
+
locale: string;
|
|
37
|
+
formId: string;
|
|
38
|
+
}) => void;
|
|
30
39
|
capture: (lead: Lead, formId: string) => void;
|
|
31
40
|
limiter: RateLimiter;
|
|
32
41
|
now: number;
|
package/dist/core/accept.js
CHANGED
|
@@ -65,6 +65,14 @@ async function accept(site, form, clientKey, deps) {
|
|
|
65
65
|
deps.log.warn(`quote: lead ${id} stored as suspected spam (${lead.spamVerdict}); not notified`);
|
|
66
66
|
return { kind: "stored", id, lead, locale, formId };
|
|
67
67
|
}
|
|
68
|
+
if (deps.enqueue) {
|
|
69
|
+
try {
|
|
70
|
+
deps.enqueue(lead, id, { locale, formId });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
deps.log.error(`quote: lead ${id} is stored but could not be queued for its webhook`, error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
68
76
|
deps.defer(async () => {
|
|
69
77
|
try {
|
|
70
78
|
await deps.notify(lead, id);
|
|
@@ -3,6 +3,7 @@ import type { LeadStore } from "../core/lead.js";
|
|
|
3
3
|
import type { Place } from "../core/place/types.js";
|
|
4
4
|
import { type Site } from "../core/site.js";
|
|
5
5
|
import type { ServerEnv } from "../server/env.js";
|
|
6
|
+
import type { LeadWebhook } from "../server/lead-webhook.js";
|
|
6
7
|
import type { LeadNotifier } from "../server/notify.js";
|
|
7
8
|
/**
|
|
8
9
|
* `app/quote/route.ts` — the no-JS path, and the one that has to keep working:
|
|
@@ -10,7 +11,7 @@ import type { LeadNotifier } from "../server/notify.js";
|
|
|
10
11
|
*
|
|
11
12
|
* ```ts
|
|
12
13
|
* export const dynamic = "force-dynamic";
|
|
13
|
-
* export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable });
|
|
14
|
+
* export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavailable });
|
|
14
15
|
* ```
|
|
15
16
|
*/
|
|
16
17
|
export interface UnavailableCopy {
|
|
@@ -23,6 +24,13 @@ export interface QuoteRouteDeps<L extends string> {
|
|
|
23
24
|
env: () => Pick<ServerEnv, "leadsDb" | "posthogKey" | "posthogHost" | "trustedProxy">;
|
|
24
25
|
/** Built once (and at boot, by the brand) so a missing sender fails startup, not a lead. */
|
|
25
26
|
notifier: () => LeadNotifier;
|
|
27
|
+
/**
|
|
28
|
+
* The signed lead webhook, or `null` when it is off (`leadWebhook`). A lead
|
|
29
|
+
* is queued before the 303 and sent after it; the brand's
|
|
30
|
+
* `instrumentation.ts` should `start()` it so a restart resumes the queue
|
|
31
|
+
* without waiting for the next lead.
|
|
32
|
+
*/
|
|
33
|
+
webhook?: () => LeadWebhook | null;
|
|
26
34
|
/** The self-contained 500's words, when the store refused the lead. */
|
|
27
35
|
unavailable: (locale: L, place: Place<L> | null) => UnavailableCopy;
|
|
28
36
|
/** Opened on first use (`next build` imports the route); defaults to `LEADS_DB_URL`'s. */
|
package/dist/next/quote-route.js
CHANGED
|
@@ -56,6 +56,15 @@ export function quoteRoute(site, deps) {
|
|
|
56
56
|
const log = deps.log ?? console;
|
|
57
57
|
let store;
|
|
58
58
|
let notifier;
|
|
59
|
+
let webhook;
|
|
60
|
+
const leadWebhook = () => {
|
|
61
|
+
if (webhook === undefined) {
|
|
62
|
+
webhook = deps.webhook?.() ?? null;
|
|
63
|
+
// Idempotent: a brand that started it at boot keeps its timer.
|
|
64
|
+
webhook?.start();
|
|
65
|
+
}
|
|
66
|
+
return webhook;
|
|
67
|
+
};
|
|
59
68
|
const leadStore = () => {
|
|
60
69
|
store ??= deps.store ? deps.store() : openLeadStore(deps.env().leadsDb);
|
|
61
70
|
return store;
|
|
@@ -104,6 +113,13 @@ export function quoteRoute(site, deps) {
|
|
|
104
113
|
notifier ??= deps.notifier();
|
|
105
114
|
return notifier.notify(lead, id);
|
|
106
115
|
},
|
|
116
|
+
enqueue: (lead, id, meta) => {
|
|
117
|
+
const hook = leadWebhook();
|
|
118
|
+
if (!hook)
|
|
119
|
+
return;
|
|
120
|
+
hook.enqueue(lead, id, meta);
|
|
121
|
+
defer(() => hook.tick().then(() => undefined, error => log.error("quote: the webhook delivery after a lead failed", error)));
|
|
122
|
+
},
|
|
107
123
|
capture: (lead, formId) => analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, lead.placeSlug).capture(EVENTS.leadSubmit, {
|
|
108
124
|
form_id: formId,
|
|
109
125
|
}),
|
package/dist/server/env.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type ProxyTrust } from "./client-key.js";
|
|
|
3
3
|
import { type LeadDb } from "./lead-store.js";
|
|
4
4
|
/**
|
|
5
5
|
* The server's runtime configuration, parsed once. Secrets (`SMTP_URL`,
|
|
6
|
-
* `SMS_TOKEN`, `POSTHOG_KEY`) arrive from the container environment, never
|
|
6
|
+
* `SMS_TOKEN`, `POSTHOG_KEY`, `LEAD_WEBHOOK_SECRET`) arrive from the container environment, never
|
|
7
7
|
* from the image.
|
|
8
8
|
*
|
|
9
9
|
* Production has no default for where state lives: a missing lead store
|
|
@@ -27,6 +27,12 @@ export interface ServerEnv {
|
|
|
27
27
|
/** Whose word the rate limit takes for the client's address; see `ProxyTrust`. */
|
|
28
28
|
trustedProxy: ProxyTrust | null;
|
|
29
29
|
smsToken: string | null;
|
|
30
|
+
/** Where queued leads are POSTed, signed; `null` → the webhook is off. */
|
|
31
|
+
leadWebhook: {
|
|
32
|
+
url: string;
|
|
33
|
+
keyId: string;
|
|
34
|
+
secret: string;
|
|
35
|
+
} | null;
|
|
30
36
|
posthogKey: string | null;
|
|
31
37
|
posthogHost: string;
|
|
32
38
|
}
|
package/dist/server/env.js
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parseProxyTrust } from "./client-key.js";
|
|
5
5
|
import { parseLeadDb } from "./lead-store.js";
|
|
6
|
+
import { checkWebhookUrl } from "./webhook-outbox.js";
|
|
6
7
|
function opt(source, name) {
|
|
7
8
|
const value = source[name]?.trim();
|
|
8
9
|
return value ? value : null;
|
|
@@ -42,6 +43,19 @@ function leadsDb(source, production, brandId) {
|
|
|
42
43
|
}
|
|
43
44
|
return { leadsDb: { kind: "sqlite", path: join(homedir(), ".local/share", brandId, "leads.db") }, leadsDbFrom: "default" };
|
|
44
45
|
}
|
|
46
|
+
/** All three or none: a URL without its key would sign nothing a receiver accepts. */
|
|
47
|
+
function leadWebhook(source) {
|
|
48
|
+
const raw = opt(source, "LEAD_WEBHOOK_URL");
|
|
49
|
+
if (raw === null)
|
|
50
|
+
return null;
|
|
51
|
+
const url = checkWebhookUrl(raw);
|
|
52
|
+
const keyId = opt(source, "LEAD_WEBHOOK_KEY_ID");
|
|
53
|
+
const secret = opt(source, "LEAD_WEBHOOK_SECRET");
|
|
54
|
+
if (keyId === null || secret === null) {
|
|
55
|
+
throw new Error("LEAD_WEBHOOK_URL is set: LEAD_WEBHOOK_KEY_ID and LEAD_WEBHOOK_SECRET are required too");
|
|
56
|
+
}
|
|
57
|
+
return { url, keyId, secret };
|
|
58
|
+
}
|
|
45
59
|
export function parseServerEnv(site, source) {
|
|
46
60
|
const production = source["NODE_ENV"] === "production";
|
|
47
61
|
const trust = opt(source, "TRUSTED_PROXY");
|
|
@@ -57,6 +71,7 @@ export function parseServerEnv(site, source) {
|
|
|
57
71
|
notifyTo: opt(source, "LEAD_NOTIFY_TO"),
|
|
58
72
|
notifyFrom: opt(source, "LEAD_NOTIFY_FROM"),
|
|
59
73
|
smsToken: opt(source, "SMS_TOKEN"),
|
|
74
|
+
leadWebhook: leadWebhook(source),
|
|
60
75
|
posthogKey: opt(source, "POSTHOG_KEY"),
|
|
61
76
|
// Explicit because PostHog rejects a project's events at the other
|
|
62
77
|
// region's host; the audience is European, so EU unless told otherwise.
|
package/dist/server/index.d.ts
CHANGED
|
@@ -13,3 +13,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
|
|
|
13
13
|
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadNotifier, type NotifyEnv } from "./notify.js";
|
|
14
14
|
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
|
|
15
15
|
export { checkLeadStore } from "./boot.js";
|
|
16
|
+
export { leadWebhook, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions } from "./lead-webhook.js";
|
|
17
|
+
export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_TICK_MS, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
|
|
18
|
+
export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
|
package/dist/server/index.js
CHANGED
|
@@ -13,3 +13,6 @@ export { parseSmtpUrl, sendMail } from "./smtp.js";
|
|
|
13
13
|
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
|
|
14
14
|
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
|
|
15
15
|
export { checkLeadStore } from "./boot.js";
|
|
16
|
+
export { leadWebhook } from "./lead-webhook.js";
|
|
17
|
+
export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
|
|
18
|
+
export { signatureHeaders, signWebhook } from "./webhook-signature.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { Lead } from "../core/lead.js";
|
|
3
|
+
import type { ServerEnv } from "./env.js";
|
|
4
|
+
import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
|
|
5
|
+
import type { WebhookSigning } from "./webhook-signature.js";
|
|
6
|
+
/** What the brand's body builder is told besides the lead. */
|
|
7
|
+
export interface LeadWebhookContext {
|
|
8
|
+
/** The row id in the leads table. */
|
|
9
|
+
leadId: number;
|
|
10
|
+
brandId: string;
|
|
11
|
+
locale: string;
|
|
12
|
+
formId: string;
|
|
13
|
+
/** When the lead was accepted. */
|
|
14
|
+
at: Date;
|
|
15
|
+
/** Fresh per lead and stored with the body, so every retry carries the same one. */
|
|
16
|
+
idempotencyKey: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The receiver's body, built by the brand: the kit serialises and signs
|
|
20
|
+
* whatever this returns and knows nothing of its shape. Called once, when the
|
|
21
|
+
* lead is queued — the stored body is what every attempt sends.
|
|
22
|
+
*/
|
|
23
|
+
export type BuildWebhookBody = (lead: Lead, ctx: LeadWebhookContext) => unknown;
|
|
24
|
+
export interface LeadWebhook {
|
|
25
|
+
/** Builds, serialises and queues the lead's body; returns the outbox row. */
|
|
26
|
+
enqueue(lead: Lead, id: number, meta: {
|
|
27
|
+
locale: string;
|
|
28
|
+
formId: string;
|
|
29
|
+
}): number;
|
|
30
|
+
tick(): Promise<TickReport>;
|
|
31
|
+
start(intervalMs?: number): void;
|
|
32
|
+
stop(): void;
|
|
33
|
+
close(): void;
|
|
34
|
+
readonly outbox: WebhookOutbox;
|
|
35
|
+
}
|
|
36
|
+
export interface LeadWebhookOptions extends WebhookOutboxOptions {
|
|
37
|
+
/** Absent → the webhook is off, whatever the environment says. */
|
|
38
|
+
buildBody?: BuildWebhookBody | undefined;
|
|
39
|
+
signing: WebhookSigning;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
|
|
43
|
+
* body builder from the brand. Built at boot by the brand, like the notifier,
|
|
44
|
+
* so a broken setting fails startup rather than a lead.
|
|
45
|
+
*/
|
|
46
|
+
export declare function leadWebhook(site: {
|
|
47
|
+
brand: {
|
|
48
|
+
id: string;
|
|
49
|
+
};
|
|
50
|
+
}, env: Pick<ServerEnv, "leadsDb" | "leadWebhook">, options: LeadWebhookOptions): LeadWebhook | null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { openWebhookOutbox } from "./webhook-outbox.js";
|
|
4
|
+
/**
|
|
5
|
+
* The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
|
|
6
|
+
* body builder from the brand. Built at boot by the brand, like the notifier,
|
|
7
|
+
* so a broken setting fails startup rather than a lead.
|
|
8
|
+
*/
|
|
9
|
+
export function leadWebhook(site, env, options) {
|
|
10
|
+
const { buildBody, signing, ...outboxOptions } = options;
|
|
11
|
+
if (!env.leadWebhook || !buildBody)
|
|
12
|
+
return null;
|
|
13
|
+
if (env.leadsDb.kind !== "sqlite")
|
|
14
|
+
throw new Error("LEAD_WEBHOOK_URL: the webhook outbox needs the sqlite lead store");
|
|
15
|
+
const outbox = openWebhookOutbox(env.leadsDb.path, { ...env.leadWebhook, signing }, outboxOptions);
|
|
16
|
+
const now = options.now ?? Date.now;
|
|
17
|
+
return {
|
|
18
|
+
outbox,
|
|
19
|
+
enqueue(lead, id, meta) {
|
|
20
|
+
const ctx = { leadId: id, brandId: site.brand.id, ...meta, at: new Date(now()), idempotencyKey: randomUUID() };
|
|
21
|
+
const body = JSON.stringify(buildBody(lead, ctx));
|
|
22
|
+
if (typeof body !== "string")
|
|
23
|
+
throw new Error("buildWebhookBody returned nothing JSON can carry");
|
|
24
|
+
return outbox.enqueue(body, `lead:${id}`);
|
|
25
|
+
},
|
|
26
|
+
tick: () => outbox.tick(),
|
|
27
|
+
start: intervalMs => outbox.start(intervalMs),
|
|
28
|
+
stop: () => outbox.stop(),
|
|
29
|
+
close: () => outbox.close(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { type WebhookSigning } from "./webhook-signature.js";
|
|
3
|
+
/**
|
|
4
|
+
* A durable queue of signed POSTs, kept in the leads file so a body is on disk
|
|
5
|
+
* before the visitor is thanked and survives a restart of the pod. Delivery is
|
|
6
|
+
* at-least-once: a row leaves `pending` only on the receiver's answer, and a
|
|
7
|
+
* crash between the answer and the update sends it again — the receiver
|
|
8
|
+
* deduplicates by whatever id the body carries.
|
|
9
|
+
*
|
|
10
|
+
* Bodies hold PII; they go out in the request and nowhere else. Logs name the
|
|
11
|
+
* row, the attempt and the status — never the body, and never the receiver's
|
|
12
|
+
* words, which may echo it: those stay in the row's `last_error`, beside the
|
|
13
|
+
* body they describe.
|
|
14
|
+
*/
|
|
15
|
+
export interface WebhookTarget {
|
|
16
|
+
url: string;
|
|
17
|
+
keyId: string;
|
|
18
|
+
secret: string;
|
|
19
|
+
signing: WebhookSigning;
|
|
20
|
+
}
|
|
21
|
+
export interface WebhookOutboxOptions {
|
|
22
|
+
/** Tries per row, the first included; past it the row is `dead`. */
|
|
23
|
+
maxAttempts?: number;
|
|
24
|
+
/** The wait after the first failure; doubled after each next one. */
|
|
25
|
+
baseDelayMs?: number;
|
|
26
|
+
maxDelayMs?: number;
|
|
27
|
+
/** Per request, so a hung receiver does not hold the tick. */
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
/** Rows sent per tick. */
|
|
30
|
+
batch?: number;
|
|
31
|
+
fetch?: typeof fetch;
|
|
32
|
+
now?: () => number;
|
|
33
|
+
/** In `[0, 1)`; spreads retries so a receiver back from an outage is not hit at once. */
|
|
34
|
+
random?: () => number;
|
|
35
|
+
log?: Pick<Console, "info" | "warn" | "error">;
|
|
36
|
+
}
|
|
37
|
+
export type OutboxState = "pending" | "delivered" | "dead";
|
|
38
|
+
export interface OutboxRow {
|
|
39
|
+
id: number;
|
|
40
|
+
ref: string | null;
|
|
41
|
+
state: OutboxState;
|
|
42
|
+
attempts: number;
|
|
43
|
+
nextAttemptAt: number;
|
|
44
|
+
lastError: string | null;
|
|
45
|
+
}
|
|
46
|
+
export interface TickReport {
|
|
47
|
+
delivered: number;
|
|
48
|
+
retried: number;
|
|
49
|
+
dead: number;
|
|
50
|
+
}
|
|
51
|
+
export interface WebhookOutbox {
|
|
52
|
+
/** Queues a body; synchronous and local, so it can sit before the response. */
|
|
53
|
+
enqueue(body: string, ref?: string | null): number;
|
|
54
|
+
/** One pass over the rows that are due; concurrent calls share the running one. */
|
|
55
|
+
tick(): Promise<TickReport>;
|
|
56
|
+
/** Ticks every `intervalMs` until `stop`; a second call is a no-op. */
|
|
57
|
+
start(intervalMs?: number): void;
|
|
58
|
+
stop(): void;
|
|
59
|
+
rows(): OutboxRow[];
|
|
60
|
+
close(): void;
|
|
61
|
+
}
|
|
62
|
+
export declare const WEBHOOK_MAX_ATTEMPTS = 12;
|
|
63
|
+
export declare const WEBHOOK_TICK_MS = 10000;
|
|
64
|
+
/**
|
|
65
|
+
* `https:` anywhere; `http:` only where nothing but the cluster sees the
|
|
66
|
+
* bytes — a Service's DNS name or loopback — since the body carries PII.
|
|
67
|
+
* Throws without the value: a URL setting may carry credentials.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkWebhookUrl(value: string, name?: string): string;
|
|
70
|
+
/**
|
|
71
|
+
* Opens (and creates) the outbox table in the SQLite file at `path` — the
|
|
72
|
+
* leads file, through a connection of its own. Every row is bound to the
|
|
73
|
+
* target URL it was queued for: after a change of `LEAD_WEBHOOK_URL` the old
|
|
74
|
+
* rows stay, are counted at open, and are not sent to a receiver that never
|
|
75
|
+
* agreed to take them.
|
|
76
|
+
*/
|
|
77
|
+
export declare function openWebhookOutbox(path: string, target: WebhookTarget, options?: WebhookOutboxOptions): WebhookOutbox;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { signatureHeaders } from "./webhook-signature.js";
|
|
5
|
+
export const WEBHOOK_MAX_ATTEMPTS = 12;
|
|
6
|
+
export const WEBHOOK_TICK_MS = 10_000;
|
|
7
|
+
const SCHEMA = `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS webhook_outbox (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
target TEXT NOT NULL,
|
|
11
|
+
ref TEXT,
|
|
12
|
+
body TEXT NOT NULL,
|
|
13
|
+
created_at INTEGER NOT NULL,
|
|
14
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
next_attempt_at INTEGER NOT NULL,
|
|
16
|
+
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'delivered', 'dead')),
|
|
17
|
+
last_error TEXT,
|
|
18
|
+
done_at INTEGER
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS webhook_outbox_due ON webhook_outbox (state, target, next_attempt_at);
|
|
21
|
+
`;
|
|
22
|
+
/** How much of a receiver's answer a row keeps. */
|
|
23
|
+
const ERROR_CAP = 500;
|
|
24
|
+
const IN_CLUSTER = /(^|\.)svc(\.cluster\.local)?$/;
|
|
25
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
26
|
+
/**
|
|
27
|
+
* `https:` anywhere; `http:` only where nothing but the cluster sees the
|
|
28
|
+
* bytes — a Service's DNS name or loopback — since the body carries PII.
|
|
29
|
+
* Throws without the value: a URL setting may carry credentials.
|
|
30
|
+
*/
|
|
31
|
+
export function checkWebhookUrl(value, name = "LEAD_WEBHOOK_URL") {
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(value);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error(`${name} is not a URL`);
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol === "https:")
|
|
40
|
+
return url.toString();
|
|
41
|
+
if (url.protocol === "http:" && (IN_CLUSTER.test(url.hostname) || LOOPBACK.has(url.hostname)))
|
|
42
|
+
return url.toString();
|
|
43
|
+
throw new Error(`${name}: https, or http to a *.svc / *.svc.cluster.local host or localhost`);
|
|
44
|
+
}
|
|
45
|
+
function sqlite() {
|
|
46
|
+
const mod = process.getBuiltinModule("node:sqlite");
|
|
47
|
+
if (!mod)
|
|
48
|
+
throw new Error("node:sqlite is unavailable — Node ≥ 22.13 is required");
|
|
49
|
+
return mod;
|
|
50
|
+
}
|
|
51
|
+
function column(row, key) {
|
|
52
|
+
return typeof row === "object" && row !== null ? Reflect.get(row, key) : undefined;
|
|
53
|
+
}
|
|
54
|
+
function int(row, key) {
|
|
55
|
+
const value = column(row, key);
|
|
56
|
+
if (typeof value === "number" || typeof value === "bigint")
|
|
57
|
+
return Number(value);
|
|
58
|
+
throw new Error(`webhook_outbox: expected an integer \`${key}\`, got ${typeof value}`);
|
|
59
|
+
}
|
|
60
|
+
function text(row, key) {
|
|
61
|
+
const value = column(row, key);
|
|
62
|
+
return typeof value === "string" ? value : null;
|
|
63
|
+
}
|
|
64
|
+
function state(row) {
|
|
65
|
+
const value = column(row, "state");
|
|
66
|
+
if (value === "pending" || value === "delivered" || value === "dead")
|
|
67
|
+
return value;
|
|
68
|
+
throw new Error(`webhook_outbox: unknown state ${String(value)}`);
|
|
69
|
+
}
|
|
70
|
+
/** `Retry-After` in seconds; the HTTP-date form is rare enough to ignore. */
|
|
71
|
+
function retryAfter(response) {
|
|
72
|
+
const value = Number(response.headers.get("retry-after"));
|
|
73
|
+
return Number.isFinite(value) && value > 0 ? value * 1000 : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A `207` names a verdict per item of the batch. `rejected` is final — the
|
|
77
|
+
* receiver refused the item for what it is, and sending it again changes
|
|
78
|
+
* nothing — so the row is delivered, with the refusals noted; the other
|
|
79
|
+
* statuses (`accepted`, `duplicate`) need no word.
|
|
80
|
+
*/
|
|
81
|
+
async function multiStatus(response, rowId, log) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = await response.json();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
log.warn(`webhook: outbox #${rowId}: a 207 with an unreadable body; taken as delivered`);
|
|
88
|
+
return { kind: "delivered", note: "207 with an unreadable body" };
|
|
89
|
+
}
|
|
90
|
+
const results = column(parsed, "results");
|
|
91
|
+
if (!Array.isArray(results))
|
|
92
|
+
return { kind: "delivered", note: null };
|
|
93
|
+
const rejected = results.filter(r => column(r, "status") === "rejected");
|
|
94
|
+
if (rejected.length === 0)
|
|
95
|
+
return { kind: "delivered", note: null };
|
|
96
|
+
for (const r of rejected) {
|
|
97
|
+
const index = column(r, "index");
|
|
98
|
+
log.warn(`webhook: outbox #${rowId}: item ${typeof index === "number" ? index : "?"} rejected by the receiver; not retried (reason in the row)`);
|
|
99
|
+
}
|
|
100
|
+
const reasons = rejected.map(r => `#${String(column(r, "index"))}: ${String(column(r, "reason") ?? "rejected")}`);
|
|
101
|
+
return { kind: "delivered", note: `rejected ${reasons.join("; ")}` };
|
|
102
|
+
}
|
|
103
|
+
async function judge(response, rowId, log) {
|
|
104
|
+
const status = response.status;
|
|
105
|
+
if (status === 207)
|
|
106
|
+
return multiStatus(response, rowId, log);
|
|
107
|
+
if (status >= 200 && status < 300) {
|
|
108
|
+
await response.body?.cancel();
|
|
109
|
+
return { kind: "delivered", note: null };
|
|
110
|
+
}
|
|
111
|
+
const said = (await response.text().catch(() => "")).slice(0, ERROR_CAP);
|
|
112
|
+
const tag = `HTTP ${status}`;
|
|
113
|
+
const why = said ? `${tag}: ${said}` : tag;
|
|
114
|
+
if (status === 408 || status === 429 || status >= 500)
|
|
115
|
+
return { kind: "retry", tag, why, afterMs: retryAfter(response) };
|
|
116
|
+
// 3xx included: redirects are not followed, since the target was vetted and its Location was not.
|
|
117
|
+
return { kind: "dead", tag, why };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Opens (and creates) the outbox table in the SQLite file at `path` — the
|
|
121
|
+
* leads file, through a connection of its own. Every row is bound to the
|
|
122
|
+
* target URL it was queued for: after a change of `LEAD_WEBHOOK_URL` the old
|
|
123
|
+
* rows stay, are counted at open, and are not sent to a receiver that never
|
|
124
|
+
* agreed to take them.
|
|
125
|
+
*/
|
|
126
|
+
export function openWebhookOutbox(path, target, options = {}) {
|
|
127
|
+
const maxAttempts = options.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
|
|
128
|
+
const baseDelay = options.baseDelayMs ?? 5_000;
|
|
129
|
+
const maxDelay = options.maxDelayMs ?? 60 * 60_000;
|
|
130
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
131
|
+
const batch = options.batch ?? 20;
|
|
132
|
+
const send = options.fetch ?? fetch;
|
|
133
|
+
const now = options.now ?? Date.now;
|
|
134
|
+
const random = options.random ?? Math.random;
|
|
135
|
+
const log = options.log ?? console;
|
|
136
|
+
if (path !== ":memory:")
|
|
137
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
138
|
+
const db = new (sqlite().DatabaseSync)(path);
|
|
139
|
+
try {
|
|
140
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
141
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
142
|
+
db.exec(SCHEMA);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
db.close();
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
const insert = db.prepare("INSERT INTO webhook_outbox (target, ref, body, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?) RETURNING id");
|
|
149
|
+
const due = db.prepare("SELECT id, body, attempts FROM webhook_outbox WHERE state = 'pending' AND target = ? AND next_attempt_at <= ? ORDER BY next_attempt_at, id LIMIT ?");
|
|
150
|
+
// A lease: another process ticking the same file skips a row while it is in flight.
|
|
151
|
+
const claim = db.prepare("UPDATE webhook_outbox SET next_attempt_at = ? WHERE id = ? AND state = 'pending' AND next_attempt_at <= ?");
|
|
152
|
+
const finish = db.prepare("UPDATE webhook_outbox SET state = ?, attempts = ?, last_error = ?, done_at = ? WHERE id = ?");
|
|
153
|
+
const reschedule = db.prepare("UPDATE webhook_outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ?");
|
|
154
|
+
const list = db.prepare("SELECT id, ref, state, attempts, next_attempt_at, last_error FROM webhook_outbox ORDER BY id");
|
|
155
|
+
const stranded = db.prepare("SELECT COUNT(*) AS n FROM webhook_outbox WHERE state = 'pending' AND target <> ?");
|
|
156
|
+
const left = int(stranded.get(target.url), "n");
|
|
157
|
+
if (left > 0)
|
|
158
|
+
log.warn(`webhook: ${left} pending row(s) queued for another target; left in place, not sent`);
|
|
159
|
+
/** Doubling from `baseDelay`, capped, plus up to a fifth more at random. */
|
|
160
|
+
function backoff(attempts, hinted) {
|
|
161
|
+
const plain = Math.min(maxDelay, baseDelay * 2 ** Math.max(0, attempts - 1));
|
|
162
|
+
return Math.min(maxDelay, Math.max(plain + Math.floor(plain * 0.2 * random()), hinted ?? 0));
|
|
163
|
+
}
|
|
164
|
+
async function deliver(id, body) {
|
|
165
|
+
try {
|
|
166
|
+
const response = await send(target.url, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "content-type": "application/json", ...signatureHeaders(target.signing, target, body, now()) },
|
|
169
|
+
body,
|
|
170
|
+
redirect: "manual",
|
|
171
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
172
|
+
});
|
|
173
|
+
return await judge(response, id, log);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// The name and code only: a fetch error's message is safe, but its cause chain is the runtime's to word.
|
|
177
|
+
const name = error instanceof Error ? error.name : "error";
|
|
178
|
+
const code = column(error instanceof Error ? error.cause : undefined, "code");
|
|
179
|
+
const tag = `network ${name}${typeof code === "string" ? ` ${code}` : ""}`;
|
|
180
|
+
return { kind: "retry", tag, why: tag, afterMs: null };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function pass() {
|
|
184
|
+
const report = { delivered: 0, retried: 0, dead: 0 };
|
|
185
|
+
const t = now();
|
|
186
|
+
for (const row of due.all(target.url, t, batch)) {
|
|
187
|
+
const id = int(row, "id");
|
|
188
|
+
const body = text(row, "body") ?? "";
|
|
189
|
+
const attempts = int(row, "attempts") + 1;
|
|
190
|
+
if (Number(claim.run(t + timeoutMs + 5_000, id, t).changes) === 0)
|
|
191
|
+
continue;
|
|
192
|
+
const verdict = await deliver(id, body);
|
|
193
|
+
if (verdict.kind === "delivered") {
|
|
194
|
+
finish.run("delivered", attempts, verdict.note?.slice(0, ERROR_CAP) ?? null, now(), id);
|
|
195
|
+
report.delivered += 1;
|
|
196
|
+
}
|
|
197
|
+
else if (verdict.kind === "dead" || attempts >= maxAttempts) {
|
|
198
|
+
finish.run("dead", attempts, verdict.why, now(), id);
|
|
199
|
+
log.error(`webhook: outbox #${id} dead after ${attempts} attempt(s): ${verdict.tag}`);
|
|
200
|
+
report.dead += 1;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const wait = backoff(attempts, verdict.afterMs);
|
|
204
|
+
reschedule.run(attempts, now() + wait, verdict.why, id);
|
|
205
|
+
log.warn(`webhook: outbox #${id} attempt ${attempts} failed (${verdict.tag}); next in ${Math.round(wait / 1000)} s`);
|
|
206
|
+
report.retried += 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return report;
|
|
210
|
+
}
|
|
211
|
+
let running = null;
|
|
212
|
+
let timer = null;
|
|
213
|
+
const tick = () => {
|
|
214
|
+
running ??= pass().finally(() => {
|
|
215
|
+
running = null;
|
|
216
|
+
});
|
|
217
|
+
return running;
|
|
218
|
+
};
|
|
219
|
+
const stop = () => {
|
|
220
|
+
if (timer)
|
|
221
|
+
clearInterval(timer);
|
|
222
|
+
timer = null;
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
enqueue(body, ref = null) {
|
|
226
|
+
const t = now();
|
|
227
|
+
return int(insert.get(target.url, ref, body, t, t), "id");
|
|
228
|
+
},
|
|
229
|
+
tick,
|
|
230
|
+
start(intervalMs = WEBHOOK_TICK_MS) {
|
|
231
|
+
if (timer)
|
|
232
|
+
return;
|
|
233
|
+
const run = () => {
|
|
234
|
+
tick().catch(error => log.error("webhook: an outbox tick failed", error));
|
|
235
|
+
};
|
|
236
|
+
timer = setInterval(run, intervalMs);
|
|
237
|
+
// The queue must not keep a process alive that is otherwise done.
|
|
238
|
+
timer.unref();
|
|
239
|
+
run();
|
|
240
|
+
},
|
|
241
|
+
stop,
|
|
242
|
+
rows() {
|
|
243
|
+
return list.all().map(row => ({
|
|
244
|
+
id: int(row, "id"),
|
|
245
|
+
ref: text(row, "ref"),
|
|
246
|
+
state: state(row),
|
|
247
|
+
attempts: int(row, "attempts"),
|
|
248
|
+
nextAttemptAt: int(row, "next_attempt_at"),
|
|
249
|
+
lastError: text(row, "last_error"),
|
|
250
|
+
}));
|
|
251
|
+
},
|
|
252
|
+
close() {
|
|
253
|
+
stop();
|
|
254
|
+
db.close();
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
/**
|
|
3
|
+
* How a signed webhook proves who sent it and when:
|
|
4
|
+
*
|
|
5
|
+
* ```text
|
|
6
|
+
* <keyId header>: the sender's key id
|
|
7
|
+
* <timestamp header>: unix seconds at send time
|
|
8
|
+
* <signature header>: hex(HMAC-SHA256(secret, prefix + timestamp + "." + body))
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The timestamp is inside the MAC, so a captured request cannot be re-stamped
|
|
12
|
+
* past the receiver's replay window. The prefix names what is signed, so a MAC
|
|
13
|
+
* made with the same secret for anything else never passes for this. Prefix
|
|
14
|
+
* and header names are the receiver's, and a brand passes them — the kit
|
|
15
|
+
* knows no receiver, so it has no defaults to guess one by.
|
|
16
|
+
*/
|
|
17
|
+
export interface WebhookSigning {
|
|
18
|
+
prefix: string;
|
|
19
|
+
headers: WebhookSignatureHeaders;
|
|
20
|
+
}
|
|
21
|
+
export interface WebhookSignatureHeaders {
|
|
22
|
+
keyId: string;
|
|
23
|
+
timestamp: string;
|
|
24
|
+
signature: string;
|
|
25
|
+
}
|
|
26
|
+
/** Lowercase hex, as the receiver compares it. */
|
|
27
|
+
export declare function signWebhook(secret: string, prefix: string, timestamp: string, body: string): string;
|
|
28
|
+
/** The three headers for one send; signed afresh each attempt, since the timestamp is. */
|
|
29
|
+
export declare function signatureHeaders(signing: WebhookSigning, key: {
|
|
30
|
+
keyId: string;
|
|
31
|
+
secret: string;
|
|
32
|
+
}, body: string, nowMs: number): Record<string, string>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
|
+
/** Lowercase hex, as the receiver compares it. */
|
|
4
|
+
export function signWebhook(secret, prefix, timestamp, body) {
|
|
5
|
+
return createHmac("sha256", secret).update(prefix).update(timestamp).update(".").update(body).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
/** The three headers for one send; signed afresh each attempt, since the timestamp is. */
|
|
8
|
+
export function signatureHeaders(signing, key, body, nowMs) {
|
|
9
|
+
const names = signing.headers;
|
|
10
|
+
const timestamp = String(Math.floor(nowMs / 1000));
|
|
11
|
+
return {
|
|
12
|
+
[names.keyId]: key.keyId,
|
|
13
|
+
[names.timestamp]: timestamp,
|
|
14
|
+
[names.signature]: signWebhook(key.secret, signing.prefix, timestamp, body),
|
|
15
|
+
};
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evinvest/kitstart",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
|
|
5
5
|
"license": "BlueOak-1.0.0",
|
|
6
6
|
"publishConfig": {
|
package/template/flake.nix
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
|
|
4
4
|
# The lib flake at the tag of the @evinvest/kitstart version in
|
|
5
5
|
# package-lock.json — mkLanding refuses a mismatch.
|
|
6
|
-
ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.
|
|
6
|
+
ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.5.0";
|
|
7
7
|
ev.inputs.v_flakes.follows = "v_flakes";
|
|
8
8
|
};
|
|
9
9
|
|
package/template/package.json
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@evinvest/analytics": "^0.3.0",
|
|
22
22
|
"@evinvest/i18n": "^0.9.0",
|
|
23
|
-
"@evinvest/kitstart": "^0.
|
|
23
|
+
"@evinvest/kitstart": "^0.5.0",
|
|
24
24
|
"@evinvest/marketing": "^0.3.0",
|
|
25
|
-
"@evinvest/uikit": "^0.
|
|
25
|
+
"@evinvest/uikit": "^0.24.0",
|
|
26
26
|
"next": "16.3.6",
|
|
27
27
|
"react": "19.3.0",
|
|
28
28
|
"react-dom": "19.3.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { expect, test, type Page } from "@playwright/test";
|
|
1
|
+
import { expect, test, type Locator, type Page } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
// The quote form's contract with the visitor: it posts before any script
|
|
4
4
|
// (the one standing in water may be on a bad connection), and once the script
|
|
@@ -51,6 +51,9 @@ test.describe("with JavaScript", () => {
|
|
|
51
51
|
test("works from the keyboard", async ({ page }) => {
|
|
52
52
|
await page.goto("/fr#quote");
|
|
53
53
|
const trigger = page.getByRole("combobox", { name: SUBJECT });
|
|
54
|
+
// Before hydration the role finds the server's select, and the focus
|
|
55
|
+
// would go to it, not the kit's list; wait for the select to be replaced.
|
|
56
|
+
await expect(page.locator("#quote select")).toHaveCount(0);
|
|
54
57
|
await trigger.focus();
|
|
55
58
|
await page.keyboard.press("ArrowDown");
|
|
56
59
|
await expect(page.getByRole("option", { name: "Ménage courant" })).toBeFocused();
|
|
@@ -65,18 +68,34 @@ test.describe("with JavaScript", () => {
|
|
|
65
68
|
});
|
|
66
69
|
|
|
67
70
|
test("the field is the same box before and after hydration", async ({ browser }) => {
|
|
68
|
-
const
|
|
71
|
+
const open = async (javaScriptEnabled: boolean) => {
|
|
69
72
|
const context = await browser.newContext({ javaScriptEnabled });
|
|
70
73
|
const page = await context.newPage();
|
|
71
74
|
await page.goto("/fr#quote");
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
return page;
|
|
76
|
+
};
|
|
77
|
+
const measure = (control: Locator) =>
|
|
78
|
+
control.evaluate(el => {
|
|
75
79
|
const r = el.getBoundingClientRect();
|
|
76
80
|
return { width: r.width, height: r.height, x: r.x, border: getComputedStyle(el).borderTopWidth, radius: getComputedStyle(el).borderTopLeftRadius };
|
|
77
81
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
|
|
83
|
+
// The server's select is a combobox of the same name, so until the page
|
|
84
|
+
// hydrates the role finds it — and hydration detaches it, leaving a
|
|
85
|
+
// measurement of zeros. Measure only once the kit's button has replaced it.
|
|
86
|
+
const scripted = await open(true);
|
|
87
|
+
await expect(scripted.locator("#quote select")).toHaveCount(0);
|
|
88
|
+
const kit = scripted.getByRole("combobox", { name: SUBJECT });
|
|
89
|
+
await expect(kit).toHaveJSProperty("tagName", "BUTTON");
|
|
90
|
+
await expect(kit).toBeVisible();
|
|
91
|
+
const hydrated = await measure(kit);
|
|
92
|
+
await scripted.context().close();
|
|
93
|
+
|
|
94
|
+
const bare = await open(false);
|
|
95
|
+
const native = bare.locator("#quote select[name=subject]");
|
|
96
|
+
await expect(native).toBeVisible();
|
|
97
|
+
const server = await measure(native);
|
|
98
|
+
await bare.context().close();
|
|
99
|
+
|
|
100
|
+
expect(hydrated).toEqual(server);
|
|
82
101
|
});
|