@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,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/http/webhookWindow.ts
4
+ /**
5
+ * The freshness window a signed webhook is checked against: the default, and the widest one accepted.
6
+ *
7
+ * **Two numbers in a module of their own, because of who has to read them.** They are the bounds a
8
+ * config schema states in its own `.describe()` — `packages/payments/src/config/config.ts` interpolates
9
+ * the maximum into the sentence an adopter reads — and a config schema is one half of a route contract,
10
+ * which a management client validates **in a browser**. `signedWebhook.ts` is a Hono middleware and
11
+ * reaches the whole Worker graph from `PithyHonoEnv` down; importing a number from it dragged `hono`,
12
+ * `kysely`, `kysely-d1` and `@cloudflare/workers-types` into `payments/src/http/schemas.ts`,
13
+ * `payments/src/http/responses.ts` and every module that reaches payments' config (#521).
14
+ *
15
+ * `tooling/browser-scopes` is the gate that caught it, and it is the gate that keeps this file a leaf:
16
+ * **nothing here may import anything.** A constant a browser-facing schema quotes belongs beside the
17
+ * other constants a browser may see, not inside the verifier that enforces it.
18
+ */
19
+ /** How far a delivery's own timestamp may be from now. Stripe's default, and generous against clock skew. */
20
+ const SIGNED_WEBHOOK_TOLERANCE_SECONDS = 300;
21
+ /**
22
+ * The widest freshness window the verifier accepts, and it refuses above rather than clamping.
23
+ *
24
+ * A tolerance is a replay window in the plainest possible units: every second of it is a second longer a
25
+ * captured delivery keeps working. The five minutes above is Stripe's own and covers clock drift plus ordinary
26
+ * delivery latency; an hour is the far end of a sender that queues a delivery and re-sends it without re-dating
27
+ * it. Past that a number has stopped covering latency and started covering a capture.
28
+ *
29
+ * Refused rather than clamped because an `86400` is a typo or a misunderstanding either way — clamped, the
30
+ * endpoint keeps working and nobody reads the line again; refused, the first delivery says which knob is wrong
31
+ * and it is a two-character fix.
32
+ */
33
+ const SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS = 3600;
34
+ //#endregion
35
+ export { SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS, SIGNED_WEBHOOK_TOLERANCE_SECONDS };
@@ -0,0 +1,11 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Context } from "hono";
4
+ /**
5
+ * Hono `onError` handler: log the fault with its full payload, then render the public response.
6
+ *
7
+ * Register it once on the root app — `createBackend` already does. `pithyErrorHandler` remains the
8
+ * bare encoder for an app that has no logger seam at all.
9
+ */
10
+ export declare function loggingErrorHandler(err: Error, c: Context): Response;
11
+ //# sourceMappingURL=errorHandler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errorHandler.d.ts","sourceRoot":"","sources":["../../src/logger/errorHandler.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AA8EpC;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,GAAG,QAAQ,CAMpE"}
@@ -0,0 +1,87 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { renderPithyError, toPithyError } from "../error/http.js";
4
+ import { createWorkerLogger } from "./worker.js";
5
+ //#region src/logger/errorHandler.ts
6
+ /**
7
+ * The `onError` handler that **writes the fault down** before it renders it.
8
+ *
9
+ * ## The defect this closes
10
+ *
11
+ * A misconfigured knob — a webhook tolerance, a lock horizon, a batch size — now refuses instead of
12
+ * silently deleting the check it was part of (#521), and every one of those refusals carries an
13
+ * `action` naming the variable an operator must fix. In a deployed Worker that `action` reached
14
+ * nothing. `clientError` strips it on the way to the wire, which is correct and is the point; the
15
+ * terminal renderer is a CLI surface no Worker runs; and the request produced exactly one record — the
16
+ * access log line, `{"msg":"request","status":500}`. So the remedy existed and nobody could read it,
17
+ * which is the unfollowable-action defect one layer out: not an action that does not help, an action
18
+ * that does not arrive.
19
+ *
20
+ * ## Why it is here and not in the encoder
21
+ *
22
+ * `error/http.ts` is on the client side of the logger boundary and `logger/boundary.test.ts` gates
23
+ * that: the three client/operator-facing error surfaces (`http.ts`, `terminal.ts`, `payload.ts`) must
24
+ * not import the logger, so a record carrying `detail` cannot reach a response through them. That is a
25
+ * deliberate decision, not an oversight, and the fix respects its direction rather than working around
26
+ * it — the log is written on the internal side, and the encoder is imported *by* it. Nothing here can
27
+ * put a log record into a response: {@link renderPithyError} takes the payload and this module never
28
+ * touches the body.
29
+ *
30
+ * ## What lands
31
+ *
32
+ * One record, with the whole payload — `code`, `status`, `message`, `action`, `detail`. The logger
33
+ * engine lifts a reserved `error` field into {@link import("./record").LogRecord.error}, so nothing
34
+ * here formats or flattens anything; the fields the request logger already binds (`request`, `method`,
35
+ * `path`, `env`, `version`) ride along, which is what makes the line correlatable with the access log
36
+ * line beside it.
37
+ *
38
+ * **The client boundary does not move.** The response is byte-identical to what
39
+ * `pithyErrorHandler` produced: `HttpError.encode` still runs through `clientError`, `action` and
40
+ * `detail` are still stripped, and a forger still learns a status and a bland sentence. Both halves are
41
+ * pinned in `errorHandler.test.ts` — the log carries the action, the wire body does not.
42
+ */
43
+ /**
44
+ * Level by status, and it is one branch on purpose.
45
+ *
46
+ * **5xx is ours.** `core/internal`, `core/upstream_failed`, `core/upstream_timeout`: the code means
47
+ * *read our logs*, so the log is where it goes, at the level an operator's alerting already watches.
48
+ *
49
+ * **4xx is the caller's**, and it is already visible — the access log line beside this one carries its
50
+ * status. Logging it at `error` would put an unauthenticated caller in charge of how much this Worker
51
+ * writes, which is a bill and a haystack rather than a signal. It is still recorded, at `debug`, with
52
+ * the identical payload: an operator chasing a 403 nobody can explain turns the level down and gets
53
+ * every field, including the `action`, without a second code path existing to drift.
54
+ */
55
+ function levelFor(status) {
56
+ return status >= 500 ? "error" : "debug";
57
+ }
58
+ /**
59
+ * The fallback logger, built once, for an app that registers this handler without
60
+ * `createBackend`'s base middleware — the email host's dev dispatch door, a hand-assembled Hono app.
61
+ *
62
+ * `noopLogger` would be the tidier default and it is the wrong one here: this handler exists so a
63
+ * fault reaches a surface, and a fallback that reaches none is the defect with a new spelling. This is
64
+ * the same CF-native adapter `createBackend` defaults to, minus the per-request correlation there is
65
+ * nothing to derive it from.
66
+ */
67
+ let fallback = null;
68
+ /** The request's logger if one was bound, else the shared fallback. */
69
+ function loggerFor(c) {
70
+ const bound = c.get("log");
71
+ if (bound) return bound;
72
+ fallback ??= createWorkerLogger();
73
+ return fallback;
74
+ }
75
+ /**
76
+ * Hono `onError` handler: log the fault with its full payload, then render the public response.
77
+ *
78
+ * Register it once on the root app — `createBackend` already does. `pithyErrorHandler` remains the
79
+ * bare encoder for an app that has no logger seam at all.
80
+ */
81
+ function loggingErrorHandler(err, c) {
82
+ const pithy = toPithyError(err);
83
+ loggerFor(c)[levelFor(pithy.payload.status)]("request failed", { error: pithy });
84
+ return renderPithyError(pithy, c);
85
+ }
86
+ //#endregion
87
+ export { loggingErrorHandler };
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { NAME_SEGMENT } from "./segment.js";
4
3
  import { PithyError, ValidationError } from "../error/pithyError.js";
