@pithy-sh/cloudflare 0.1.6 → 0.2.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.
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
4
  import { z } from "zod";
5
- import { CloudflareInvalidResponseError, CloudflareRequestError } from "../client/errors";
5
+ import { CloudflareInvalidResponseError, cloudflareApiErrors, cloudflareRefusal } from "../client/errors";
6
6
  import type { CloudflareWorkersManager } from "../workers/workersManager";
7
7
  import type { IntegrationCreds } from "./harness";
8
8
 
@@ -169,10 +169,15 @@ export async function readObservedInbound(options: {
169
169
  const response = await fetch(url, { headers: { Authorization: `Bearer ${creds.apiToken}` } });
170
170
  if (response.status === 404) return null;
171
171
  if (!response.ok) {
172
- throw new CloudflareRequestError({
173
- message: "Could not read the recorded inbound message.",
174
- action: "Check the token carries Workers KV Storage: Edit on this account.",
175
- detail: `KV read for the inbound record returned ${response.status}.`,
172
+ // Composed where every Cloudflare refusal is — see `client/errors.ts`. The polling loop reads this
173
+ // on every failed attempt, so it is worth the code Cloudflare actually answered with.
174
+ const failure = await response.text();
175
+ throw cloudflareRefusal({
176
+ problem: "Could not read the recorded inbound message.",
177
+ apiErrors: cloudflareApiErrors(parseEnvelope(failure)),
178
+ status: response.status,
179
+ permission: "Workers KV Storage",
180
+ detail: `KV read for the inbound record returned ${response.status}: ${failure}`.slice(0, 2000),
176
181
  });
177
182
  }
178
183
 
@@ -187,3 +192,12 @@ export async function readObservedInbound(options: {
187
192
  }
188
193
  return ObservedInbound.parse(body);
189
194
  }
195
+
196
+ /** A CF response body as an object, or `undefined` when it is not JSON — `cloudflareApiErrors` reads either. */
197
+ function parseEnvelope(body: string): unknown {
198
+ try {
199
+ return JSON.parse(body);
200
+ } catch {
201
+ return undefined;
202
+ }
203
+ }
@@ -4,15 +4,34 @@
4
4
  import { z } from "zod";
5
5
  import {
6
6
  CloudflareNotConfiguredError,
7
- CloudflareRequestError,
7
+ cloudflareApiErrors,
8
+ cloudflareRefusal,
8
9
  cloudflareRequest,
10
+ cloudflareSaid,
9
11
  decodeResponse,
10
12
  isAuthorizationError,
11
13
  messageOf,
14
+ statusOf,
12
15
  } from "../client/errors";
13
16
  import { CloudflareManager } from "../client/manager";
14
17
  import { CfTokenVerification } from "../user/userManager";
15
18
 
19
+ /**
20
+ * The account permission group behind every endpoint in this file, handed to `cloudflareRequest` so an
21
+ * auth-class refusal names it.
22
+ *
23
+ * It has to be stated rather than derived, because Cloudflare's `documentation_url` for
24
+ * `accounts/tokens` yields `accounts` — a segment `ACCOUNT_PERMISSIONS` deliberately does not map, since
25
+ * account-scoped endpoints span every product. And it has to be on **every** call here rather than only
26
+ * on the mint: `pithy token mint` is `rollToken → findTokenByName → listTokens`, then
27
+ * `listPermissionGroups`, and every one of those runs before `tokens.create` on the same credential. A
28
+ * hint reachable only from the mint is a hint an under-scoped bootstrap token never reaches (#534).
29
+ *
30
+ * `verifyToken` is the deliberate omission: any account token may call `/tokens/verify` with no
31
+ * permission at all, so a refusal there is a dead token or the wrong account, never a missing grant.
32
+ */
33
+ const API_TOKENS_ENDPOINT = { permission: "API Tokens" } as const;
34
+
16
35
  /**
17
36
  * One access policy to attach to a minted token: a set of permission groups (named, resolved to ids
18
37
  * at mint time) and the resources they apply to. This is the reusable unit — every use case that
@@ -132,30 +151,38 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
132
151
  * with no permission at all. Every other failure still throws.
133
152
  */
134
153
  async getTokenName(tokenId: string): Promise<string | null> {
135
- const raw = await cloudflareRequest(`get account token ${tokenId}`, async () => {
136
- try {
137
- return await this.getClient().accounts.tokens.get(tokenId, { account_id: this.accountId });
138
- } catch (error) {
139
- if (isAuthorizationError(error)) return null;
140
- throw error;
141
- }
142
- });
154
+ const raw = await cloudflareRequest(
155
+ `get account token ${tokenId}`,
156
+ async () => {
157
+ try {
158
+ return await this.getClient().accounts.tokens.get(tokenId, { account_id: this.accountId });
159
+ } catch (error) {
160
+ if (isAuthorizationError(error)) return null;
161
+ throw error;
162
+ }
163
+ },
164
+ API_TOKENS_ENDPOINT,
165
+ );
143
166
  const parsed = TokenName.safeParse(raw);
144
167
  return parsed.success ? parsed.data.name : null;
145
168
  }
146
169
 
147
170
  /** Every permission group available to account-owned tokens in this account (SDK auto-paginates). */
148
171
  async listPermissionGroups(): Promise<CfPermissionGroup[]> {
149
- return cloudflareRequest("list account token permission groups", async () => {
150
- const out: CfPermissionGroup[] = [];
151
- for await (const group of this.getClient().accounts.tokens.permissionGroups.list({
152
- account_id: this.accountId,
153
- })) {
154
- const parsed = CfPermissionGroup.safeParse(group);
155
- if (parsed.success) out.push(parsed.data);
156
- }
157
- return out;
158
- });
172
+ return cloudflareRequest(
173
+ "list account token permission groups",
174
+ async () => {
175
+ const out: CfPermissionGroup[] = [];
176
+ for await (const group of this.getClient().accounts.tokens.permissionGroups.list({
177
+ account_id: this.accountId,
178
+ })) {
179
+ const parsed = CfPermissionGroup.safeParse(group);
180
+ if (parsed.success) out.push(parsed.data);
181
+ }
182
+ return out;
183
+ },
184
+ API_TOKENS_ENDPOINT,
185
+ );
159
186
  }
160
187
 
161
188
  /**
@@ -189,19 +216,40 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
189
216
  raw = await this.getClient().accounts.tokens.create({ account_id: this.accountId, name, policies });
190
217
  } catch (error) {
191
218
  if (isAuthorizationError(error)) {
219
+ // The 403 keeps its own diagnosis — the token got in and may not create tokens, which is more
220
+ // than the generic three-way refusal can say — and it no longer keeps it *instead of*
221
+ // Cloudflare's answer. Round two moved the fallback below onto the shared composer and left
222
+ // this branch composing its own sentence, so the one status that carries a real diagnosis was
223
+ // also the one that dropped the API's `errors[]` on the floor. `cloudflareSaid` is that
224
+ // composition, exported for exactly this: a call site with a better problem line still gets
225
+ // the API's words, on one line, bounded, with the structured fields in `params`.
226
+ const said = cloudflareSaid(
227
+ "The Cloudflare API token is not allowed to create account tokens.",
228
+ cloudflareApiErrors(error),
229
+ );
192
230
  throw new CloudflareNotConfiguredError(
193
231
  {
194
- message: "The Cloudflare API token is not allowed to create account tokens.",
232
+ message: said.message,
195
233
  action: "Grant it 'Account API Tokens Write' (Account → API Tokens → Edit), then re-run.",
234
+ params: said.params,
196
235
  detail: `mint account token '${name}': ${messageOf(error)}`,
197
236
  },
198
237
  { cause: error },
199
238
  );
200
239
  }
201
- throw new CloudflareRequestError(
202
- { message: `Failed to mint account token '${name}'.`, detail: messageOf(error) },
203
- { cause: error },
204
- );
240
+ // The shared composer, never a hand-built refusal. The branch above is 403-only on purpose —
241
+ // `isAuthorizationError` stays strictly 403 because its other callers *swallow* a denial — and a
242
+ // 401 is the status the token bootstrap actually fails under, so every 401 landed here and
243
+ // printed one sentence with no code, no link and nothing to do. `permission` names the group
244
+ // this endpoint needs, because Cloudflare's docs link for `accounts/tokens` does not.
245
+ throw cloudflareRefusal({
246
+ problem: `Failed to mint account token '${name}'.`,
247
+ apiErrors: cloudflareApiErrors(error),
248
+ status: statusOf(error),
249
+ permission: "API Tokens",
250
+ detail: messageOf(error),
251
+ cause: error,
252
+ });
205
253
  }
206
254
  return decodeResponse(MintedAccountToken, raw, "account token create");
207
255
  }
@@ -235,31 +283,41 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
235
283
 
236
284
  /** Every account-owned token's metadata (SDK auto-paginates). No secret values — list is metadata only. */
237
285
  async listTokens(): Promise<AccountTokenSummary[]> {
238
- return cloudflareRequest("list account tokens", async () => {
239
- const out: AccountTokenSummary[] = [];
240
- for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
241
- const parsed = AccountTokenSummary.safeParse(token);
242
- if (parsed.success) out.push(parsed.data);
243
- }
244
- return out;
245
- });
286
+ return cloudflareRequest(
287
+ "list account tokens",
288
+ async () => {
289
+ const out: AccountTokenSummary[] = [];
290
+ for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
291
+ const parsed = AccountTokenSummary.safeParse(token);
292
+ if (parsed.success) out.push(parsed.data);
293
+ }
294
+ return out;
295
+ },
296
+ API_TOKENS_ENDPOINT,
297
+ );
246
298
  }
247
299
 
248
300
  /** Find an account token by exact name, or `null` — for idempotent re-mint (SDK auto-paginates). */
249
301
  async findTokenByName(name: string): Promise<AccountTokenSummary | null> {
250
- return cloudflareRequest(`find account token ${name}`, async () => {
251
- for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
252
- const parsed = AccountTokenSummary.safeParse(token);
253
- if (parsed.success && parsed.data.name === name) return parsed.data;
254
- }
255
- return null;
256
- });
302
+ return cloudflareRequest(
303
+ `find account token ${name}`,
304
+ async () => {
305
+ for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
306
+ const parsed = AccountTokenSummary.safeParse(token);
307
+ if (parsed.success && parsed.data.name === name) return parsed.data;
308
+ }
309
+ return null;
310
+ },
311
+ API_TOKENS_ENDPOINT,
312
+ );
257
313
  }
