@pithy-sh/cloudflare 0.4.2 → 0.6.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 (95) 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/analytics/analyticsManager.d.ts +78 -0
  6. package/dist/analytics/analyticsManager.d.ts.map +1 -0
  7. package/dist/analytics/analyticsManager.js +206 -0
  8. package/dist/analytics/datasets.d.ts +26 -0
  9. package/dist/analytics/datasets.d.ts.map +1 -0
  10. package/dist/analytics/datasets.js +26 -0
  11. package/dist/analytics/window.d.ts +50 -0
  12. package/dist/analytics/window.d.ts.map +1 -0
  13. package/dist/analytics/window.js +67 -0
  14. package/dist/artifacts/artifactsManager.d.ts +52 -0
  15. package/dist/artifacts/artifactsManager.d.ts.map +1 -0
  16. package/dist/artifacts/artifactsManager.js +150 -0
  17. package/dist/client/clients.d.ts +13 -1
  18. package/dist/client/clients.d.ts.map +1 -1
  19. package/dist/client/clients.js +22 -1
  20. package/dist/client/manager.d.ts +13 -2
  21. package/dist/client/manager.d.ts.map +1 -1
  22. package/dist/client/manager.js +18 -5
  23. package/dist/d1/d1Manager.d.ts.map +1 -1
  24. package/dist/d1/d1Manager.js +3 -3
  25. package/dist/d1/d1PreparedStatement.d.ts +12 -0
  26. package/dist/d1/d1PreparedStatement.d.ts.map +1 -1
  27. package/dist/d1/d1PreparedStatement.js +13 -3
  28. package/dist/media/imageManager.d.ts +25 -0
  29. package/dist/media/imageManager.d.ts.map +1 -1
  30. package/dist/media/imageManager.js +58 -2
  31. package/dist/media/streamManager.d.ts +15 -0
  32. package/dist/media/streamManager.d.ts.map +1 -1
  33. package/dist/media/streamManager.js +28 -1
  34. package/dist/oauth/callback.d.ts +32 -0
  35. package/dist/oauth/callback.d.ts.map +1 -0
  36. package/dist/oauth/callback.js +58 -0
  37. package/dist/oauth/catalog.d.ts +26 -0
  38. package/dist/oauth/catalog.d.ts.map +1 -0
  39. package/dist/oauth/catalog.js +30 -0
  40. package/dist/oauth/client.d.ts +126 -0
  41. package/dist/oauth/client.d.ts.map +1 -0
  42. package/dist/oauth/client.js +200 -0
  43. package/dist/oauth/scopes.d.ts +62 -0
  44. package/dist/oauth/scopes.d.ts.map +1 -0
  45. package/dist/oauth/scopes.js +79 -0
  46. package/dist/queue/queueProvisioner.d.ts +30 -0
  47. package/dist/queue/queueProvisioner.d.ts.map +1 -0
  48. package/dist/queue/queueProvisioner.js +72 -0
  49. package/dist/r2/r2Manager.d.ts +11 -1
  50. package/dist/r2/r2Manager.d.ts.map +1 -1
  51. package/dist/r2/r2Manager.js +18 -1
  52. package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
  53. package/dist/secrets/secretsStoreManager.js +3 -2
  54. package/dist/test-utils/settle.d.ts +79 -0
  55. package/dist/test-utils/settle.d.ts.map +1 -0
  56. package/dist/test-utils/settle.js +105 -0
  57. package/dist/tokens/accountTokensManager.d.ts +8 -7
  58. package/dist/tokens/accountTokensManager.d.ts.map +1 -1
  59. package/dist/tokens/accountTokensManager.js +2 -1
  60. package/dist/tokens/permissions.d.ts +49 -0
  61. package/dist/tokens/permissions.d.ts.map +1 -1
  62. package/dist/tokens/permissions.js +56 -2
  63. package/dist/tokens/profiles.d.ts +10 -30
  64. package/dist/tokens/profiles.d.ts.map +1 -1
  65. package/dist/tokens/profiles.js +48 -36
  66. package/dist/tokens/runtime.d.ts +16 -0
  67. package/dist/tokens/runtime.d.ts.map +1 -0
  68. package/dist/tokens/runtime.js +18 -0
  69. package/dist/workers/buildsManager.js +1 -1
  70. package/dist/workflows/stepFailure.js +1 -1
  71. package/dist/workflows/workflowsClient.js +1 -1
  72. package/package.json +3 -3
  73. package/src/ai/aiManager.ts +44 -5
  74. package/src/analytics/analyticsManager.ts +311 -0
  75. package/src/analytics/datasets.ts +30 -0
  76. package/src/analytics/window.ts +101 -0
  77. package/src/artifacts/artifactsManager.ts +199 -0
  78. package/src/client/clients.ts +28 -1
  79. package/src/client/manager.ts +24 -6
  80. package/src/d1/d1Manager.ts +7 -3
  81. package/src/d1/d1PreparedStatement.ts +21 -2
  82. package/src/media/imageManager.ts +93 -1
  83. package/src/media/streamManager.ts +34 -0
  84. package/src/oauth/callback.ts +70 -0
  85. package/src/oauth/catalog.ts +39 -0
  86. package/src/oauth/client.ts +265 -0
  87. package/src/oauth/scopes.ts +91 -0
  88. package/src/queue/queueProvisioner.ts +79 -0
  89. package/src/r2/r2Manager.ts +29 -2
  90. package/src/secrets/secretsStoreManager.ts +2 -1
  91. package/src/test-utils/settle.ts +139 -0
  92. package/src/tokens/accountTokensManager.ts +12 -0
  93. package/src/tokens/permissions.ts +81 -0
  94. package/src/tokens/profiles.ts +58 -70
  95. package/src/tokens/runtime.ts +18 -0