4
+ import { NAME_SEGMENT } from "./segment.js";
5
5
  import { z } from "zod";
6
6
  //#region src/naming/environment.ts
7
7
  /**
@@ -19,7 +19,11 @@ import { type FeatureIdentity, type FeatureResourceKind } from "./feature";
19
19
  *
20
20
  * - {@link environmentScope} — a **declared** environment (`staging`, `prod`, whatever the root
21
21
  * `pithy.config.ts` lists). Names are `<project>-<env>-<thing>`, the rule every other namer in the
22
- * kit follows, so the environment segment of a name is always the stanza the name lives in.
22
+ * kit follows, so the environment segment of a name is always the stanza the name lives in — unless
23
+ * the thing being named is one the whole project shares, which a binding says with
24
+ * {@link BindingNaming} and a secret with its registry, and which puts the literal `global` in that
25
+ * slot instead. `global` is a scope beside the environments rather than one of them, so this is the
26
+ * same statement and not an exception to it: the segment still says which scope owns the resource.
23
27
  * - {@link featureScope} — one branch's **ephemeral** environment. Names are
24
28
  * `<project>-f<issue>-<slug>-<thing>`, with no environment segment because a feature *is* an
25
29
  * environment, and the `f<issue>` marker is what teardown recomputes and what keeps a feature's
@@ -33,6 +37,43 @@ import { type FeatureIdentity, type FeatureResourceKind } from "./feature";
33
37
  * `SecretRegistryEntrySeam` types `backend` as a `string`.
34
38
  */
35
39
  export type SecretNameScope = "environment" | "global";
