@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.
- 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 +51 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/manifest.d.ts +66 -0
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/manifest.js +62 -2
- 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/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +1 -0
- package/dist/error/http.d.ts +27 -5
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/http.js +33 -9
- package/dist/http/oidcWebhook.d.ts +283 -0
- package/dist/http/oidcWebhook.d.ts.map +1 -0
- package/dist/http/oidcWebhook.js +414 -0
- package/dist/http/signedWebhook.d.ts +9 -65
- package/dist/http/signedWebhook.d.ts.map +1 -1
- package/dist/http/signedWebhook.js +19 -9
- package/dist/http/webhookWindow.d.ts +33 -0
- package/dist/http/webhookWindow.d.ts.map +1 -0
- package/dist/http/webhookWindow.js +35 -0
- package/dist/logger/errorHandler.d.ts +11 -0
- package/dist/logger/errorHandler.d.ts.map +1 -0
- package/dist/logger/errorHandler.js +87 -0
- package/dist/naming/environment.js +1 -1
- package/dist/naming/provisionScope.d.ts +108 -3
- package/dist/naming/provisionScope.d.ts.map +1 -1
- package/dist/naming/provisionScope.js +27 -2
- package/dist/naming/resource.js +1 -1
- package/dist/naming/resourceNames.js +1 -1
- package/dist/schema/describedness.d.ts +30 -1
- package/dist/schema/describedness.d.ts.map +1 -1
- package/dist/schema/describedness.js +42 -6
- package/dist/test-utils/oidcFixtures.d.ts +93 -0
- package/dist/test-utils/oidcFixtures.d.ts.map +1 -0
- package/dist/test-utils/oidcFixtures.js +111 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/naming.js +1 -1
- package/package.json +1 -1
- package/src/capability/bindings.ts +49 -0
- package/src/capability/capability.ts +52 -0
- package/src/capability/manifest.ts +94 -0
- package/src/createBackend.ts +7 -3
- package/src/createEntrypoint.ts +5 -0
- package/src/error/http.ts +34 -10
- package/src/http/oidcWebhook.ts +672 -0
- package/src/http/signedWebhook.ts +44 -16
- package/src/http/webhookWindow.ts +35 -0
- package/src/logger/errorHandler.ts +94 -0
- package/src/naming/provisionScope.ts +150 -4
- package/src/schema/describedness.ts +51 -6
- package/src/test-utils/oidcFixtures.ts +138 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,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
|
-
/**
|
|
57
|
-
|
|
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":"
|
|
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
|
-
|
|
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 };
|
package/dist/naming/resource.js
CHANGED
|
@@ -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
|
-
*
|
|
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;
|
|
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
|
-
*
|
|
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
|
|
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
|
|
115
|
-
if (!TRANSPARENT.has(kindOf(current))) return
|
|
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
|
-
|
|
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"}
|