@pithy-sh/cloudflare 0.3.1 → 0.4.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.
@@ -68,7 +68,7 @@ export interface CloudflareApiError {
68
68
  documentationUrl?: string;
69
69
  }
70
70
  /**
71
- * Run a Cloudflare SDK call and turn any failure into a `CloudflareRequestError`, preserving the
71
+ * Run a Cloudflare SDK call and turn any failure into a typed refusal, preserving the
72
72
  * original error as `cause` and its message as internal `detail`. `operation` names the call for
73
73
  * the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
74
74
  * not-configured guard) passes through untouched — only foreign throws get wrapped.
@@ -111,8 +111,11 @@ export declare function cloudflareRequest<T>(operation: string, fn: () => Promis
111
111
  * translating client can render them under the error code. "Add this grant, then re-run" names a
112
112
  * console the operator is sitting in front of, so it is an `action`. The raw body stays `detail`.
113
113
  *
114
- * The code stays `cloudflare/request_failed` at 502: a dedicated sibling of `core/upstream_failed`,
115
- * already the upstream class CLAUDE.md asks for, and pinned by `core`'s payload test.
114
+ * **The code is keyed on the status, because a retry policy reads it and nothing else can.** See
115
+ * {@link isTransientStatus}. A refusal Cloudflare answered with a 4xx stays `cloudflare/request_failed`
116
+ * at 502 — a dedicated sibling of `core/upstream_failed`, already the upstream class CLAUDE.md asks
117
+ * for, and pinned by `core`'s payload test. The words are composed the same way either side of that
118
+ * branch, so only the two machine-readable fields differ.
116
119
  *
117
120
  * **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
118
121
  * of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
@@ -143,7 +146,7 @@ export declare function cloudflareRefusal(args: {
143
146
  detail: string;
144
147
  /** The original throw. */
145
148
  cause?: unknown;
146
- }): CloudflareRequestError;
149
+ }): PithyError;
147
150
  /**
148
151
  * Pithy's problem line with Cloudflare's own answer on the end of it, and the same answer as `params`.
149
152
  *
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/client/errors.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { PithyError, UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
3
+ import { PithyError, UpstreamError, UpstreamTimeoutError } from "@pithy-sh/core/src/error/pithyError";
4
4
  import { MAX_WORKFLOW_STEP_TEXT } from "@pithy-sh/core/src/workflow/stepMessage";
5
5
  //#region src/client/errors.ts
6
6
  /** A required piece of configuration (token, account id, resource id) is missing. */
@@ -144,7 +144,7 @@ const ACCOUNT_PERMISSIONS = {
144
144
  workers: "Workers Scripts"
145
145
  };
