@pithy-sh/core 0.2.0 → 0.3.1

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.
Files changed (68) hide show
  1. package/dist/capability/bindings.d.ts +5 -0
  2. package/dist/capability/bindings.d.ts.map +1 -1
  3. package/dist/capability/bindings.js +44 -3
  4. package/dist/capability/capability.d.ts +33 -0
  5. package/dist/capability/capability.d.ts.map +1 -1
  6. package/dist/capability/manifest.d.ts +5 -0
  7. package/dist/capability/manifest.d.ts.map +1 -1
  8. package/dist/capability/manifest.js +1 -1
  9. package/dist/capability/validateBindings.js +1 -1
  10. package/dist/controlPlane/http/cors.js +1 -1
  11. package/dist/createBackend.d.ts +2 -1
  12. package/dist/createBackend.d.ts.map +1 -1
  13. package/dist/createBackend.js +5 -4
  14. package/dist/error/http.d.ts +27 -5
  15. package/dist/error/http.d.ts.map +1 -1
  16. package/dist/error/http.js +33 -9
  17. package/dist/error/messageParams.d.ts +28 -0
  18. package/dist/error/messageParams.d.ts.map +1 -0
  19. package/dist/error/messageParams.js +31 -0
  20. package/dist/error/terminal.d.ts +16 -5
  21. package/dist/error/terminal.d.ts.map +1 -1
  22. package/dist/error/terminal.js +16 -5
  23. package/dist/http/oidcWebhook.d.ts +283 -0
  24. package/dist/http/oidcWebhook.d.ts.map +1 -0
  25. package/dist/http/oidcWebhook.js +414 -0
  26. package/dist/http/signedWebhook.d.ts +9 -65
  27. package/dist/http/signedWebhook.d.ts.map +1 -1
  28. package/dist/http/signedWebhook.js +19 -9
  29. package/dist/http/webhookWindow.d.ts +33 -0
  30. package/dist/http/webhookWindow.d.ts.map +1 -0
  31. package/dist/http/webhookWindow.js +35 -0
  32. package/dist/logger/errorHandler.d.ts +11 -0
  33. package/dist/logger/errorHandler.d.ts.map +1 -0
  34. package/dist/logger/errorHandler.js +87 -0
  35. package/dist/naming/environment.js +1 -1
  36. package/dist/naming/provisionScope.d.ts +108 -3
  37. package/dist/naming/provisionScope.d.ts.map +1 -1
  38. package/dist/naming/provisionScope.js +27 -2
  39. package/dist/naming/resource.js +1 -1
  40. package/dist/naming/resourceNames.js +1 -1
  41. package/dist/schema/describedness.d.ts +30 -1
  42. package/dist/schema/describedness.d.ts.map +1 -1
  43. package/dist/schema/describedness.js +42 -6
  44. package/dist/test-utils/oidcFixtures.d.ts +93 -0
  45. package/dist/test-utils/oidcFixtures.d.ts.map +1 -0
  46. package/dist/test-utils/oidcFixtures.js +111 -0
  47. package/dist/version.generated.d.ts +1 -1
  48. package/dist/version.generated.js +1 -1
  49. package/dist/workflow/naming.js +1 -1
  50. package/dist/workflow/stepMessage.d.ts +28 -3
  51. package/dist/workflow/stepMessage.d.ts.map +1 -1
  52. package/dist/workflow/stepMessage.js +48 -6
  53. package/package.json +1 -1
  54. package/src/capability/bindings.ts +49 -0
  55. package/src/capability/capability.ts +34 -0
  56. package/src/createBackend.ts +7 -3
  57. package/src/error/http.ts +34 -10
  58. package/src/error/messageParams.ts +38 -0
  59. package/src/error/terminal.ts +16 -5
  60. package/src/http/oidcWebhook.ts +672 -0
  61. package/src/http/signedWebhook.ts +44 -16
  62. package/src/http/webhookWindow.ts +35 -0
  63. package/src/logger/errorHandler.ts +94 -0
  64. package/src/naming/provisionScope.ts +150 -4
  65. package/src/schema/describedness.ts +51 -6
  66. package/src/test-utils/oidcFixtures.ts +138 -0
  67. package/src/version.generated.ts +1 -1
  68. package/src/workflow/stepMessage.ts +52 -6
