@pithy-sh/cloudflare 0.4.1 → 0.5.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 (52) hide show
  1. package/README.md +3 -3
  2. package/dist/ai/aiManager.d.ts +15 -1
  3. package/dist/ai/aiManager.d.ts.map +1 -1
  4. package/dist/ai/aiManager.js +39 -6
  5. package/dist/client/manager.d.ts +13 -2
  6. package/dist/client/manager.d.ts.map +1 -1
  7. package/dist/client/manager.js +18 -5
  8. package/dist/d1/d1Manager.d.ts.map +1 -1
  9. package/dist/d1/d1Manager.js +3 -3
  10. package/dist/d1/d1PreparedStatement.d.ts +12 -0
  11. package/dist/d1/d1PreparedStatement.d.ts.map +1 -1
  12. package/dist/d1/d1PreparedStatement.js +13 -3
  13. package/dist/oauth/callback.d.ts +32 -0
  14. package/dist/oauth/callback.d.ts.map +1 -0
  15. package/dist/oauth/callback.js +58 -0
  16. package/dist/oauth/catalog.d.ts +26 -0
  17. package/dist/oauth/catalog.d.ts.map +1 -0
  18. package/dist/oauth/catalog.js +30 -0
  19. package/dist/oauth/client.d.ts +126 -0
  20. package/dist/oauth/client.d.ts.map +1 -0
  21. package/dist/oauth/client.js +200 -0
  22. package/dist/oauth/scopes.d.ts +62 -0
  23. package/dist/oauth/scopes.d.ts.map +1 -0
  24. package/dist/oauth/scopes.js +79 -0
  25. package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
  26. package/dist/secrets/secretsStoreManager.js +3 -2
  27. package/dist/test-utils/settle.d.ts +79 -0
  28. package/dist/test-utils/settle.d.ts.map +1 -0
  29. package/dist/test-utils/settle.js +105 -0
  30. package/dist/tokens/accountTokensManager.d.ts +7 -7
  31. package/dist/tokens/permissions.d.ts +39 -0
  32. package/dist/tokens/permissions.d.ts.map +1 -1
  33. package/dist/tokens/permissions.js +45 -1
  34. package/dist/tokens/profiles.d.ts +4 -0
  35. package/dist/tokens/profiles.d.ts.map +1 -1
  36. package/dist/tokens/profiles.js +4 -0
  37. package/dist/workers/buildsManager.js +1 -1
  38. package/dist/workflows/stepFailure.js +1 -1
  39. package/dist/workflows/workflowsClient.js +1 -1
  40. package/package.json +2 -2
  41. package/src/ai/aiManager.ts +44 -5
  42. package/src/client/manager.ts +24 -6
  43. package/src/d1/d1Manager.ts +7 -3
  44. package/src/d1/d1PreparedStatement.ts +21 -2
  45. package/src/oauth/callback.ts +70 -0
  46. package/src/oauth/catalog.ts +39 -0
  47. package/src/oauth/client.ts +265 -0
  48. package/src/oauth/scopes.ts +91 -0
  49. package/src/secrets/secretsStoreManager.ts +2 -1
  50. package/src/test-utils/settle.ts +139 -0
  51. package/src/tokens/permissions.ts +54 -0
  52. package/src/tokens/profiles.ts +4 -0
package/README.md CHANGED
@@ -16,7 +16,7 @@ _Everything on the adopter side is on the site. `pithy.sh/docs` is canonical —
16
16
 
17
17
  ## Live integration tests
18
18
 
19
- Mocks prove our call *shapes*. They cannot prove the request, the response decoding, and the error handling are functionally correct against real Cloudflare — that only surfaces live. So every manager that makes real CF calls also has a `*.integration.test.ts` that creates a throwaway resource, exercises the manager against it, and tears it down. These are excluded from the default suite and run via `bun run test:integration` (the `vitest.integration.config.ts` project), gated on credentials so they skip cleanly without them.
19
+ Mocks prove our call *shapes*. They cannot prove the request, the response decoding, and the error handling are functionally correct against real Cloudflare — that only surfaces live. So every manager that makes real CF calls also has a `*.integration.test.ts` that creates a throwaway resource, exercises the manager against it, and tears it down. These are excluded from the default suite and run via turbo's `test:integration` (the `vitest.integration.config.ts` project), gated on credentials so they skip cleanly without them. Turbo builds `@pithy-sh/core` first, because the suites import it through its `exports` map into `dist/`; the package's own `bun run test:integration` skips that build, so it fails on a fresh checkout until something has built core.
20
20
 
21
21
  Credentials come from `packages/cloudflare/.dev.vars`, with `process.env` overlaid per key for anything the file does not set (`loadCloudflareEnv`). **Nothing creates that file.** It used to be a symlink to the root's, wired by a `vars:local` task; #154 removed both, and `pithy dev` and `pithy seed` generate `apps/<worker>/.dev.vars` in an adopter's project — `apps/` is the registry, so nothing regenerates a file in a kit package. Write it, or export the variables:
22
22
 
@@ -25,10 +25,10 @@ cat > .dev.vars <<'EOF' # a real file, in this package, git-ignored
25
25
  CLOUDFLARE_ACCOUNT_ID=…
26
26
  CLOUDFLARE_API_TOKEN=…
27
27
  EOF