40
+ /**
41
+ * What a binding's manifest says about the *name* of the resource behind it — the two fields of
42
+ * `BindingSpec` that a composed name reads, and nothing else.
43
+ *
44
+ * Structural rather than an import of `BindingSpec`, for the reason {@link SecretNameScope} is a literal
45
+ * union rather than an import of `SecretScope`: this module composes names and must not depend on the
46
+ * capability contract to do it. A caller with a whole spec passes the spec; a caller with neither field
47
+ * passes `{}`.
48
+ *
49
+ * **Both fields absent is the ordinary binding**, and it composes exactly what it always did:
50
+ * `<project>-<env>-<kebabbed binding>`.
51
+ */
52
+ export interface BindingNaming {
53
+ /**
54
+ * `global` where one resource serves the whole project, absent or `environment` where each environment
55
+ * gets its own. See `BindingSpec.scope` — this is that field, and the reason it exists is that
56
+ * `EMAIL_SUPPRESSIONS` and `SECRETS` sit one line apart in the same env and no convention tells them
57
+ * apart.
58
+ */
59
+ readonly scope?: SecretNameScope;
60
+ /**
61
+ * The `<thing>` segment, where it is not the binding name. See `BindingSpec.resource` —
62
+ * `SUPPORT_BUCKET` backs `<project>-global-support`.
63
+ */
64
+ readonly resource?: string;
65
+ }
66
+ /**
67
+ * A {@link BindingNaming} that says `global` in its **type**, not only in its value.
68
+ *
69
+ * The distinction earns its keep at exactly one call: {@link bindingResourceName} needs a per-environment
70
+ * fallback from a writer filling in one stanza, and cannot need one from a capability's own namer, which
71
+ * has no environment to fall back to because its resource has none. Typing the declaration is what lets
72
+ * the compiler tell those two callers apart, so the namer omits an argument it could only have invented.
73
+ */
74
+ export interface ProjectGlobalNaming extends BindingNaming {
75
+ readonly scope: "global";
76
+ }
36
77
  /** Where a provisioning run's resources are named, and where their ids are written. */