@@ -1,7 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { cloudflareRequest } from "../client/errors.js";
3
+ import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors.js";
4
4
  import { CloudflareManager } from "../client/manager.js";
5
+ import { z } from "zod";
5
6
  //#region src/media/imageManager.ts
6
7
  /**
7
8
  * Per-request CF SDK options for every Images call: a 10s timeout and up to 3 retries. Uploads and
@@ -19,6 +20,30 @@ const requestOptions = {
19
20
  function encodeMetadata(metadata) {
20
21
  return metadata === void 0 || metadata === null ? metadata : JSON.stringify(metadata);
21
22
  }
23
+ /** One of the account's Images URL-signing keys. The value is the HMAC secret signed URLs are minted with. */
24
+ const ImagesSigningKey = z.object({
25
+ name: z.string().min(1).describe("The key's name. Cloudflare provisions one named `default`."),
26
+ value: z.string().min(1).describe("The key itself — the HMAC-SHA256 secret a signed delivery URL is minted with.")
27
+ }).describe("One Cloudflare Images URL-signing key.");
28
+ const SigningKeysResponse = z.object({ keys: z.array(ImagesSigningKey).optional().describe("The account's signing keys.") }).describe("The Images signing-keys response.");
29
+ /** One Images variant, and whether it serves an image without a signature whatever the image requires. */
30
+ const ImagesVariant = z.object({
31
+ id: z.string().describe("The variant's name, as it appears in a delivery URL."),
32
+ neverRequireSignedURLs: z.boolean().describe("Whether this variant serves every image unsigned, even one uploaded with requireSignedURLs.")
33
+ }).describe("One Cloudflare Images variant's signing posture.");
34
+ const VariantsResponse = z.object({ variants: z.record(z.string(), z.object({
35
+ id: z.string().describe("The variant name."),
36
+ neverRequireSignedURLs: z.boolean().optional().describe("Whether it ignores signing.")
37
+ }).describe("One variant as the API returns it.")).optional().describe("Every variant, keyed by name.") }).describe("The Images variants response.");
38
+ /** Parse an Images response the SDK types loosely, refusing a shape we cannot read. */
39
+ function parseResponse(schema, response, what) {
40
+ const result = schema.safeParse(response);
41
+ if (!result.success) throw new CloudflareInvalidResponseError({
42
+ message: `An Images ${what} response had an unexpected shape.`,
43
+ detail: result.error.message
44
+ });
45
+ return result.data;
46
+ }
22
47
  /**
23
48
  * Out-of-Worker Cloudflare Images access over the REST API: upload, fetch, edit, delete, list, and
24
49
  * direct-upload URLs from a CLI/CI/provisioning context. Image metadata is an arbitrary
@@ -88,6 +113,37 @@ var CloudflareImageManager = class extends CloudflareManager {
88
113
  return allImages;
89
114
  });
90
115
  }
116
+ /**
117
+ * The account's URL-signing keys — what a signed delivery URL for an image uploaded with
118
+ * `requireSignedURLs` is minted with. Cloudflare provisions a `default` key per account.
119
+ */
120
+ async signingKeys() {
121
+ return cloudflareRequest("Images list signing keys", async () => {
122
+ const response = await this.getClient().images.v1.keys.list({ account_id: this.accountId }, requestOptions);
123
+ return parseResponse(SigningKeysResponse, response, "signing keys").keys ?? [];
124
+ });
125
+ }
126
+ /** Create a named signing key, or roll it if it exists. Answers with every key the account then holds. */
127
+ async createSigningKey(name) {
128
+ return cloudflareRequest(`Images create signing key '${name}'`, async () => {
129
+ const response = await this.getClient().images.v1.keys.update(name, { account_id: this.accountId }, requestOptions);
130
+ return parseResponse(SigningKeysResponse, response, "signing keys").keys ?? [];
131
+ });
132
+ }
133
+ /**
134
+ * Every variant, with whether it serves unsigned. A variant set to `neverRequireSignedURLs` delivers a
135
+ * private image to anyone holding its URL, which is why media's provisioning reads this.
136
+ */
137
+ async listVariants() {
138
+ return cloudflareRequest("Images list variants", async () => {
139
+ const response = await this.getClient().images.v1.variants.list({ account_id: this.accountId }, requestOptions);
140
+ const variants = parseResponse(VariantsResponse, response, "variants").variants ?? {};
141
+ return Object.values(variants).map((variant) => ({
142
+ id: variant.id,
143
+ neverRequireSignedURLs: variant.neverRequireSignedURLs === true
144
+ }));
145
+ });
146
+ }
91
147
  getServiceType() {
92
148
  return "Cloudflare Images";
93
149
  }