28
- bun run test:integration # against the account in those creds
28
+ bunx turbo run test:integration --filter=@pithy-sh/cloudflare # builds core first, then runs against those creds
29
29
  ```
30
30
 
31
- Exporting them instead works identically and is how CI runs — the workflow sets `CLOUDFLARE_*` and `SECRETS_STORE_ID` from the `E2E Integration Testing` environment with no `.dev.vars` present. Set a key in both places and the file wins.
31
+ Exporting them instead works identically and is how CI runs — the workflow sets `CLOUDFLARE_*` and `SECRETS_STORE_ID` from the `E2E Integration Testing` environment with no `.dev.vars` present, and runs the suites through turbo, which builds first and hands a task only the variables `test:integration` lists in `passThroughEnv` (`turbo.jsonc`). Set a key in both places and the file wins.
32
32
 
33
33
  Point them at a **dedicated test account** — they create and delete real resources.
34
34
 
@@ -64,6 +64,17 @@ export interface AnalyzeImageOptions {
64
64
  /** Upper bound on generated tokens. */
65
65
  maxTokens?: number;
66
66
  }
67
+ /**
68
+ * The REST path that runs `model`: `/accounts/<id>/ai/run/@cf/baai/bge-m3`, the model's slashes literal.
69
+ *
70
+ * **Not the SDK's `ai.run` (#709).** cloudflare 7.0.0 builds that path with its `path` tag, which
71
+ * percent-encodes every path parameter, the `/`s inside a model name included. Cloudflare routes on the
72
+ * literal slashes, so `/ai/run/@cf%2Fbaai%2Fbge-m3` answers 7000 "No route for that URI" for every model.
73
+ * The path is built here instead, and a name that could change its shape is refused by {@link AI_MODEL_NAME}.
74
+ *
75
+ * @throws ValidationError (`validation/invalid_input`) when `model` is not a Workers AI model name.
76
+ */
77
+ export declare function aiRunPath(accountId: string, model: string): string;
67
78
  /**
68
79
  * Out-of-Worker Workers AI access over the REST API: run any model, generate text, generate
69
80
  * embeddings, analyze an image, and list models from a CLI/CI/provisioning context. Inside a
@@ -74,7 +85,10 @@ export interface AnalyzeImageOptions {
74
85
  export declare class CloudflareAIManager extends CloudflareManager {
75
86
  /**
76
87
  * Run a Cloudflare AI model by name with an arbitrary JSON input. The single seam every typed
77
- * helper builds on. The account id is injected for you; the rest of `input` is the model's body.
88
+ * helper builds on. The account is addressed for you; `input` is the model's body.
89
+ *
90
+ * Through the SDK's raw `post` to {@link aiRunPath}, not `ai.run`, whose path encoding Cloudflare cannot
91
+ * route. A malformed model name throws `validation/invalid_input` before any request is sent.
78
92
  */
79
93
  runModel(model: string, input: Record<string, unknown>): Promise<AIRunResponse>;
80
94
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"aiManager.d.ts","sourceRoot":"","sources":["../../src/ai/aiManager.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAe,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC7E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAC;AAE/E,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAiBtD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB;;;iBAKgE,CAAC;AAC9F,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAuChE,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;GAckE,CAAC;AAC9F,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE7D;;GAEG;AACH,eAAO,MAAM,WAAW;;iBAI8D,CAAC;AACvF,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,kCAAkC;AAClC,MAAM,WAAW,mBAAmB;IAClC,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,+DAA+D;AAC/D,MAAM,WAAW,WAAW;IAC1B,+BAA+B;IAC/B,IAAI,EAAE,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;IACtC,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,kCAAkC;AAClC,MAAM,WAAW,mBAAmB;IAClC,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uCAAuC;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID;;;;;;GAMG;AACH,qBAAa,mBAAoB,SAAQ,iBAAiB;IACxD;;;OAGG;IACG,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAIpF;IAED;;;OAGG;IACG,YAAY,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,CAclG;IAED;;;OAGG;IACG,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAU5F;IAED;;;OAGG;IACG,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,CAAC,CAezF;IAED,kGAAkG;IAC5F,UAAU,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAK/C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oDAAoD;IAC9C,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;CACF"}
1
+ {"version":3,"file":"aiManager.d.ts","sourceRoot":"","sources":["../../src/ai/aiManager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAC;AAE/E,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAiBtD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB;;;iBAKgE,CAAC;AAC9F,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAuChE,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;GAckE,CAAC;AAC9F,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE7D;;GAEG;AACH,eAAO,MAAM,WAAW;;iBAI8D,CAAC;AACvF,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,kCAAkC;AAClC,MAAM,WAAW,mBAAmB;IAClC,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,+DAA+D;AAC/D,MAAM,WAAW,WAAW;IAC1B,+BAA+B;IAC/B,IAAI,EAAE,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;IACtC,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,kCAAkC;AAClC,MAAM,WAAW,mBAAmB;IAClC,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uCAAuC;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgBD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CASlE;AAED;;;;;;GAMG;AACH,qBAAa,mBAAoB,SAAQ,iBAAiB;IACxD;;;;;;OAMG;IACG,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAMpF;IAED;;;OAGG;IACG,YAAY,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,CAclG;IAED;;;OAGG;IACG,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAU5F;IAED;;;OAGG;IACG,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,CAAC,CAezF;IAED,kGAAkG;IAC5F,UAAU,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAK/C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oDAAoD;IAC9C,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;CACF"}
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
6
  import { fromUint8Array } from "js-base64";
6
7
  import { z } from "zod";
7
8
  //#region src/ai/aiManager.ts
@@ -62,6 +63,35 @@ const TextGeneration = z.union([FlatTextGeneration, ChatCompletionText]).describ
62
63
  const ImageToText = z.object({ description: z.string().describe("The model's textual description of the image.") }).describe("A Cloudflare image-to-text result: a description of the supplied image.");
63
64
  const DEFAULT_IMAGE_PROMPT = "Describe this image in a concise sentence suitable for alt text.";
64
65
  /**
66
+ * What a Workers AI model name may look like: `@<namespace>/` and then one to three segments, each made of
67
+ * letters, digits, `.`, `_` and `-` and starting with a letter or digit — `@cf/baai/bge-m3`,
68
+ * `@cf/llava-hf/llava-1.5-7b-hf`, `@hf/thebloke/deepseek-coder-6.7b-instruct-awq`.
69
+ *
70
+ * **The name goes into the request path unencoded, so this pattern is the whole of the defense.** A segment
71
+ * cannot be `.` or `..` (it must start with a letter or digit), and nothing that ends a path, opens a query
72
+ * or a fragment, or escapes a character (`?`, `#`, `%`, `\`, whitespace) is in the alphabet. A name outside
73
+ * it is refused before a request exists.
74
+ */
75
+ const AI_MODEL_NAME = /^@[a-z0-9][a-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*){1,3}$/;
76
+ /**
77
+ * The REST path that runs `model`: `/accounts/<id>/ai/run/@cf/baai/bge-m3`, the model's slashes literal.
78
+ *
79
+ * **Not the SDK's `ai.run` (#709).** cloudflare 7.0.0 builds that path with its `path` tag, which
80
+ * percent-encodes every path parameter, the `/`s inside a model name included. Cloudflare routes on the
81
+ * literal slashes, so `/ai/run/@cf%2Fbaai%2Fbge-m3` answers 7000 "No route for that URI" for every model.
82
+ * The path is built here instead, and a name that could change its shape is refused by {@link AI_MODEL_NAME}.
83
+ *
84
+ * @throws ValidationError (`validation/invalid_input`) when `model` is not a Workers AI model name.
85
+ */
86
+ function aiRunPath(accountId, model) {
87
+ if (!AI_MODEL_NAME.test(model)) throw new ValidationError({
88
+ message: `"${model.slice(0, 120)}" is not a Workers AI model name.`,
89
+ action: "Pass a model name such as @cf/baai/bge-m3. The Workers AI model catalog lists them.",
90
+ detail: `model name ${JSON.stringify(model)} does not match ${AI_MODEL_NAME}`
91
+ });
92
+ return `/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`;
93
+ }
94
+ /**
65
95
  * Out-of-Worker Workers AI access over the REST API: run any model, generate text, generate
66
96
  * embeddings, analyze an image, and list models from a CLI/CI/provisioning context. Inside a
67
97
  * Worker you use the `Ai` binding directly — this manager is the REST counterpart, addressed by
@@ -71,13 +101,16 @@ const DEFAULT_IMAGE_PROMPT = "Describe this image in a concise sentence suitable
71
101
  var CloudflareAIManager = class extends CloudflareManager {
72
102
  /**
73
103
  * Run a Cloudflare AI model by name with an arbitrary JSON input. The single seam every typed
74
- * helper builds on. The account id is injected for you; the rest of `input` is the model's body.
104
+ * helper builds on. The account is addressed for you; `input` is the model's body.
105
+ *
106
+ * Through the SDK's raw `post` to {@link aiRunPath}, not `ai.run`, whose path encoding Cloudflare cannot
107
+ * route. A malformed model name throws `validation/invalid_input` before any request is sent.
75
108
  */
