@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
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import type { MiddlewareHandler } from "hono";
|
|
5
5
|
import type { PithyHonoEnv } from "../capability/capability";
|
|
6
6
|
import { InternalError, WebhookUnverifiedError } from "../error/pithyError";
|
|
7
|
+
// The bounds live in a leaf so a browser-facing config schema can quote them without reaching this
|
|
8
|
+
// middleware's Worker graph (#521). See `webhookWindow.ts`.
|
|
9
|
+
import { SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS, SIGNED_WEBHOOK_TOLERANCE_SECONDS } from "./webhookWindow";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* The `signed-webhook` strategy, for any sender. Secret, header name, tolerance and the exact received
|
|
@@ -43,6 +46,11 @@ import { InternalError, WebhookUnverifiedError } from "../error/pithyError";
|
|
|
43
46
|
* crafted one, and neither is a delivery to act on; accepting it would also hand a forger an unbounded replay
|
|
44
47
|
* window the moment a secret leaks.
|
|
45
48
|
*
|
|
49
|
+
* The window's own width is checked before any delivery is. `skew > tolerance` is false when `tolerance` is
|
|
50
|
+
* `NaN`, so a non-finite tolerance does not widen the window — it deletes it, and every capture verifies
|
|
51
|
+
* forever. A tolerance outside `0 … {@link SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS}` is therefore refused as the
|
|
52
|
+
* configuration fault it is, never honored and never clamped.
|
|
53
|
+
*
|
|
46
54
|
* ## Why the comparison is `crypto.subtle.verify`
|
|
47
55
|
*
|
|
48
56
|
* Comparing HMACs with `===` leaks how many leading bytes matched, and that leak is enough to forge a
|
|
@@ -66,9 +74,6 @@ import { InternalError, WebhookUnverifiedError } from "../error/pithyError";
|
|
|
66
74
|
* kit's error.
|
|
67
75
|
*/
|
|
68
76
|
|
|
69
|
-
/** How far a delivery's own timestamp may be from now. Stripe's default, and generous against clock skew. */
|
|
70
|
-
export const SIGNED_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
71
|
-
|
|
72
77
|
/** The header key naming the timestamp, unless a sender names it otherwise. */
|
|
73
78
|
const DEFAULT_TIMESTAMP_KEY = "t";
|
|
74
79
|
|
|
@@ -103,7 +108,12 @@ export interface SignedWebhookScheme {
|
|
|
103
108
|
timestampKey?: string;
|
|
104
109
|
/** The header key holding a signature. Defaults to `v1`. */
|
|
105
110
|
signatureKey?: string;
|
|
106
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* The freshness window, in seconds, either side of now. Defaults to {@link SIGNED_WEBHOOK_TOLERANCE_SECONDS}
|
|
113
|
+
* and must be a finite number from 0 to {@link SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS}; anything else is
|
|
114
|
+
* `core/internal`, because this is the second operand of the freshness comparison and one `NaN` does not
|
|
115
|
+
* widen the window, it removes it.
|
|
116
|
+
*/
|
|
107
117
|
toleranceSeconds?: number;
|
|
108
118
|
}
|
|
109
119
|
|
|
@@ -225,8 +235,9 @@ export type SignedWebhookRefusal =
|
|
|
225
235
|
* each caller keeps its vocabulary, rather than the whole verifier existing twice with two sets of comments
|
|
226
236
|
* to keep in step. Most callers want {@link verifySignedWebhook}.
|
|
227
237
|
*
|
|
228
|
-
* It still throws for **our** failure: an endpoint holding no usable secret,
|
|
229
|
-
* clock, is a configuration fault rather than a refusal, and every
|
|
238
|
+
* It still throws for **our** failure: an endpoint holding no usable secret, given a tolerance that is not a
|
|
239
|
+
* window, or handed a clock that is not a clock, is a configuration fault rather than a refusal, and every
|
|
240
|
+
* caller reports that the same way. Those three are checked first, before anything the sender wrote.
|
|
230
241
|
*
|
|
231
242
|
* `body` must be the **exact received bytes**. A parsed-and-re-serialized object is different bytes — key
|
|
232
243
|
* order alone changes it — and would never verify.
|
|
@@ -252,18 +263,29 @@ export async function checkSignedWebhook(
|
|
|
252
263
|
});
|
|
253
264
|
}
|
|
254
265
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
266
|
+
// The freshness window is a comparison between two numbers a caller supplies, and **either** of them
|
|
267
|
+
// non-finite does not widen it — it removes it, because `NaN > x` and `x > NaN` are both false. So both are
|
|
268
|
+
// checked here, beside the empty secret, before a byte the sender wrote is read: a configuration fault must
|
|
269
|
+
// be the same answer whatever arrived, rather than one that surfaces only on a delivery well-formed enough
|
|
270
|
+
// to reach the comparison.
|
|
260
271
|
const tolerance = options.toleranceSeconds ?? SIGNED_WEBHOOK_TOLERANCE_SECONDS;
|
|
272
|
+
// The realistic source is not a hostile caller — it is a route reading `Number(env.WEBHOOK_TOLERANCE)` for a
|
|
273
|
+
// variable nobody set or somebody misspelled. TypeScript types that `number`, every layer between passes it
|
|
274
|
+
// on with `??` (which catches `undefined`, never `NaN`), and nothing downstream looks again. The upper bound
|
|
275
|
+
// is the same argument as the constant it defaults to: a tolerance is a replay window, so it is configuration
|
|
276
|
+
// that has to be small, not merely present.
|
|
277
|
+
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS) {
|
|
278
|
+
throw new InternalError({
|
|
279
|
+
message: "This webhook endpoint is not configured.",
|
|
280
|
+
action: `Give the ${options.header} guard a tolerance between 0 and ${SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS} seconds.`,
|
|
281
|
+
detail: `The signed-webhook guard on ${options.header} was given a tolerance of ${String(tolerance)} seconds, and a freshness window must be a number from 0 to ${SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS}.`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
261
284
|
const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// a
|
|
266
|
-
// digit-checked string before it is a number, so this is our fault and takes our code, like the secret above.
|
|
285
|
+
// The other operand, and the same fault one field over. `Math.abs(NaN - t) > tolerance` is false, so an
|
|
286
|
+
// invalid Date passes a delivery of any age. Comparing against a sentinel instead would still be a comparison
|
|
287
|
+
// against a number nobody chose. Only a caller-supplied `now` reaches here as NaN: the header's timestamp is
|
|
288
|
+
// a digit-checked string before it is a number, so this is our fault and takes our code, like the two above.
|
|
267
289
|
if (!Number.isFinite(nowSeconds)) {
|
|
268
290
|
throw new InternalError({
|
|
269
291
|
message: "This webhook endpoint is not configured.",
|
|
@@ -271,6 +293,12 @@ export async function checkSignedWebhook(
|
|
|
271
293
|
detail: `The signed-webhook guard on ${options.header} was handed a clock that is not a valid Date, so no delivery's freshness can be judged.`,
|
|
272
294
|
});
|
|
273
295
|
}
|
|
296
|
+
|
|
297
|
+
const timestampKey = options.timestampKey ?? DEFAULT_TIMESTAMP_KEY;
|
|
298
|
+
const signatureKey = options.signatureKey ?? DEFAULT_SIGNATURE_KEY;
|
|
299
|
+
const parsed = header === null ? undefined : parseSignedWebhookHeader(header, timestampKey, signatureKey);
|
|
300
|
+
if (parsed === undefined) return { reason: "unreadable" };
|
|
301
|
+
|
|
274
302
|
const skew = Math.abs(nowSeconds - parsed.timestamp);
|
|
275
303
|
if (skew > tolerance) return { reason: "stale", skew, tolerance };
|
|
276
304
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
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
|
+
|
|
20
|
+
/** How far a delivery's own timestamp may be from now. Stripe's default, and generous against clock skew. */
|
|
21
|
+
export const SIGNED_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The widest freshness window the verifier accepts, and it refuses above rather than clamping.
|
|
25
|
+
*
|
|
26
|
+
* A tolerance is a replay window in the plainest possible units: every second of it is a second longer a
|
|
27
|
+
* captured delivery keeps working. The five minutes above is Stripe's own and covers clock drift plus ordinary
|
|
28
|
+
* delivery latency; an hour is the far end of a sender that queues a delivery and re-sends it without re-dating
|
|
29
|
+
* it. Past that a number has stopped covering latency and started covering a capture.
|
|
30
|
+
*
|
|
31
|
+
* Refused rather than clamped because an `86400` is a typo or a misunderstanding either way — clamped, the
|
|
32
|
+
* endpoint keeps working and nobody reads the line again; refused, the first delivery says which knob is wrong
|
|
33
|
+
* and it is a two-character fix.
|
|
34
|
+
*/
|
|
35
|
+
export const SIGNED_WEBHOOK_MAX_TOLERANCE_SECONDS = 3600;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import { renderPithyError, toPithyError } from "../error/http";
|
|
6
|
+
import type { Logger } from "./logger";
|
|
7
|
+
import { createWorkerLogger } from "./worker";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The `onError` handler that **writes the fault down** before it renders it.
|
|
11
|
+
*
|
|
12
|
+
* ## The defect this closes
|
|
13
|
+
*
|
|
14
|
+
* A misconfigured knob — a webhook tolerance, a lock horizon, a batch size — now refuses instead of
|
|
15
|
+
* silently deleting the check it was part of (#521), and every one of those refusals carries an
|
|
16
|
+
* `action` naming the variable an operator must fix. In a deployed Worker that `action` reached
|
|
17
|
+
* nothing. `clientError` strips it on the way to the wire, which is correct and is the point; the
|
|
18
|
+
* terminal renderer is a CLI surface no Worker runs; and the request produced exactly one record — the
|
|
19
|
+
* access log line, `{"msg":"request","status":500}`. So the remedy existed and nobody could read it,
|
|
20
|
+
* which is the unfollowable-action defect one layer out: not an action that does not help, an action
|
|
21
|
+
* that does not arrive.
|
|
22
|
+
*
|
|
23
|
+
* ## Why it is here and not in the encoder
|
|
24
|
+
*
|
|
25
|
+
* `error/http.ts` is on the client side of the logger boundary and `logger/boundary.test.ts` gates
|
|
26
|
+
* that: the three client/operator-facing error surfaces (`http.ts`, `terminal.ts`, `payload.ts`) must
|
|
27
|
+
* not import the logger, so a record carrying `detail` cannot reach a response through them. That is a
|
|
28
|
+
* deliberate decision, not an oversight, and the fix respects its direction rather than working around
|
|
29
|
+
* it — the log is written on the internal side, and the encoder is imported *by* it. Nothing here can
|
|
30
|
+
* put a log record into a response: {@link renderPithyError} takes the payload and this module never
|
|
31
|
+
* touches the body.
|
|
32
|
+
*
|
|
33
|
+
* ## What lands
|
|
34
|
+
*
|
|
35
|
+
* One record, with the whole payload — `code`, `status`, `message`, `action`, `detail`. The logger
|
|
36
|
+
* engine lifts a reserved `error` field into {@link import("./record").LogRecord.error}, so nothing
|
|
37
|
+
* here formats or flattens anything; the fields the request logger already binds (`request`, `method`,
|
|
38
|
+
* `path`, `env`, `version`) ride along, which is what makes the line correlatable with the access log
|
|
39
|
+
* line beside it.
|
|
40
|
+
*
|
|
41
|
+
* **The client boundary does not move.** The response is byte-identical to what
|
|
42
|
+
* `pithyErrorHandler` produced: `HttpError.encode` still runs through `clientError`, `action` and
|
|
43
|
+
* `detail` are still stripped, and a forger still learns a status and a bland sentence. Both halves are
|
|
44
|
+
* pinned in `errorHandler.test.ts` — the log carries the action, the wire body does not.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Level by status, and it is one branch on purpose.
|
|
49
|
+
*
|
|
50
|
+
* **5xx is ours.** `core/internal`, `core/upstream_failed`, `core/upstream_timeout`: the code means
|
|
51
|
+
* *read our logs*, so the log is where it goes, at the level an operator's alerting already watches.
|
|
52
|
+
*
|
|
53
|
+
* **4xx is the caller's**, and it is already visible — the access log line beside this one carries its
|
|
54
|
+
* status. Logging it at `error` would put an unauthenticated caller in charge of how much this Worker
|
|
55
|
+
* writes, which is a bill and a haystack rather than a signal. It is still recorded, at `debug`, with
|
|
56
|
+
* the identical payload: an operator chasing a 403 nobody can explain turns the level down and gets
|
|
57
|
+
* every field, including the `action`, without a second code path existing to drift.
|
|
58
|
+
*/
|
|
59
|
+
function levelFor(status: number): "debug" | "error" {
|
|
60
|
+
return status >= 500 ? "error" : "debug";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The fallback logger, built once, for an app that registers this handler without
|
|
65
|
+
* `createBackend`'s base middleware — the email host's dev dispatch door, a hand-assembled Hono app.
|
|
66
|
+
*
|
|
67
|
+
* `noopLogger` would be the tidier default and it is the wrong one here: this handler exists so a
|
|
68
|
+
* fault reaches a surface, and a fallback that reaches none is the defect with a new spelling. This is
|
|
69
|
+
* the same CF-native adapter `createBackend` defaults to, minus the per-request correlation there is
|
|
70
|
+
* nothing to derive it from.
|
|
71
|
+
*/
|
|
72
|
+
let fallback: Logger | null = null;
|
|
73
|
+
|
|
74
|
+
/** The request's logger if one was bound, else the shared fallback. */
|
|
75
|
+
function loggerFor(c: Context): Logger {
|
|
76
|
+
const bound: unknown = (c as Context<{ Variables: { log?: Logger } }>).get("log");
|
|
77
|
+
if (bound) return bound as Logger;
|
|
78
|
+
fallback ??= createWorkerLogger();
|
|
79
|
+
return fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Hono `onError` handler: log the fault with its full payload, then render the public response.
|
|
84
|
+
*
|
|
85
|
+
* Register it once on the root app — `createBackend` already does. `pithyErrorHandler` remains the
|
|
86
|
+
* bare encoder for an app that has no logger seam at all.
|
|
87
|
+
*/
|
|
88
|
+
export function loggingErrorHandler(err: Error, c: Context): Response {
|
|
89
|
+
const pithy = toPithyError(err);
|
|
90
|
+
// The same object the response is rendered from, so the record and the body can never describe two
|
|
91
|
+
// different faults. `error` is the reserved field the engine lifts — `action` and `detail` included.
|
|
92
|
+
loggerFor(c)[levelFor(pithy.payload.status)]("request failed", { error: pithy });
|
|
93
|
+
return renderPithyError(pithy, c);
|
|
94
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
import { InternalError } from "../error/pithyError";
|
|
4
5
|
import { assertValidEnvironment, FEATURE_ENVIRONMENT, GLOBAL_SCOPE } from "./environment";
|
|
5
6
|
import {
|
|
6
7
|
type FeatureIdentity,
|
|
@@ -29,7 +30,11 @@ import { resourceNames } from "./resourceNames";
|
|
|
29
30
|
*
|
|
30
31
|
* - {@link environmentScope} — a **declared** environment (`staging`, `prod`, whatever the root
|
|
31
32
|
* `pithy.config.ts` lists). Names are `<project>-<env>-<thing>`, the rule every other namer in the
|
|
32
|
-
* kit follows, so the environment segment of a name is always the stanza the name lives in
|
|
33
|
+
* kit follows, so the environment segment of a name is always the stanza the name lives in — unless
|
|
34
|
+
* the thing being named is one the whole project shares, which a binding says with
|
|
35
|
+
* {@link BindingNaming} and a secret with its registry, and which puts the literal `global` in that
|
|
36
|
+
* slot instead. `global` is a scope beside the environments rather than one of them, so this is the
|
|
37
|
+
* same statement and not an exception to it: the segment still says which scope owns the resource.
|
|
33
38
|
* - {@link featureScope} — one branch's **ephemeral** environment. Names are
|
|
34
39
|
* `<project>-f<issue>-<slug>-<thing>`, with no environment segment because a feature *is* an
|
|
35
40
|
* environment, and the `f<issue>` marker is what teardown recomputes and what keeps a feature's
|
|
@@ -45,6 +50,45 @@ import { resourceNames } from "./resourceNames";
|
|
|
45
50
|
*/
|
|
46
51
|
export type SecretNameScope = "environment" | "global";
|
|
47
52
|
|
|
53
|
+
/**
|
|
54
|
+
* What a binding's manifest says about the *name* of the resource behind it — the two fields of
|
|
55
|
+
* `BindingSpec` that a composed name reads, and nothing else.
|
|
56
|
+
*
|
|
57
|
+
* Structural rather than an import of `BindingSpec`, for the reason {@link SecretNameScope} is a literal
|
|
58
|
+
* union rather than an import of `SecretScope`: this module composes names and must not depend on the
|
|
59
|
+
* capability contract to do it. A caller with a whole spec passes the spec; a caller with neither field
|
|
60
|
+
* passes `{}`.
|
|
61
|
+
*
|
|
62
|
+
* **Both fields absent is the ordinary binding**, and it composes exactly what it always did:
|
|
63
|
+
* `<project>-<env>-<kebabbed binding>`.
|
|
64
|
+
*/
|
|
65
|
+
export interface BindingNaming {
|
|
66
|
+
/**
|
|
67
|
+
* `global` where one resource serves the whole project, absent or `environment` where each environment
|
|
68
|
+
* gets its own. See `BindingSpec.scope` — this is that field, and the reason it exists is that
|
|
69
|
+
* `EMAIL_SUPPRESSIONS` and `SECRETS` sit one line apart in the same env and no convention tells them
|
|
70
|
+
* apart.
|
|
71
|
+
*/
|
|
72
|
+
readonly scope?: SecretNameScope;
|
|
73
|
+
/**
|
|
74
|
+
* The `<thing>` segment, where it is not the binding name. See `BindingSpec.resource` —
|
|
75
|
+
* `SUPPORT_BUCKET` backs `<project>-global-support`.
|
|
76
|
+
*/
|
|
77
|
+
readonly resource?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A {@link BindingNaming} that says `global` in its **type**, not only in its value.
|
|
82
|
+
*
|
|
83
|
+
* The distinction earns its keep at exactly one call: {@link bindingResourceName} needs a per-environment
|
|
84
|
+
* fallback from a writer filling in one stanza, and cannot need one from a capability's own namer, which
|
|
85
|
+
* has no environment to fall back to because its resource has none. Typing the declaration is what lets
|
|
86
|
+
* the compiler tell those two callers apart, so the namer omits an argument it could only have invented.
|
|
87
|
+
*/
|
|
88
|
+
export interface ProjectGlobalNaming extends BindingNaming {
|
|
89
|
+
readonly scope: "global";
|
|
90
|
+
}
|
|
91
|
+
|
|
48
92
|
/** Where a provisioning run's resources are named, and where their ids are written. */
|
|
49
93
|
export interface ProvisionScope {
|
|
50
94
|
/** The `env.<stanza>` key in each Worker's config that this scope's ids are written into. */
|
|
@@ -65,8 +109,30 @@ export interface ProvisionScope {
|
|
|
65
109
|
* called?" and "which stanza does it go in?" — because those three answers have to agree.
|
|
66
110
|
*/
|
|
67
111
|
readonly source: boolean;
|
|
68
|
-
/**
|
|
69
|
-
|
|
112
|
+
/**
|
|
113
|
+
* This scope's Cloudflare name for a provisionable binding, given what the binding's manifest says
|
|
114
|
+
* about naming it.
|
|
115
|
+
*
|
|
116
|
+
* **The naming is required, not defaulted.** A caller with nothing to say passes `{}` and gets the
|
|
117
|
+
* name it always got; what it may not do is *omit* the question. `pithy add` and `pithy provision`
|
|
118
|
+
* both compose this name, from two different call paths, and the whole of #513 is that one of them
|
|
119
|
+
* had no way to hear "this resource is one per project" — so the app Worker bound three suppression
|
|
120
|
+
* databases where the email Worker bound one, and an unsubscribe on either was invisible to the other.
|
|
121
|
+
* A parameter with a default is a parameter a new call site inherits an answer to without ever
|
|
122
|
+
* reading the question.
|
|
123
|
+
*/
|
|
124
|
+
resource(binding: string, kind: FeatureResourceKind, naming: BindingNaming): string;
|
|
125
|
+
/**
|
|
126
|
+
* Whether a binding that declares itself project-global is actually named that way here — and so
|
|
127
|
+
* belongs to the project rather than to this run.
|
|
128
|
+
*
|
|
129
|
+
* The asymmetry between the two scopes, stated by the object that implements it rather than inferred
|
|
130
|
+
* by a caller comparing two composed names. A declared environment honors `global` and shares the one
|
|
131
|
+
* resource; {@link featureScope} does not, so a feature's copy is the feature's own. A caller reading
|
|
132
|
+
* the manifest alone gets that backwards, and the way it goes wrong is silent: the feature's database
|
|
133
|
+
* is created, judged to be the project's, kept out of the teardown record, and orphaned.
|
|
134
|
+
*/
|
|
135
|
+
readonly honorsGlobal: boolean;
|
|
70
136
|
/** The script name a Worker deploys under in this scope. */
|
|
71
137
|
worker(worker: string): string;
|
|
72
138
|
/**
|
|
@@ -94,6 +160,67 @@ function secretEntryName(project: string, secret: string, secretScope: SecretNam
|
|
|
94
160
|
return scoped();
|
|
95
161
|
}
|
|
96
162
|
|
|
163
|
+
/**
|
|
164
|
+
* The same branch, one line down, for a provisioned resource: a `global` binding resolves to the
|
|
165
|
+
* project's single `<project>-global-<thing>`, and everything else to the scope's own name for it.
|
|
166
|
+
*
|
|
167
|
+
* Written beside {@link secretEntryName} and shaped like it deliberately — the argument is the same
|
|
168
|
+
* argument. A value defined as one value must have one name, or the scoping mints a second copy of it;
|
|
169
|
+
* the only difference is that a secret's second copy is read and a database's is *written to*, which is
|
|
170
|
+
* why this one is the bug where an unsubscribe recorded in `staging` was invisible in `prod` (#513).
|
|
171
|
+
*
|
|
172
|
+
* `thing` is `naming.resource` when the manifest states one and the binding name otherwise. Either way
|
|
173
|
+
* it goes to the same composer, which kebabs it — so a binding passed verbatim keeps the name it has
|
|
174
|
+
* always had (`kebab("EMAIL_SUPPRESSIONS") === "email-suppressions"`), and this function cannot change
|
|
175
|
+
* what any existing spec composes.
|
|
176
|
+
*
|
|
177
|
+
* **Exported, because `pithy add` composes this name too and has no {@link ProvisionScope} to do it
|
|
178
|
+
* with.** It writes a `database_name` into a `wrangler.jsonc` stanza long before an account is reached,
|
|
179
|
+
* from a Worker's declared environment rather than a provisioning run's — and it is the writer that got
|
|
180
|
+
* #513 wrong. Handing it the scoped name as a callback is what lets the *global* branch live here once:
|
|
181
|
+
* a second copy of it in the writer is the arrangement this issue exists to end.
|
|
182
|
+
*
|
|
183
|
+
* **And exported for the third writer, which is the capability's own namer.** `suppressionDatabaseName`
|
|
184
|
+
* and `supportBucketName` name the same two resources their manifests declare, and they composed them
|
|
185
|
+
* with a second expression until #513 — which is how `<project>-global-email-suppressions` and
|
|
186
|
+
* `<project>-staging-email-suppressions` came to be the same database under two names. They pass a
|
|
187
|
+
* {@link ProjectGlobalNaming} and no callback: a namer for a project-global resource has no environment,
|
|
188
|
+
* so there is no scoped name for it to supply and the overload will not let it pretend otherwise.
|
|
189
|
+
*/
|
|
190
|
+
export function bindingResourceName(
|
|
191
|
+
project: string,
|
|
192
|
+
binding: string,
|
|
193
|
+
kind: FeatureResourceKind,
|
|
194
|
+
naming: ProjectGlobalNaming,
|
|
195
|
+
): string;
|
|
196
|
+
export function bindingResourceName(
|
|
197
|
+
project: string,
|
|
198
|
+
binding: string,
|
|
199
|
+
kind: FeatureResourceKind,
|
|
200
|
+
naming: BindingNaming,
|
|
201
|
+
scoped: (thing: string) => string,
|
|
202
|
+
): string;
|
|
203
|
+
export function bindingResourceName(
|
|
204
|
+
project: string,
|
|
205
|
+
binding: string,
|
|
206
|
+
kind: FeatureResourceKind,
|
|
207
|
+
naming: BindingNaming,
|
|
208
|
+
scoped?: (thing: string) => string,
|
|
209
|
+
): string {
|
|
210
|
+
const thing = naming.resource ?? binding;
|
|
211
|
+
if (naming.scope === GLOBAL_SCOPE) return resourceNames(project).global[KIND_NAMER[kind]](thing);
|
|
212
|
+
// The second wall behind the overloads, not the first. A caller with no environment reaching a naming
|
|
213
|
+
// that is not `global` has nothing this function could compose, and the honest answer is to say so
|
|
214
|
+
// rather than return a name with a segment nobody chose.
|
|
215
|
+
if (scoped === undefined) {
|
|
216
|
+
throw new InternalError({
|
|
217
|
+
message: "A resource name could not be composed.",
|
|
218
|
+
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"\`.`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return scoped(thing);
|
|
222
|
+
}
|
|
223
|
+
|
|
97
224
|
/**
|
|
98
225
|
* A declared environment's scope — `staging`, `prod`, or whatever the root `pithy.config.ts` lists.
|
|
99
226
|
*
|
|
@@ -116,7 +243,10 @@ export function environmentScope(project: string, environment: string): Provisio
|
|
|
116
243
|
stanza: environment,
|
|
117
244
|
// Long-lived ids for an environment the project ships to: reviewed, committed, kept.
|
|
118
245
|
source: true,
|
|
119
|
-
resource
|
|
246
|
+
// A declared environment is one of the scopes a project-global resource is shared *between*.
|
|
247
|
+
honorsGlobal: true,
|
|
248
|
+
resource: (binding, kind, naming) =>
|
|
249
|
+
bindingResourceName(project, binding, kind, naming, (thing) => names[KIND_NAMER[kind]](thing)),
|
|
120
250
|
worker: (worker) => `${worker}-${environment}`,
|
|
121
251
|
secretEntry: (secret, secretScope) =>
|
|
122
252
|
secretEntryName(project, secret, secretScope, () => names.secretEntry(secret)),
|
|
@@ -129,12 +259,28 @@ export function environmentScope(project: string, environment: string): Provisio
|
|
|
129
259
|
*
|
|
130
260
|
* Its stanza is {@link FEATURE_ENVIRONMENT} and nothing else, which is what makes a feature's resources
|
|
131
261
|
* in a declared environment's stanza unexpressible rather than merely discouraged.
|
|
262
|
+
*
|
|
263
|
+
* **A feature takes a binding's {@link BindingNaming} and ignores it — and that is the asymmetry against
|
|
264
|
+
* `secretEntry` one line below, which honors `global` here as everywhere.** A global *secret* is a value
|
|
265
|
+
* defined once for the project, so a feature binds the project's copy rather than minting a second: it is
|
|
266
|
+
* **read**. A global *database* is a resource a feature would **migrate** — `provisionFeature` runs
|
|
267
|
+
* `pithy migrate` against everything it names — so honoring `global` here would point one branch's
|
|
268
|
+
* schema changes at the project's live suppression list, and point teardown at it afterwards. A feature
|
|
269
|
+
* owns every resource it names, exactly so that destroying the feature destroys them all; there is no
|
|
270
|
+
* shared name to match and nothing for teardown to spare.
|
|
271
|
+
*
|
|
272
|
+
* `resource` is ignored for the same reason it is moot: a feature's `<thing>` segment is the binding plus
|
|
273
|
+
* the kind, and the whole name is recomputed on teardown rather than looked up, so nothing outside the
|
|
274
|
+
* feature reads the string.
|
|
132
275
|
*/
|
|
133
276
|
export function featureScope(identity: FeatureIdentity): ProvisionScope {
|
|
134
277
|
return {
|
|
135
278
|
stanza: FEATURE_ENVIRONMENT,
|
|
136
279
|
// One job's ids, for resources `destroy` deletes. Never a tracked file.
|
|
137
280
|
source: false,
|
|
281
|
+
// A feature owns every resource it names — see the docstring above for why a database and a secret
|
|
282
|
+
// part company here.
|
|
283
|
+
honorsGlobal: false,
|
|
138
284
|
resource: (binding, kind) => featureResourceName(identity, binding, kind),
|
|
139
285
|
worker: (worker) => featureWorkerName(identity, worker),
|
|
140
286
|
secretEntry: (secret, secretScope) =>
|
|
@@ -40,7 +40,7 @@ interface Def {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** The kind tag Zod puts on a schema's definition. */
|
|
43
|
-
function kindOf(schema: z.ZodType): string {
|
|
43
|
+
export function kindOf(schema: z.ZodType): string {
|
|
44
44
|
return (schema as unknown as { def: Def }).def.type;
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -150,22 +150,67 @@ export function childSchemas(schema: z.ZodType, path: string): z.ZodType[] {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
*
|
|
153
|
+
* The description documenting a field, from anywhere in its transparent-wrapper chain.
|
|
154
154
|
*
|
|
155
155
|
* `.describe("…").optional()` puts it on the inner type and `.optional().describe("…")` puts it on the
|
|
156
156
|
* wrapper. Both are one described field. Only {@link TRANSPARENT} wrappers are followed — an `array`
|
|
157
157
|
* or a `pipe` describes something other than what it holds.
|
|
158
|
+
*
|
|
159
|
+
* The **words**, and not only whether there are any, because the descriptions are the object model's
|
|
160
|
+
* documentation (CLAUDE.md §Zod) and a surface that shows them to a human — `pithy secrets create`
|
|
161
|
+
* asking for one field at a time — needs the same chain the gate walks. Two walks of one chain is how
|
|
162
|
+
* a field renders undocumented in a prompt while the meta-test calls it described.
|
|
158
163
|
*/
|
|
159
|
-
export function
|
|
164
|
+
export function descriptionInChain(schema: z.ZodType): string | undefined {
|
|
160
165
|
let current: z.ZodType | undefined = schema;
|
|
161
166
|
const seen = new Set<z.ZodType>();
|
|
162
167
|
while (current && !seen.has(current)) {
|
|
163
|
-
if (current.description) return
|
|
164
|
-
if (!TRANSPARENT.has(kindOf(current))) return
|
|
168
|
+
if (current.description) return current.description;
|
|
169
|
+
if (!TRANSPARENT.has(kindOf(current))) return undefined;
|
|
165
170
|
seen.add(current);
|
|
166
171
|
current = (current as unknown as { def?: { innerType?: z.ZodType } }).def?.innerType;
|
|
167
172
|
}
|
|
168
|
-
return
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether a description sits anywhere in a field's transparent-wrapper chain. See {@link descriptionInChain}. */
|
|
177
|
+
export function describedInChain(schema: z.ZodType): boolean {
|
|
178
|
+
return descriptionInChain(schema) !== undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One field of an object, with its transparent wrappers taken off. See {@link unwrapField}. */
|
|
182
|
+
export interface UnwrappedField {
|
|
183
|
+
/** What the field actually is, once `.optional()` and the rest of {@link TRANSPARENT} are removed. */
|
|
184
|
+
schema: z.ZodType;
|
|
185
|
+
/** Whether the field may be left out of the object entirely — an `optional`, `default` or `prefault`. */
|
|
186
|
+
optional: boolean;
|
|
187
|
+
/** The description documenting it, from anywhere in the chain. See {@link descriptionInChain}. */
|
|
188
|
+
description?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One field, unwrapped: what it is, whether it may be omitted, and the words that document it.
|
|
193
|
+
*
|
|
194
|
+
* **The unwrapping half of this walk, exported rather than copied.** `collectMissing` already steps
|
|
195
|
+
* through {@link TRANSPARENT} on its way down, and a caller asking *what is this field* asks exactly
|
|
196
|
+
* that question — so it asks it here. The nineteen private copies this module replaced all started as
|
|
197
|
+
* one function that looked too small to share.
|
|
198
|
+
*
|
|
199
|
+
* `nullable` is not omissible: `null` is a value a caller states, and the key is still required.
|
|
200
|
+
*/
|
|
201
|
+
export function unwrapField(schema: z.ZodType, path = "schema"): UnwrappedField {
|
|
202
|
+
const description = descriptionInChain(schema);
|
|
203
|
+
let current = schema;
|
|
204
|
+
let optional = false;
|
|
205
|
+
const seen = new Set<z.ZodType>();
|
|
206
|
+
while (TRANSPARENT.has(kindOf(current)) && !seen.has(current)) {
|
|
207
|
+
if (kindOf(current) !== "nullable" && kindOf(current) !== "readonly") optional = true;
|
|
208
|
+
seen.add(current);
|
|
209
|
+
const inner = childSchemas(current, path)[0];
|
|
210
|
+
if (!inner) break;
|
|
211
|
+
current = inner;
|
|
212
|
+
}
|
|
213
|
+
return { schema: current, optional, ...(description === undefined ? {} : { description }) };
|
|
169
214
|
}
|
|
170
215
|
|
|
171
216
|
/** What one walk found: the complaints, and how much it actually looked at. */
|