@@ -102,4 +158,4 @@ var CloudflareImageManager = class extends CloudflareManager {
102
158
  }
103
159
  };
104
160
  //#endregion
105
- export { CloudflareImageManager };
161
+ export { CloudflareImageManager, ImagesSigningKey, ImagesVariant };
@@ -44,6 +44,16 @@ export declare const StreamDownloadStatus: z.ZodObject<{
44
44
  }, z.core.$strip>>;
45
45
  }, z.core.$strip>;
46
46
  export type StreamDownloadStatus = z.infer<typeof StreamDownloadStatus>;
47
+ /**
48
+ * A Stream signing key: what a signed playback token for a video uploaded with `requireSignedURLs` is
49
+ * signed with. The JWK is base64 of the key's JSON, as Cloudflare returns it — a single line, so it is
50
+ * the form stored, where the PEM is not.
51
+ */
52
+ export declare const StreamSigningKey: z.ZodObject<{
53
+ id: z.ZodString;
54
+ jwk: z.ZodString;
55
+ }, z.core.$strip>;
56
+ export type StreamSigningKey = z.infer<typeof StreamSigningKey>;
47
57
  /**
48
58
  * Out-of-Worker Cloudflare Stream access over the REST API: delete, fetch, edit, direct uploads,
49
59
  * and download (MP4 + audio-only) management from a CLI/CI/provisioning context. Video metadata is
@@ -78,6 +88,11 @@ export declare class CloudflareStreamManager extends CloudflareManager {
78
88
  audioDownloadStatus(videoId: string): Promise<DownloadEntry>;
79
89
  /** Create a direct-upload URL for a client-side video upload. */