146
146
  /**
147
- * Run a Cloudflare SDK call and turn any failure into a `CloudflareRequestError`, preserving the
147
+ * Run a Cloudflare SDK call and turn any failure into a typed refusal, preserving the
148
148
  * original error as `cause` and its message as internal `detail`. `operation` names the call for
149
149
  * the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
150
150
  * not-configured guard) passes through untouched — only foreign throws get wrapped.
@@ -204,8 +204,11 @@ async function cloudflareRequest(operation, fn, options) {
204
204
  * translating client can render them under the error code. "Add this grant, then re-run" names a
205
205
  * console the operator is sitting in front of, so it is an `action`. The raw body stays `detail`.
206
206
  *
207
- * The code stays `cloudflare/request_failed` at 502: a dedicated sibling of `core/upstream_failed`,
208
- * already the upstream class CLAUDE.md asks for, and pinned by `core`'s payload test.
207
+ * **The code is keyed on the status, because a retry policy reads it and nothing else can.** See
208
+ * {@link isTransientStatus}. A refusal Cloudflare answered with a 4xx stays `cloudflare/request_failed`
209
+ * at 502 — a dedicated sibling of `core/upstream_failed`, already the upstream class CLAUDE.md asks
210
+ * for, and pinned by `core`'s payload test. The words are composed the same way either side of that
211
+ * branch, so only the two machine-readable fields differ.
209
212
  *
210
213
  * **The message is one line, and that is a kit-wide rule this had to learn the hard way.** Round one
211
214
  * of #534 made it a small document — problem line, `errors[]` indented beneath, action last — which
@@ -221,12 +224,78 @@ async function cloudflareRequest(operation, fn, options) {
221
224
  function cloudflareRefusal(args) {
222
225
  const shown = args.apiErrors.slice(0, MAX_API_ERRORS);
223
226
  const said = cloudflareSaid(args.problem, shown);
224
- return new CloudflareRequestError({
227
+ const refusal = {
225
228
  message: said.message,
226
229
  action: permissionAction(shown, args.status, args.permission),
227
230
  params: said.params,
228
231
  detail: args.detail
229
- }, { cause: args.cause });
232
+ };
233
+ if (isTransientStatus(args.status) || isConnectionFailure(args.cause)) return new UpstreamError(refusal, { cause: args.cause });
234
+ return new CloudflareRequestError(refusal, { cause: args.cause });
235
+ }
236
+ /**
237
+ * Whether the status Cloudflare answered under means "ask again" rather than "this request is wrong".
238
+ *
239
+ * **The classification lives here because here is where the status is still in hand.** Everything a
240
+ * refusal crosses afterwards — a durable step record, a `--json` line, a retry policy — sees the
241
+ * `code` and nothing else, so a fault that is worth retrying has to say so in the code at the moment
242
+ * it is raised. `@pithy-sh/secrets`' `secretsWorkflowRetry` lists `core/upstream_failed` and
243
+ * `core/upstream_timeout` and treats everything absent as terminal; with every non-timeout failure
244
+ * arriving as `cloudflare/request_failed`, a 503 on the at-rest master-key write ended the rotation
245
+ * instance permanently, in the one state where ending it is expensive.
246
+ *
247
+ * **It is keyed here and not relaxed in the policy, and the difference is a revoked token.** Making
248
+ * the whole `cloudflare/*` family retryable would loop a 403 forever at whatever cadence the Workflow
249
+ * sleeps on, and nothing would ever say why. 4xx is Cloudflare answering and refusing — the same call
250
+ * refuses again — so it stays terminal, and CLAUDE.md §Errors is what says the other half belongs to
251
+ * the upstream pair: a failure in something we do not control is never a code that says we broke.
252
+ *
253
+ * 5xx is every server-side status, Cloudflare's own `52x` family included; `429` is rate limiting,
254
+ * which is the transient failure by definition. The timeout statuses never reach here — `isTimeout`
255
+ * claims 408, 504 and 524 first, and they are the 504 half of the same pair.
256
+ *
257
+ * **A statusless failure is not covered here, and deliberately so (#647 review).** A connection that reset
258
+ * mid-write is genuinely transient and ending a rotation on one is expensive — but "carries no status" is
259
+ * far wider than "the network faltered". A `TypeError` raised in our own request builder carries no status
260
+ * either, and a predicate that called it transient would retry a code defect on the Workflow's own cadence,
261
+ * forever, silently. `errors.test.ts` pins that: a throw that never reached Cloudflare stays terminal.
262
+ * {@link isConnectionFailure} names the narrow case instead, the way {@link statesTimeout} already does.
263
+ */
264
+ function isTransientStatus(status) {
265
+ return status !== void 0 && (status === 429 || status >= 500);
266
+ }
267
+ /**
268
+ * The producer names for "the connection did not hold", as distinct from "out of time" and from a defect
269
+ * in our own code.
270
+ *
271
+ * Duck-typed by name and `code`, for the reason {@link TIMEOUT_NAMES} gives: the SDK is one producer and
272
+ * `fetch` is another, and an `instanceof` across two copies of the SDK answers false.
273
+ */
274
+ const CONNECTION_NAMES = /* @__PURE__ */ new Set(["APIConnectionError", "FetchError"]);
275
+ const CONNECTION_CODES = /* @__PURE__ */ new Set([
276
+ "ECONNRESET",
277
+ "ECONNREFUSED",
278
+ "ENOTFOUND",
279
+ "EAI_AGAIN",
280
+ "EPIPE",
281
+ "UND_ERR_SOCKET"
282
+ ]);
283
+ /**
284
+ * Whether a failure is a connection that did not hold — a reset, a refused socket, a name that would not
285
+ * resolve. Cloudflare never answered, so nothing was refused, and the next attempt may well succeed.
286
+ *
287
+ * Narrow on purpose. This is the half of "no status" that is genuinely worth another attempt; everything
288
+ * else with no status stays terminal, so a defect in our own code still stops rather than looping.
289
+ */
290
+ function isConnectionFailure(error) {
291
+ let current = error;
292
+ for (let depth = 0; depth < 5 && current !== null && typeof current === "object"; depth++) {
293
+ const { name, code } = current;
294
+ if (typeof name === "string" && CONNECTION_NAMES.has(name)) return true;
295
+ if (typeof code === "string" && CONNECTION_CODES.has(code)) return true;
296
+ current = current.cause;
297
+ }
298
+ return false;
230
299
  }
231
300
  /**
232
301
  * Pithy's problem line with Cloudflare's own answer on the end of it, and the same answer as `params`.
@@ -17,6 +17,7 @@ export declare const CfSecretEntry: z.ZodObject<{
17
17
  }>;
18
18
  created: z.ZodCodec<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodDate]>, z.ZodDate>;
19
19
  modified: z.ZodCodec<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodDate]>, z.ZodDate>;
20
+ comment: z.ZodOptional<z.ZodNullable<z.ZodString>>;
20
21
  }, z.core.$strip>;
21
22
  export type CfSecretEntry = z.output<typeof CfSecretEntry>;
22
23
  /** What {@link CloudflareSecretsStoreManager.createSecretIfAbsent} did — see it for each value. */