76
109
  async runModel(model, input) {
77
- return cloudflareRequest(`AI run model '${model}'`, () => this.getClient().ai.run(model, {
78
- account_id: this.accountId,
79
- ...input
80
- }));
110
+ const path = aiRunPath(this.accountId, model);
111
+ return cloudflareRequest(`AI run model '${model}'`, async () => {
112
+ return (await this.getClient().post(path, { body: input })).result;
113
+ });
81
114
  }
82
115
  /**
83
116
  * Generate text from a chat prompt. Returns the validated `{ response }` payload. A response that
@@ -147,4 +180,4 @@ var CloudflareAIManager = class extends CloudflareManager {
147
180
  }
148
181
  };
149
182
  //#endregion
150
- export { CloudflareAIManager, ImageToText, TextGeneration, VectorEmbeddings };
183
+ export { CloudflareAIManager, ImageToText, TextGeneration, VectorEmbeddings, aiRunPath };
@@ -14,6 +14,15 @@ export interface CloudflareManagerConfig {
14
14
  apiToken: string;
15
15
  /** The Cloudflare account id all operations target (`CLOUDFLARE_ACCOUNT_ID`). */
16
16
  accountId: string;
17
+ /**
18
+ * The token to call with *now*, read at every call, when it can change during the manager's life (#759).
19
+ *
20
+ * A signed-in session's access token lasts an hour and is renewed on disk ahead of that, so a manager
21
+ * built at the start of a long run would otherwise keep sending the token it was constructed with.
22
+ * Absent, {@link apiToken} is the token for good — which is every command that finishes inside the hour
23
+ * dispatch guarantees it.
24
+ */
25
+ tokenProvider?: () => string;
17
26
  }
18
27
  /**
19
28
  * Base for every Cloudflare resource manager. Holds the configured SDK client and account id, and
@@ -22,8 +31,9 @@ export interface CloudflareManagerConfig {
22
31
  * (`validateServiceAccess`).
23
32
  */
