@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.
- 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/error/messageParams.d.ts +28 -0
- package/dist/error/messageParams.d.ts.map +1 -0
- package/dist/error/messageParams.js +31 -0
- package/dist/error/terminal.d.ts +16 -5
- package/dist/error/terminal.d.ts.map +1 -1
- package/dist/error/terminal.js +16 -5
- 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/dist/workflow/stepMessage.d.ts +28 -3
- package/dist/workflow/stepMessage.d.ts.map +1 -1
- package/dist/workflow/stepMessage.js +48 -6
- 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/error/messageParams.ts +38 -0
- package/src/error/terminal.ts +16 -5
- 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
- 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
|
+
}
|
package/src/version.generated.ts
CHANGED
|
@@ -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
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* is
|
|
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
|
|
136
|
-
if (action ===
|
|
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
|
|