@pithy-sh/core 0.2.0 → 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 (56) 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 +33 -0
  5. package/dist/capability/capability.d.ts.map +1 -1
  6. package/dist/capability/manifest.d.ts +5 -0
  7. package/dist/capability/manifest.d.ts.map +1 -1
  8. package/dist/capability/manifest.js +1 -1
  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/error/http.d.ts +27 -5
  15. package/dist/error/http.d.ts.map +1 -1
  16. package/dist/error/http.js +33 -9
  17. package/dist/http/oidcWebhook.d.ts +283 -0
  18. package/dist/http/oidcWebhook.d.ts.map +1 -0
  19. package/dist/http/oidcWebhook.js +414 -0
  20. package/dist/http/signedWebhook.d.ts +9 -65
  21. package/dist/http/signedWebhook.d.ts.map +1 -1
  22. package/dist/http/signedWebhook.js +19 -9
  23. package/dist/http/webhookWindow.d.ts +33 -0
  24. package/dist/http/webhookWindow.d.ts.map +1 -0
  25. package/dist/http/webhookWindow.js +35 -0
  26. package/dist/logger/errorHandler.d.ts +11 -0
  27. package/dist/logger/errorHandler.d.ts.map +1 -0
  28. package/dist/logger/errorHandler.js +87 -0
  29. package/dist/naming/environment.js +1 -1
  30. package/dist/naming/provisionScope.d.ts +108 -3
  31. package/dist/naming/provisionScope.d.ts.map +1 -1
  32. package/dist/naming/provisionScope.js +27 -2
  33. package/dist/naming/resource.js +1 -1
  34. package/dist/naming/resourceNames.js +1 -1
  35. package/dist/schema/describedness.d.ts +30 -1
  36. package/dist/schema/describedness.d.ts.map +1 -1
  37. package/dist/schema/describedness.js +42 -6
  38. package/dist/test-utils/oidcFixtures.d.ts +93 -0
  39. package/dist/test-utils/oidcFixtures.d.ts.map +1 -0
  40. package/dist/test-utils/oidcFixtures.js +111 -0
  41. package/dist/version.generated.d.ts +1 -1
  42. package/dist/version.generated.js +1 -1
  43. package/dist/workflow/naming.js +1 -1
  44. package/package.json +1 -1
  45. package/src/capability/bindings.ts +49 -0
  46. package/src/capability/capability.ts +34 -0
  47. package/src/createBackend.ts +7 -3
  48. package/src/error/http.ts +34 -10
  49. package/src/http/oidcWebhook.ts +672 -0
  50. package/src/http/signedWebhook.ts +44 -16
  51. package/src/http/webhookWindow.ts +35 -0
  52. package/src/logger/errorHandler.ts +94 -0
  53. package/src/naming/provisionScope.ts +150 -4
  54. package/src/schema/describedness.ts +51 -6
  55. package/src/test-utils/oidcFixtures.ts +138 -0
  56. 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
- /** The freshness window, in seconds, either side of now. Defaults to {@link SIGNED_WEBHOOK_TOLERANCE_SECONDS}. */
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, or handed a clock that is not a
229
- * clock, is a configuration fault rather than a refusal, and every caller reports that the same way.
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
- const timestampKey = options.timestampKey ?? DEFAULT_TIMESTAMP_KEY;
256
- const signatureKey = options.signatureKey ?? DEFAULT_SIGNATURE_KEY;
257
- const parsed = header === null ? undefined : parseSignedWebhookHeader(header, timestampKey, signatureKey);
258
- if (parsed === undefined) return { reason: "unreadable" };
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
- // Fail closed on a clock that is not one. `Math.abs(NaN - t) > tolerance` is false, so an invalid Date would
263
- // pass the window silently — the one check in this file that could fail open, in the file whose whole
264
- // argument is that it fails closed. Comparing against a sentinel instead would still be a comparison against
265
- // a number nobody chose. Only a caller-supplied `now` reaches here as NaN: the header's timestamp is 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
- /** This scope's Cloudflare name for a provisionable binding. */
69
- resource(binding: string, kind: FeatureResourceKind): string;
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: (binding, kind) => names[KIND_NAMER[kind]](binding),
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
- * Whether a description sits anywhere in a field's transparent-wrapper chain.
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 describedInChain(schema: z.ZodType): boolean {
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 true;
164
- if (!TRANSPARENT.has(kindOf(current))) return false;
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 false;
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. */