@@ -48,8 +49,17 @@ export declare class CloudflareSecretsStoreManager extends CloudflareManager {
48
49
  * update leaves the prior value intact and bound — there is no window where the secret is absent
49
50
  * from the store (which, for a secret like the master encryption key, is a platform-level outage).
50
51
  * Scopes are re-sent so an entry converges on the same shape whichever branch wrote it.
52
+ *
53
+ * **It upserts, and at a name nothing reads that is the whole problem.** A caller replacing a value
54
+ * that must already exist wants {@link updateExistingSecret}: here a stale or misspelled name finds
55
+ * nothing, creates an entry, and answers 200 — leaving live key material at an address no binding
56
+ * resolves while the value the caller meant to replace goes on being served. This one is for a
57
+ * caller that genuinely means either branch.
58
+ *
59
+ * `comment` is the non-secret text written beside the value, and the only thing about a value REST
60
+ * can read back. Omit it and whatever comment is on the entry is left exactly as it stands.
51
61
  */
52
- putSecret(name: string, value: string): Promise<void>;
62
+ putSecret(name: string, value: string, comment?: string): Promise<void>;
53
63
  /**
54
64
  * Create a secret only if no entry of that name is there, and say what happened — never "somebody else did"
55
65
  * when this call cannot know that.
@@ -66,7 +76,35 @@ export declare class CloudflareSecretsStoreManager extends CloudflareManager {
66
76
  * if another is older this one is the loser and removes itself. The oldest wins, deterministically, whichever
67
77
  * run looks. A failure with no entry there afterwards — an outage, a refused token — is thrown as it came.
68
78
  */
69
- createSecretIfAbsent(name: string, value: string): Promise<CreateSecretOutcome>;
79
+ createSecretIfAbsent(name: string, value: string, comment?: string): Promise<CreateSecretOutcome>;
80
+ /**
81
+ * Write a value to an entry that must already be there, refusing to create one.
82
+ *
83
+ * **The verb that exists because {@link putSecret} upserts.** A put addressed to a name nothing reads
84
+ * — a composed name gone stale, a misspelled binding, an environment segment that never got
85
+ * substituted — finds no entry, creates one, and answers 200. The caller hears success, the value it
86
+ * meant to replace is untouched and still bound, and an orphan entry nobody reads sits in the store
87
+ * holding live key material. That is how a sibling codebase lost an environment's secrets: the
88
+ * master-key envelope went to a name the `SECRETS_ENCRYPTION_KEYS` binding does not resolve, every
89
+ * row was re-encrypted under the new key, and the binding kept serving the old one. Nothing returned
90
+ * an error, anywhere.
91
+ *
92
+ * So the create branch is removed rather than guarded. A caller replacing a value has already
93
+ * asserted the entry exists — it is bound, something reads it — and where that assertion is false the
94
+ * honest answer is a refusal before any write, not a second entry. {@link createSecretIfAbsent}
95
+ * establishes a secret, this replaces one, and `putSecret` stays for a caller that means either.
96
+ *
97
+ * **Two live entries of one name is a refusal too, and the same one in spirit.** The oldest wins
98
+ * everywhere else here, which is right for a create that raced. It is wrong for a replace: editing
99
+ * one of two is a coin toss over which value the binding goes on serving. The store is ambiguous, so
100
+ * the caller is told, before anything is written.
101
+ *
102
+ * Name-addressed rather than id-addressed, deliberately: the name is what a caller composes and what
103
+ * a binding resolves, so addressing by it keeps the misaddressing this verb exists to catch inside
104
+ * the one thing that can catch it. Returns the entry id written, so a caller reading back afterwards
105
+ * can assert it is looking at the same address. `comment` is written beside the value when given.
106
+ */
107
+ updateExistingSecret(name: string, value: string, comment?: string): Promise<string>;
70
108
  /** Delete a secret by name. Resolves the id via `listSecrets`, then issues DELETE by id. */
71
109
  deleteSecret(name: string): Promise<void>;
72
110
  /**
@@ -89,6 +127,20 @@ export declare class CloudflareSecretsStoreManager extends CloudflareManager {
89
127
  * full set in one array. Each entry is Zod-validated (`CfSecretEntry`) at the wire boundary.
90
128
  */
91
129
  listSecrets(): Promise<CfSecretEntry[]>;
130
+ /**
131
+ * Every live entry carrying this name, oldest first.
132
+ *
133
+ * **The plural is the point.** Cloudflare does not document that the store refuses a duplicate name,
134
+ * and #643 established that it does not — so "the entry named X" is a claim rather than a given. A
135
+ * caller whose correctness depends on there being exactly one checks `length` here and says what it
136
+ * wants done when the store disagrees; {@link findByName} is the answer for a caller that only needs
137
+ * the entry the rest of this manager would act on.
138
+ *
139
+ * **A `deleted` entry is not one** (#643): Cloudflare lists a secret's lifecycle status as `pending`,
140
+ * `active` or `deleted` without saying what each means, and a deleted entry is the one reading that
141
+ * cannot be "there". `pending` is taken as there, since creating over it would duplicate it.
142
+ */
143
+ entriesNamed(name: string): Promise<CfSecretEntry[]>;
92
144
  /** Whether a secret with the given name currently exists in the store. */
93
145
  exists(name: string): Promise<boolean>;
94
146
  /** The store this manager targets and the account it lives in. */
@@ -99,10 +151,9 @@ export declare class CloudflareSecretsStoreManager extends CloudflareManager {
99
151
  /** Create a single secret with the default scopes, and return the id Cloudflare gave it when it says one. */
100
152
  private createSecret;
101
153
  /**
102
- * The entry of this name, or `undefined`. **A `deleted` entry is not one** (#643): Cloudflare lists a secret's
103
- * lifecycle status as `pending`, `active` or `deleted` without saying what each means, and a deleted entry is
104
- * the one reading that cannot be "there". `pending` is taken as there, since creating over it would duplicate
105
- * it. Of several entries of one name, the oldest, which is the one {@link createSecretIfAbsent} leaves standing.
154
+ * The entry of this name every verb here acts on, or `undefined`. Of several entries of one name, the
155
+ * oldest — which is the one {@link createSecretIfAbsent} leaves standing. See {@link entriesNamed} for
156
+ * why there can be several, and for what counts as being there at all.
106
157
  */
107
158
  private findByName;
108
159
  }
@@ -1 +1 @@
1
- {"version":3,"file":"secretsStoreManager.d.ts","sourceRoot":"","sources":["../../src/secrets/secretsStoreManager.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAKpF;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;iBAQqE,CAAC;AAChG,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D,mGAAmG;AACnG,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AAOxE,gGAAgG;AAChG,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,qBAAa,6BAA8B,SAAQ,iBAAiB;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IAEjC,YAAY,MAAM,EAAE,yBAAyB,EAM5C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;;;;;OAQG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAe1D;IAED;;;;;;;;;;;;;;;OAeG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAoBpF;IAED,4FAA4F;IACtF,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAc9C;IAED;;;;;;;;;;;;;OAaG;IACG,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1D;IAED;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAU5C;IAED,0EAA0E;IACpE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED,kEAAkE;IAClE,mBAAmB,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAE5D;IAED,6GAA6G;YAC/F,YAAY;IAiB1B;;;;;OAKG;YACW,UAAU;CAIzB"}
1
+ {"version":3,"file":"secretsStoreManager.d.ts","sourceRoot":"","sources":["../../src/secrets/secretsStoreManager.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAKpF;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;iBAeqE,CAAC;AAChG,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D,mGAAmG;AACnG,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AA4BxE,gGAAgG;AAChG,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,qBAAa,6BAA8B,SAAQ,iBAAiB;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IAEjC,YAAY,MAAM,EAAE,yBAAyB,EAM5C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB5E;IAED;;;;;;;;;;;;;;;OAeG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAmBtG;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BzF;IAED,4FAA4F;IACtF,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAc9C;IAED;;;;;;;;;;;;;OAaG;IACG,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1D;IAED;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAU5C;IAED;;;;;;;;;;;;OAYG;IACG,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAEzD;IAED,0EAA0E;IACpE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED,kEAAkE;IAClE,mBAAmB,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAE5D;IAED,6GAA6G;YAC/F,YAAY;IAiB1B;;;;OAIG;YACW,UAAU;CAGzB"}
@@ -3,7 +3,7 @@
3
3
  import { CloudflareNotConfiguredError, cloudflareRequest, decodeResponse } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
5
  import { z } from "zod";
6
- import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { ConflictError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
7
7
  import { JsonDate } from "@pithy-sh/core/src/data/codecs";
8
8
  //#region src/secrets/secretsStoreManager.ts
9
9
  /** Scopes attached to every secret we create. CF requires at least one; "workers" is the bind target. */
@@ -22,11 +22,31 @@ const CfSecretEntry = z.object({
22
22
  "deleted"
23
23
  ]).describe("The secret's lifecycle status in the store."),
24
24
  created: JsonDate.describe("When the secret was created (ISO string on the wire, Date in app)."),
25
- modified: JsonDate.describe("When the secret was last modified (ISO string on the wire, Date in app).")
25
+ modified: JsonDate.describe("When the secret was last modified (ISO string on the wire, Date in app)."),
26
+ comment: z.string().nullable().optional().describe("Freeform text Cloudflare stores beside the value and returns on list and get. The one field of an entry a write sets and a read answers, so it is the only REST-readable fact *about* a value — never key material, and never a substitute for reading the value through its binding. Nullable **and** optional because the live API returns an explicit `null` for an entry nothing annotated, not an absent key: declared `.optional()` alone, every list against a store holding one un-annotated entry failed its own schema and threw `cloudflare/invalid_response`, which took `entriesNamed` and therefore the rotation's edit-only write with it. Found against a real store, not a mock (#647).")
26
27
  }).describe("A single Cloudflare Secrets Store secret's metadata (never its plaintext value).");
27
- /** The oldest of some entries by creation time, the smaller id breaking a tie — one answer whoever asks. */
28
- function oldestEntry(entries) {
29
- return [...entries].sort((a, b) => a.created.getTime() - b.created.getTime() || a.id.localeCompare(b.id))[0];
28
+ /** Oldest first, the smaller id breaking a tie — one order whoever asks, so two readers never disagree. */
29
+ function byAge(a, b) {
30
+ return a.created.getTime() - b.created.getTime() || a.id.localeCompare(b.id);
31
+ }
32
+ /**
33
+ * A `deleted` entry is not there (#643); a `pending` one is, since creating over it would duplicate it.
34
+ * Cloudflare lists the three statuses without saying what each means, and a deleted entry is the one
35
+ * reading that cannot be "there".
36
+ */
37
+ function isLive(entry) {
38
+ return entry.status !== "deleted";
39
+ }
40
+ /**
41
+ * The `comment` a write sends, or no such field at all when the caller passed none.
42
+ *
43
+ * Omitted rather than sent empty, so a write with no comment is byte-identical to what this manager
44
+ * sent before the field existed — and so a caller that has nothing to say cannot blank an annotation
45
+ * somebody else wrote. The comment is a plain string here on purpose: what belongs in one is
46
+ * `@pithy-sh/secrets`' question, and the codec lives where it is read rather than where it is posted.
47
+ */
48
+ function commentField(comment) {
49
+ return comment === void 0 ? {} : { comment };
30
50
  }
31
51
  /**
32
52
  * Out-of-Worker access to the account-level Cloudflare Secrets Store over the REST API: provisioning
@@ -63,18 +83,28 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
63
83
  * update leaves the prior value intact and bound — there is no window where the secret is absent
64
84
  * from the store (which, for a secret like the master encryption key, is a platform-level outage).
65
85
  * Scopes are re-sent so an entry converges on the same shape whichever branch wrote it.
86
+ *
87
+ * **It upserts, and at a name nothing reads that is the whole problem.** A caller replacing a value
88
+ * that must already exist wants {@link updateExistingSecret}: here a stale or misspelled name finds
89
+ * nothing, creates an entry, and answers 200 — leaving live key material at an address no binding
90
+ * resolves while the value the caller meant to replace goes on being served. This one is for a
91
+ * caller that genuinely means either branch.
92
+ *
93
+ * `comment` is the non-secret text written beside the value, and the only thing about a value REST
94
+ * can read back. Omit it and whatever comment is on the entry is left exactly as it stands.
66
95
  */
67
- async putSecret(name, value) {
96
+ async putSecret(name, value, comment) {
68
97
  const existing = await this.findByName(name);
69
98
  if (!existing) {
70
- await this.createSecret(name, value);
99
+ await this.createSecret(name, value, comment);
71
100
  return;
72
101
  }
73
102
  await cloudflareRequest(`put secret ${name}`, () => this.getClient().secretsStore.stores.secrets.edit(existing.id, {
74
103
  account_id: this.accountId,
75
104
  store_id: this.storeId,
76
105
  value,
77
- scopes: [...DEFAULT_SCOPES]
106
+ scopes: [...DEFAULT_SCOPES],
107
+ ...commentField(comment)
78
108
  }));
79
109
  }
80
110
  /**
@@ -93,16 +123,16 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
93
123
  * if another is older this one is the loser and removes itself. The oldest wins, deterministically, whichever
94
124
  * run looks. A failure with no entry there afterwards — an outage, a refused token — is thrown as it came.
95
125
  */
96
- async createSecretIfAbsent(name, value) {
126
+ async createSecretIfAbsent(name, value, comment) {
97
127
  if (await this.findByName(name)) return "present";
98
128
  let ownId;
99
129
  try {
100
- ownId = await this.createSecret(name, value);
130
+ ownId = await this.createSecret(name, value, comment);
101
131
  } catch (error) {
102
132
  if (await this.findByName(name)) return "unconfirmed";
103
133
  throw error;
104
134
  }
105
- const oldest = oldestEntry((await this.listSecrets()).filter((entry) => entry.name === name && entry.status !== "deleted"));
135
+ const oldest = (await this.entriesNamed(name))[0];
106
136
  if (oldest === void 0 || ownId === void 0 || oldest.id === ownId) return "created";
107
137
  await cloudflareRequest(`delete duplicate secret ${name}`, () => this.getClient().secretsStore.stores.secrets.delete(ownId, {
108
138
  account_id: this.accountId,
@@ -110,6 +140,55 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
110
140
  }));
111
141
  return "present";
112
142
  }
143
+ /**
144
+ * Write a value to an entry that must already be there, refusing to create one.
145
+ *
146
+ * **The verb that exists because {@link putSecret} upserts.** A put addressed to a name nothing reads
147
+ * — a composed name gone stale, a misspelled binding, an environment segment that never got
148
+ * substituted — finds no entry, creates one, and answers 200. The caller hears success, the value it
149
+ * meant to replace is untouched and still bound, and an orphan entry nobody reads sits in the store
150
+ * holding live key material. That is how a sibling codebase lost an environment's secrets: the
151
+ * master-key envelope went to a name the `SECRETS_ENCRYPTION_KEYS` binding does not resolve, every
152
+ * row was re-encrypted under the new key, and the binding kept serving the old one. Nothing returned
153
+ * an error, anywhere.
154
+ *
155
+ * So the create branch is removed rather than guarded. A caller replacing a value has already
156
+ * asserted the entry exists — it is bound, something reads it — and where that assertion is false the
157
+ * honest answer is a refusal before any write, not a second entry. {@link createSecretIfAbsent}
158
+ * establishes a secret, this replaces one, and `putSecret` stays for a caller that means either.
159
+ *
160
+ * **Two live entries of one name is a refusal too, and the same one in spirit.** The oldest wins
161
+ * everywhere else here, which is right for a create that raced. It is wrong for a replace: editing
162
+ * one of two is a coin toss over which value the binding goes on serving. The store is ambiguous, so
163
+ * the caller is told, before anything is written.
164
+ *
165
+ * Name-addressed rather than id-addressed, deliberately: the name is what a caller composes and what
166
+ * a binding resolves, so addressing by it keeps the misaddressing this verb exists to catch inside
167
+ * the one thing that can catch it. Returns the entry id written, so a caller reading back afterwards
168
+ * can assert it is looking at the same address. `comment` is written beside the value when given.
169
+ */
170
+ async updateExistingSecret(name, value, comment) {
171
+ const named = await this.entriesNamed(name);
172
+ const target = named[0];
173
+ if (!target) throw new NotFoundError({
174
+ message: `Secret '${name}' is not in the store, so there is nothing to update.`,
175
+ action: "Run `pithy secrets provision` to establish the entry, then run this again.",
176
+ detail: `update secret ${name}: no live entry of that name, and this verb never creates one`
177
+ });
178
+ if (named.length > 1) throw new ConflictError({
179
+ message: `Secret '${name}' names ${named.length} live entries in the store, so a replacement is ambiguous.`,
180
+ action: "Delete the entries nothing binds from the Secrets Store in the Cloudflare dashboard, then re-run.",
181
+ detail: `update secret ${name}: ${named.map((entry) => entry.id).join(", ")}`
182
+ });
183
+ await cloudflareRequest(`update secret ${name}`, () => this.getClient().secretsStore.stores.secrets.edit(target.id, {
184
+ account_id: this.accountId,
185
+ store_id: this.storeId,
186
+ value,
187
+ scopes: [...DEFAULT_SCOPES],
188
+ ...commentField(comment)
189
+ }));
190
+ return target.id;
191
+ }
113
192
  /** Delete a secret by name. Resolves the id via `listSecrets`, then issues DELETE by id. */
114
193
  async deleteSecret(name) {
115
194
  const existing = await this.findByName(name);
@@ -156,6 +235,22 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
156
235
  return out;
157
236
  });
158
237
  }
238
+ /**
239
+ * Every live entry carrying this name, oldest first.
240
+ *
241
+ * **The plural is the point.** Cloudflare does not document that the store refuses a duplicate name,
242
+ * and #643 established that it does not — so "the entry named X" is a claim rather than a given. A
243
+ * caller whose correctness depends on there being exactly one checks `length` here and says what it
244
+ * wants done when the store disagrees; {@link findByName} is the answer for a caller that only needs
245
+ * the entry the rest of this manager would act on.
246
+ *
247
+ * **A `deleted` entry is not one** (#643): Cloudflare lists a secret's lifecycle status as `pending`,
248
+ * `active` or `deleted` without saying what each means, and a deleted entry is the one reading that
249
+ * cannot be "there". `pending` is taken as there, since creating over it would duplicate it.
250
+ */
251
+ async entriesNamed(name) {
252
+ return (await this.listSecrets()).filter((entry) => entry.name === name && isLive(entry)).sort(byAge);
253
+ }
159
254
  /** Whether a secret with the given name currently exists in the store. */
160
255
  async exists(name) {
161
256
  return await this.findByName(name) !== void 0;
@@ -168,14 +263,15 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
168
263
  };
169
264
  }
170
265
  /** Create a single secret with the default scopes, and return the id Cloudflare gave it when it says one. */
171
- async createSecret(name, value) {
266
+ async createSecret(name, value, comment) {
172
267
  return cloudflareRequest(`create secret ${name}`, async () => {
173
268
  const response = await this.getClient().secretsStore.stores.secrets.create(this.storeId, {
174
269
  account_id: this.accountId,
175
270
  body: [{
176
271
  name,
177
272
  value,
178
- scopes: [...DEFAULT_SCOPES]
273
+ scopes: [...DEFAULT_SCOPES],
274
+ ...commentField(comment)
179
275
  }]
180
276
  });
181
277
  const items = response?.result ?? response;
@@ -184,13 +280,12 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
184
280
  });
185
281
  }
186
282
  /**
187
- * The entry of this name, or `undefined`. **A `deleted` entry is not one** (#643): Cloudflare lists a secret's
188
- * lifecycle status as `pending`, `active` or `deleted` without saying what each means, and a deleted entry is
189
- * the one reading that cannot be "there". `pending` is taken as there, since creating over it would duplicate
190
- * it. Of several entries of one name, the oldest, which is the one {@link createSecretIfAbsent} leaves standing.
283
+ * The entry of this name every verb here acts on, or `undefined`. Of several entries of one name, the
284
+ * oldest — which is the one {@link createSecretIfAbsent} leaves standing. See {@link entriesNamed} for
285
+ * why there can be several, and for what counts as being there at all.
191
286
  */
192
287
  async findByName(name) {
193
- return oldestEntry((await this.listSecrets()).filter((entry) => entry.name === name && entry.status !== "deleted"));
288
+ return (await this.entriesNamed(name))[0];
194
289
  }
195
290
  };
196
291
  //#endregion
@@ -159,6 +159,13 @@ export declare const LIVE_FIXTURES: {
159
159
  readonly consequence: "Turnstile sign-in gating on a workers.dev hostname (#84) skips.";
160
160
  readonly doc: "docs/FIXTURES.md#turnstile-widget";
161
161
  };
162
+ readonly "workers-route-zone": {
163
+ readonly name: "workers-route-zone";
164
+ readonly keys: readonly ["WORKERS_ROUTE_ZONE"];
165
+ readonly shape: "credential";
166
+ readonly consequence: "The route-scoped CI token round trip (#651) skips: nothing proves a minted token can attach a route.";
167
+ readonly doc: "docs/FIXTURES.md#workers-route-zone";
168
+ };
162
169
  };
163
170
  /** Every declared fixture name. A typo is a type error rather than a silently skipped suite. */
164
171
  export type FixtureName = keyof typeof LIVE_FIXTURES;
@@ -1 +1 @@
1
- {"version":3,"file":"fixtures.d.ts","sourceRoot":"","sources":["../../src/test-utils/fixtures.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fixtures.d.ts","sourceRoot":"","sources":["../../src/test-utils/fixtures.ts"],"names":[],"mappings":"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"}
@@ -107,6 +107,13 @@ const LIVE_FIXTURES = {
107
107
  shape: "credential",
108
108
  consequence: "Turnstile sign-in gating on a workers.dev hostname (#84) skips.",
109
109
  doc: "docs/FIXTURES.md#turnstile-widget"
110
+ },
111
+ "workers-route-zone": {
112
+ name: "workers-route-zone",
113
+ keys: ["WORKERS_ROUTE_ZONE"],
114
+ shape: "credential",
115
+ consequence: "The route-scoped CI token round trip (#651) skips: nothing proves a minted token can attach a route.",
116
+ doc: "docs/FIXTURES.md#workers-route-zone"
110
117
  }
111
118
  };
112
119
  /**
@@ -23,6 +23,18 @@ export interface TokenPermission {
23
23
  * exact resource key format lives in a single place instead of being hand-spelled per use case.
24
24
  */
25
25
  export declare function accountResource(accountId: string): Record<string, string>;
26
+ /**
27
+ * The resource scope for a **zone-level** policy: one `com.cloudflare.api.account.zone.<id>` key per
28
+ * zone, each `"*"`.
29
+ *
30
+ * Every zone is named individually, and that is the point (#651). Cloudflare also spells "every zone in
31
+ * this account" — `{ "com.cloudflare.api.account.<id>": { "com.cloudflare.api.account.zone.*": "*" } }` —
32
+ * and a `ci-system` token must never carry it: the credential a pipeline runs under gets the zones the
33
+ * project's own declared domains sit in and no others, so an account holding a customer's zone beside
34
+ * this project's is not one compromise away from both. De-duped, because two environments and two
35
+ * Workers routinely share one zone.
36
+ */
37
+ export declare function zoneResources(zoneIds: readonly string[]): Record<string, string>;
26
38
  /**
27
39
  * A permission group available to account-owned tokens, as returned by the permission-groups list.
28
40
  * Only the id and name matter for resolution — the manager maps a requested name to its id.
@@ -41,26 +53,60 @@ export declare const MintedAccountToken: z.ZodObject<{
41
53
  id: z.ZodString;
42
54
  value: z.ZodString;
43
55
  name: z.ZodOptional<z.ZodString>;
44
- status: z.ZodOptional<z.ZodEnum<{
56
+ status: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
45
57
  active: "active";
46
58
  disabled: "disabled";
47
59
  expired: "expired";
48
- }>>;
60
+ }>>>;
49
61
  }, z.core.$strip>;
50
62
  export type MintedAccountToken = z.output<typeof MintedAccountToken>;
63
+ /**
64
+ * One access policy on an existing token, as the token list returns it.
65
+ *
66
+ * **Every field is `nullish`, and that is load-bearing rather than defensive.** `listTokens` decodes
67
+ * each entry with `safeParse` and drops what fails, so a single `null` where this expected a value
68
+ * makes a live token invisible to `findTokenByName` — and the next roll mints a *second* credential of
69
+ * the same name beside the first, with nothing saying so (#651).
70
+ */
71
+ export declare const AccountTokenPolicy: z.ZodObject<{
72
+ effect: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
73
+ allow: "allow";
74
+ deny: "deny";
75
+ }>>>;
76
+ permission_groups: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
77
+ id: z.ZodString;
78
+ }, z.core.$strip>>>>;
79
+ resources: z.ZodRecord<z.ZodString, z.ZodUnknown>;
80
+ }, z.core.$strip>;
81
+ export type AccountTokenPolicy = z.output<typeof AccountTokenPolicy>;
51
82
  /**
52
83
  * An existing account-owned token's metadata, as returned by list/get. No `value` — Cloudflare never
53
- * returns a token's secret after creation — so this is only enough to find a token by name and
54
- * address it for deletion.
84
+ * returns a token's secret after creation — so this is only enough to find a token by name, report on
85
+ * it, and address it for deletion.
86
+ *
87
+ * **It reads what it uses and nothing more.** It briefly carried `expires_on`, `not_before` and
88
+ * `condition` so a re-scoping mint could resend them; that mint is gone (see {@link
89
+ * CloudflareAccountTokensManager.rollToken}), and reading a field only to hand it straight back is a
90
+ * field that can be read wrong.
55
91
  */