258
314
 
259
315
  /** Delete an account token by id. */
260
316
  async deleteToken(tokenId: string): Promise<void> {
261
- await cloudflareRequest(`delete account token ${tokenId}`, () =>
262
- this.getClient().accounts.tokens.delete(tokenId, { account_id: this.accountId }),
317
+ await cloudflareRequest(
318
+ `delete account token ${tokenId}`,
319
+ () => this.getClient().accounts.tokens.delete(tokenId, { account_id: this.accountId }),
320
+ API_TOKENS_ENDPOINT,
263
321
  );
264
322
  }
265
323
 
@@ -269,17 +327,21 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
269
327
  * when none match. Used by teardown.
270
328
  */
271
329
  async deleteTokensByName(name: string): Promise<number> {
272
- return cloudflareRequest(`delete account tokens named ${name}`, async () => {
273
- const ids: string[] = [];
274
- for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
275
- const parsed = AccountTokenSummary.safeParse(token);
276
- if (parsed.success && parsed.data.name === name) ids.push(parsed.data.id);
277
- }
278
- for (const id of ids) {
279
- await this.getClient().accounts.tokens.delete(id, { account_id: this.accountId });
280
- }
281
- return ids.length;
282
- });
330
+ return cloudflareRequest(
331
+ `delete account tokens named ${name}`,
332
+ async () => {
333
+ const ids: string[] = [];
334
+ for await (const token of this.getClient().accounts.tokens.list({ account_id: this.accountId })) {
335
+ const parsed = AccountTokenSummary.safeParse(token);
336
+ if (parsed.success && parsed.data.name === name) ids.push(parsed.data.id);
337
+ }
338
+ for (const id of ids) {
339
+ await this.getClient().accounts.tokens.delete(id, { account_id: this.accountId });
340
+ }
341
+ return ids.length;
342
+ },
343
+ API_TOKENS_ENDPOINT,
344
+ );
283
345
  }
