@pithy-sh/core 0.1.5 → 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.
Files changed (60) 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 +51 -0
  5. package/dist/capability/capability.d.ts.map +1 -1
  6. package/dist/capability/manifest.d.ts +66 -0
  7. package/dist/capability/manifest.d.ts.map +1 -1
  8. package/dist/capability/manifest.js +62 -2
  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/createEntrypoint.d.ts.map +1 -1
  15. package/dist/createEntrypoint.js +1 -0
  16. package/dist/error/http.d.ts +27 -5
  17. package/dist/error/http.d.ts.map +1 -1
  18. package/dist/error/http.js +33 -9
  19. package/dist/http/oidcWebhook.d.ts +283 -0
  20. package/dist/http/oidcWebhook.d.ts.map +1 -0
  21. package/dist/http/oidcWebhook.js +414 -0
  22. package/dist/http/signedWebhook.d.ts +9 -65
  23. package/dist/http/signedWebhook.d.ts.map +1 -1
  24. package/dist/http/signedWebhook.js +19 -9
  25. package/dist/http/webhookWindow.d.ts +33 -0
  26. package/dist/http/webhookWindow.d.ts.map +1 -0
  27. package/dist/http/webhookWindow.js +35 -0
  28. package/dist/logger/errorHandler.d.ts +11 -0
  29. package/dist/logger/errorHandler.d.ts.map +1 -0
  30. package/dist/logger/errorHandler.js +87 -0
  31. package/dist/naming/environment.js +1 -1
  32. package/dist/naming/provisionScope.d.ts +108 -3
  33. package/dist/naming/provisionScope.d.ts.map +1 -1
  34. package/dist/naming/provisionScope.js +27 -2
  35. package/dist/naming/resource.js +1 -1
  36. package/dist/naming/resourceNames.js +1 -1
  37. package/dist/schema/describedness.d.ts +30 -1
  38. package/dist/schema/describedness.d.ts.map +1 -1
  39. package/dist/schema/describedness.js +42 -6
  40. package/dist/test-utils/oidcFixtures.d.ts +93 -0
  41. package/dist/test-utils/oidcFixtures.d.ts.map +1 -0
  42. package/dist/test-utils/oidcFixtures.js +111 -0
  43. package/dist/version.generated.d.ts +1 -1
  44. package/dist/version.generated.js +1 -1
  45. package/dist/workflow/naming.js +1 -1
  46. package/package.json +1 -1
  47. package/src/capability/bindings.ts +49 -0
  48. package/src/capability/capability.ts +52 -0
  49. package/src/capability/manifest.ts +94 -0
  50. package/src/createBackend.ts +7 -3
  51. package/src/createEntrypoint.ts +5 -0
  52. package/src/error/http.ts +34 -10
  53. package/src/http/oidcWebhook.ts +672 -0
  54. package/src/http/signedWebhook.ts +44 -16
  55. package/src/http/webhookWindow.ts +35 -0
  56. package/src/logger/errorHandler.ts +94 -0
  57. package/src/naming/provisionScope.ts +150 -4
  58. package/src/schema/describedness.ts +51 -6
  59. package/src/test-utils/oidcFixtures.ts +138 -0
  60. package/src/version.generated.ts +1 -1
@@ -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.1.5";
16
+ export const PACKAGE_VERSION = "0.3.0";