80
90
  createDirectUpload(params: Omit<DirectUploadCreateParams, "account_id">): Promise<DirectUploadCreateResponse>;
91
+ /**
92
+ * Create a signing key. Cloudflare returns the private half once, on creation, and never again — so the
93
+ * caller stores it, and a lost key is replaced by creating another.
94
+ */
95
+ createSigningKey(): Promise<StreamSigningKey>;
81
96
  getServiceType(): string;
82
97
  /** Prove access by listing videos. Never throws. */
83
98
  validateServiceAccess(): Promise<boolean>;
@@ -1 +1 @@
1
- {"version":3,"file":"streamManager.d.ts","sourceRoot":"","sources":["../../src/media/streamManager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,0BAA0B,EAAE,MAAM,2CAA2C,CAAC;AACtH,OAAO,KAAK,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,oCAAoC,CAAC;AAClF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAWtD;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;iBAM8C,CAAC;AACzE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;GAGG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;iBAKwD,CAAC;AAC1F,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AA2BxE;;;;;;;;;GASG;AACH,qBAAa,uBAAwB,SAAQ,iBAAiB;IAC5D,wCAAwC;IAClC,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhD;IAED,sCAAsC;IAChC,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAIrD;IAED,8CAA8C;IACxC,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,gBAAgB,EAAE,YAAY,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAI/F;IAED,iEAAiE;IAC3D,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CASxE;IAED,qEAAqE;IAC/D,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CASxE;IAED;;;;OAIG;IACG,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA4BjE;IAED;;;OAGG;IACG,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAUjE;IAED,iEAAiE;IAC3D,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,wBAAwB,EAAE,YAAY,CAAC,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAIlH;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oDAAoD;IAC9C,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;CACF"}
1
+ {"version":3,"file":"streamManager.d.ts","sourceRoot":"","sources":["../../src/media/streamManager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,wBAAwB,EAAE,0BAA0B,EAAE,MAAM,2CAA2C,CAAC;AACtH,OAAO,KAAK,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,oCAAoC,CAAC;AAClF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAWtD;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;iBAM8C,CAAC;AACzE,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;GAGG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;iBAKwD,CAAC;AAC1F,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AA2BxE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB;;;iBAKoB,CAAC;AAClD,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE;;;;;;;;;GASG;AACH,qBAAa,uBAAwB,SAAQ,iBAAiB;IAC5D,wCAAwC;IAClC,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIhD;IAED,sCAAsC;IAChC,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAIrD;IAED,8CAA8C;IACxC,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,gBAAgB,EAAE,YAAY,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAI/F;IAED,iEAAiE;IAC3D,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CASxE;IAED,qEAAqE;IAC/D,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CASxE;IAED;;;;OAIG;IACG,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA4BjE;IAED;;;OAGG;IACG,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAUjE;IAED,iEAAiE;IAC3D,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,wBAAwB,EAAE,YAAY,CAAC,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAIlH;IAED;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAelD;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oDAAoD;IAC9C,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;CACF"}
@@ -50,6 +50,15 @@ function parseDownloadStatus(response) {
50
50
  return result.data;
51
51
  }
52
52
  /**
53
+ * A Stream signing key: what a signed playback token for a video uploaded with `requireSignedURLs` is
54
+ * signed with. The JWK is base64 of the key's JSON, as Cloudflare returns it — a single line, so it is
55
+ * the form stored, where the PEM is not.
56
+ */
57
+ const StreamSigningKey = z.object({
58
+ id: z.string().min(1).describe("The key id — the `kid` a signed token names."),
59
+ jwk: z.string().min(1).describe("The RSA private key as base64-encoded JWK, as Cloudflare returns it.")
60
+ }).describe("One Cloudflare Stream signing key.");
61
+ /**
53
62
  * Out-of-Worker Cloudflare Stream access over the REST API: delete, fetch, edit, direct uploads,
54
63
  * and download (MP4 + audio-only) management from a CLI/CI/provisioning context. Video metadata is
55
64
  * the SDK's own `Video` shape — this class imposes none of its own.
@@ -130,6 +139,24 @@ var CloudflareStreamManager = class extends CloudflareManager {
130
139
  ...params
131
140
  }, requestOptions));
132
141
  }
142
+ /**
143
+ * Create a signing key. Cloudflare returns the private half once, on creation, and never again — so the
144
+ * caller stores it, and a lost key is replaced by creating another.
145
+ */
146
+ async createSigningKey() {
147
+ return cloudflareRequest("Stream create signing key", async () => {
148
+ const response = await this.getClient().stream.keys.create({
149
+ account_id: this.accountId,
150
+ body: {}
151
+ }, requestOptions);
152
+ const parsed = StreamSigningKey.safeParse(response);
153
+ if (!parsed.success) throw new CloudflareInvalidResponseError({
154
+ message: "A Stream signing-key response had an unexpected shape.",
155
+ detail: parsed.error.message
156
+ });
157
+ return parsed.data;
158
+ });
159
+ }
133
160
  getServiceType() {
134
161
  return "Cloudflare Stream";
135
162
  }
@@ -144,4 +171,4 @@ var CloudflareStreamManager = class extends CloudflareManager {
144
171
  }
145
172
  };
146
173
  //#endregion
147
- export { CloudflareStreamManager, DownloadEntry, StreamDownloadStatus };
174
+ export { CloudflareStreamManager, DownloadEntry, StreamDownloadStatus, StreamSigningKey };
@@ -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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"}
@@ -0,0 +1,200 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { cloudflareRequest, decodeResponse } from "../client/errors.js";
4
+ import { UpstreamError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+ import { commandName } from "@pithy-sh/core/src/cli/command";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ //#region src/oauth/client.ts
9
+ /**
10
+ * **The Pithy OAuth client: one, owned by us, authorized by each adopter against their own account (#759).**
11
+ *
12
+ * Registered 2026-10-15 on the `pithy.` account as `Pithy CLI` — a public client, so no secret ships in
13
+ * this package: `token_endpoint_auth_method: none`, `authorization_code` with PKCE `S256`, and
14
+ * `refresh_token`. Verified by a DNS TXT record on `pithy.sh`, its `client_uri`, and public, which is
15
+ * what lets a user on any account sign in to it. An identifier, not a credential.
16
+ */
17
+ const PITHY_OAUTH_CLIENT_ID = "5ae99cf157ae3e57d354e9c25ad3f7e2";
18
+ /** The endpoints, from `https://dash.cloudflare.com/.well-known/openid-configuration`. */
19
+ const CLOUDFLARE_OAUTH = {
20
+ authorize: "https://dash.cloudflare.com/oauth2/auth",
21
+ token: "https://dash.cloudflare.com/oauth2/token",
22
+ revoke: "https://dash.cloudflare.com/oauth2/revoke"
23
+ };
24
+ /** How long a token-endpoint call may take before it is a timeout rather than a wait. */
25
+ const OAUTH_TIMEOUT_MS = 3e4;
26
+ /**
27
+ * A fresh PKCE pair.
28
+ *
29
+ * **This is what makes a pasted address safe to accept.** The code in the address a browser lands on is
30
+ * redeemable only with the verifier, and the verifier exists only in the CLI that opened the flow — so a
31
+ * code read off a screen, typed across an SSH session, or seen by anyone else is worth nothing elsewhere.
32
+ */
33
+ function pkcePair() {
34
+ const verifier = randomBytes(32).toString("base64url");
35
+ return {
36
+ verifier,
37
+ challenge: createHash("sha256").update(verifier).digest("base64url")
38
+ };
39
+ }
40
+ /** A fresh `state`. Cloudflare refuses one under eight characters; this is twenty-two. */
41
+ function oauthState() {
42
+ return randomBytes(16).toString("base64url");
43
+ }
44
+ /** The URL a browser opens to sign in and consent. */
45
+ function authorizeUrl(request) {
46
+ const url = new URL(CLOUDFLARE_OAUTH.authorize);
47
+ url.search = new URLSearchParams({
48
+ response_type: "code",
49
+ client_id: PITHY_OAUTH_CLIENT_ID,
50
+ redirect_uri: request.redirectUri,
51
+ scope: request.scopes.join(" "),
52
+ state: request.state,
53
+ code_challenge: request.challenge,
54
+ code_challenge_method: "S256"
55
+ }).toString();
56
+ return url.toString();
57
+ }
58
+ /** The token endpoint's answer to a code exchange or a refresh. */
59
+ const OAuthTokenResponse = z.object({
60
+ access_token: z.string().min(1).describe("The bearer token API calls carry. One hour, measured."),
61
+ refresh_token: z.string().min(1).optional().describe("Present only when `offline_access` was granted. Rotates on every use."),
62
+ token_type: z.string().describe("Always `bearer`."),
63
+ expires_in: z.number().int().positive().describe("Seconds until the access token expires."),
64
+ scope: z.string().describe("The scopes granted, space-separated. Compare it with what was requested.")
65
+ }).describe("Cloudflare's token endpoint response, `POST /oauth2/token`.");
66
+ /**
67
+ * The token endpoint's refusal body, per RFC 6749 §5.2.
68
+ *
69
+ * **Not the REST API's `errors[]`, so not `cloudflareRefusal`'s to word.** That composer reads the
70
+ * envelope `api.cloudflare.com` answers with and writes a permission-grant action for it; the OAuth
71
+ * endpoints on `dash.cloudflare.com` answer `{ error, error_description }`, and the remedy is a sign-in,
72
+ * not a grant. They are a service we do not control, so a refusal is `core/upstream_failed`.
73
+ */
74
+ const OAuthErrorResponse = z.object({
75
+ error: z.string(),
76
+ error_description: z.string().optional()
77
+ });
78
+ /** One form POST to an OAuth endpoint: the status, and the body parsed as JSON where it is JSON. */
79
+ async function post(operation, url, form, options) {
80
+ const send = options.fetch ?? globalThis.fetch;
81
+ return cloudflareRequest(operation, async () => {
82
+ const response = await send(url, {
83
+ method: "POST",
84
+ headers: {
85
+ "content-type": "application/x-www-form-urlencoded",
86
+ accept: "application/json"
87
+ },
88
+ body: new URLSearchParams(form),
89
+ signal: AbortSignal.timeout(OAUTH_TIMEOUT_MS)
90
+ });
91
+ const text = await response.text();
92
+ let body;
93
+ try {
94
+ body = text === "" ? {} : JSON.parse(text);
95
+ } catch {
96
+ body = {
97
+ error: "invalid_response",
98
+ error_description: text.slice(0, 200)
99
+ };
100
+ }
101
+ return {
102
+ status: response.status,
103
+ body
104
+ };
105
+ });
106
+ }
107
+ /** The refusal's own words, or `null` when the body is not an RFC 6749 error. */
108
+ function refusalOf(body) {
109
+ const parsed = OAuthErrorResponse.safeParse(body);
110
+ if (!parsed.success) return null;
111
+ return {
112
+ error: parsed.data.error,
113
+ description: parsed.data.error_description ?? parsed.data.error
114
+ };
115
+ }
116
+ function grantOf(body, context, now, keepRefresh) {
117
+ const token = decodeResponse(OAuthTokenResponse, body, context);
118
+ return {
119
+ accessToken: token.access_token,
120
+ refreshToken: token.refresh_token ?? keepRefresh,
121
+ expiresAt: new Date(now.getTime() + token.expires_in * 1e3),
122
+ scope: token.scope
123
+ };
124
+ }
125
+ /**
126
+ * Redeem an authorization code for a session.
127
+ *
128
+ * A refusal is a `core/upstream_failed` carrying Cloudflare's own description — an expired code, a
129
+ * `redirect_uri` that does not match, a verifier that does not fit the challenge. Each is a sign-in that
130
+ * has to start over, and the description is what says which.
131
+ */
132
+ async function exchangeCode(request) {
133
+ const now = request.now ?? (() => /* @__PURE__ */ new Date());
134
+ const answer = await post("exchange the sign-in code", CLOUDFLARE_OAUTH.token, {
135
+ grant_type: "authorization_code",
136
+ code: request.code,
137
+ redirect_uri: request.redirectUri,
138
+ client_id: PITHY_OAUTH_CLIENT_ID,
139
+ code_verifier: request.verifier
140
+ }, request);
141
+ if (answer.status !== 200) {
142
+ const refusal = refusalOf(answer.body);
143
+ throw new UpstreamError({
144
+ message: `Cloudflare refused the sign-in: ${refusal?.description ?? `HTTP ${answer.status}`}`,
145
+ action: `Run ${commandName("cloudflare connect")} again. A sign-in code works once, and only for a few minutes.`,
146
+ detail: `token endpoint ${answer.status}: ${refusal?.error ?? "no error body"}`
147
+ });
148
+ }
149
+ return grantOf(answer.body, "token exchange", now(), null);
150
+ }
151
+ /**
152
+ * Renew a session without a browser.
153
+ *
154
+ * **A refusal is an answer, not a throw.** A spent, revoked or expired refresh token means *sign in
155
+ * again*, and the caller decides how to say so — at dispatch it is deferred to the first command that
156
+ * actually needs Cloudflare. Only a transport failure throws, because that one is worth retrying.
157
+ *
158
+ * The refresh token **rotates** on every use (measured). The one handed back must replace the one sent,
159
+ * on disk, before anything else reads it; the caller holds the profile's lock across that write.
160
+ */
161
+ async function refreshGrant(request) {
162
+ const now = request.now ?? (() => /* @__PURE__ */ new Date());
163
+ const answer = await post("renew the Cloudflare session", CLOUDFLARE_OAUTH.token, {
164
+ grant_type: "refresh_token",
165
+ refresh_token: request.refreshToken,
166
+ client_id: PITHY_OAUTH_CLIENT_ID
167
+ }, request);
168
+ if (answer.status !== 200) return {
169
+ kind: "refused",
170
+ ...refusalOf(answer.body) ?? {
171
+ error: `http_${answer.status}`,
172
+ description: `HTTP ${answer.status}`
173
+ }
174
+ };
175
+ return {
176
+ kind: "refreshed",
177
+ grant: grantOf(answer.body, "token refresh", now(), request.refreshToken)
178
+ };
179
+ }
180
+ /**
181
+ * Revoke one token at Cloudflare. Throws on a refusal, so `disconnect` never reports a revocation that did
182
+ * not happen — the local file is deleted either way, and the operator is told which half held.
183
+ */
184
+ async function revokeToken(request) {
185
+ const answer = await post("revoke the Cloudflare session", CLOUDFLARE_OAUTH.revoke, {
186
+ token: request.token,
187
+ token_type_hint: request.hint,
188
+ client_id: PITHY_OAUTH_CLIENT_ID
189
+ }, request);
190
+ if (answer.status !== 200) {
191
+ const refusal = refusalOf(answer.body);
192
+ throw new UpstreamError({
193
+ message: `Cloudflare did not revoke the session: ${refusal?.description ?? `HTTP ${answer.status}`}`,
194
+ action: "Sign in to the Cloudflare dashboard and revoke the Pithy CLI's access to your account there.",
195
+ detail: `revoke endpoint ${answer.status}: ${refusal?.error ?? "no error body"}`
196
+ });
197
+ }
198
+ }
199
+ //#endregion
200
+ export { CLOUDFLARE_OAUTH, OAuthTokenResponse, PITHY_OAUTH_CLIENT_ID, authorizeUrl, exchangeCode, oauthState, pkcePair, refreshGrant, revokeToken };