56
92
  export declare const AccountTokenSummary: z.ZodObject<{
57
93
  id: z.ZodString;
58
94
  name: z.ZodString;
59
- status: z.ZodOptional<z.ZodEnum<{
95
+ status: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
60
96
  active: "active";
61
97
  disabled: "disabled";
62
98
  expired: "expired";
63
- }>>;
99
+ }>>>;
100
+ policies: z.ZodCatch<z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
101
+ effect: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
102
+ allow: "allow";
103
+ deny: "deny";
104
+ }>>>;
105
+ permission_groups: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
106
+ id: z.ZodString;
107
+ }, z.core.$strip>>>>;
108
+ resources: z.ZodRecord<z.ZodString, z.ZodUnknown>;
109
+ }, z.core.$strip>>>>>;
64
110
  }, z.core.$strip>;
65
111
  export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;
66
112
  /**
@@ -153,6 +199,18 @@ export declare class CloudflareAccountTokensManager extends CloudflareManager {
153
199
  * Either way the caller gets a usable secret to store — the idempotent path for a credential that
154
200
  * lives in the Secrets Store, since Cloudflare never returns a token's existing secret. Mirrors the
155
201
  * dashboard's roll-or-create.
202
+ *
203
+ * **A roll never changes the policy set, and that is a decision rather than an omission (#651).** It
204
+ * briefly did: `pithy token list` tells an adopter their CI token predates zone-scoped routes, and
205
+ * re-scoping here made the remedy one command. But Cloudflare's token update is a full
206
+ * representation, so doing it meant every mint had to resend the whole token correctly — `condition`,
207
+ * `expires_on`, `not_before`, `status` — and three review rounds each found a new way that went
208
+ * wrong: a cleared IP allowlist, a re-enabled disabled token, a `null` that hid the token and minted a
209
+ * duplicate beside it.
210
+ *
211
+ * {@link mintToken} plus a delete is what actually re-scopes, and `pithy token rotate` has always been
212
+ * exactly that. So the remedy names rotate, and this stays the one thing it can be relied on to be:
213
+ * a new secret on the same token, with nothing else touched.
156
214
  */
157
215
  rollToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken>;
158
216
  }