284
346
 
285
347
  /**
@@ -289,8 +351,10 @@ export class CloudflareAccountTokensManager extends CloudflareManager {
289
351
  * builds on: a credential self-rolls without ever changing identity.
290
352
  */
291
353
  async rollTokenValue(tokenId: string): Promise<string> {
292
- const raw = await cloudflareRequest(`roll account token value ${tokenId}`, () =>
293
- this.getClient().accounts.tokens.value.update(tokenId, { account_id: this.accountId, body: {} }),
354
+ const raw = await cloudflareRequest(
355
+ `roll account token value ${tokenId}`,
356
+ () => this.getClient().accounts.tokens.value.update(tokenId, { account_id: this.accountId, body: {} }),
357
+ API_TOKENS_ENDPOINT,
294
358
  );
295
359
  return decodeResponse(RolledTokenValue, raw, "account token value roll");
296
360
  }
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  import {
6
6
  CloudflareInvalidResponseError,
7
7
  CloudflareRequestError,
8
+ cloudflareApiErrors,
9
+ cloudflareRefusal,
8
10
  cloudflareRequest,
9
11
  decodeResponse,
10
12
  messageOf,
@@ -32,7 +34,17 @@ const NOT_FOUND = 12000;
32
34
  const CfEnvelope = z.object({
33
35
  result: z.unknown(),
34
36
  success: z.boolean().optional(),
35
- errors: z.array(z.object({ code: z.number().optional(), message: z.string().optional() }).loose()).optional(),
37
+ errors: z
38
+ .array(
39
+ z
40
+ .object({
41
+ code: z.number().optional(),
42
+ message: z.string().optional(),
43
+ documentation_url: z.string().optional(),
44
+ })
45
+ .loose(),
46
+ )
47
+ .optional(),
36
48
  });
37
49
 
38
50
  /** Arguments for `createRepoConnection`. */
@@ -138,12 +150,15 @@ export class CloudflareBuildsManager extends CloudflareManager {
138
150
  // CF signals failure two ways: a non-2xx status, or a 200 body with `success: false`.
139
151
  const apiReportedFailure = envelope.success && envelope.data.success === false;
140
152
  if (!response.ok || apiReportedFailure) {
141
- const codes = envelope.success
142
- ? (envelope.data.errors ?? []).map((e) => e.code).filter((c): c is number => c !== undefined)
143
- : [];
153
+ const errors = envelope.success ? (envelope.data.errors ?? []) : [];
154
+ const codes = errors.map((e) => e.code).filter((c): c is number => c !== undefined);
144
155
  const marker = codes.length > 0 ? `[cf-codes:${codes.join(",")}] ` : "";
145
- throw new CloudflareRequestError({
146
- message: `Cloudflare Builds returned ${response.status}.`,
156
+ // The shared composer, so the one manager on the raw-`fetch` escape hatch refuses with the same
157
+ // projection of Cloudflare's `errors[]` as the 133 calls that go through `cloudflareRequest`.
158
+ throw cloudflareRefusal({
159
+ problem: `Cloudflare Builds returned ${response.status}.`,
160
+ apiErrors: cloudflareApiErrors(errors),
161
+ status: response.status,
147
162
  detail: `${marker}${method} ${path} → ${response.status}: ${text}`.slice(0, 2000),
148
163
  });
149
164
  }
@@ -5,11 +5,12 @@ import { NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyEr
5
5
  import type { Cloudflare } from "cloudflare";
6
6
  import type { RouteCreateResponse, RouteListResponse } from "cloudflare/resources/workers/routes";
7
7
  import type { Deployment } from "cloudflare/resources/workers/scripts/deployments";
8
+ import type { ScriptAndVersionSettingGetResponse } from "cloudflare/resources/workers/scripts/script-and-version-settings";
8
9
  import type { Script } from "cloudflare/resources/workers/scripts/scripts";
9
10
  import type { SecretListResponse } from "cloudflare/resources/workers/scripts/secrets";
10
11
  import type { SettingEditParams } from "cloudflare/resources/workers/scripts/settings";
11
12
  import type { VersionGetResponse, VersionListResponse } from "cloudflare/resources/workers/scripts/versions";
12
- import { CloudflareInvalidResponseError, cloudflareRequest, messageOf } from "../client/errors";
13
+ import { CloudflareInvalidResponseError, cloudflareRequest, isNotFoundError, messageOf } from "../client/errors";
13
14
  import { CloudflareManager } from "../client/manager";
14
15
 
15
16
  /** Per-call SDK timeout + retry budget for Worker management operations. */
@@ -212,6 +213,45 @@ export class CloudflareWorkersManager extends CloudflareManager {
212
213
  );
213
214
  }
214
215
 
216
+ /**
217
+ * A deployed Worker's **plain-text vars**, keyed by binding name — or `null` when the account holds
218
+ * no Worker of that name.
219
+ *
220
+ * Read through `scripts/script-and-version-settings`, which answers the *deployed* script's binding
221
+ * list in one call. {@link listVersions} + {@link getVersion} would answer the same question in two,
222
+ * off the newest *uploaded* version — which is not necessarily the deployed one under gradual
223
+ * rollouts, and which costs a round trip to learn a version id nobody wanted. So: settings.
224
+ *
225
+ * **Only `plain_text`.** A `secret_text` binding's value is never returned by the API, and a caller
226
+ * that wanted one would be asking the wrong question anyway; every other binding kind is a resource
227
+ * reference rather than a value. So this is the vars block a `wrangler.jsonc` declares, read back.
228
+ *
229
+ * `null` for a missing Worker rather than a throw, because "there is no such Worker" is an ordinary
230
+ * answer to this question — it is what a first deploy looks like. Every other failure throws, so a
231
+ * caller can tell *not deployed* from *the account would not answer*: {@link CloudflareWorkersManager}
232
+ * has no business collapsing those two into one silence, and #537's gate reports them differently.
233
+ */
234
+ async getWorkerVars(scriptName: string): Promise<Record<string, string> | null> {
235
+ return cloudflareRequest(`get vars for '${scriptName}'`, async () => {
236
+ let settings: ScriptAndVersionSettingGetResponse;
237
+ try {
238
+ settings = await this.getClient().workers.scripts.scriptAndVersionSettings.get(
239
+ scriptName,
240
+ { account_id: this.accountId },
241
+ requestOptions,
242
+ );
243
+ } catch (error) {
244
+ if (isNotFoundError(error)) return null;
245
+ throw error;
246
+ }
247
+ const vars: Record<string, string> = {};
248
+ for (const binding of settings.bindings ?? []) {
249
+ if (binding.type === "plain_text") vars[binding.name] = binding.text;
250
+ }
251
+ return vars;
252
+ });
253
+ }
254
+
215
255
  /** Edit a Worker's settings (observability, logpush, tags, …). */
216
256
  async updateSettings(scriptName: string, settings: WorkerSettings): Promise<void> {
217
257
  await cloudflareRequest(`update settings for '${scriptName}'`, () =>
@@ -1,8 +1,10 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
+ import { guaranteedErrorParams } from "@pithy-sh/core/src/error/messageParams";
4
5
  import { ErrorPayload, kitErrorStatus } from "@pithy-sh/core/src/error/payload";
5
6
  import { PithyError } from "@pithy-sh/core/src/error/pithyError";
7
+ import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
6
8
  import {
7
9
  decodeWorkflowStepMessage,
8
10
  MAX_WORKFLOW_STEP_TEXT,
@@ -250,6 +252,34 @@ export const WORKFLOW_FAILED_STATUS = 500 as const;
250
252
  * the boundary — and nothing derived from the step's own `detail` is in it, because the step's
251
253
  * `detail` never left the step. The only fields promoted from the far side are `message` and `action`,
252
254
  * both already public, both already proved kit-authored by `kitSentence`.
255
+ *
256
+ * ## `params` did not cross either, and a recovered code still has to keep their promise
257
+ *
258
+ * `core`'s `GUARANTEED_ERROR_PARAMS` states the names a code's throw sites pass on **every** path, and
259
+ * a locale writes `{apiAnswer}` into its sentence against that statement. Re-raising a recovered code
260
+ * here is a throw site like any other — it just happens to be one whose inputs came over a wire that
261
+ * carries three fields. So every guaranteed name is supplied, as the empty string: `interpolate` leaves
262
+ * an unsupplied placeholder written out, and `{apiAnswer}` on a Spanish reader's screen is the exact
263
+ * defect the declaration exists to prevent. Empty is also the honest value — it is what the catalogs
264
+ * define as *no answer to quote*, and the answer genuinely did not cross.
265
+ *
266
+ * **One reader does lose something by it, and it is worth naming rather than glossing.** An earlier
267
+ * version of this comment said nothing was lost, on the grounds that the API's words are already inside
268
+ * the promoted English `message`. That is true of the operator's terminal, the `--json` line and any
269
+ * client with no catalog — but the contract is `t.maybe(payload.code, payload.params) ?? payload.message`
270
+ * (`core/src/i18n/translator.ts`), and `maybe` returns null only on a catalog **miss**. `es` has an entry
271
+ * for this code, so a Spanish reader gets the translated sentence with an empty `{apiAnswer}` rather
272
+ * than falling through to the English one that carries the answer:
273
+ *
274
+ * ```
275
+ * at the CLI No se ha podido completar la llamada a Cloudflare: 10000 Authentication error.
276
+ * across a step No se ha podido completar la llamada a Cloudflare.
277
+ * ```
278
+ *
279
+ * The step channel carries three fields and `params` is not one of them, so closing this means widening
280
+ * the channel or re-parsing the promoted sentence — the first is a change to a durable format, the
281
+ * second reconstructs data from prose. Neither belongs in a re-raise, so the gap stands and is written
282
+ * down here instead of being claimed away.
253
283
  */
254
284
  export function terminalWorkflowError(args: {
255
285
  /** What the instance's steps said, or `null` when no step reported a failure. */
@@ -267,7 +297,7 @@ export function terminalWorkflowError(args: {
267
297
 
268
298
  const status = failure?.code === undefined ? undefined : kitErrorStatus(failure.code);
269
299
  if (failure?.sentence !== undefined && failure.code !== undefined && status !== undefined) {
270
- const candidate = { code: failure.code, status, message, action, detail };
300
+ const candidate = { code: failure.code, status, message, action, detail, params: crossedParams(failure.code) };
271
301
  // Parsed rather than trusted. A recovered code is a string from a Worker we did not write, and one
272
302
  // kit member (`validation/invalid_input`) requires a field this boundary has no way to supply — so
273
303
  // a payload that would not validate must not be thrown from the error path. It falls through to
@@ -278,3 +308,16 @@ export function terminalWorkflowError(args: {
278
308
 
279
309
  return new PithyError({ code: WORKFLOW_FAILED_CODE, status: WORKFLOW_FAILED_STATUS, message, action, detail });
280
310
  }
311
+
312
+ /**
313
+ * The `params` a re-raised code owes a translating client: every name its throw sites guarantee, empty.
314
+ *
315
+ * `undefined` when the code guarantees none, so a payload for the other 60-odd codes is byte-identical
316
+ * to what it was — an empty `params` object on every recovered error would be a new field on the wire
317
+ * for no reader.
318
+ */
319
+ function crossedParams(code: string): MessageParams | undefined {
320
+ const names = guaranteedErrorParams(code);
321
+ if (names.length === 0) return undefined;
322
+ return Object.fromEntries(names.map((name) => [name, ""]));
323
+ }