24
33
  export declare abstract class CloudflareManager {
25
- private readonly client;
26
- private readonly apiToken;
34
+ /** The SDK client, and the token it was built for — rebuilt when {@link getApiToken} answers another. */
35
+ private client;
36
+ private readonly token;
27
37
  protected readonly accountId: string;
28
38
  constructor(config: CloudflareManagerConfig);
29
39
  /** The configured SDK client. Subclasses route typed calls through this. */
@@ -31,6 +41,7 @@ export declare abstract class CloudflareManager {
31
41
  /**
32
42
  * The raw API token, for subclasses that fall back to direct `fetch()` for endpoints the typed
33
43
  * SDK does not cover (e.g. Workers Builds, event subscriptions). The documented escape hatch.
44
+ * Read through the provider at every call, so it is the current token, never the constructed one.
34
45
  */
35
46
  protected getApiToken(): string;
36
47
  /** The account id all operations target. */
@@ -1 +1 @@
1
- {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/client/manager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wCAAwC,CAAC;AAGtE;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IACtC,oGAAoG;IACpG,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,8BAAsB,iBAAiB;IACrC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAElC,SAAS,CAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAErC,YAAY,MAAM,EAAE,uBAAuB,EAU1C;IAED,4EAA4E;IAC5E,SAAS,CAAC,SAAS,IAAI,UAAU,CAEhC;IAED;;;OAGG;IACH,SAAS,CAAC,WAAW,IAAI,MAAM,CAE9B;IAED,4CAA4C;IAC5C,SAAS,CAAC,YAAY,IAAI,MAAM,CAE/B;IAED,wFAAwF;IAClF,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC,CAEvC;IAED,wFAAwF;IAClF,mBAAmB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO5C;IAED,4EAA4E;IAC5E,QAAQ,CAAC,cAAc,IAAI,MAAM,CAAC;IAElC,kGAAkG;IAClG,QAAQ,CAAC,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CACpD"}
1
+ {"version":3,"file":"manager.d.ts","sourceRoot":"","sources":["../../src/client/manager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACxC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,wCAAwC,CAAC;AAGtE;;;;;;GAMG;AACH,MAAM,WAAW,uBAAuB;IACtC,oGAAoG;IACpG,QAAQ,EAAE,MAAM,CAAC;IACjB,iFAAiF;IACjF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,8BAAsB,iBAAiB;IACrC,yGAAyG;IACzG,OAAO,CAAC,MAAM,CAAqC;IAEnD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAe;IAErC,SAAS,CAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAErC,YAAY,MAAM,EAAE,uBAAuB,EAW1C;IAED,4EAA4E;IAC5E,SAAS,CAAC,SAAS,IAAI,UAAU,CAIhC;IAED;;;;OAIG;IACH,SAAS,CAAC,WAAW,IAAI,MAAM,CAM9B;IAED,4CAA4C;IAC5C,SAAS,CAAC,YAAY,IAAI,MAAM,CAE/B;IAED,wFAAwF;IAClF,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC,CAEvC;IAED,wFAAwF;IAClF,mBAAmB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO5C;IAED,4EAA4E;IAC5E,QAAQ,CAAC,cAAc,IAAI,MAAM,CAAC;IAElC,kGAAkG;IAClG,QAAQ,CAAC,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CACpD"}
@@ -10,26 +10,39 @@ import { Cloudflare } from "cloudflare";
10
10
  * (`validateServiceAccess`).
11
11
  */
12
12
  var CloudflareManager = class {
13
+ /** The SDK client, and the token it was built for — rebuilt when {@link getApiToken} answers another. */
13
14
  client;
14
- apiToken;
15
+ token;
15
16
  accountId;
16
17
  constructor(config) {
17
18
  if (!config.apiToken) throw new CloudflareNotConfiguredError({ detail: "Missing apiToken in CloudflareManagerConfig." });
18
19
  if (!config.accountId) throw new CloudflareNotConfiguredError({ detail: "Missing accountId in CloudflareManagerConfig." });
19
- this.client = new Cloudflare({ apiToken: config.apiToken });
20
- this.apiToken = config.apiToken;
20
+ const initial = config.apiToken;
21
+ this.token = config.tokenProvider ?? (() => initial);
22
+ this.client = {
23
+ token: initial,
24
+ sdk: new Cloudflare({ apiToken: initial })
25
+ };
21
26
  this.accountId = config.accountId;
22
27
  }
23
28
  /** The configured SDK client. Subclasses route typed calls through this. */
24
29
  getClient() {
25
- return this.client;
30
+ const token = this.getApiToken();
31
+ if (token !== this.client.token) this.client = {
32
+ token,
33
+ sdk: new Cloudflare({ apiToken: token })
34
+ };
35
+ return this.client.sdk;
26
36
  }
27
37
  /**
28
38
  * The raw API token, for subclasses that fall back to direct `fetch()` for endpoints the typed
29
39
  * SDK does not cover (e.g. Workers Builds, event subscriptions). The documented escape hatch.
40
+ * Read through the provider at every call, so it is the current token, never the constructed one.
30
41
  */
31
42
  getApiToken() {
32
- return this.apiToken;
43
+ const token = this.token();
44
+ if (!token) throw new CloudflareNotConfiguredError({ detail: "The tokenProvider answered no apiToken." });
45
+ return token;
33
46
  }
34
47
  /** The account id all operations target. */
35
48
  getAccountId() {
@@ -1 +1 @@
1
- {"version":3,"file":"d1Manager.d.ts","sourceRoot":"","sources":["../../src/d1/d1Manager.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,mBAAmB,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAE3F,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,4BAA4B,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AAEpE,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAGpF,wFAAwF;AACxF,MAAM,WAAW,eAAgB,SAAQ,uBAAuB;IAC9D,mEAAmE;IACnE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,+FAA+F;AAC/F,MAAM,WAAW,aAAa;IAC5B,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,qBAAa,mBAAoB,SAAQ,iBAAkB,YAAW,UAAU;IAC9E,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC,YAAY,MAAM,EAAE,eAAe,EAMlC;IAID,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,mBAAmB,CAE1C;IAED;;;;;;OAMG;IACG,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAMlF;IAEK,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAM/C;IAED,6FAA6F;IACvF,IAAI,IAAI,OAAO,CAAC,WAAW,CAAC,CAEjC;IAED,8EAA8E;IAC9E,WAAW,IAAI,KAAK,CAEnB;IAID,iEAAiE;IAC3D,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CAKzE;IAED;;;OAGG;IACG,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAW/F;IAED,oCAAoC;IAC9B,eAAe,IAAI,OAAO,CAAC,EAAE,CAAC,CAInC;IAED,4CAA4C;IACtC,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAMpC;IAED;;;;OAIG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAS1D;IAED,qEAAqE;IACrE,SAAS,IAAI;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAErD;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;CACF"}
1
+ {"version":3,"file":"d1Manager.d.ts","sourceRoot":"","sources":["../../src/d1/d1Manager.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,mBAAmB,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAE3F,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,4BAA4B,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAC;AAEpE,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAGpF,wFAAwF;AACxF,MAAM,WAAW,eAAgB,SAAQ,uBAAuB;IAC9D,mEAAmE;IACnE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,+FAA+F;AAC/F,MAAM,WAAW,aAAa;IAC5B,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,0DAA0D;IAC1D,OAAO,EAAE,OAAO,CAAC;IACjB,gDAAgD;IAChD,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,qBAAa,mBAAoB,SAAQ,iBAAkB,YAAW,UAAU;IAC9E,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC,YAAY,MAAM,EAAE,eAAe,EAMlC;IAID,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,mBAAmB,CAE1C;IAED;;;;;;OAMG;IACG,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAMlF;IAEK,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAM/C;IAED,6FAA6F;IACvF,IAAI,IAAI,OAAO,CAAC,WAAW,CAAC,CAEjC;IAED,8EAA8E;IAC9E,WAAW,IAAI,KAAK,CAEnB;IAID,iEAAiE;IAC3D,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CASzE;IAED;;;OAGG;IACG,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAW/F;IAED,oCAAoC;IAC9B,eAAe,IAAI,OAAO,CAAC,EAAE,CAAC,CAInC;IAED,4CAA4C;IACtC,UAAU,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAMpC;IAED;;;;OAIG;IACG,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAS1D;IAED,qEAAqE;IACrE,SAAS,IAAI;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAErD;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;CACF"}
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { CloudflareNotConfiguredError, cloudflareRequest, reasonOf } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
- import { D1PreparedStatementREST } from "./d1PreparedStatement.js";
5
+ import { D1PreparedStatementREST, D1_QUERY_REQUEST } from "./d1PreparedStatement.js";
6
6
  import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
7
  //#region src/d1/d1Manager.ts
8
8
  /**
@@ -38,7 +38,7 @@ var CloudflareD1Manager = class extends CloudflareManager {
38
38
  const result = (await cloudflareRequest("D1 exec", () => this.getClient().d1.database.query(this.databaseId, {
39
39
  account_id: this.accountId,
40
40
  sql: query
41
- }))).result[0];
41
+ }, D1_QUERY_REQUEST))).result[0];
42
42
  return {
43
43
  count: result?.results?.length ?? 0,
44
44
  duration: result?.meta?.duration ?? 0
@@ -58,7 +58,7 @@ var CloudflareD1Manager = class extends CloudflareManager {
58
58
  account_id: this.accountId,
59
59
  sql,
60
60
  params
61
- }))).result;
61
+ }, D1_QUERY_REQUEST))).result;
62
62
  }
63
63
  /**
64
64
  * Run many independent queries, collecting a per-query result instead of failing the whole batch
@@ -2,6 +2,18 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import type { D1PreparedStatement, D1Result } from "@cloudflare/workers-types";
4
4
  import type { Cloudflare } from "cloudflare";
5
+ /**
6
+ * The request options every D1 query is sent with: **no retries** (#709).
7
+ *
8
+ * The SDK retries a request twice by default, on a connection timeout, a 408, a 409, a 429 or a 5xx. That is
9
+ * safe for a read of a resource and wrong for a query, which is not idempotent: D1 can apply a statement and
10
+ * answer slowly, and the resent copy then runs against the changed database. A migration's `CREATE TABLE` did
11
+ * exactly that and failed `table already exists`, on a step that had succeeded. Sent once, a slow answer
12
+ * surfaces as the timeout it was, and `cloudflareRequest` already tells the operator to check before retrying.
13
+ */
14
+ export declare const D1_QUERY_REQUEST: {
15
+ readonly maxRetries: 0;
16
+ };
5
17
  export declare class D1PreparedStatementREST<T = Record<string, unknown>> implements D1PreparedStatement {
6
18
  private readonly sql;
7
19
  private readonly boundParams;
@@ -1 +1 @@
1
- {"version":3,"file":"d1PreparedStatement.d.ts","sourceRoot":"","sources":["../../src/d1/d1PreparedStatement.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAU,mBAAmB,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACvF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAuC7C,qBAAa,uBAAuB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAE,YAAW,mBAAmB;IAC9F,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAE7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAY;IAExC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC,YAAY,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,GAAE,OAAO,EAAO,EAM9G;IAED,gGAAgG;IAChG,IAAI,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAErD;IAED,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAEjC;IAED,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAEjC;IAEK,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACvD,KAAK,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IASlC,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,OAAO,EAAE;QAAE,WAAW,EAAE,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAChF,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,KAAK,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;YAe5D,YAAY;CAY3B"}
1
+ {"version":3,"file":"d1PreparedStatement.d.ts","sourceRoot":"","sources":["../../src/d1/d1PreparedStatement.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAU,mBAAmB,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AACvF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AA6B7C;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB;aAAK,UAAU,EAAE,CAAC;CAAW,CAAC;AAY3D,qBAAa,uBAAuB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAE,YAAW,mBAAmB;IAC9F,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAE7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAY;IAExC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IAEpC,YAAY,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,GAAE,OAAO,EAAO,EAM9G;IAED,gGAAgG;IAChG,IAAI,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,uBAAuB,CAAC,CAAC,CAAC,CAErD;IAED,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAEjC;IAED,GAAG,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAEjC;IAEK,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACvD,KAAK,CAAC,CAAC,GAAG,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IASlC,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,OAAO,EAAE;QAAE,WAAW,EAAE,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,MAAM,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAChF,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,KAAK,CAAA;KAAE,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;YAmB5D,YAAY;CAgB3B"}
@@ -26,6 +26,16 @@ function mapMeta(restMeta) {
26
26
  changes: restMeta?.changes ?? 0
27
27
  };
28
28
  }
29
+ /**
30
+ * The request options every D1 query is sent with: **no retries** (#709).
31
+ *
32
+ * The SDK retries a request twice by default, on a connection timeout, a 408, a 409, a 429 or a 5xx. That is
33
+ * safe for a read of a resource and wrong for a query, which is not idempotent: D1 can apply a statement and
34
+ * answer slowly, and the resent copy then runs against the changed database. A migration's `CREATE TABLE` did
35
+ * exactly that and failed `table already exists`, on a step that had succeeded. Sent once, a slow answer
36
+ * surfaces as the timeout it was, and `cloudflareRequest` already tells the operator to check before retrying.
37
+ */
38
+ const D1_QUERY_REQUEST = { maxRetries: 0 };
29
39
  /** The REST `params` array is strings; coerce each bound value the way the D1 REST API expects. */
30
40
  function toStringParams(params) {
31
41
  return params.map((value) => {
@@ -70,7 +80,7 @@ var D1PreparedStatementREST = class D1PreparedStatementREST {
70
80
  account_id: this.accountId,
71
81
  sql: this.sql,
72
82
  params
73
- }))).result[0];
83
+ }, D1_QUERY_REQUEST))).result[0];
74
84
  const rows = rawResult?.results?.rows ?? [];
75
85
  if (options?.columnNames) return [rawResult?.results?.columns ?? [], ...rows];
76
86
  return rows;
@@ -81,7 +91,7 @@ var D1PreparedStatementREST = class D1PreparedStatementREST {
81
91
  account_id: this.accountId,
82
92
  sql: this.sql,
83
93
  params
84
- }))).result[0];
94
+ }, D1_QUERY_REQUEST))).result[0];
85
95
  return {
86
96
  success: true,
87
97
  meta: mapMeta(queryResult?.meta),
@@ -90,4 +100,4 @@ var D1PreparedStatementREST = class D1PreparedStatementREST {
90
100
  }
91
101
  };
