@pithy-sh/core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capability/bindings.d.ts +5 -0
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +44 -3
- package/dist/capability/capability.d.ts +33 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/manifest.d.ts +5 -0
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +1 -1
- package/dist/capability/validateBindings.js +1 -1
- package/dist/controlPlane/http/cors.js +1 -1
- package/dist/createBackend.d.ts +2 -1
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +5 -4
- package/dist/error/http.d.ts +27 -5
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/http.js +33 -9
- package/dist/http/oidcWebhook.d.ts +283 -0
- package/dist/http/oidcWebhook.d.ts.map +1 -0
- package/dist/http/oidcWebhook.js +414 -0
- package/dist/http/signedWebhook.d.ts +9 -65
- package/dist/http/signedWebhook.d.ts.map +1 -1
- package/dist/http/signedWebhook.js +19 -9
- package/dist/http/webhookWindow.d.ts +33 -0
- package/dist/http/webhookWindow.d.ts.map +1 -0
- package/dist/http/webhookWindow.js +35 -0
- package/dist/logger/errorHandler.d.ts +11 -0
- package/dist/logger/errorHandler.d.ts.map +1 -0
- package/dist/logger/errorHandler.js +87 -0
- package/dist/naming/environment.js +1 -1
- package/dist/naming/provisionScope.d.ts +108 -3
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +27 -2
- package/dist/naming/resource.js +1 -1
- package/dist/naming/resourceNames.js +1 -1
- package/dist/schema/describedness.d.ts +30 -1
- package/dist/schema/describedness.d.ts.map +1 -1
- package/dist/schema/describedness.js +42 -6
- package/dist/test-utils/oidcFixtures.d.ts +93 -0
- package/dist/test-utils/oidcFixtures.d.ts.map +1 -0
- package/dist/test-utils/oidcFixtures.js +111 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/naming.js +1 -1
- package/package.json +1 -1
- package/src/capability/bindings.ts +49 -0
- package/src/capability/capability.ts +34 -0
- package/src/createBackend.ts +7 -3
- package/src/error/http.ts +34 -10
- package/src/http/oidcWebhook.ts +672 -0
- package/src/http/signedWebhook.ts +44 -16
- package/src/http/webhookWindow.ts +35 -0
- package/src/logger/errorHandler.ts +94 -0
- package/src/naming/provisionScope.ts +150 -4
- package/src/schema/describedness.ts +51 -6
- package/src/test-utils/oidcFixtures.ts +138 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/test-utils/oidcFixtures.ts
|
|
4
|
+
/**
|
|
5
|
+
* Mint one RSA key pair and publish its public half as a JWK.
|
|
6
|
+
*
|
|
7
|
+
* Real WebCrypto, not a fixture: a hand-rolled key and a hand-rolled signature would let every signature
|
|
8
|
+
* assertion pass by agreeing with itself. A second minted key is what makes "signed by somebody else" a
|
|
9
|
+
* real statement rather than a label — mint one, publish only the first, and the refusal is the verifier's
|
|
10
|
+
* rather than the test's.
|
|
11
|
+
*
|
|
12
|
+
* Key generation is the slow part of any suite using this and a key is immutable, so mint in a
|
|
13
|
+
* `beforeAll` and reuse.
|
|
14
|
+
*/
|
|
15
|
+
async function mintKey(kid) {
|
|
16
|
+
const pair = await crypto.subtle.generateKey({
|
|
17
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
18
|
+
hash: "SHA-256",
|
|
19
|
+
modulusLength: 2048,
|
|
20
|
+
publicExponent: new Uint8Array([
|
|
21
|
+
1,
|
|
22
|
+
0,
|
|
23
|
+
1
|
|
24
|
+
])
|
|
25
|
+
}, true, ["sign", "verify"]);
|
|
26
|
+
const exported = await crypto.subtle.exportKey("jwk", pair.publicKey);
|
|
27
|
+
if (exported.kty === void 0 || exported.n === void 0 || exported.e === void 0) throw new Error("WebCrypto exported an RSA public key with no kty, n, or e.");
|
|
28
|
+
return {
|
|
29
|
+
kid,
|
|
30
|
+
jwk: {
|
|
31
|
+
...exported,
|
|
32
|
+
kty: exported.kty,
|
|
33
|
+
n: exported.n,
|
|
34
|
+
e: exported.e,
|
|
35
|
+
kid,
|
|
36
|
+
alg: "RS256",
|
|
37
|
+
use: "sig"
|
|
38
|
+
},
|
|
39
|
+
privateKey: pair.privateKey
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* base64url without padding — how every segment of a compact JWS is encoded.
|
|
44
|
+
*
|
|
45
|
+
* Exported because a test that builds a token by hand needs it: the tamper case swaps a claims segment and
|
|
46
|
+
* keeps the signature, and re-encoding that segment with padding or with `+`/`/` would make the token
|
|
47
|
+
* refused for the wrong reason.
|
|
48
|
+
*/
|
|
49
|
+
function base64Url(bytes) {
|
|
50
|
+
let binary = "";
|
|
51
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
52
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Sign a compact JWT the way an identity provider signs an OIDC token: `RS256`, `typ: "JWT"`, and the
|
|
56
|
+
* signer's `kid` in the header.
|
|
57
|
+
*
|
|
58
|
+
* `payload` is the claims, written as the provider writes them — `iss`, `aud`, `exp`, and whatever the
|
|
59
|
+
* route's `claims` predicate reads. Nothing here defaults a claim, because a fixture that quietly supplied
|
|
60
|
+
* one would be testing itself.
|
|
61
|
+
*
|
|
62
|
+
* `header` overrides let a test present a header no provider would — `alg: "none"`, `alg: "RS512"`, a `kid`
|
|
63
|
+
* nobody published, or `kid: undefined` — which is how a route proves algorithm confusion and an
|
|
64
|
+
* unpublished key are rejected, instead of trusting that the kit covers it somewhere.
|
|
65
|
+
*/
|
|
66
|
+
async function signToken(payload, signer, header = {}) {
|
|
67
|
+
const encoder = new TextEncoder();
|
|
68
|
+
const head = base64Url(encoder.encode(JSON.stringify({
|
|
69
|
+
alg: "RS256",
|
|
70
|
+
typ: "JWT",
|
|
71
|
+
kid: signer.kid,
|
|
72
|
+
...header
|
|
73
|
+
})));
|
|
74
|
+
const body = base64Url(encoder.encode(JSON.stringify(payload)));
|
|
75
|
+
return `${head}.${body}.${base64Url(new Uint8Array(await crypto.subtle.sign("RSASSA-PKCS1-v1_5", signer.privateKey, encoder.encode(`${head}.${body}`))))}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A JWKS transport that publishes `keys` at `jwksUrl` and nothing anywhere else, recording every URL it was
|
|
79
|
+
* asked for in `seen`.
|
|
80
|
+
*
|
|
81
|
+
* **The response is the seam's own shape, which is the whole point of shipping this.** `text()`, never
|
|
82
|
+
* `json()` — {@link OidcJwksResponse} reads the body as text deliberately, so a proxy's HTML error page is a
|
|
83
|
+
* diagnosis rather than a throw. A hand-rolled transport that answers `json()` produces a 502 on every
|
|
84
|
+
* delivery from a fixture that looks correct. Typed as {@link OidcJwksFetch}, that mistake will not compile.
|
|
85
|
+
*
|
|
86
|
+
* **The URL is a parameter rather than a wildcard.** A transport that served keys at any URL would pass a
|
|
87
|
+
* route whose configured `jwksUrl` points at nothing — the one configuration mistake a test of a webhook
|
|
88
|
+
* route exists to catch. Anything else gets a 404 with a body, which is what an issuer's CDN actually
|
|
89
|
+
* returns and what the verifier maps to a 502.
|
|
90
|
+
*
|
|
91
|
+
* `seen` is an array the caller owns, so a test can assert the fetch count: one per delivery with no cache,
|
|
92
|
+
* one per TTL with one, and zero when a cache is pre-seeded. Pass `publishing(url, [], seen)` to prove a
|
|
93
|
+
* path never reaches the network at all — an empty key set is refused, so an unexpected fetch fails loudly.
|
|
94
|
+
*/
|
|
95
|
+
function publishing(jwksUrl, keys, seen = []) {
|
|
96
|
+
return async (url) => {
|
|
97
|
+
seen.push(url);
|
|
98
|
+
if (url !== jwksUrl) return {
|
|
99
|
+
ok: false,
|
|
100
|
+
status: 404,
|
|
101
|
+
text: async () => "{}"
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
status: 200,
|
|
106
|
+
text: async () => JSON.stringify({ keys })
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
//#endregion
|
|
111
|
+
export { base64Url, mintKey, publishing, signToken };
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
/** This package's npm name — the join key against a release feed. */
|
|
4
4
|
export declare const PACKAGE_NAME = "@pithy-sh/core";
|
|
5
5
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
6
|
-
export declare const PACKAGE_VERSION = "0.
|
|
6
|
+
export declare const PACKAGE_VERSION = "0.3.0";
|
|
7
7
|
//# sourceMappingURL=version.generated.d.ts.map
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
/** This package's npm name — the join key against a release feed. */
|
|
5
5
|
const PACKAGE_NAME = "@pithy-sh/core";
|
|
6
6
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
7
|
-
const PACKAGE_VERSION = "0.
|
|
7
|
+
const PACKAGE_VERSION = "0.3.0";
|
|
8
8
|
//#endregion
|
|
9
9
|
export { PACKAGE_NAME, PACKAGE_VERSION };
|
package/dist/workflow/naming.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import { NAME_SEGMENT } from "../naming/segment.js";
|
|
4
3
|
import { InternalError } from "../error/pithyError.js";
|
|
4
|
+
import { NAME_SEGMENT } from "../naming/segment.js";
|
|
5
5
|
import { NAMESPACE_LIMITS } from "../naming/limits.js";
|
|
6
6
|
import { assertValidProjectName, kebab } from "../naming/resource.js";
|
|
7
7
|
//#region src/workflow/naming.ts
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { FEATURE_RESOURCE_KINDS } from "../naming/feature";
|
|
5
6
|
import { NAME_SEGMENT } from "../naming/segment";
|
|
6
7
|
|
|
7
8
|
export const BindingType = z
|
|
@@ -100,6 +101,19 @@ const CLASS_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
|
100
101
|
*/
|
|
101
102
|
const CLASS_MODULE = /^(@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*(\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)*$/;
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The kinds whose resource Pithy names and creates — the only kinds `scope` and `resource` mean anything
|
|
106
|
+
* for.
|
|
107
|
+
*
|
|
108
|
+
* {@link FEATURE_RESOURCE_KINDS} rather than a fresh literal, because it is already the repo's answer to
|
|
109
|
+
* "which bindings does a provisioning run compose a `<project>-<scope>-<thing>` name for": it is the
|
|
110
|
+
* domain of `ProvisionScope.resource`, and the CLI's own `PROVISIONABLE` map is the same three. Every
|
|
111
|
+
* other kind's name is composed by a different rule with no `<thing>` slot to fill — a Workflow's from
|
|
112
|
+
* the capability and the job, a Worker script's by wrangler — and a `secret` already states its scope in
|
|
113
|
+
* `defineSecretRegistry`, where the value it names lives.
|
|
114
|
+
*/
|
|
115
|
+
const NAMED_RESOURCE_KINDS = new Set<string>(FEATURE_RESOURCE_KINDS);
|
|
116
|
+
|
|
103
117
|
/**
|
|
104
118
|
* Declares a Cloudflare binding a capability requires in the Worker env. The authoring
|
|
105
119
|
* shape lets `optional` be omitted (defaults false); `createBackend` normalizes via parse.
|
|
@@ -151,6 +165,24 @@ export const BindingSpec = z
|
|
|
151
165
|
.describe(
|
|
152
166
|
"Reach the real Cloudflare resource during local development instead of a local emulation. Set it for a binding that has none — Vectorize and Workers AI both lack local simulation — so `wrangler dev` and any Workflow host, which always runs locally, still work. Left unset rather than defaulting to false, so a spec that does not care emits no flag at all. Ignored in a deployed Worker.",
|
|
153
167
|
),
|
|
168
|
+
scope: z
|
|
169
|
+
.enum(["environment", "global"])
|
|
170
|
+
.optional()
|
|
171
|
+
.describe(
|
|
172
|
+
"Whether the resource behind this binding is one per environment (absent, the default) or one for the whole project. A `global` resource takes the literal `global` where the environment segment goes — `<project>-global-<thing>` — so every environment's stanza names the identical resource and provisioning creates it once. Declare it where the data is not an environment-local fact: `@pithy-sh/email`'s suppression list is one per project, because an unsubscribe recorded through a staging Worker has to be honored by a production one, and three per-environment databases meant it was not (#513). Say nothing where it is: a session store, a cache, and `@pithy-sh/secrets`' own database are per environment, and one line apart in the same env there is no convention to tell them apart by — which is why this is declared rather than inferred. Meaningful only for a `d1`, `kv` or `r2` binding.",
|
|
173
|
+
),
|
|
174
|
+
resource: z
|
|
175
|
+
.string()
|
|
176
|
+
.min(1)
|
|
177
|
+
// The same rule the composed name answers to, asserted here rather than only where the name is
|
|
178
|
+
// built: a manifest is third-party data read out of `node_modules`, and this string is the last
|
|
179
|
+
// segment of a Cloudflare resource name. A manifest stating something a provisioning run would
|
|
180
|
+
// refuse is refused at parse instead, attributed to the capability, before anything is created.
|
|
181
|
+
.regex(NAME_SEGMENT, "A resource is one name segment: lowercase letters, digits, and single hyphens.")
|
|
182
|
+
.optional()
|
|
183
|
+
.describe(
|
|
184
|
+
"The `<thing>` segment of this resource's name, when it is not the kebabbed binding name. Absent is the ordinary case and means the binding name is the segment (`EMAIL_SUPPRESSIONS` → `email-suppressions`). Declare it where the two must differ: `SUPPORT_BUCKET` backs `<project>-global-support`, because the binding keeps `_BUCKET` for whoever reads `env.SUPPORT_BUCKET` in a Worker while the resource drops it — `r2` is already the composed name's kind, so the suffix repeats it. Meaningful only for a `d1`, `kv` or `r2` binding.",
|
|
185
|
+
),
|
|
154
186
|
})
|
|
155
187
|
.describe("Declares a Cloudflare binding a capability requires in the Worker env.")
|
|
156
188
|
.check((ctx) => {
|
|
@@ -189,6 +221,23 @@ export const BindingSpec = z
|
|
|
189
221
|
message: `Service binding "${ctx.value.name}" needs a service — the Worker it calls, as named in apps/.`,
|
|
190
222
|
});
|
|
191
223
|
}
|
|
224
|
+
// `scope` and `resource` are the two halves of a *provisioned resource's name*, and only
|
|
225
|
+
// {@link NAMED_RESOURCE_KINDS} have one Pithy composes. On anything else they are inert — a Workflow
|
|
226
|
+
// would keep its `<capability>-<job>` name, a `secret` its registry's scope — so a manifest that
|
|
227
|
+
// states one is not describing a resource that exists. Refused rather than ignored, and refused here
|
|
228
|
+
// rather than in the writer, because a silently-dropped `scope: "global"` reads, in the manifest, as
|
|
229
|
+
// exactly the declaration #513 exists to make true.
|
|
230
|
+
if (!NAMED_RESOURCE_KINDS.has(ctx.value.type)) {
|
|
231
|
+
for (const field of ["scope", "resource"] as const) {
|
|
232
|
+
if (ctx.value[field] === undefined) continue;
|
|
233
|
+
ctx.issues.push({
|
|
234
|
+
code: "custom",
|
|
235
|
+
input: ctx.value,
|
|
236
|
+
path: [field],
|
|
237
|
+
message: `Binding "${ctx.value.name}" declares a ${field}, which only a d1, kv or r2 binding has — this one is a ${ctx.value.type}.`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
192
241
|
});
|
|
193
242
|
export type BindingSpec = z.infer<typeof BindingSpec>;
|
|
194
243
|
|
|
@@ -160,6 +160,34 @@ export interface SecretRegistryEntrySeam {
|
|
|
160
160
|
/** A capability's secret-registry slice: secret name → {@link SecretRegistryEntrySeam}. */
|
|
161
161
|
export type SecretRegistrySeam = Record<string, SecretRegistryEntrySeam>;
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* **Which branches of a `json` secret's schema this Worker's configuration actually uses** — secret
|
|
165
|
+
* name → the top-level keys that apply, in the order they should be offered.
|
|
166
|
+
*
|
|
167
|
+
* A `json` secret is often a bundle of independent blocks: `payments-provider-credentials` is five
|
|
168
|
+
* optional rail blocks, `turnstile-secret-keys` is two optional widgets. The schema says which blocks
|
|
169
|
+
* are *possible*; only the composed config says which are *real*, and a credential for a rail that is
|
|
170
|
+
* off is a value nothing will ever read. So `pithy secrets create` asks per field for the blocks named
|
|
171
|
+
* here, and never offers — never writes — one that is not.
|
|
172
|
+
*
|
|
173
|
+
* **It is declared, not matched by name.** `PaymentsRailToggles.paddle` and
|
|
174
|
+
* `PaymentsProviderCredentials.paddle` happen to be spelled the same today, and a CLI that keyed off
|
|
175
|
+
* that would be right by coincidence: nothing makes a config flag and a schema key the same thing, and
|
|
176
|
+
* two of them one line apart cannot be told apart by convention (#513). The capability already knows
|
|
177
|
+
* the answer at construction — it has both halves in hand — so it states it, and the CLI reads a fact
|
|
178
|
+
* instead of inferring one.
|
|
179
|
+
*
|
|
180
|
+
* **It lives on the capability rather than on the entry** because a registry entry is plain data,
|
|
181
|
+
* shared by every Worker that declares the name and projected into `pithy.manifest.json`. This answer
|
|
182
|
+
* is per composition — the same secret, two Workers, two configs — so it belongs where the config is.
|
|
183
|
+
*
|
|
184
|
+
* An absent declaration is not an empty one. A capability that says nothing leaves the CLI with no way
|
|
185
|
+
* to know which blocks apply, and it falls back to asking for the whole document rather than guessing;
|
|
186
|
+
* an empty array says *none of them apply*, which is the same fallback for a different reason. A
|
|
187
|
+
* secret whose schema has no branches at all needs no declaration — every field is asked.
|
|
188
|
+
*/
|
|
189
|
+
export type SecretBranchSeam = Record<string, readonly string[]>;
|
|
190
|
+
|
|
163
191
|
/**
|
|
164
192
|
* The structural seam for one capability's token-profile slice — profile name → the scoped CF API
|
|
165
193
|
* token that capability's code needs. Declared next to the code that uses it (alongside
|
|
@@ -298,6 +326,12 @@ export interface Capability<
|
|
|
298
326
|
* every declared secret in one batch — and no capability needs to know another's secrets.
|
|
299
327
|
*/
|
|
300
328
|
secretRegistry?: SecretRegistrySeam;
|
|
329
|
+
/**
|
|
330
|
+
* Which branches of this capability's `json` secrets this Worker's configuration uses — see
|
|
331
|
+
* {@link SecretBranchSeam}. Additive and optional: a capability whose secrets are flat, or which has
|
|
332
|
+
* no `json` secret at all, declares nothing.
|
|
333
|
+
*/
|
|
334
|
+
secretBranches?: SecretBranchSeam;
|
|
301
335
|
/**
|
|
302
336
|
* The scoped CF API tokens this capability's code needs, as a token-profile slice (profile name →
|
|
303
337
|
* {@link TokenProfileSeam}). Additive and optional, and declared next to {@link Capability.secretRegistry}
|
package/src/createBackend.ts
CHANGED
|
@@ -18,11 +18,11 @@ import { isControlPlaneCapability } from "./controlPlane/capability";
|
|
|
18
18
|
import { allowedOriginSet, corsSurface, registerControlPlaneCors } from "./controlPlane/http/cors";
|
|
19
19
|
import { buildDbRegistry, composeDatabases, type DbRegistry } from "./data/databases";
|
|
20
20
|
import { noEntitlementProvider } from "./entitlement/entitlement";
|
|
21
|
-
import { pithyErrorHandler } from "./error/http";
|
|
22
21
|
import { ValidationError } from "./error/pithyError";
|
|
23
22
|
import { catalogFor, composeMessages } from "./i18n/registry";
|
|
24
23
|
import { bakedTranslator, DEFAULT_LOCALE } from "./i18n/translator";
|
|
25
24
|
import { buildKvRegistry, composeKv, type KvRegistry } from "./kv/namespaces";
|
|
25
|
+
import { loggingErrorHandler } from "./logger/errorHandler";
|
|
26
26
|
import type { Logger } from "./logger/logger";
|
|
27
27
|
import { bindRequestContext, createWorkerLogger } from "./logger/worker";
|
|
28
28
|
import { HEALTH_PATH } from "./worker/health";
|
|
@@ -111,7 +111,8 @@ function envNameOf(env: Record<string, unknown>, fallback: string | undefined):
|
|
|
111
111
|
* each capability's middleware; and mounts the capability (then app) routes. On every request
|
|
112
112
|
* `c.var.db` is the typed database registry — one `Kysely` per named database (`c.var.db.app`) —
|
|
113
113
|
* and `c.var.kv` the typed namespace registry — one `TypedKv` per store (`c.var.kv.cms.pages`).
|
|
114
|
-
* `PithyError`s become their declared HTTP status via {@link
|
|
114
|
+
* `PithyError`s become their declared HTTP status via {@link loggingErrorHandler}, which also writes
|
|
115
|
+
* each fault to the request logger with its `action` and `detail` — the fields the wire body strips.
|
|
115
116
|
*
|
|
116
117
|
* Binding validation runs **once on the first request**, not at module load: in Workers `env` is
|
|
117
118
|
* per-request, so there is no env to check until a request arrives. The result is memoized.
|
|
@@ -178,7 +179,10 @@ export function createBackend<
|
|
|
178
179
|
// Build internally against the loose base env (db/kv are `unknown`, so `c.set` accepts the merged
|
|
179
180
|
// registries); the precise types ride on the return below.
|
|
180
181
|
const app = new Hono<PithyHonoEnv>();
|
|
181
|
-
|
|
182
|
+
// The logging handler, not the bare encoder: a fault's `action` names the var or command an operator
|
|
183
|
+
// must fix, `clientError` strips it on the way to the wire (correctly), and without this the remedy
|
|
184
|
+
// reached no surface at all in a deployed Worker — one access log line reading `status: 500` (#521).
|
|
185
|
+
app.onError(loggingErrorHandler);
|
|
182
186
|
|
|
183
187
|
// Set once at assembly (the per-request `env` constraint holds: correlation binds per request below,
|
|
184
188
|
// not here). Defaults to the CF-native Mode 2 logger — structured records to Workers Logs, zero-config.
|
package/src/error/http.ts
CHANGED
|
@@ -73,20 +73,44 @@ function fromHttpException(error: HTTPException): PithyError | null {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* carrying the original as `cause` (kept internal) and a
|
|
80
|
-
* CLI's "unexpected crash" path.
|
|
76
|
+
* Whatever was thrown, as the `PithyError` the response will be rendered from.
|
|
77
|
+
*
|
|
78
|
+
* A `PithyError` is itself; a Hono `HTTPException` 400 becomes `validation/invalid_input`; anything
|
|
79
|
+
* else is wrapped as a `core/internal` 500 carrying the original as `cause` (kept internal) and a
|
|
80
|
+
* generic public message — mirroring the CLI's "unexpected crash" path.
|
|
81
|
+
*
|
|
82
|
+
* Split out from {@link pithyErrorHandler} so a caller that wants to **read** the fault before it is
|
|
83
|
+
* encoded — the logging handler in `logger/errorHandler.ts` — works from the same object the response
|
|
84
|
+
* is built from, rather than translating a second time and logging a different instance of it. The
|
|
85
|
+
* split is what keeps this module free of the logger: the translation is exported, the log is written
|
|
86
|
+
* on the other side of the boundary. See `logger/boundary.test.ts`.
|
|
81
87
|
*/
|
|
82
|
-
export function
|
|
88
|
+
export function toPithyError(err: Error): PithyError {
|
|
89
|
+
if (err instanceof PithyError) return err;
|
|
83
90
|
const translated = err instanceof HTTPException ? fromHttpException(err) : null;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
return translated ?? new InternalError({ detail: err instanceof Error ? err.message : String(err) }, { cause: err });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Render an already-translated fault as its HTTP response — the client half, and only that. */
|
|
95
|
+
export function renderPithyError(pithy: PithyError, c: Context): Response {
|
|
88
96
|
// A kit member pins a literal status; an adopter's carries a `number` the schema has already
|
|
89
97
|
// bounded to 400–599. Hono types the argument as its own literal union, and that is the one gap
|
|
90
98
|
// between the two — the value is validated, so the assertion narrows rather than trusts.
|
|
91
99
|
return c.json({ error: HttpError.encode(pithy.payload) }, pithy.payload.status as ContentfulStatusCode);
|
|
92
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Hono `onError` handler — the **encoder**, with no log of its own.
|
|
104
|
+
*
|
|
105
|
+
* Any `PithyError` becomes `{ error: <public payload> }` at its declared status; a Hono
|
|
106
|
+
* `HTTPException` 400 becomes `validation/invalid_input`; any other throw is wrapped as a
|
|
107
|
+
* `core/internal` 500.
|
|
108
|
+
*
|
|
109
|
+
* **Prefer `loggingErrorHandler` from `logger/errorHandler.ts`** — `createBackend` registers that one,
|
|
110
|
+
* and it is what makes a misconfiguration's `action` reach an operator instead of dying at the wire
|
|
111
|
+
* (#521). This one stays exported for an app assembled without the logger seam, and it stays here,
|
|
112
|
+
* logger-free, because this module is on the client side of the logger boundary.
|
|
113
|
+
*/
|
|
114
|
+
export function pithyErrorHandler(err: Error, c: Context): Response {
|
|
115
|
+
return renderPithyError(toPithyError(err), c);
|
|
116
|
+
}
|