37
78
  export interface ProvisionScope {
38
79
  /** The `env.<stanza>` key in each Worker's config that this scope's ids are written into. */
@@ -53,8 +94,30 @@ export interface ProvisionScope {
53
94
  * called?" and "which stanza does it go in?" — because those three answers have to agree.
54
95
  */
55
96
  readonly source: boolean;
56
- /** This scope's Cloudflare name for a provisionable binding. */
57
- resource(binding: string, kind: FeatureResourceKind): string;
97
+ /**
98
+ * This scope's Cloudflare name for a provisionable binding, given what the binding's manifest says
99
+ * about naming it.
100
+ *
101
+ * **The naming is required, not defaulted.** A caller with nothing to say passes `{}` and gets the
102
+ * name it always got; what it may not do is *omit* the question. `pithy add` and `pithy provision`
103
+ * both compose this name, from two different call paths, and the whole of #513 is that one of them
104
+ * had no way to hear "this resource is one per project" — so the app Worker bound three suppression
105
+ * databases where the email Worker bound one, and an unsubscribe on either was invisible to the other.
106
+ * A parameter with a default is a parameter a new call site inherits an answer to without ever
107
+ * reading the question.
108
+ */
109
+ resource(binding: string, kind: FeatureResourceKind, naming: BindingNaming): string;
110
+ /**
111
+ * Whether a binding that declares itself project-global is actually named that way here — and so
112
+ * belongs to the project rather than to this run.
113
+ *
114
+ * The asymmetry between the two scopes, stated by the object that implements it rather than inferred
115
+ * by a caller comparing two composed names. A declared environment honors `global` and shares the one
116
+ * resource; {@link featureScope} does not, so a feature's copy is the feature's own. A caller reading
117
+ * the manifest alone gets that backwards, and the way it goes wrong is silent: the feature's database
118
+ * is created, judged to be the project's, kept out of the teardown record, and orphaned.
119
+ */
120
+ readonly honorsGlobal: boolean;
58
121
  /** The script name a Worker deploys under in this scope. */
59
122
  worker(worker: string): string;
60
123
  /**
@@ -66,6 +129,35 @@ export interface ProvisionScope {
66
129
  */
67
130
  secretEntry(secret: string, secretScope: SecretNameScope): string;
68
131
  }
132
+ /**
133
+ * The same branch, one line down, for a provisioned resource: a `global` binding resolves to the
134
+ * project's single `<project>-global-<thing>`, and everything else to the scope's own name for it.
135
+ *
136
+ * Written beside {@link secretEntryName} and shaped like it deliberately — the argument is the same
137
+ * argument. A value defined as one value must have one name, or the scoping mints a second copy of it;
138
+ * the only difference is that a secret's second copy is read and a database's is *written to*, which is
139
+ * why this one is the bug where an unsubscribe recorded in `staging` was invisible in `prod` (#513).
140
+ *
141
+ * `thing` is `naming.resource` when the manifest states one and the binding name otherwise. Either way
142
+ * it goes to the same composer, which kebabs it — so a binding passed verbatim keeps the name it has
143
+ * always had (`kebab("EMAIL_SUPPRESSIONS") === "email-suppressions"`), and this function cannot change
144
+ * what any existing spec composes.
145
+ *
146
+ * **Exported, because `pithy add` composes this name too and has no {@link ProvisionScope} to do it
147
+ * with.** It writes a `database_name` into a `wrangler.jsonc` stanza long before an account is reached,
148
+ * from a Worker's declared environment rather than a provisioning run's — and it is the writer that got
149
+ * #513 wrong. Handing it the scoped name as a callback is what lets the *global* branch live here once:
150
+ * a second copy of it in the writer is the arrangement this issue exists to end.
151
+ *
152
+ * **And exported for the third writer, which is the capability's own namer.** `suppressionDatabaseName`
153
+ * and `supportBucketName` name the same two resources their manifests declare, and they composed them
154
+ * with a second expression until #513 — which is how `<project>-global-email-suppressions` and
155
+ * `<project>-staging-email-suppressions` came to be the same database under two names. They pass a
156
+ * {@link ProjectGlobalNaming} and no callback: a namer for a project-global resource has no environment,
157
+ * so there is no scoped name for it to supply and the overload will not let it pretend otherwise.
158
+ */
159
+ export declare function bindingResourceName(project: string, binding: string, kind: FeatureResourceKind, naming: ProjectGlobalNaming): string;
160
+ export declare function bindingResourceName(project: string, binding: string, kind: FeatureResourceKind, naming: BindingNaming, scoped: (thing: string) => string): string;
69
161
  /**
70
162
  * A declared environment's scope — `staging`, `prod`, or whatever the root `pithy.config.ts` lists.
71
163
  *
@@ -88,6 +180,19 @@ export declare function environmentScope(project: string, environment: string):
88
180
  *
89
181
  * Its stanza is {@link FEATURE_ENVIRONMENT} and nothing else, which is what makes a feature's resources
90
182
  * in a declared environment's stanza unexpressible rather than merely discouraged.
183
+ *
184
+ * **A feature takes a binding's {@link BindingNaming} and ignores it — and that is the asymmetry against
185
+ * `secretEntry` one line below, which honors `global` here as everywhere.** A global *secret* is a value
186
+ * defined once for the project, so a feature binds the project's copy rather than minting a second: it is
187
+ * **read**. A global *database* is a resource a feature would **migrate** — `provisionFeature` runs
188
+ * `pithy migrate` against everything it names — so honoring `global` here would point one branch's
189
+ * schema changes at the project's live suppression list, and point teardown at it afterwards. A feature
190
+ * owns every resource it names, exactly so that destroying the feature destroys them all; there is no
191
+ * shared name to match and nothing for teardown to spare.
192
+ *
193
+ * `resource` is ignored for the same reason it is moot: a feature's `<thing>` segment is the binding plus
194
+ * the kind, and the whole name is recomputed on teardown rather than looked up, so nothing outside the
195
+ * feature reads the string.
91
196
  */
92
197
  export declare function featureScope(identity: FeatureIdentity): ProvisionScope;
93
198
  //# sourceMappingURL=provisionScope.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"provisionScope.d.ts","sourceRoot":"","sources":["../../src/naming/provisionScope.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EAIzB,MAAM,WAAW,CAAC;AAGnB;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,QAAQ,CAAC;AAEvD,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAAC;IAC7D,4DAA4D;IAC5D,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,GAAG,MAAM,CAAC;CACnE;AAiBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,cAAc,CAYrF;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,cAAc,CAUtE"}
1
+ {"version":3,"file":"provisionScope.d.ts","sourceRoot":"","sources":["../../src/naming/provisionScope.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EAIzB,MAAM,WAAW,CAAC;AAGnB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,QAAQ,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAoB,SAAQ,aAAa;IACxD,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;CAC1B;AAED,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAAC;IACpF;;;;;;;;;OASG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,4DAA4D;IAC5D,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,GAAG,MAAM,CAAC;CACnE;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,mBAAmB,GAC1B,MAAM,CAAC;AACV,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAChC,MAAM,CAAC;AAsBV;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,cAAc,CAerF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,cAAc,CAatE"}
@@ -1,5 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
+ import { InternalError } from "../error/pithyError.js";
3
4
  import { FEATURE_ENVIRONMENT, assertValidEnvironment } from "./environment.js";
4
5
  import { featureResourceName, featureSecretEntryName, featureWorkerName } from "./feature.js";
5
6
  import { resourceNames } from "./resourceNames.js";
@@ -21,6 +22,15 @@ function secretEntryName(project, secret, secretScope, scoped) {
21
22
  if (secretScope === "global") return resourceNames(project).global.secretEntry(secret);
22
23
  return scoped();
23
24
  }
25
+ function bindingResourceName(project, binding, kind, naming, scoped) {
26
+ const thing = naming.resource ?? binding;
27
+ if (naming.scope === "global") return resourceNames(project).global[KIND_NAMER[kind]](thing);
28
+ if (scoped === void 0) throw new InternalError({
29
+ message: "A resource name could not be composed.",
30
+ detail: `bindingResourceName was asked for a scoped name for "${binding}" with no scope to compose it in. Pass the environment's composer, or declare the binding \`scope: "global"\`.`
31
+ });
32
+ return scoped(thing);
33
+ }
24
34
  /**
25
35
  * A declared environment's scope — `staging`, `prod`, or whatever the root `pithy.config.ts` lists.
26
36
  *
@@ -42,7 +52,8 @@ function environmentScope(project, environment) {
42
52
  return {
43
53
  stanza: environment,
44
54
  source: true,
45
- resource: (binding, kind) => names[KIND_NAMER[kind]](binding),
55
+ honorsGlobal: true,
56
+ resource: (binding, kind, naming) => bindingResourceName(project, binding, kind, naming, (thing) => names[KIND_NAMER[kind]](thing)),
46
57
  worker: (worker) => `${worker}-${environment}`,
47
58
  secretEntry: (secret, secretScope) => secretEntryName(project, secret, secretScope, () => names.secretEntry(secret))
48
59
  };
@@ -53,15 +64,29 @@ function environmentScope(project, environment) {
53
64
  *
54
65
  * Its stanza is {@link FEATURE_ENVIRONMENT} and nothing else, which is what makes a feature's resources
55
66
  * in a declared environment's stanza unexpressible rather than merely discouraged.
67
+ *
68
+ * **A feature takes a binding's {@link BindingNaming} and ignores it — and that is the asymmetry against
69
+ * `secretEntry` one line below, which honors `global` here as everywhere.** A global *secret* is a value
70
+ * defined once for the project, so a feature binds the project's copy rather than minting a second: it is
71
+ * **read**. A global *database* is a resource a feature would **migrate** — `provisionFeature` runs
72
+ * `pithy migrate` against everything it names — so honoring `global` here would point one branch's
73
+ * schema changes at the project's live suppression list, and point teardown at it afterwards. A feature
74
+ * owns every resource it names, exactly so that destroying the feature destroys them all; there is no
75
+ * shared name to match and nothing for teardown to spare.
76
+ *
77
+ * `resource` is ignored for the same reason it is moot: a feature's `<thing>` segment is the binding plus
78
+ * the kind, and the whole name is recomputed on teardown rather than looked up, so nothing outside the
79
+ * feature reads the string.
56
80
  */
57
81
  function featureScope(identity) {
58
82
  return {
59
83
  stanza: FEATURE_ENVIRONMENT,
60
84
  source: false,
85
+ honorsGlobal: false,
61
86
  resource: (binding, kind) => featureResourceName(identity, binding, kind),
62
87
  worker: (worker) => featureWorkerName(identity, worker),
63
88
  secretEntry: (secret, secretScope) => secretEntryName(identity.project, secret, secretScope, () => featureSecretEntryName(identity, secret))
64
89
  };
65
90
  }
66
91
  //#endregion
67
- export { environmentScope, featureScope };
92
+ export { bindingResourceName, environmentScope, featureScope };
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { NAME_SEGMENT } from "./segment.js";
4
3
  import { InternalError, ValidationError } from "../error/pithyError.js";
4
+ import { NAME_SEGMENT } from "./segment.js";
5
5
  import { MAX_ENVIRONMENT_NAME } from "./environment.js";
6
6
  import { FEATURE_DERIVED_PROJECT_NAME, NAMESPACE_LIMITS, WORKFLOW_DERIVED_PROJECT_NAME } from "./limits.js";
7
7
  //#region src/naming/resource.ts
@@ -4,8 +4,8 @@ import { InternalError, ValidationError } from "../error/pithyError.js";
4
4
  import { GLOBAL_SCOPE, assertValidEnvironment } from "./environment.js";
5
5
  import { NAMESPACE_LIMITS } from "./limits.js";
6
6
  import { assertValidProjectName, kebab, resourceName } from "./resource.js";
7
- import { workflowHostName, workflowScriptName } from "../workflow/naming.js";
8
7
  import { featureResourceName, featureWorkerName } from "./feature.js";
8
+ import { workflowHostName, workflowScriptName } from "../workflow/naming.js";
9
9
  //#region src/naming/resourceNames.ts
10
10
  /**
11
11
  * Compose `<project>-<scope>-<thing>` under one namespace's rule.
@@ -1,6 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { z } from "zod";
4
+ /** The kind tag Zod puts on a schema's definition. */
5
+ export declare function kindOf(schema: z.ZodType): string;
4
6
  /**
5
7
  * The children of one schema, by kind. **Throws for a kind this walk has never been taught** — a
6
8
  * walker whose unknown case is silently empty cannot report the thing it exists to report.
@@ -15,13 +17,40 @@ import { z } from "zod";
15
17
  */
16
18
  export declare function childSchemas(schema: z.ZodType, path: string): z.ZodType[];
17
19
  /**
18
- * Whether a description sits anywhere in a field's transparent-wrapper chain.
20
+ * The description documenting a field, from anywhere in its transparent-wrapper chain.
19
21
  *
20
22
  * `.describe("…").optional()` puts it on the inner type and `.optional().describe("…")` puts it on the
21
23
  * wrapper. Both are one described field. Only {@link TRANSPARENT} wrappers are followed — an `array`
22
24
  * or a `pipe` describes something other than what it holds.
25
+ *
26
+ * The **words**, and not only whether there are any, because the descriptions are the object model's
27
+ * documentation (CLAUDE.md §Zod) and a surface that shows them to a human — `pithy secrets create`
28
+ * asking for one field at a time — needs the same chain the gate walks. Two walks of one chain is how
29
+ * a field renders undocumented in a prompt while the meta-test calls it described.
23
30
  */
31
+ export declare function descriptionInChain(schema: z.ZodType): string | undefined;
32
+ /** Whether a description sits anywhere in a field's transparent-wrapper chain. See {@link descriptionInChain}. */
24
33
  export declare function describedInChain(schema: z.ZodType): boolean;
34
+ /** One field of an object, with its transparent wrappers taken off. See {@link unwrapField}. */
35
+ export interface UnwrappedField {
36
+ /** What the field actually is, once `.optional()` and the rest of {@link TRANSPARENT} are removed. */
37
+ schema: z.ZodType;
38
+ /** Whether the field may be left out of the object entirely — an `optional`, `default` or `prefault`. */
39
+ optional: boolean;
40
+ /** The description documenting it, from anywhere in the chain. See {@link descriptionInChain}. */
41
+ description?: string;
42
+ }
43
+ /**
44
+ * One field, unwrapped: what it is, whether it may be omitted, and the words that document it.
45
+ *
46
+ * **The unwrapping half of this walk, exported rather than copied.** `collectMissing` already steps
47
+ * through {@link TRANSPARENT} on its way down, and a caller asking *what is this field* asks exactly
48
+ * that question — so it asks it here. The nineteen private copies this module replaced all started as
49
+ * one function that looked too small to share.
50
+ *
51
+ * `nullable` is not omissible: `null` is a value a caller states, and the key is still required.
52
+ */
53
+ export declare function unwrapField(schema: z.ZodType, path?: string): UnwrappedField;
25
54
  /** What one walk found: the complaints, and how much it actually looked at. */
26
55
  export interface SchemaWalk {
27
56
  /** One line per object, enum, union or field with no `.describe()` anywhere that documents it. */
@@ -1 +1 @@
1
- {"version":3,"file":"describedness.d.ts","sourceRoot":"","sources":["../../src/schema/describedness.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAgGxB;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,CAsCzE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,GAAG,OAAO,CAU3D;AAED,+EAA+E;AAC/E,MAAM,WAAW,UAAU;IACzB,kGAAkG;IAClG,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yFAAyF;IACzF,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,CAAC,CAAC,OAAO,EACjB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EACpB,SAAS,UAAQ,GAChB,IAAI,CAsBN;AAED,mEAAmE;AACnE,wBAAgB,WAAW,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,SAAW,GAAG,UAAU,CAI1E;AAED,sFAAsF;AACtF,MAAM,WAAW,UAAW,SAAQ,UAAU;IAC5C,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,UAAU,CAY/F"}
1
+ {"version":3,"file":"describedness.d.ts","sourceRoot":"","sources":["../../src/schema/describedness.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAsCxB,sDAAsD;AACtD,wBAAgB,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,GAAG,MAAM,CAEhD;AAuDD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,CAsCzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,GAAG,MAAM,GAAG,SAAS,CAUxE;AAED,kHAAkH;AAClH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,GAAG,OAAO,CAE3D;AAED,gGAAgG;AAChG,MAAM,WAAW,cAAc;IAC7B,sGAAsG;IACtG,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC;IAClB,yGAAyG;IACzG,QAAQ,EAAE,OAAO,CAAC;IAClB,kGAAkG;IAClG,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,SAAW,GAAG,cAAc,CAa9E;AAED,+EAA+E;AAC/E,MAAM,WAAW,UAAU;IACzB,kGAAkG;IAClG,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yFAAyF;IACzF,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,CAAC,CAAC,OAAO,EACjB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EACpB,SAAS,UAAQ,GAChB,IAAI,CAsBN;AAED,mEAAmE;AACnE,wBAAgB,WAAW,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,SAAW,GAAG,UAAU,CAI1E;AAED,sFAAsF;AACtF,MAAM,WAAW,UAAW,SAAQ,UAAU;IAC5C,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,UAAU,CAY/F"}
@@ -101,22 +101,58 @@ function childSchemas(schema, path) {
101
101
  }
102
102
  }
103
103
  /**
104
- * Whether a description sits anywhere in a field's transparent-wrapper chain.
104
+ * The description documenting a field, from anywhere in its transparent-wrapper chain.
105
105
  *
106
106
  * `.describe("…").optional()` puts it on the inner type and `.optional().describe("…")` puts it on the
107
107
  * wrapper. Both are one described field. Only {@link TRANSPARENT} wrappers are followed — an `array`
108
108
  * or a `pipe` describes something other than what it holds.
109
+ *
110
+ * The **words**, and not only whether there are any, because the descriptions are the object model's
111
+ * documentation (CLAUDE.md §Zod) and a surface that shows them to a human — `pithy secrets create`
112
+ * asking for one field at a time — needs the same chain the gate walks. Two walks of one chain is how
113
+ * a field renders undocumented in a prompt while the meta-test calls it described.
109
114
  */
110
- function describedInChain(schema) {
115
+ function descriptionInChain(schema) {
111
116
  let current = schema;
112
117
  const seen = /* @__PURE__ */ new Set();
113
118
  while (current && !seen.has(current)) {
114
- if (current.description) return true;
115
- if (!TRANSPARENT.has(kindOf(current))) return false;
119
+ if (current.description) return current.description;
120
+ if (!TRANSPARENT.has(kindOf(current))) return void 0;
116
121
  seen.add(current);
117
122
  current = current.def?.innerType;
118
123
  }
119
- return false;
124
+ }
125
+ /** Whether a description sits anywhere in a field's transparent-wrapper chain. See {@link descriptionInChain}. */
126
+ function describedInChain(schema) {
127
+ return descriptionInChain(schema) !== void 0;
128
+ }
129
+ /**
130
+ * One field, unwrapped: what it is, whether it may be omitted, and the words that document it.
131
+ *
132
+ * **The unwrapping half of this walk, exported rather than copied.** `collectMissing` already steps
133
+ * through {@link TRANSPARENT} on its way down, and a caller asking *what is this field* asks exactly
134
+ * that question — so it asks it here. The nineteen private copies this module replaced all started as
135
+ * one function that looked too small to share.
136
+ *
137
+ * `nullable` is not omissible: `null` is a value a caller states, and the key is still required.
138
+ */
139
+ function unwrapField(schema, path = "schema") {
140
+ const description = descriptionInChain(schema);
141
+ let current = schema;
142
+ let optional = false;
143
+ const seen = /* @__PURE__ */ new Set();
144
+ while (TRANSPARENT.has(kindOf(current)) && !seen.has(current)) {
145
+ if (kindOf(current) !== "nullable" && kindOf(current) !== "readonly") optional = true;
146
+ seen.add(current);
147
+ const inner = childSchemas(current, path)[0];
148
+ if (!inner) break;
149
+ current = inner;
150
+ }
151
+ return {
152
+ schema: current,
153
+ optional,
154
+ ...description === void 0 ? {} : { description }
155
+ };
120
156
  }
121
157
  /**
122
158
  * Record any object/enum/union missing a `.describe()` — on the schema itself or on any of its
@@ -179,4 +215,4 @@ function undescribedExports(modules) {
179
215
  };
180
216
  }
181
217
  //#endregion
182
- export { childSchemas, collectMissing, describedInChain, undescribed, undescribedExports };
218
+ export { childSchemas, collectMissing, describedInChain, descriptionInChain, kindOf, undescribed, undescribedExports, unwrapField };
@@ -0,0 +1,93 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { OidcJwk, OidcJwksFetch } from "../http/oidcWebhook";
4
+ /**
5
+ * The identity provider's half of the protocol `requireOidcWebhook` verifies: mint a signing key,
6
+ * publish it as a JWKS, and sign tokens with it.
7
+ *
8
+ * **Why it ships rather than staying private to the kit's own suite.** A route guarded by
9
+ * `requireOidcWebhook` can only be tested by presenting a token the
10
+ * guard accepts, and building one means WebCrypto key generation, base64url without padding, a compact
11
+ * JWS, and a transport that publishes the public half. That came to 103 lines the first time an adopter
12
+ * wrote it, none of it adopter-specific — and a hand-rolled copy is **a second definition of the wire
13
+ * format**, which is the argument this repository already makes about signers. It can agree with itself
14
+ * and disagree with the verifier. The first copy did: its fake transport returned `json()` where
15
+ * {@link OidcJwksResponse} declares `text()`, so every delivery 502'd from a fixture that looked right.
16
+ * Here that is unrepresentable — {@link publishing} is typed as the seam, so a `json()` is a red build
17
+ * rather than a wrong answer at runtime.
18
+ *
19
+ * **Real WebCrypto, never a stubbed signature.** Every negative case an adopter writes with these is a
20
+ * token somebody really signed — the impostor key, the wrong `kid`, the tampered claims. A hand-rolled
21
+ * key and a hand-rolled signature would let every signature assertion pass by agreeing with itself.
22
+ *
23
+ * **What is deliberately not here.** No helper that builds a *provider's* claims: GitHub's
24
+ * `repo:owner/name:environment:x` is the adopter's business and differs per provider, so claims stay a
25
+ * plain object the test writes. And no algorithm-confusion forgery — that proves a property of the kit's
26
+ * verifier, which the kit tests, rather than of an adopter's route.
27
+ *
28
+ * `oidcFixtures.test.ts` holds all of this to the real `verifyOidcToken`:
29
+ * a token it accepts, and one it refuses for each reason. That is what keeps the fixture and the verifier
30
+ * from drifting apart while both stay green.
31
+ */
32
+ /** A minted signing key: the JWK a verifier is given, and the private half a test signs with. */
33
+ export interface MintedKey {
34
+ /** The key id, matched against a token header's `kid`. */
35
+ kid: string;
36
+ /** The public key as a JWK, in the shape a JWKS endpoint publishes it. */
37
+ jwk: OidcJwk;
38
+ /** The private half. What signs a token. */
39
+ privateKey: CryptoKey;
40
+ }
41
+ /**
42
+ * Mint one RSA key pair and publish its public half as a JWK.
43
+ *
44
+ * Real WebCrypto, not a fixture: a hand-rolled key and a hand-rolled signature would let every signature
45
+ * assertion pass by agreeing with itself. A second minted key is what makes "signed by somebody else" a
46
+ * real statement rather than a label — mint one, publish only the first, and the refusal is the verifier's
47
+ * rather than the test's.
48
+ *
49
+ * Key generation is the slow part of any suite using this and a key is immutable, so mint in a
50
+ * `beforeAll` and reuse.
51
+ */
52
+ export declare function mintKey(kid: string): Promise<MintedKey>;
53
+ /**
54
+ * base64url without padding — how every segment of a compact JWS is encoded.
55
+ *
56
+ * Exported because a test that builds a token by hand needs it: the tamper case swaps a claims segment and
57
+ * keeps the signature, and re-encoding that segment with padding or with `+`/`/` would make the token
58
+ * refused for the wrong reason.
59
+ */
60
+ export declare function base64Url(bytes: Uint8Array): string;
61
+ /**
62
+ * Sign a compact JWT the way an identity provider signs an OIDC token: `RS256`, `typ: "JWT"`, and the
63
+ * signer's `kid` in the header.
64
+ *
65
+ * `payload` is the claims, written as the provider writes them — `iss`, `aud`, `exp`, and whatever the
66
+ * route's `claims` predicate reads. Nothing here defaults a claim, because a fixture that quietly supplied
67
+ * one would be testing itself.
68
+ *
69
+ * `header` overrides let a test present a header no provider would — `alg: "none"`, `alg: "RS512"`, a `kid`
70
+ * nobody published, or `kid: undefined` — which is how a route proves algorithm confusion and an
71
+ * unpublished key are rejected, instead of trusting that the kit covers it somewhere.
72
+ */
73
+ export declare function signToken(payload: unknown, signer: MintedKey, header?: Record<string, unknown>): Promise<string>;
74
+ /**
75
+ * A JWKS transport that publishes `keys` at `jwksUrl` and nothing anywhere else, recording every URL it was
76
+ * asked for in `seen`.
77
+ *
78
+ * **The response is the seam's own shape, which is the whole point of shipping this.** `text()`, never
79
+ * `json()` — {@link OidcJwksResponse} reads the body as text deliberately, so a proxy's HTML error page is a
80
+ * diagnosis rather than a throw. A hand-rolled transport that answers `json()` produces a 502 on every
81
+ * delivery from a fixture that looks correct. Typed as {@link OidcJwksFetch}, that mistake will not compile.
82
+ *
83
+ * **The URL is a parameter rather than a wildcard.** A transport that served keys at any URL would pass a
84
+ * route whose configured `jwksUrl` points at nothing — the one configuration mistake a test of a webhook
85
+ * route exists to catch. Anything else gets a 404 with a body, which is what an issuer's CDN actually
86
+ * returns and what the verifier maps to a 502.
87
+ *
88
+ * `seen` is an array the caller owns, so a test can assert the fetch count: one per delivery with no cache,
89
+ * one per TTL with one, and zero when a cache is pre-seeded. Pass `publishing(url, [], seen)` to prove a
90
+ * path never reaches the network at all — an empty key set is refused, so an unexpected fetch fails loudly.
91
+ */
92
+ export declare function publishing(jwksUrl: string, keys: readonly OidcJwk[], seen?: string[]): OidcJwksFetch;
93
+ //# sourceMappingURL=oidcFixtures.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oidcFixtures.d.ts","sourceRoot":"","sources":["../../src/test-utils/oidcFixtures.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAoB,MAAM,qBAAqB,CAAC;AAEpF;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB,0DAA0D;IAC1D,GAAG,EAAE,MAAM,CAAC;IACZ,0EAA0E;IAC1E,GAAG,EAAE,OAAO,CAAC;IACb,4CAA4C;IAC5C,UAAU,EAAE,SAAS,CAAC;CACvB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAiB7D;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAInD;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,SAAS,CAC7B,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,SAAS,EACjB,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACnC,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,OAAO,EAAE,EAAE,IAAI,GAAE,MAAM,EAAO,GAAG,aAAa,CAMxG"}