92
102
  //#endregion
93
- export { D1PreparedStatementREST };
103
+ export { D1PreparedStatementREST, D1_QUERY_REQUEST };
@@ -0,0 +1,32 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /** What a callback said: a code to redeem, or Cloudflare's refusal. */
4
+ export type CallbackAnswer = {
5
+ kind: "code";
6
+ code: string;
7
+ } | {
8
+ kind: "error";
9
+ error: string;
10
+ description: string;
11
+ };
12
+ /** What the address must match: the `state` and `redirect_uri` this sign-in sent. */
13
+ export interface CallbackExpectation {
14
+ /** The `state` in the authorize URL. */
15
+ state: string;
16
+ /** The `redirect_uri` in the authorize URL, verbatim. */
17
+ redirectUri: string;
18
+ }
19
+ /**
20
+ * Read the address a browser landed on — from the loopback listener or pasted by hand — into an answer.
21
+ *
22
+ * **One reader for both paths, so they cannot check different things.** A remote session pastes what its
23
+ * browser shows, because the redirect lands on the browser's own loopback interface, where nothing is
24
+ * listening (measured over SSH, 2026-10-15). The pasted address is held to exactly what a delivered
25
+ * callback is: the same origin and path this sign-in sent, the same `state`, and a `code` or an `error`.
26
+ *
27
+ * Accepting a paste is safe because of PKCE, not because of this check — a code is worthless without the
28
+ * verifier, and the verifier never left this process. The `state` check is what stops a code from a
29
+ * *different* sign-in being redeemed into this one.
30
+ */
31
+ export declare function readCallback(address: string, expect: CallbackExpectation): CallbackAnswer;
32
+ //# sourceMappingURL=callback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"callback.d.ts","sourceRoot":"","sources":["../../src/oauth/callback.ts"],"names":[],"mappings":"AAMA,uEAAuE;AACvE,MAAM,MAAM,cAAc,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpH,qFAAqF;AACrF,MAAM,WAAW,mBAAmB;IAClC,wCAAwC;IACxC,KAAK,EAAE,MAAM,CAAC;IACd,yDAAyD;IACzD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,mBAAmB,GAAG,cAAc,CAgCzF"}
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
4
+ import { commandName } from "@pithy-sh/core/src/cli/command";
5
+ //#region src/oauth/callback.ts
6
+ /**
7
+ * Read the address a browser landed on — from the loopback listener or pasted by hand — into an answer.
8
+ *
9
+ * **One reader for both paths, so they cannot check different things.** A remote session pastes what its
10
+ * browser shows, because the redirect lands on the browser's own loopback interface, where nothing is
11
+ * listening (measured over SSH, 2026-10-15). The pasted address is held to exactly what a delivered
12
+ * callback is: the same origin and path this sign-in sent, the same `state`, and a `code` or an `error`.
13
+ *
14
+ * Accepting a paste is safe because of PKCE, not because of this check — a code is worthless without the
15
+ * verifier, and the verifier never left this process. The `state` check is what stops a code from a
16
+ * *different* sign-in being redeemed into this one.
17
+ */
18
+ function readCallback(address, expect) {
19
+ const text = address.trim().replace(/^["']|["']$/g, "");
20
+ let url;
21
+ try {
22
+ url = new URL(text);
23
+ } catch {
24
+ throw notTheAddress(expect, "it is not an address");
25
+ }
26
+ const sent = new URL(expect.redirectUri);
27
+ if (url.origin !== sent.origin || url.pathname !== sent.pathname) throw notTheAddress(expect, `it is ${url.origin}${url.pathname}`);
28
+ if (url.searchParams.get("state") !== expect.state) throw new ValidationError({
29
+ message: "That address belongs to a different sign-in: its state does not match.",
30
+ action: `Paste the address from the browser tab this sign-in opened, or run ${commandName("cloudflare connect")} again.`,
31
+ detail: "callback state did not match the state this sign-in sent"
32
+ });
33
+ const error = url.searchParams.get("error");
34
+ if (error !== null) return {
35
+ kind: "error",
36
+ error,
37
+ description: url.searchParams.get("error_description") ?? error
38
+ };
39
+ const code = url.searchParams.get("code");
40
+ if (code === null || code === "") throw new ValidationError({
41
+ message: "That address carries no code and no error.",
42
+ action: "Paste the whole address from the browser's address bar, after you approve.",
43
+ detail: "callback had neither `code` nor `error`"
44
+ });
45
+ return {
46
+ kind: "code",
47
+ code
48
+ };
49
+ }
50
+ function notTheAddress(expect, why) {
51
+ return new ValidationError({
52
+ message: `That is not the address this sign-in sent the browser to: ${why}.`,
53
+ action: `Paste the whole address your browser landed on. It starts with ${expect.redirectUri}.`,
54
+ detail: `expected ${expect.redirectUri}`
55
+ });
56
+ }
57
+ //#endregion
58
+ export { readCallback };
@@ -0,0 +1,26 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /** One OAuth scope, as Cloudflare's catalog lists it. */
5
+ export declare const OAuthScope: z.ZodObject<{
6
+ id: z.ZodString;
7
+ name: z.ZodString;
8
+ category: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strip>;
10
+ export type OAuthScope = z.output<typeof OAuthScope>;
11
+ /** The catalog's endpoint. Global: it needs authentication and no account. */
12
+ export declare const OAUTH_SCOPES_URL = "https://api.cloudflare.com/client/v4/oauth/scopes";
13
+ /**
14
+ * **Cloudflare's OAuth scope catalog: every scope a third-party client may register (#759).**
15
+ *
16
+ * The third column of the permission table is checked against this the way the group names are checked
17
+ * against the account, because a scope id that drifts from it is a sign-in Cloudflare refuses with
18
+ * `invalid_scope` — at the consent screen of an adopter, rather than in this repository's CI.
19
+ */
20
+ export declare function listOAuthScopes(options: {
21
+ apiToken: string;
22
+ fetch?: (url: string, init: {
23
+ headers: Record<string, string>;
24
+ }) => Promise<Response>;
25
+ }): Promise<OAuthScope[]>;
26
+ //# sourceMappingURL=catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/oauth/catalog.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,yDAAyD;AACzD,eAAO,MAAM,UAAU;;;;iBAMyB,CAAC;AACjD,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAIrD,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB,sDAAsD,CAAC;AAEpF;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE;IAC7C,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CACvF,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAOxB"}
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { cloudflareRequest, decodeResponse } from "../client/errors.js";
4
+ import { z } from "zod";
5
+ //#region src/oauth/catalog.ts
6
+ /** One OAuth scope, as Cloudflare's catalog lists it. */
7
+ const OAuthScope = z.object({
8
+ id: z.string().describe("The scope a client requests: `d1.write`."),
9
+ name: z.string().describe("Its display name, which is the permission group's own: `D1 Write`."),
10
+ category: z.string().optional().describe("Cloudflare's grouping, e.g. `developer_platform`.")
11
+ }).describe("One scope in `GET /oauth/scopes`.");
12
+ const ScopeList = z.object({ result: z.array(OAuthScope) });
13
+ /** The catalog's endpoint. Global: it needs authentication and no account. */
14
+ const OAUTH_SCOPES_URL = "https://api.cloudflare.com/client/v4/oauth/scopes";
15
+ /**
16
+ * **Cloudflare's OAuth scope catalog: every scope a third-party client may register (#759).**
17
+ *
18
+ * The third column of the permission table is checked against this the way the group names are checked
19
+ * against the account, because a scope id that drifts from it is a sign-in Cloudflare refuses with
20
+ * `invalid_scope` — at the consent screen of an adopter, rather than in this repository's CI.
21
+ */
22
+ async function listOAuthScopes(options) {
23
+ const send = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
24
+ const raw = await cloudflareRequest("list OAuth scopes", async () => {
25
+ return await (await send(OAUTH_SCOPES_URL, { headers: { authorization: `Bearer ${options.apiToken}` } })).json();
26
+ });
27
+ return decodeResponse(ScopeList, raw, "oauth scopes list").result;
28
+ }
29
+ //#endregion
30
+ export { OAUTH_SCOPES_URL, OAuthScope, listOAuthScopes };
@@ -0,0 +1,126 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ /**
5
+ * **The Pithy OAuth client: one, owned by us, authorized by each adopter against their own account (#759).**
6
+ *
7
+ * Registered 2026-10-15 on the `pithy.` account as `Pithy CLI` — a public client, so no secret ships in
8
+ * this package: `token_endpoint_auth_method: none`, `authorization_code` with PKCE `S256`, and
9
+ * `refresh_token`. Verified by a DNS TXT record on `pithy.sh`, its `client_uri`, and public, which is
10
+ * what lets a user on any account sign in to it. An identifier, not a credential.
11
+ */
12
+ export declare const PITHY_OAUTH_CLIENT_ID = "5ae99cf157ae3e57d354e9c25ad3f7e2";
13
+ /** The endpoints, from `https://dash.cloudflare.com/.well-known/openid-configuration`. */
14
+ export declare const CLOUDFLARE_OAUTH: {
15
+ readonly authorize: "https://dash.cloudflare.com/oauth2/auth";
16
+ readonly token: "https://dash.cloudflare.com/oauth2/token";
17
+ readonly revoke: "https://dash.cloudflare.com/oauth2/revoke";
18
+ };
19
+ /** The slice of `fetch` this module calls: a form POST. Injected, so no test reaches Cloudflare. */
20
+ export type OAuthFetch = (url: string, init: {
21
+ method: "POST";
22
+ headers: Record<string, string>;
23
+ body: URLSearchParams;
24
+ signal: AbortSignal;
25
+ }) => Promise<Response>;
26
+ /** A PKCE verifier and the challenge derived from it. The verifier never leaves the process that made it. */
27
+ export interface PkcePair {
28
+ /** 43 base64url characters of randomness — sent only to the token endpoint, with the code. */
29
+ verifier: string;
30
+ /** `BASE64URL(SHA256(verifier))` — sent in the authorize URL. */
31
+ challenge: string;
32
+ }
33
+ /**
34
+ * A fresh PKCE pair.
35
+ *
36
+ * **This is what makes a pasted address safe to accept.** The code in the address a browser lands on is
37
+ * redeemable only with the verifier, and the verifier exists only in the CLI that opened the flow — so a
38
+ * code read off a screen, typed across an SSH session, or seen by anyone else is worth nothing elsewhere.
39
+ */
40
+ export declare function pkcePair(): PkcePair;
41
+ /** A fresh `state`. Cloudflare refuses one under eight characters; this is twenty-two. */
42
+ export declare function oauthState(): string;
43
+ /** What an authorize URL is built from. */
44
+ export interface AuthorizeRequest {
45
+ /** The scopes to request — `sessionScopes`, never a hand-written list. */
46
+ scopes: readonly string[];
47
+ /** The loopback redirect: `http://127.0.0.1:<port>/callback`. */
48
+ redirectUri: string;
49
+ /** The `state` the callback must echo. */
50
+ state: string;
51
+ /** The PKCE challenge. */
52
+ challenge: string;
53
+ }
54
+ /** The URL a browser opens to sign in and consent. */
55
+ export declare function authorizeUrl(request: AuthorizeRequest): string;
56
+ /** The token endpoint's answer to a code exchange or a refresh. */
57
+ export declare const OAuthTokenResponse: z.ZodObject<{
58
+ access_token: z.ZodString;
59
+ refresh_token: z.ZodOptional<z.ZodString>;
60
+ token_type: z.ZodString;
61
+ expires_in: z.ZodNumber;
62
+ scope: z.ZodString;
63
+ }, z.core.$strip>;
64
+ export type OAuthTokenResponse = z.output<typeof OAuthTokenResponse>;
65
+ /** A session's tokens, as this kit holds them. */
66
+ export interface OAuthGrant {
67
+ /** The bearer token. */
68
+ accessToken: string;
69
+ /** The refresh token, or `null` when `offline_access` was not granted. */
70
+ refreshToken: string | null;
71
+ /** When {@link accessToken} stops working, from `expires_in` and the clock at the answer. */
72
+ expiresAt: Date;
73
+ /** The granted scopes, space-separated, verbatim. */
74
+ scope: string;
75
+ }
76
+ /** What every token-endpoint call takes beyond its own fields. */
77
+ interface EndpointOptions {
78
+ /** Defaults to the global `fetch`. */
79
+ fetch?: OAuthFetch;
80
+ /** Defaults to `() => new Date()`. */
81
+ now?: () => Date;
82
+ }
83
+ /**
84
+ * Redeem an authorization code for a session.
85
+ *
86
+ * A refusal is a `core/upstream_failed` carrying Cloudflare's own description — an expired code, a
87
+ * `redirect_uri` that does not match, a verifier that does not fit the challenge. Each is a sign-in that
88
+ * has to start over, and the description is what says which.
89
+ */
90
+ export declare function exchangeCode(request: {
91
+ code: string;
92
+ verifier: string;
93
+ redirectUri: string;
94
+ } & EndpointOptions): Promise<OAuthGrant>;
95
+ /** What a refresh comes back as: a new grant, or the server's refusal in its own words. */
96
+ export type RefreshResult = {
97
+ kind: "refreshed";
98
+ grant: OAuthGrant;
99
+ } | {
100
+ kind: "refused";
101
+ error: string;
102
+ description: string;
103
+ };
104
+ /**
105
+ * Renew a session without a browser.
106
+ *
107
+ * **A refusal is an answer, not a throw.** A spent, revoked or expired refresh token means *sign in
108
+ * again*, and the caller decides how to say so — at dispatch it is deferred to the first command that
109
+ * actually needs Cloudflare. Only a transport failure throws, because that one is worth retrying.
110
+ *
111
+ * The refresh token **rotates** on every use (measured). The one handed back must replace the one sent,
112
+ * on disk, before anything else reads it; the caller holds the profile's lock across that write.
113
+ */
114
+ export declare function refreshGrant(request: {
115
+ refreshToken: string;
116
+ } & EndpointOptions): Promise<RefreshResult>;
117
+ /**
118
+ * Revoke one token at Cloudflare. Throws on a refusal, so `disconnect` never reports a revocation that did
119
+ * not happen — the local file is deleted either way, and the operator is told which half held.
120
+ */
121
+ export declare function revokeToken(request: {
122
+ token: string;
123
+ hint: "access_token" | "refresh_token";
124
+ } & EndpointOptions): Promise<void>;
125
+ export {};
126
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/oauth/client.ts"],"names":[],"mappings":"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"}