@@ -0,0 +1,138 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { OidcJwk, OidcJwksFetch, OidcJwksResponse } from "../http/oidcWebhook";
5
+
6
+ /**
7
+ * The identity provider's half of the protocol `requireOidcWebhook` verifies: mint a signing key,
8
+ * publish it as a JWKS, and sign tokens with it.
9
+ *
10
+ * **Why it ships rather than staying private to the kit's own suite.** A route guarded by
11
+ * `requireOidcWebhook` can only be tested by presenting a token the
12
+ * guard accepts, and building one means WebCrypto key generation, base64url without padding, a compact
13
+ * JWS, and a transport that publishes the public half. That came to 103 lines the first time an adopter
14
+ * wrote it, none of it adopter-specific — and a hand-rolled copy is **a second definition of the wire
15
+ * format**, which is the argument this repository already makes about signers. It can agree with itself
16
+ * and disagree with the verifier. The first copy did: its fake transport returned `json()` where
17
+ * {@link OidcJwksResponse} declares `text()`, so every delivery 502'd from a fixture that looked right.
18
+ * Here that is unrepresentable — {@link publishing} is typed as the seam, so a `json()` is a red build
19
+ * rather than a wrong answer at runtime.
20
+ *
21
+ * **Real WebCrypto, never a stubbed signature.** Every negative case an adopter writes with these is a
22
+ * token somebody really signed — the impostor key, the wrong `kid`, the tampered claims. A hand-rolled
23
+ * key and a hand-rolled signature would let every signature assertion pass by agreeing with itself.
24
+ *
25
+ * **What is deliberately not here.** No helper that builds a *provider's* claims: GitHub's
26
+ * `repo:owner/name:environment:x` is the adopter's business and differs per provider, so claims stay a
27
+ * plain object the test writes. And no algorithm-confusion forgery — that proves a property of the kit's
28
+ * verifier, which the kit tests, rather than of an adopter's route.
29
+ *
30
+ * `oidcFixtures.test.ts` holds all of this to the real `verifyOidcToken`:
31
+ * a token it accepts, and one it refuses for each reason. That is what keeps the fixture and the verifier
32
+ * from drifting apart while both stay green.
33
+ */
34
+
35
+ /** A minted signing key: the JWK a verifier is given, and the private half a test signs with. */
36
+ export interface MintedKey {
37
+ /** The key id, matched against a token header's `kid`. */
38
+ kid: string;
39
+ /** The public key as a JWK, in the shape a JWKS endpoint publishes it. */
40
+ jwk: OidcJwk;
41
+ /** The private half. What signs a token. */
42
+ privateKey: CryptoKey;
43
+ }
44
+
45
+ /**
46
+ * Mint one RSA key pair and publish its public half as a JWK.
47
+ *
48
+ * Real WebCrypto, not a fixture: a hand-rolled key and a hand-rolled signature would let every signature
49
+ * assertion pass by agreeing with itself. A second minted key is what makes "signed by somebody else" a
50
+ * real statement rather than a label — mint one, publish only the first, and the refusal is the verifier's
51
+ * rather than the test's.
52
+ *
53
+ * Key generation is the slow part of any suite using this and a key is immutable, so mint in a
54
+ * `beforeAll` and reuse.
55
+ */
56
+ export async function mintKey(kid: string): Promise<MintedKey> {
57
+ const pair = (await crypto.subtle.generateKey(
58
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) },
59
+ true,
60
+ ["sign", "verify"],
61
+ )) as CryptoKeyPair;
62
+ const exported = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as JsonWebKey;
63
+ if (exported.kty === undefined || exported.n === undefined || exported.e === undefined) {
64
+ // Cannot happen for an RSA key, and a fixture that silently minted one with an empty modulus would make
65
+ // every signature test pass against nothing.
66
+ throw new Error("WebCrypto exported an RSA public key with no kty, n, or e.");
67
+ }
68
+ return {
69
+ kid,
70
+ jwk: { ...exported, kty: exported.kty, n: exported.n, e: exported.e, kid, alg: "RS256", use: "sig" },
71
+ privateKey: pair.privateKey,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * base64url without padding — how every segment of a compact JWS is encoded.
77
+ *
78
+ * Exported because a test that builds a token by hand needs it: the tamper case swaps a claims segment and
79
+ * keeps the signature, and re-encoding that segment with padding or with `+`/`/` would make the token
80
+ * refused for the wrong reason.
81
+ */
82
+ export function base64Url(bytes: Uint8Array): string {
83
+ let binary = "";
84
+ for (const byte of bytes) binary += String.fromCharCode(byte);
85
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
86
+ }
87
+
88
+ /**
89
+ * Sign a compact JWT the way an identity provider signs an OIDC token: `RS256`, `typ: "JWT"`, and the
90
+ * signer's `kid` in the header.
91
+ *
92
+ * `payload` is the claims, written as the provider writes them — `iss`, `aud`, `exp`, and whatever the
93
+ * route's `claims` predicate reads. Nothing here defaults a claim, because a fixture that quietly supplied
94
+ * one would be testing itself.
95
+ *
96
+ * `header` overrides let a test present a header no provider would — `alg: "none"`, `alg: "RS512"`, a `kid`
97
+ * nobody published, or `kid: undefined` — which is how a route proves algorithm confusion and an
98
+ * unpublished key are rejected, instead of trusting that the kit covers it somewhere.
99
+ */
100
+ export async function signToken(
101
+ payload: unknown,
102
+ signer: MintedKey,
103
+ header: Record<string, unknown> = {},
104
+ ): Promise<string> {
105
+ const encoder = new TextEncoder();
106
+ const head = base64Url(encoder.encode(JSON.stringify({ alg: "RS256", typ: "JWT", kid: signer.kid, ...header })));
107
+ const body = base64Url(encoder.encode(JSON.stringify(payload)));
108
+ const signature = new Uint8Array(
109
+ await crypto.subtle.sign("RSASSA-PKCS1-v1_5", signer.privateKey, encoder.encode(`${head}.${body}`)),
110
+ );
111
+ return `${head}.${body}.${base64Url(signature)}`;
112
+ }
113
+
114
+ /**
115
+ * A JWKS transport that publishes `keys` at `jwksUrl` and nothing anywhere else, recording every URL it was
116
+ * asked for in `seen`.
117
+ *
118
+ * **The response is the seam's own shape, which is the whole point of shipping this.** `text()`, never
119
+ * `json()` — {@link OidcJwksResponse} reads the body as text deliberately, so a proxy's HTML error page is a
120
+ * diagnosis rather than a throw. A hand-rolled transport that answers `json()` produces a 502 on every
121
+ * delivery from a fixture that looks correct. Typed as {@link OidcJwksFetch}, that mistake will not compile.
122
+ *
123
+ * **The URL is a parameter rather than a wildcard.** A transport that served keys at any URL would pass a
124
+ * route whose configured `jwksUrl` points at nothing — the one configuration mistake a test of a webhook
125
+ * route exists to catch. Anything else gets a 404 with a body, which is what an issuer's CDN actually
126
+ * returns and what the verifier maps to a 502.
127
+ *
128
+ * `seen` is an array the caller owns, so a test can assert the fetch count: one per delivery with no cache,
129
+ * one per TTL with one, and zero when a cache is pre-seeded. Pass `publishing(url, [], seen)` to prove a
130
+ * path never reaches the network at all — an empty key set is refused, so an unexpected fetch fails loudly.
131
+ */
132
+ export function publishing(jwksUrl: string, keys: readonly OidcJwk[], seen: string[] = []): OidcJwksFetch {
133
+ return async (url) => {
134
+ seen.push(url);
135
+ if (url !== jwksUrl) return { ok: false, status: 404, text: async () => "{}" };
136
+ return { ok: true, status: 200, text: async () => JSON.stringify({ keys }) };
137
+ };
138
+ }
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/core";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.2.0";
16
+ export const PACKAGE_VERSION = "0.3.1";
@@ -69,6 +69,21 @@
69
69
  * `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
70
70
  * `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
71
71
  * includes it. This stops losing a field that was always meant for this reader.
72
+ *
73
+ * ## The encoder owns producing something the reader accepts
74
+ *
75
+ * The reader is strict, and it has to be: text the kit did not write must not become an operator's
76
+ * sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
77
+ * the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
78
+ * with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
79
+ * `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
80
+ * step stopped crossing this boundary at all: the first newline read as the code/action separator and
81
+ * the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
82
+ *
83
+ * So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
84
+ * back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
85
+ * dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
86
+ * kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
72
87
  */
73
88
 
74
89
  /**
@@ -123,17 +138,48 @@ export function splitWorkflowStepCode(text: string): { code?: string; rest: stri
123
138
  return { code: matched[1], rest: text.slice(matched[0].length) };
124
139
  }
125
140
 
141
+ /**
142
+ * One half as this encoding can carry it: a single line, within the bound.
143
+ *
144
+ * Both reshapes exist because the alternative is losing the field, and the two ways a half can be
145
+ * unreadable are the two ways a writer actually gets it wrong. A line break would encode a shape the
146
+ * reader declines — the sentence would take the remedy down with it, or the remedy would forge a
147
+ * second field — so every break collapses to a space, exactly as `@pithy-sh/cloudflare` flattens an
148
+ * upstream's own text before it lands in `message`. Length is the same argument with a different
149
+ * cause: half a sentence naming its code and its remedy beats the platform's prose about durable
150
+ * execution, so the tail is spent rather than the field. The whole of it was never here anyway —
151
+ * `detail` holds the raw text, on the far side of a boundary it does not cross.
152
+ */
153
+ function encodable(text: string): string {
154
+ // The break and the whitespace that dressed it: an indent under a line is structure, and structure
155
+ // that survives as run-on spaces reads as a typo. Whitespace elsewhere in the half is left alone —
156
+ // this reshapes what the line break did, not what the sentence says.
157
+ const flattened = text.replace(/\s*[\n\r]+\s*/g, " ").trim();
158
+ if (flattened.length <= MAX_WORKFLOW_STEP_TEXT) return flattened;
159
+ return `${flattened.slice(0, MAX_WORKFLOW_STEP_TEXT - 1).trimEnd()}…`;
160
+ }
161
+
126
162
  /**
127
163
  * The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
128
164
  *
129
- * An action is appended only when there is one to append and it is a single line. An action carrying a
130
- * break would encode a shape the reader declines, taking the sentence down with it — so the sentence
131
- * is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
165
+ * **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
166
+ * goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
167
+ * one is truncated rather than declined. An action is appended only when there is one left after that —
168
+ * a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
169
+ * is a field the reader would have to decline.
170
+ *
171
+ * **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
172
+ * `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
173
+ * not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
174
+ * has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
175
+ * the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
176
+ * operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
177
+ * `defineErrorPayload` is checked against the same shape.
132
178
  */
133
179
  export function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string {
134
- const stated = `${fields.code}: ${fields.message}`;
135
- const action = fields.action?.trim();
136
- if (action === undefined || action === "" || LINE_BREAK.test(action)) return stated;
180
+ const stated = `${fields.code}: ${encodable(fields.message)}`;
181
+ const action = encodable(fields.action ?? "");
182
+ if (action === "") return stated;
137
183
  return `${stated}${WORKFLOW_STEP_SEPARATOR}${action}`;
138
184
  }
139
185