@pithy-sh/core 0.1.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/LICENSE +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MiddlewareHandler } from "hono";
|
|
5
|
+
import type { PithyHonoEnv } from "../capability/capability";
|
|
6
|
+
import { InternalError, WebhookUnverifiedError } from "../error/pithyError";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `signed-webhook` strategy, for any sender. Secret, header name, tolerance and the exact received
|
|
10
|
+
* bytes in; verified, or a `PithyError`, out.
|
|
11
|
+
*
|
|
12
|
+
* `signed-webhook` has been a first-class strategy in {@link ./verification.VerificationStrategy} since the
|
|
13
|
+
* contract landed, but the only implementation was `@pithy-sh/payments`' webhook guard — welded to a rail
|
|
14
|
+
* catalog, a D1 table and a dedup insert, because it also records deliveries. So an adopter declaring the
|
|
15
|
+
* strategy on their own route had the word and nothing to put behind it. This is the word's implementation,
|
|
16
|
+
* and `@pithy-sh/payments`' Stripe rail is now one of its callers rather than a second copy of it.
|
|
17
|
+
*
|
|
18
|
+
* **Dedup and persistence stay the caller's**: at-least-once delivery is a property of the sender, not of the
|
|
19
|
+
* signature, and a verifier that also owned a table could not be composed by anyone whose table differs. Say
|
|
20
|
+
* the consequence plainly, because it is a security property and not only an availability one: this proves a
|
|
21
|
+
* delivery is authentic and fresh, never that it is *new*. Inside the tolerance a captured delivery replays
|
|
22
|
+
* as many times as it is sent. A handler that grants, charges, or deletes needs its own uniqueness key —
|
|
23
|
+
* payments spends a `UNIQUE (rail, providerEventId)` insert on exactly this.
|
|
24
|
+
*
|
|
25
|
+
* ## The scheme
|
|
26
|
+
*
|
|
27
|
+
* One header carries both halves of the proof: `<header>: t=1785931200,v1=5257a8…` — the timestamp the
|
|
28
|
+
* delivery claims, then one hex HMAC-SHA256 per signature, each computed over `<timestamp>.<body>`. Stripe's
|
|
29
|
+
* format, deliberately, because it is the one every other sender copied and because it gets the four things
|
|
30
|
+
* below right. The key names are options — a sender writing `v0` costs one line — but the signed payload is
|
|
31
|
+
* fixed. A caller-supplied template would be the one part of the scheme that must be byte-identical at both
|
|
32
|
+
* ends, and getting it wrong yields a verifier that verifies nothing while still returning.
|
|
33
|
+
*
|
|
34
|
+
* ## Why the timestamp is inside the signed payload
|
|
35
|
+
*
|
|
36
|
+
* A signature with no freshness window is valid forever: whoever captures one delivery can replay it for as
|
|
37
|
+
* long as the secret lives. The window only closes that if it cannot be stepped around — and it cannot,
|
|
38
|
+
* because the timestamp is part of what was signed. Re-dating a captured delivery to escape the window
|
|
39
|
+
* invalidates its own signature. A timestamp in a second header, or alongside the signature but outside it,
|
|
40
|
+
* would be a suggestion.
|
|
41
|
+
*
|
|
42
|
+
* The window is checked in **both** directions. A delivery dated far in the future is a clock problem or a
|
|
43
|
+
* crafted one, and neither is a delivery to act on; accepting it would also hand a forger an unbounded replay
|
|
44
|
+
* window the moment a secret leaks.
|
|
45
|
+
*
|
|
46
|
+
* ## Why the comparison is `crypto.subtle.verify`
|
|
47
|
+
*
|
|
48
|
+
* Comparing HMACs with `===` leaks how many leading bytes matched, and that leak is enough to forge a
|
|
49
|
+
* signature one byte at a time given enough attempts. WebCrypto's `verify` does the comparison itself, in
|
|
50
|
+
* constant time, in the platform — so the requirement is met by not hand-writing the compare at all.
|
|
51
|
+
* (`timingSafeEqual` in `../controlPlane/token/digest` is the string equivalent, for values WebCrypto will
|
|
52
|
+
* not compare for you.) Every listed signature is tried, because a sender rotating its secret signs with each
|
|
53
|
+
* active one, and refusing a delivery whose second signature matched would drop every delivery for the length
|
|
54
|
+
* of the rotation.
|
|
55
|
+
*
|
|
56
|
+
* ## What a refusal says
|
|
57
|
+
*
|
|
58
|
+
* `core/webhook_unverified`, 401, one code for every failing step. The step goes in `detail`, which the HTTP
|
|
59
|
+
* codec strips — so an operator reading a log learns which check refused it and the sender learns only that
|
|
60
|
+
* something did. **Never the secret, never a signature, never the body.** A refusal is the one message a
|
|
61
|
+
* hostile caller is guaranteed to receive, so it is the last place to echo anything it supplied or anything
|
|
62
|
+
* it is trying to guess.
|
|
63
|
+
*
|
|
64
|
+
* A caller that must word its own refusal — a rail whose own code is part of its contract — calls
|
|
65
|
+
* {@link checkSignedWebhook}, which reports rather than throws. {@link verifySignedWebhook} is that plus the
|
|
66
|
+
* kit's error.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/** How far a delivery's own timestamp may be from now. Stripe's default, and generous against clock skew. */
|
|
70
|
+
export const SIGNED_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
71
|
+
|
|
72
|
+
/** The header key naming the timestamp, unless a sender names it otherwise. */
|
|
73
|
+
const DEFAULT_TIMESTAMP_KEY = "t";
|
|
74
|
+
|
|
75
|
+
/** The header key naming a signature, unless a sender names it otherwise. */
|
|
76
|
+
const DEFAULT_SIGNATURE_KEY = "v1";
|
|
77
|
+
|
|
78
|
+
/** HMAC-SHA256 — the algorithm this scheme is. Pinned as a literal, never read from the delivery. */
|
|
79
|
+
const HMAC_SHA256 = { name: "HMAC", hash: "SHA-256" } as const;
|
|
80
|
+
|
|
81
|
+
/** The byte length of an HMAC-SHA256 signature. A candidate of any other length cannot be one. */
|
|
82
|
+
const SIGNATURE_BYTES = 32;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How many candidate signatures one delivery may spend, after the unusable ones are dropped.
|
|
86
|
+
*
|
|
87
|
+
* The list is written by whoever sent the request, and every entry on it costs a full HMAC over the whole
|
|
88
|
+
* body, once per configured secret. Uncapped, a 16 KiB header holds a few hundred well-formed candidates and
|
|
89
|
+
* one anonymous POST buys hundreds of times the CPU a real delivery does — billed CPU, on a runtime with a
|
|
90
|
+
* burst limit. A sender rotating its secret lists two, occasionally three. Eight is far past generous and
|
|
91
|
+
* turns an unbounded multiplier into a constant.
|
|
92
|
+
*/
|
|
93
|
+
export const SIGNED_WEBHOOK_MAX_CANDIDATES = 8;
|
|
94
|
+
|
|
95
|
+
/** What the two ends of the scheme must agree on, beyond the secret. */
|
|
96
|
+
export interface SignedWebhookScheme {
|
|
97
|
+
/**
|
|
98
|
+
* The header carrying the proof, lower case — that is how Hono presents a header name. Required and
|
|
99
|
+
* never defaulted: a default would be a header some sender happens not to use, read silently.
|
|
100
|
+
*/
|
|
101
|
+
header: string;
|
|
102
|
+
/** The header key holding the timestamp. Defaults to `t`. */
|
|
103
|
+
timestampKey?: string;
|
|
104
|
+
/** The header key holding a signature. Defaults to `v1`. */
|
|
105
|
+
signatureKey?: string;
|
|
106
|
+
/** The freshness window, in seconds, either side of now. Defaults to {@link SIGNED_WEBHOOK_TOLERANCE_SECONDS}. */
|
|
107
|
+
toleranceSeconds?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The secret(s) this endpoint accepts. An array is the receiver's own rotation overlap: both the outgoing and
|
|
112
|
+
* the incoming secret verify until the sender is switched over. `secretsStore(…).getVersions(NAME)` is what
|
|
113
|
+
* feeds it, through `Object.values(secret.versions)` — it returns `{ currentVersion, versions }`, not an array,
|
|
114
|
+
* and only a `valueType: "text"` secret yields strings to pass here. (The sender's rotation is the other
|
|
115
|
+
* axis — several signatures in one header — and needs nothing from the caller.)
|
|
116
|
+
*
|
|
117
|
+
* An entry that resolved to nothing is dropped rather than tried: a blank version is a configuration fault,
|
|
118
|
+
* and WebCrypto refuses a zero-length key with a `DOMException` that would otherwise escape as a 500 whose
|
|
119
|
+
* text names nothing. An endpoint left with no usable secret is reported as the configuration fault it is.
|
|
120
|
+
*/
|
|
121
|
+
export type SignedWebhookSecret = string | readonly string[];
|
|
122
|
+
|
|
123
|
+
/** Everything {@link verifySignedWebhook} needs beyond the bytes and the header value. */
|
|
124
|
+
export interface VerifySignedWebhookOptions extends SignedWebhookScheme {
|
|
125
|
+
/** The signing secret, or every secret still honored during a rotation. */
|
|
126
|
+
secret: SignedWebhookSecret;
|
|
127
|
+
/** The clock. Injectable so the freshness window is deterministic in tests rather than wall-clock dependent. */
|
|
128
|
+
now?: Date;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Everything {@link requireSignedWebhook} needs, as a route wears it. */
|
|
132
|
+
export interface SignedWebhookGuardOptions extends SignedWebhookScheme {
|
|
133
|
+
/**
|
|
134
|
+
* The secret, or a resolver reading it from the Worker env at the point of need. A guard is built at
|
|
135
|
+
* module scope and `env` only exists per request, so a secret read through `@pithy-sh/secrets` can only
|
|
136
|
+
* arrive as the second form — and reading it per request is also what makes a rotation take effect
|
|
137
|
+
* without a deploy.
|
|
138
|
+
*/
|
|
139
|
+
secret: SignedWebhookSecret | ((env: Record<string, unknown>) => SignedWebhookSecret | Promise<SignedWebhookSecret>);
|
|
140
|
+
/** The clock, for the freshness window. Injected so tests are deterministic. */
|
|
141
|
+
now?: () => Date;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A parsed proof header: when the delivery says it was signed, and with what. */
|
|
145
|
+
export interface SignedWebhookHeader {
|
|
146
|
+
/** The timestamp value, in seconds since the epoch. Part of the signed payload as well as of the header. */
|
|
147
|
+
timestamp: number;
|
|
148
|
+
/** Every signature value, in the order listed. One per secret the sender currently signs with. */
|
|
149
|
+
signatures: readonly string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read a proof header, or `undefined` when it is not one.
|
|
154
|
+
*
|
|
155
|
+
* Undefined rather than a throw per unreadable shape, because the caller turns them all into one refusal:
|
|
156
|
+
* distinguishing "no timestamp" from "no signature" would describe our parser to a sender, and neither is
|
|
157
|
+
* actionable by anyone but an operator reading the endpoint's configuration.
|
|
158
|
+
*
|
|
159
|
+
* Unknown keys are dropped rather than refused. A sender may list a scheme this build does not know — Stripe
|
|
160
|
+
* sends `v0` beside `v1` — and a delivery whose known signature is good is authentic whatever else it
|
|
161
|
+
* carries. A header carrying *only* unknown keys has proved nothing, so it yields undefined.
|
|
162
|
+
*
|
|
163
|
+
* The timestamp is the exception: it is read strictly, once, and only in the exact spelling it was signed in.
|
|
164
|
+
* Two of them is a header that has not said when it was signed, and taking the last is how a parser and a
|
|
165
|
+
* sender come to disagree about what was covered. A non-canonical one (`00178…`) could never verify either,
|
|
166
|
+
* because the bytes signed are the digits the sender wrote — refusing it here names the digit rather than
|
|
167
|
+
* sending an operator after a rotated secret.
|
|
168
|
+
*/
|
|
169
|
+
export function parseSignedWebhookHeader(
|
|
170
|
+
header: string,
|
|
171
|
+
timestampKey: string,
|
|
172
|
+
signatureKey: string,
|
|
173
|
+
): SignedWebhookHeader | undefined {
|
|
174
|
+
let timestamp: number | undefined;
|
|
175
|
+
const signatures: string[] = [];
|
|
176
|
+
|
|
177
|
+
for (const part of header.split(",")) {
|
|
178
|
+
const separator = part.indexOf("=");
|
|
179
|
+
if (separator < 0) continue;
|
|
180
|
+
const key = part.slice(0, separator).trim();
|
|
181
|
+
const value = part.slice(separator + 1).trim();
|
|
182
|
+
if (key === timestampKey) {
|
|
183
|
+
// A canonical decimal integer and nothing else. `Number("")` is 0 and `Number("1.5")` is a float, and
|
|
184
|
+
// both would otherwise pass into the window comparison as a plausible-looking date; a leading zero
|
|
185
|
+
// parses to the right instant but is not the string the signature covers. And a second one is refused
|
|
186
|
+
// rather than overwriting the first.
|
|
187
|
+
if (timestamp !== undefined || !/^(?:0|[1-9]\d*)$/.test(value)) return undefined;
|
|
188
|
+
timestamp = Number(value);
|
|
189
|
+
} else if (key === signatureKey) {
|
|
190
|
+
signatures.push(value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (timestamp === undefined || signatures.length === 0) return undefined;
|
|
195
|
+
return { timestamp, signatures };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Why a delivery did not verify, for a caller that words its own refusal.
|
|
200
|
+
*
|
|
201
|
+
* Three reasons, because there are three checks. They are for the caller's `detail` and an operator's log,
|
|
202
|
+
* never for the response body: a sender that learns which check refused it learns how close it got.
|
|
203
|
+
*/
|
|
204
|
+
export type SignedWebhookRefusal =
|
|
205
|
+
/** No header, or one carrying no timestamp and signature this scheme can read. */
|
|
206
|
+
| { reason: "unreadable" }
|
|
207
|
+
/** Read, but dated outside the freshness window — a clock, or a replay. */
|
|
208
|
+
| { reason: "stale"; skew: number; tolerance: number }
|
|
209
|
+
/**
|
|
210
|
+
* Read and fresh, and nothing in it verified.
|
|
211
|
+
*
|
|
212
|
+
* `compared` is how many listed values were actually put to a secret — the candidates left after the ones
|
|
213
|
+
* that cannot be an HMAC-SHA256 are dropped and the cap is applied. Zero is a different finding from the
|
|
214
|
+
* rest: no comparison ran at all, so the answer is the sender's signature format, not the endpoint's
|
|
215
|
+
* secret. A refusal that does not carry the number cannot tell an operator which of the two it is.
|
|
216
|
+
*/
|
|
217
|
+
| { reason: "unmatched"; compared: number };
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Check one delivery, and **report** rather than throw: `undefined` when a holder of the secret signed these
|
|
221
|
+
* exact bytes inside the window, a {@link SignedWebhookRefusal} when nobody did.
|
|
222
|
+
*
|
|
223
|
+
* This is the seam a rail composes. `@pithy-sh/payments` must keep raising `payments/verification_failed`
|
|
224
|
+
* with its own Stripe-worded `detail`, and that is a contract of its own — so the scheme lives here once and
|
|
225
|
+
* each caller keeps its vocabulary, rather than the whole verifier existing twice with two sets of comments
|
|
226
|
+
* to keep in step. Most callers want {@link verifySignedWebhook}.
|
|
227
|
+
*
|
|
228
|
+
* It still throws for **our** failure: an endpoint holding no usable secret, or handed a clock that is not a
|
|
229
|
+
* clock, is a configuration fault rather than a refusal, and every caller reports that the same way.
|
|
230
|
+
*
|
|
231
|
+
* `body` must be the **exact received bytes**. A parsed-and-re-serialized object is different bytes — key
|
|
232
|
+
* order alone changes it — and would never verify.
|
|
233
|
+
*/
|
|
234
|
+
export async function checkSignedWebhook(
|
|
235
|
+
body: string,
|
|
236
|
+
header: string | null,
|
|
237
|
+
options: VerifySignedWebhookOptions,
|
|
238
|
+
): Promise<SignedWebhookRefusal | undefined> {
|
|
239
|
+
const declared = typeof options.secret === "string" ? [options.secret] : options.secret;
|
|
240
|
+
// A version that resolved to nothing is dropped, not tried. `importKey` answers a zero-length key with a
|
|
241
|
+
// raw DOMException, so one blank entry would otherwise take the endpoint down — and *which* entry it was
|
|
242
|
+
// would decide whether it did, because the throw lands before the later, good secret is reached.
|
|
243
|
+
const secrets = declared.filter((secret) => secret.length > 0);
|
|
244
|
+
if (secrets.length === 0) {
|
|
245
|
+
// Not the sender's failure, so not the sender's code. An endpoint holding no secret refuses every
|
|
246
|
+
// delivery, and reporting that as an unverified webhook sends an operator hunting a forger who is not
|
|
247
|
+
// there while the real answer is a secret that never resolved.
|
|
248
|
+
throw new InternalError({
|
|
249
|
+
message: "This webhook endpoint is not configured.",
|
|
250
|
+
action: `Give the ${options.header} guard a signing secret.`,
|
|
251
|
+
detail: `The signed-webhook guard on ${options.header} resolved no signing secret, so it can verify nothing.`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const timestampKey = options.timestampKey ?? DEFAULT_TIMESTAMP_KEY;
|
|
256
|
+
const signatureKey = options.signatureKey ?? DEFAULT_SIGNATURE_KEY;
|
|
257
|
+
const parsed = header === null ? undefined : parseSignedWebhookHeader(header, timestampKey, signatureKey);
|
|
258
|
+
if (parsed === undefined) return { reason: "unreadable" };
|
|
259
|
+
|
|
260
|
+
const tolerance = options.toleranceSeconds ?? SIGNED_WEBHOOK_TOLERANCE_SECONDS;
|
|
261
|
+
const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
262
|
+
// Fail closed on a clock that is not one. `Math.abs(NaN - t) > tolerance` is false, so an invalid Date would
|
|
263
|
+
// pass the window silently — the one check in this file that could fail open, in the file whose whole
|
|
264
|
+
// argument is that it fails closed. Comparing against a sentinel instead would still be a comparison against
|
|
265
|
+
// a number nobody chose. Only a caller-supplied `now` reaches here as NaN: the header's timestamp is a
|
|
266
|
+
// digit-checked string before it is a number, so this is our fault and takes our code, like the secret above.
|
|
267
|
+
if (!Number.isFinite(nowSeconds)) {
|
|
268
|
+
throw new InternalError({
|
|
269
|
+
message: "This webhook endpoint is not configured.",
|
|
270
|
+
action: `Give the ${options.header} guard a valid clock.`,
|
|
271
|
+
detail: `The signed-webhook guard on ${options.header} was handed a clock that is not a valid Date, so no delivery's freshness can be judged.`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const skew = Math.abs(nowSeconds - parsed.timestamp);
|
|
275
|
+
if (skew > tolerance) return { reason: "stale", skew, tolerance };
|
|
276
|
+
|
|
277
|
+
// The timestamp is signed with the body. That is what makes the window above a boundary: re-dating a
|
|
278
|
+
// captured delivery changes these bytes, so its captured signature stops matching.
|
|
279
|
+
const signed = new TextEncoder().encode(`${parsed.timestamp}.${body}`) as unknown as ArrayBuffer;
|
|
280
|
+
const candidates = parsed.signatures
|
|
281
|
+
.map(hexBytes)
|
|
282
|
+
// A candidate that is not 32 bytes of hex cannot be an HMAC-SHA256, so it is dropped rather than
|
|
283
|
+
// refused on — another entry in the same header may still be the good one. Dropping before the cap is
|
|
284
|
+
// what keeps a flood of malformed entries from crowding the real signature out of the window.
|
|
285
|
+
.filter((bytes): bytes is Uint8Array => bytes !== undefined && bytes.length === SIGNATURE_BYTES)
|
|
286
|
+
.slice(0, SIGNED_WEBHOOK_MAX_CANDIDATES);
|
|
287
|
+
|
|
288
|
+
for (const secret of secrets) {
|
|
289
|
+
const key = await crypto.subtle.importKey(
|
|
290
|
+
"raw",
|
|
291
|
+
new TextEncoder().encode(secret) as unknown as ArrayBuffer,
|
|
292
|
+
HMAC_SHA256,
|
|
293
|
+
false,
|
|
294
|
+
["verify"],
|
|
295
|
+
);
|
|
296
|
+
for (const candidate of candidates) {
|
|
297
|
+
if (await crypto.subtle.verify(HMAC_SHA256.name, key, candidate as unknown as ArrayBuffer, signed)) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { reason: "unmatched", compared: candidates.length };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The kit's wording for each refusal, in one place — so the guard's early exit says exactly what the full
|
|
308
|
+
* check would have said, rather than a second sentence about the same finding.
|
|
309
|
+
*
|
|
310
|
+
* Every string states what was actually determined and nothing more. `detail` is what an operator reads to
|
|
311
|
+
* decide where to look, and a refusal that asserts a comparison it never ran sends them somewhere else.
|
|
312
|
+
*/
|
|
313
|
+
function webhookRefusal(refusal: SignedWebhookRefusal, scheme: SignedWebhookScheme): WebhookUnverifiedError {
|
|
314
|
+
const timestampKey = scheme.timestampKey ?? DEFAULT_TIMESTAMP_KEY;
|
|
315
|
+
const signatureKey = scheme.signatureKey ?? DEFAULT_SIGNATURE_KEY;
|
|
316
|
+
switch (refusal.reason) {
|
|
317
|
+
case "unreadable":
|
|
318
|
+
return new WebhookUnverifiedError({
|
|
319
|
+
detail: `The delivery carries no readable ${scheme.header} header with a ${timestampKey}= timestamp and a ${signatureKey}= HMAC.`,
|
|
320
|
+
});
|
|
321
|
+
case "stale":
|
|
322
|
+
return new WebhookUnverifiedError({
|
|
323
|
+
detail: `The delivery is dated ${refusal.skew}s from now, outside the ${refusal.tolerance}s tolerance. Check this Worker's clock, or a replayed delivery.`,
|
|
324
|
+
});
|
|
325
|
+
case "unmatched":
|
|
326
|
+
// Two findings wear one code, and they send an operator to different places. When nothing survived the
|
|
327
|
+
// 32-byte hex filter no secret was ever tried, so naming the secret would be an assertion about a
|
|
328
|
+
// comparison that did not happen — the sender's signature format is what is wrong.
|
|
329
|
+
return new WebhookUnverifiedError({
|
|
330
|
+
detail:
|
|
331
|
+
refusal.compared === 0
|
|
332
|
+
? `No ${signatureKey}= value in the ${scheme.header} header is 32 bytes of hex, so no signature was compared. Check the sender's signature format.`
|
|
333
|
+
: `Compared ${refusal.compared} signature${refusal.compared === 1 ? "" : "s"} from the ${scheme.header} header against every configured signing secret; none matches these bytes. Check that the secret belongs to this endpoint and this environment.`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Verify one delivery. Resolves when a holder of the secret signed these exact bytes inside the window, and
|
|
340
|
+
* throws `core/webhook_unverified` otherwise.
|
|
341
|
+
*/
|
|
342
|
+
export async function verifySignedWebhook(
|
|
343
|
+
body: string,
|
|
344
|
+
header: string | null,
|
|
345
|
+
options: VerifySignedWebhookOptions,
|
|
346
|
+
): Promise<void> {
|
|
347
|
+
const refusal = await checkSignedWebhook(body, header, options);
|
|
348
|
+
if (refusal !== undefined) throw webhookRefusal(refusal, options);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The `signed-webhook` gate, as a route wears it: `app.post(path, requireSignedWebhook({…}), zValidator("json",
|
|
353
|
+
* Body, validationHook), handler)`.
|
|
354
|
+
*
|
|
355
|
+
* ## Why the header is read before anything else
|
|
356
|
+
*
|
|
357
|
+
* The proof header is the cheapest thing to check and the commonest thing to be missing, so it is checked
|
|
358
|
+
* first. A delivery carrying no readable one is refused whatever the body holds — so resolving the secret (a
|
|
359
|
+
* D1 read and a decrypt) and buffering the body before looking would be unauthenticated work bought by an
|
|
360
|
+
* anonymous POST, on the one route whose whole purpose is to refuse unauthenticated callers.
|
|
361
|
+
*
|
|
362
|
+
* The header is then parsed a second time inside the full check rather than threaded through it. Parsing a
|
|
363
|
+
* short header twice costs nothing; two entry points into the verification, one of them holding a
|
|
364
|
+
* half-verified state, would cost the property that there is exactly one way through this scheme.
|
|
365
|
+
*
|
|
366
|
+
* One consequence, and it is the right one: an endpoint whose secret never resolved answers a proof-less POST
|
|
367
|
+
* with the same 401 every other endpoint gives it, and reports the configuration fault to the first delivery
|
|
368
|
+
* that actually carries a proof. A caller with nothing to verify learns nothing about our configuration.
|
|
369
|
+
*
|
|
370
|
+
* ## Why it reads the body itself, and with `c.req.text()`
|
|
371
|
+
*
|
|
372
|
+
* The proof covers the exact received bytes, so the gate must see what arrived rather than a reconstruction.
|
|
373
|
+
* That is the sanctioned use the Biome plugin leaves open under `src/http/**`: `c.req.json()` is banned,
|
|
374
|
+
* `c.req.text()` is not.
|
|
375
|
+
*
|
|
376
|
+
* `c.req.text()` rather than `c.req.raw.text()`, and the distinction is load-bearing. Hono caches its own
|
|
377
|
+
* body reads and serves `c.req.json()` out of the cached text, so a `zValidator("json", …)` after this gate
|
|
378
|
+
* parses identical bytes. Reading `c.req.raw` would consume the stream and leave the validator with nothing.
|
|
379
|
+
*/
|
|
380
|
+
export function requireSignedWebhook(options: SignedWebhookGuardOptions): MiddlewareHandler<PithyHonoEnv> {
|
|
381
|
+
return async (c, next) => {
|
|
382
|
+
const { secret: declared, now, ...scheme } = options;
|
|
383
|
+
|
|
384
|
+
// The cheapest possible rejection, ahead of every cost an anonymous caller could otherwise buy.
|
|
385
|
+
const proof = c.req.header(scheme.header) ?? null;
|
|
386
|
+
const readable =
|
|
387
|
+
proof !== null &&
|
|
388
|
+
parseSignedWebhookHeader(
|
|
389
|
+
proof,
|
|
390
|
+
scheme.timestampKey ?? DEFAULT_TIMESTAMP_KEY,
|
|
391
|
+
scheme.signatureKey ?? DEFAULT_SIGNATURE_KEY,
|
|
392
|
+
) !== undefined;
|
|
393
|
+
if (!readable) throw webhookRefusal({ reason: "unreadable" }, scheme);
|
|
394
|
+
|
|
395
|
+
// Resolved per request, at the point of need — never cached in a module variable, never logged.
|
|
396
|
+
const secret = typeof declared === "function" ? await declared(c.env as Record<string, unknown>) : declared;
|
|
397
|
+
|
|
398
|
+
// The exact received bytes. Hono caches this read, so the route's json validator sees the same ones.
|
|
399
|
+
const body = await c.req.text();
|
|
400
|
+
|
|
401
|
+
await verifySignedWebhook(body, proof, { ...scheme, secret, now: now?.() ?? new Date() });
|
|
402
|
+
|
|
403
|
+
await next();
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Decode lower- or upper-case hex, or `undefined` when it is not hex at all. */
|
|
408
|
+
function hexBytes(value: string): Uint8Array | undefined {
|
|
409
|
+
if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) return undefined;
|
|
410
|
+
const bytes = new Uint8Array(value.length / 2);
|
|
411
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
412
|
+
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
|
413
|
+
}
|
|
414
|
+
return bytes;
|
|
415
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import { fromZodError } from "../error/pithyError";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How a route declares what it accepts. Its sibling {@link ./verification.VerificationStrategy}
|
|
9
|
+
* declares how a caller is verified; this declares the shape the caller may send. Both are
|
|
10
|
+
* mandatory, and both belong on the route line — reading a route tells you what it takes without
|
|
11
|
+
* opening the handler.
|
|
12
|
+
*
|
|
13
|
+
* Routes use `zValidator(target, Schema, validationHook)` from `@hono/zod-validator`. The library
|
|
14
|
+
* owns the middleware and the `c.req.valid(target)` typing; this file owns exactly one thing —
|
|
15
|
+
* what happens when the parse fails. Without the hook, zod-validator answers with its own JSON
|
|
16
|
+
* body, which is neither a `PithyError` payload nor routed through `pithyErrorHandler`. With it,
|
|
17
|
+
* a malformed request is indistinguishable from every other failure the app can produce.
|
|
18
|
+
*
|
|
19
|
+
* The hook is deliberately not a wrapper around `zValidator`. A wrapper would be a second name for
|
|
20
|
+
* a library we do not re-export (CLAUDE.md §Toolchain), and it would hide the target from the route
|
|
21
|
+
* line — the one thing the migration exists to make visible.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The outcome `@hono/zod-validator` hands a hook. Declared structurally rather than by importing
|
|
26
|
+
* the library's `Hook` generic: `Hook` is parameterized by env, path, target and schema, so a
|
|
27
|
+
* single shared hook value could not name it without pinning all four. Every real outcome is
|
|
28
|
+
* assignable to this.
|
|
29
|
+
*/
|
|
30
|
+
export type ValidationOutcome = { success: true } | { success: false; error: z.core.$ZodError };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The one validation-failure hook every provided route passes to `zValidator`. Maps the `ZodError`
|
|
34
|
+
* through `fromZodError`, so the client sees the same `validation/invalid_input` 400 with the same
|
|
35
|
+
* `issues[]` that the hand-rolled parse helpers produced before — the migration changes where
|
|
36
|
+
* validation is declared, not what a caller sees when it fails.
|
|
37
|
+
*/
|
|
38
|
+
export function validationHook(outcome: ValidationOutcome): void {
|
|
39
|
+
if (outcome.success) return;
|
|
40
|
+
throw fromZodError(outcome.error as z.ZodError);
|
|
41
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How a route verifies the caller's identity/authenticity. Every route declares one —
|
|
8
|
+
* there is no implicit auth. `@pithy-sh/auth` implements `bearer`/`session`; others map to
|
|
9
|
+
* their own checks. (Turnstile is a humanity check applied as middleware, not an identity
|
|
10
|
+
* strategy, so it is deliberately not listed here.)
|
|
11
|
+
*/
|
|
12
|
+
export const VerificationStrategy = z
|
|
13
|
+
.union([
|
|
14
|
+
z.literal("bearer").describe("Short-lived access token via Authorization: Bearer; validated by @pithy-sh/auth."),
|
|
15
|
+
z.literal("session").describe("Cookie-based web session, CSRF-protected; validated by @pithy-sh/auth."),
|
|
16
|
+
z
|
|
17
|
+
.literal("signed-webhook")
|
|
18
|
+
.describe(
|
|
19
|
+
"Inbound webhook, authenticated by the sender's own proof over the exact received bytes — one name over several mechanisms (an HMAC, a signed JWS chain, an OIDC token), because the sender chooses it. `requireSignedWebhook` (./signedWebhook) implements the timestamped-HMAC form; a rail whose sender proves it differently implements its own.",
|
|
20
|
+
),
|
|
21
|
+
z.literal("control-plane").describe("M2M admin via a customer-issued scoped credential; default-denied."),
|
|
22
|
+
z.literal("public").describe("No authentication — the route is open. Must be a deliberate choice."),
|
|
23
|
+
])
|
|
24
|
+
.describe("How a route verifies the caller's identity/authenticity. There is no implicit auth.");
|
|
25
|
+
export type VerificationStrategy = z.infer<typeof VerificationStrategy>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { MAX_LOCALE_TAG_LENGTH } from "./locale";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The most `Accept-Language` entries this parser will read from one header.
|
|
8
|
+
*
|
|
9
|
+
* A browser sends a handful. A caller that sends ten thousand is not negotiating, and the matcher
|
|
10
|
+
* walks every entry against every supported locale — so the bound is on the input, where it costs
|
|
11
|
+
* nothing, rather than on the loop, where it would have to be argued about at each call site.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_ACCEPT_LANGUAGE_ENTRIES = 32;
|
|
14
|
+
|
|
15
|
+
/** The most characters this parser will read from one `Accept-Language` header, for the same reason. */
|
|
16
|
+
const MAX_ACCEPT_LANGUAGE_LENGTH = 4096;
|
|
17
|
+
|
|
18
|
+
/** One entry of an `Accept-Language` header: the tag the caller asked for, and how much they meant it. */
|
|
19
|
+
export interface LanguageRange {
|
|
20
|
+
/** The language range, lower-cased. `*` survives as itself — it is a valid range, not a tag. */
|
|
21
|
+
readonly range: string;
|
|
22
|
+
/** The entry's q-weight, `0` through `1`. Absent weights are `1`, per RFC 9110. */
|
|
23
|
+
readonly quality: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a q-weight parameter parses to a usable number. `q=` and `q=abc` are neither, and drop out. */
|
|
27
|
+
function quality(parameter: string): number | null {
|
|
28
|
+
const match = /^q=(\d(?:\.\d{0,3})?)$/.exec(parameter);
|
|
29
|
+
if (!match?.[1]) return null;
|
|
30
|
+
const value = Number.parseFloat(match[1]);
|
|
31
|
+
return Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An `Accept-Language` header as q-weighted ranges, most-wanted first.
|
|
36
|
+
*
|
|
37
|
+
* **Tokenize, strip q-weights, then validate — in that order, and all of it before anything reaches
|
|
38
|
+
* `Intl`.** The header is caller-supplied and its real-world contents include `*`, `en_US`, empty
|
|
39
|
+
* tokens, and tags still carrying `;q=0.9`; `new Intl.Locale()` throws a `RangeError` on every one of
|
|
40
|
+
* them. A header that cannot be read is an empty list, never a throw, so the resolver chain falls
|
|
41
|
+
* through to its next link instead of 500-ing.
|
|
42
|
+
*
|
|
43
|
+
* The **full** weighted list is returned, not the first entry: `pt-PT;q=1.0, es;q=0.8, en;q=0.5` from a
|
|
44
|
+
* reader who has no Portuguese is a request for Spanish, and reading only the head answers English.
|
|
45
|
+
* Equal weights keep header order, which is what a browser means by listing them that way.
|
|
46
|
+
*/
|
|
47
|
+
export function parseAcceptLanguage(header: string | null | undefined): LanguageRange[] {
|
|
48
|
+
if (!header || header.length > MAX_ACCEPT_LANGUAGE_LENGTH) return [];
|
|
49
|
+
const ranges: LanguageRange[] = [];
|
|
50
|
+
for (const entry of header.split(",")) {
|
|
51
|
+
if (ranges.length >= MAX_ACCEPT_LANGUAGE_ENTRIES) break;
|
|
52
|
+
const [rawRange, ...parameters] = entry.split(";");
|
|
53
|
+
const range = rawRange?.trim().toLowerCase() ?? "";
|
|
54
|
+
// `*` is a legal range and no tag at all, so it is kept verbatim and special-cased by the matcher.
|
|
55
|
+
// Everything else must look like a tag here, which is what keeps `en_US` and `` out of `Intl`.
|
|
56
|
+
if (range !== "*" && !/^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/.test(range)) continue;
|
|
57
|
+
if (range.length > MAX_LOCALE_TAG_LENGTH) continue;
|
|
58
|
+
let weight = 1;
|
|
59
|
+
for (const parameter of parameters) {
|
|
60
|
+
const parsed = quality(parameter.trim().toLowerCase());
|
|
61
|
+
if (parsed !== null) weight = parsed;
|
|
62
|
+
}
|
|
63
|
+
// `q=0` is an explicit refusal, not a weak preference. Dropping it here is what keeps it out of
|
|
64
|
+
// the match — a reader who wrote `de;q=0` must not be answered in German by a later fallback.
|
|
65
|
+
if (weight > 0) ranges.push({ range, quality: weight });
|
|
66
|
+
}
|
|
67
|
+
// Stable by construction: `sort` is stable in every runtime Pithy targets, so equal weights keep
|
|
68
|
+
// the order the caller wrote them in.
|
|
69
|
+
return ranges.sort((left, right) => right.quality - left.quality);
|
|
70
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { Locale } from "./locale";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The grammar of a catalog key: `<domain>/<path>`.
|
|
9
|
+
*
|
|
10
|
+
* `<domain>` is a capability's `name` — the same segment that is already the `pithy add` argument, the
|
|
11
|
+
* migration namespace, the `pithy_<name>_*` table prefix and the error-code domain. `<path>` is that
|
|
12
|
+
* capability's own name for the string, and may carry dots for the screens (`auth/sign_in.title`).
|
|
13
|
+
*
|
|
14
|
+
* **For an error the key *is* the code.** `auth/invalid_token` is a catalog key and an error code and
|
|
15
|
+
* the same string, so there is no second identifier to keep in sync, and `KitErrorCode` is the
|
|
16
|
+
* exhaustive checklist a locale has to cover.
|
|
17
|
+
*
|
|
18
|
+
* **Which is why the domain admits `_` as well as the tail**, and it is the same reason: this grammar
|
|
19
|
+
* has to accept every code the taxonomy already spells, and `rate_limit/exceeded` is one of them. It
|
|
20
|
+
* did not, at first — the domain was `[a-z][a-z0-9]*` while the tail was `[a-z][a-z0-9]*(?:_…)*`, so
|
|
21
|
+
* the single most-thrown code in the kit was a valid error code and an invalid catalog key. The kit's
|
|
22
|
+
* own Spanish carried it anyway (a translating client renders `t.maybe(payload.code, payload.params) ??
|
|
23
|
+
* payload.message` for whatever it is sent), and an adopter trying to override that one sentence was
|
|
24
|
+
* refused by their own config. A grammar narrower than the thing it names is not stricter, it is wrong.
|
|
25
|
+
*/
|
|
26
|
+
const MESSAGE_KEY = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*\/[a-z0-9]+(?:_[a-z0-9]+)*(?:\.[a-z0-9]+(?:_[a-z0-9]+)*)*$/;
|
|
27
|
+
|
|
28
|
+
/** The longest catalog key. Bounds the domain check and matches the error taxonomy's own ceiling. */
|
|
29
|
+
const MAX_MESSAGE_KEY_LENGTH = 129;
|
|
30
|
+
|
|
31
|
+
/** A catalog key — `<domain>/<path>`, where `<domain>` is the contributing capability's name. */
|
|
32
|
+
export const MessageKey = z
|
|
33
|
+
.string()
|
|
34
|
+
.max(MAX_MESSAGE_KEY_LENGTH)
|
|
35
|
+
.regex(MESSAGE_KEY, { message: "A message key is `<domain>/<path>`, where the domain is a capability name." })
|
|
36
|
+
.describe("A catalog key, `<domain>/<path>` — for an error, the error code itself.");
|
|
37
|
+
export type MessageKey = z.infer<typeof MessageKey>;
|
|
38
|
+
|
|
39
|
+
/** The values a message may interpolate. Scalars only — a catalog renders text, never a structure. */
|
|
40
|
+
export const MessageParams = z
|
|
41
|
+
.record(
|
|
42
|
+
z.string().describe("The placeholder name, as written between braces in the message."),
|
|
43
|
+
z
|
|
44
|
+
.union([z.string(), z.number(), z.boolean()])
|
|
45
|
+
.describe("The value substituted for that placeholder. Scalar, so a message stays a sentence."),
|
|
46
|
+
)
|
|
47
|
+
.describe("Values a message interpolates, keyed by placeholder name.");
|
|
48
|
+
export type MessageParams = z.infer<typeof MessageParams>;
|
|
49
|
+
|
|
50
|
+
/** One locale's words: key → message. */
|
|
51
|
+
export const MessageCatalog = z
|
|
52
|
+
.record(MessageKey.describe("The key this message answers."), z.string().describe("The message, in this locale."))
|
|
53
|
+
.describe("One locale's messages, keyed by `<domain>/<path>`.");
|
|
54
|
+
export type MessageCatalog = z.infer<typeof MessageCatalog>;
|
|
55
|
+
|
|
56
|
+
/** A set of catalogs, keyed by locale — what a capability contributes and what an adopter overrides with. */
|
|
57
|
+
export const LocaleCatalogs = z
|
|
58
|
+
.record(Locale.describe("The locale these messages are written in."), MessageCatalog)
|
|
59
|
+
.describe("Catalogs keyed by locale — a capability's `messages` contribution, or an adopter's overrides.");
|
|
60
|
+
export type LocaleCatalogs = z.infer<typeof LocaleCatalogs>;
|
|
61
|
+
|
|
62
|
+
/** The domain of a catalog key — everything before the `/`. */
|
|
63
|
+
export function messageDomain(key: string): string {
|
|
64
|
+
const cut = key.indexOf("/");
|
|
65
|
+
return cut < 0 ? key : key.slice(0, cut);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `template` with every `{placeholder}` replaced by its parameter.
|
|
70
|
+
*
|
|
71
|
+
* A placeholder with no parameter is **left as written**, not blanked. A missing value is a bug in the
|
|
72
|
+
* call site or a typo in the catalog, and `Renews {date}.` on the screen says which; `Renews .` says
|
|
73
|
+
* nothing and reads like finished copy.
|
|
74
|
+
*
|
|
75
|
+
* Substitution is textual and performs no escaping, because the two consumers escape differently and
|
|
76
|
+
* both do it better than this could: React escapes what it renders, and the email engine precompiles
|
|
77
|
+
* `subject` and `text` with `noEscape` deliberately. A catalog value reaching an unescaped surface is
|
|
78
|
+
* therefore a fact about that surface — stated in `docs/I18N.md`, and the reason kit catalogs carry no
|
|
79
|
+
* markup.
|
|
80
|
+
*/
|
|
81
|
+
export function interpolate(template: string, params?: MessageParams): string {
|
|
82
|
+
if (!params) return template;
|
|
83
|
+
// `Object.hasOwn`, never a bare index. A plain object inherits `constructor`, `toString` and the
|
|
84
|
+
// rest, so `params[name]` answers a native function for a placeholder nobody passed — and
|
|
85
|
+
// `{constructor}` in a message then renders `function Object() { [native code] }` into user-facing
|
|
86
|
+
// copy, which is the opposite of the promise two paragraphs up.
|
|
87
|
+
return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (whole, name: string) => {
|
|
88
|
+
if (!Object.hasOwn(params, name)) return whole;
|
|
89
|
+
const value = params[name];
|
|
90
|
+
return value === undefined ? whole : String(value);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The first layer that has `key`, or `null` when none does.
|
|
96
|
+
*
|
|
97
|
+
* **Per key, never per catalog.** An adopter who translates one sentence passes one entry, and every
|
|
98
|
+
* key they did not mention keeps flowing from the package — which is what makes an override a merge
|
|
99
|
+
* rather than a fork. Layer order is the caller's: `@pithy-sh/i18n` walks adopter-locale, then
|
|
100
|
+
* adopter-default, then kit-locale, then kit-default.
|
|
101
|
+
*/
|
|
102
|
+
export function lookupMessage(layers: readonly (MessageCatalog | undefined)[], key: string): string | null {
|
|
103
|
+
for (const layer of layers) {
|
|
104
|
+
// Own keys only. A catalog is a plain object, so `layer["constructor"]` answers `Object` itself —
|
|
105
|
+
// and a lookup that returned it would hand a *function* to `interpolate`, which calls `.replace`
|
|
106
|
+
// on it. `t` is documented as total; walking the prototype chain is how it stops being.
|
|
107
|
+
if (layer && Object.hasOwn(layer, key)) {
|
|
108
|
+
const message = layer[key];
|
|
109
|
+
if (message !== undefined) return message;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|