@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
@@ -4,6 +4,8 @@
4
4
  import { CloudflareAIManager } from "../ai/aiManager";
5
5
  import { CloudflareVectorizeManager } from "../ai/vectorizeManager";
6
6
  import { CloudflareVectorizeProvisioner } from "../ai/vectorizeProvisioner";
7
+ import { CloudflareAnalyticsManager } from "../analytics/analyticsManager";
8
+ import { CloudflareArtifactsManager } from "../artifacts/artifactsManager";
7
9
  import { CloudflareD1Manager } from "../d1/d1Manager";
8
10
  import { CloudflareD1Provisioner } from "../d1/d1Provisioner";
9
11
  import { CloudflareEmailRoutingManager } from "../email/emailRoutingManager";
@@ -14,6 +16,7 @@ import { CloudflareKVProvisioner } from "../kv/kvProvisioner";
14
16
  import { CloudflareImageManager } from "../media/imageManager";
15
17
  import { CloudflareStreamManager } from "../media/streamManager";
16
18
  import { CloudflareQueueManager } from "../queue/queueManager";
19
+ import { CloudflareQueueProvisioner } from "../queue/queueProvisioner";
17
20
  import type { R2Credentials } from "../r2/r2Credentials";
18
21
  import { CloudflareR2Manager } from "../r2/r2Manager";
19
22
  import { CloudflareR2Provisioner } from "../r2/r2Provisioner";
@@ -35,7 +38,7 @@ import type { CloudflareManagerConfig } from "./manager";
35
38
  *
36
39
  * Resource-scoped managers (KV, D1, queues, Vectorize indexes, secret stores, hostname zones, R2
37
40
  * buckets) are keyed by their resource id, so repeated calls for the same id return one shared
38
- * instance. Account-scoped managers (AI, Images, Stream, Workers, Builds, Turnstile) are singletons.
41
+ * instance. Account-scoped managers (AI, Images, Stream, Analytics, Workers, Builds, Turnstile) are singletons.
39
42
  */
40
43
  export class CloudflareClients {
41
44
  private readonly config: CloudflareManagerConfig;
@@ -62,6 +65,8 @@ export class CloudflareClients {
62
65
 
63
66
  private streamManager?: CloudflareStreamManager;
64
67
 
68
+ private analyticsManager?: CloudflareAnalyticsManager;
69
+
65
70
  private workersManager?: CloudflareWorkersManager;
66
71
 
67
72
  private buildsManager?: CloudflareBuildsManager;
@@ -81,6 +86,10 @@ export class CloudflareClients {
81
86
 
82
87
  private r2ProvisionerManager?: CloudflareR2Provisioner;
83
88
 
89
+ private queueProvisionerManager?: CloudflareQueueProvisioner;
90
+
91
+ private artifactsManager?: CloudflareArtifactsManager;
92
+
84
93
  private vectorizeProvisionerManager?: CloudflareVectorizeProvisioner;
85
94
 
86
95
  constructor(config: CloudflareManagerConfig) {
@@ -167,6 +176,12 @@ export class CloudflareClients {
167
176
  return this.streamManager;
168
177
  }
169
178
 
179
+ /** The account-scoped GraphQL Analytics manager. */
180
+ analytics(): CloudflareAnalyticsManager {
181
+ this.analyticsManager ??= new CloudflareAnalyticsManager(this.config);
182
+ return this.analyticsManager;
183
+ }
184
+
170
185
  /** The account-scoped Workers (scripts/deployments) manager. */
171
186
  workers(): CloudflareWorkersManager {
172
187
  this.workersManager ??= new CloudflareWorkersManager(this.config);
@@ -209,6 +224,18 @@ export class CloudflareClients {
209
224
  return this.r2ProvisionerManager;
210
225
  }
211
226
 
227
+ /** The account-scoped Queues control plane — create, find, and delete queues. */
228
+ queueProvisioner(): CloudflareQueueProvisioner {
229
+ this.queueProvisionerManager ??= new CloudflareQueueProvisioner(this.config);
230
+ return this.queueProvisionerManager;
231
+ }
232
+
233
+ /** The account-scoped Artifacts control plane — list and delete the repos in a namespace (#673). */
234
+ artifacts(): CloudflareArtifactsManager {
235
+ this.artifactsManager ??= new CloudflareArtifactsManager(this.config);
236
+ return this.artifactsManager;
237
+ }
238
+
212
239
  /** The account-scoped Vectorize control plane — create, find, list, and delete indexes and their metadata indexes. */
213
240
  vectorizeProvisioner(): CloudflareVectorizeProvisioner {
214
241
  this.vectorizeProvisionerManager ??= new CloudflareVectorizeProvisioner(this.config);
@@ -17,6 +17,15 @@ export interface CloudflareManagerConfig {
17
17
  apiToken: string;
18
18
  /** The Cloudflare account id all operations target (`CLOUDFLARE_ACCOUNT_ID`). */
19
19
  accountId: string;
20
+ /**
21
+ * The token to call with *now*, read at every call, when it can change during the manager's life (#759).
22
+ *
23
+ * A signed-in session's access token lasts an hour and is renewed on disk ahead of that, so a manager
24
+ * built at the start of a long run would otherwise keep sending the token it was constructed with.
25
+ * Absent, {@link apiToken} is the token for good — which is every command that finishes inside the hour
26
+ * dispatch guarantees it.
27
+ */
28
+ tokenProvider?: () => string;
20
29
  }
21
30
 
22
31
  /**
@@ -26,9 +35,10 @@ export interface CloudflareManagerConfig {
26
35
  * (`validateServiceAccess`).
27
36
  */
28
37
  export abstract class CloudflareManager {
29
- private readonly client: Cloudflare;
38
+ /** The SDK client, and the token it was built for — rebuilt when {@link getApiToken} answers another. */
39
+ private client: { token: string; sdk: Cloudflare };
30
40
 
31
- private readonly apiToken: string;
41
+ private readonly token: () => string;
32
42
 
33
43
  protected readonly accountId: string;
34
44
 
@@ -39,22 +49,30 @@ export abstract class CloudflareManager {
39
49
  if (!config.accountId) {
40
50
  throw new CloudflareNotConfiguredError({ detail: "Missing accountId in CloudflareManagerConfig." });
41
51
  }
42
- this.client = new Cloudflare({ apiToken: config.apiToken });
43
- this.apiToken = config.apiToken;
52
+ const initial = config.apiToken;
53
+ this.token = config.tokenProvider ?? (() => initial);
54
+ this.client = { token: initial, sdk: new Cloudflare({ apiToken: initial }) };
44
55
  this.accountId = config.accountId;
45
56
  }
46
57
 
47
58
  /** The configured SDK client. Subclasses route typed calls through this. */
48
59
  protected getClient(): Cloudflare {
49
- return this.client;
60
+ const token = this.getApiToken();
61
+ if (token !== this.client.token) this.client = { token, sdk: new Cloudflare({ apiToken: token }) };
62
+ return this.client.sdk;
50
63
  }
51
64
 
52
65
  /**
53
66
  * The raw API token, for subclasses that fall back to direct `fetch()` for endpoints the typed
54
67
  * SDK does not cover (e.g. Workers Builds, event subscriptions). The documented escape hatch.
68
+ * Read through the provider at every call, so it is the current token, never the constructed one.
55
69
  */
56
70
  protected getApiToken(): string {
57
- return this.apiToken;
71
+ const token = this.token();
72
+ if (!token) {
73
+ throw new CloudflareNotConfiguredError({ detail: "The tokenProvider answered no apiToken." });
74
+ }
75
+ return token;
58
76
  }
59
77
 
60
78
  /** The account id all operations target. */
@@ -7,7 +7,7 @@ import type { D1 } from "cloudflare/resources/d1/d1";
7
7
  import type { QueryResult } from "cloudflare/resources/d1/database";
8
8
  import { CloudflareNotConfiguredError, cloudflareRequest, reasonOf } from "../client/errors";
9
9
  import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
10
- import { D1PreparedStatementREST } from "./d1PreparedStatement";
10
+ import { D1_QUERY_REQUEST, D1PreparedStatementREST } from "./d1PreparedStatement";
11
11
 
12
12
  /** Config for the D1 manager: the shared client config plus the database it targets. */
13
13
  export interface D1ManagerConfig extends CloudflareManagerConfig {
@@ -68,7 +68,7 @@ export class CloudflareD1Manager extends CloudflareManager implements D1Database
68
68
 
69
69
  async exec(query: string): Promise<D1ExecResult> {
70
70
  const response = await cloudflareRequest("D1 exec", () =>
71
- this.getClient().d1.database.query(this.databaseId, { account_id: this.accountId, sql: query }),
71
+ this.getClient().d1.database.query(this.databaseId, { account_id: this.accountId, sql: query }, D1_QUERY_REQUEST),
72
72
  );
73
73
  const result = response.result[0];
74
74
  return { count: result?.results?.length ?? 0, duration: result?.meta?.duration ?? 0 };
@@ -89,7 +89,11 @@ export class CloudflareD1Manager extends CloudflareManager implements D1Database
89
89
  /** Run one SQL statement and return its raw REST result sets. */
90
90
  async executeQuery(sql: string, params?: string[]): Promise<QueryResult[]> {
91
91
  const response = await cloudflareRequest("D1 query", () =>
92
- this.getClient().d1.database.query(this.databaseId, { account_id: this.accountId, sql, params }),
92
+ this.getClient().d1.database.query(
93
+ this.databaseId,
94
+ { account_id: this.accountId, sql, params },
95
+ D1_QUERY_REQUEST,
96
+ ),
93
97
  );
94
98
  return response.result;
95
99
  }
@@ -31,6 +31,17 @@ function mapMeta(restMeta?: QueryResult.Meta): D1Meta & Record<string, unknown>
31
31
  };
32
32
  }
33
33
 
34
+ /**
35
+ * The request options every D1 query is sent with: **no retries** (#709).
36
+ *
37
+ * The SDK retries a request twice by default, on a connection timeout, a 408, a 409, a 429 or a 5xx. That is
38
+ * safe for a read of a resource and wrong for a query, which is not idempotent: D1 can apply a statement and
39
+ * answer slowly, and the resent copy then runs against the changed database. A migration's `CREATE TABLE` did
40
+ * exactly that and failed `table already exists`, on a step that had succeeded. Sent once, a slow answer
41
+ * surfaces as the timeout it was, and `cloudflareRequest` already tells the operator to check before retrying.
42
+ */
43
+ export const D1_QUERY_REQUEST = { maxRetries: 0 } as const;
44
+
34
45
  /** The REST `params` array is strings; coerce each bound value the way the D1 REST API expects. */
35
46
  function toStringParams(params: unknown[]): string[] {
36
47
  return params.map((value) => {
@@ -88,7 +99,11 @@ export class D1PreparedStatementREST<T = Record<string, unknown>> implements D1P
88
99
  async raw<U = unknown[]>(options?: { columnNames?: boolean }): Promise<U[] | [string[], ...U[]]> {
89
100
  const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : undefined;
90
101
  const response = await cloudflareRequest("D1 raw query", () =>
91
- this.client.d1.database.raw(this.databaseId, { account_id: this.accountId, sql: this.sql, params }),
102
+ this.client.d1.database.raw(
103
+ this.databaseId,
104
+ { account_id: this.accountId, sql: this.sql, params },
105
+ D1_QUERY_REQUEST,
106
+ ),
92
107
  );
93
108
  const rawResult = response.result[0];
94
109
  const rows = (rawResult?.results?.rows ?? []) as U[];
@@ -102,7 +117,11 @@ export class D1PreparedStatementREST<T = Record<string, unknown>> implements D1P
102
117
  private async executeQuery<U>(): Promise<D1Result<U>> {
103
118
  const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : undefined;
104
119
  const response = await cloudflareRequest("D1 query", () =>
105
- this.client.d1.database.query(this.databaseId, { account_id: this.accountId, sql: this.sql, params }),
120
+ this.client.d1.database.query(
121
+ this.databaseId,
122
+ { account_id: this.accountId, sql: this.sql, params },
123
+ D1_QUERY_REQUEST,
124
+ ),
106
125
  );
107
126
  const queryResult = response.result[0];
108
127
  return {
@@ -8,7 +8,8 @@ import type {
8
8
  DirectUploadCreateResponse,
9
9
  } from "cloudflare/resources/images/v2/direct-uploads";
10
10
  import type { V2ListParams } from "cloudflare/resources/images/v2/v2";
11
- import { cloudflareRequest } from "../client/errors";
11
+ import { z } from "zod";
12
+ import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors";
12
13
  import { CloudflareManager } from "../client/manager";
13
14
 
14
15
  /**
@@ -29,6 +30,59 @@ function encodeMetadata(metadata: unknown): unknown {
29
30
  return metadata === undefined || metadata === null ? metadata : JSON.stringify(metadata);
30
31
  }
31
32
 
33
+ /** One of the account's Images URL-signing keys. The value is the HMAC secret signed URLs are minted with. */
34
+ export const ImagesSigningKey = z
35
+ .object({
36
+ name: z.string().min(1).describe("The key's name. Cloudflare provisions one named `default`."),
37
+ value: z.string().min(1).describe("The key itself — the HMAC-SHA256 secret a signed delivery URL is minted with."),
38
+ })
39
+ .describe("One Cloudflare Images URL-signing key.");
40
+ export type ImagesSigningKey = z.infer<typeof ImagesSigningKey>;
41
+
42
+ const SigningKeysResponse = z
43
+ .object({ keys: z.array(ImagesSigningKey).optional().describe("The account's signing keys.") })
44
+ .describe("The Images signing-keys response.");
45
+
46
+ /** One Images variant, and whether it serves an image without a signature whatever the image requires. */
47
+ export const ImagesVariant = z
48
+ .object({
49
+ id: z.string().describe("The variant's name, as it appears in a delivery URL."),
50
+ neverRequireSignedURLs: z
51
+ .boolean()
52
+ .describe("Whether this variant serves every image unsigned, even one uploaded with requireSignedURLs."),
53
+ })
54
+ .describe("One Cloudflare Images variant's signing posture.");
55
+ export type ImagesVariant = z.infer<typeof ImagesVariant>;
56
+
57
+ const VariantsResponse = z
58
+ .object({
59
+ variants: z
60
+ .record(
61
+ z.string(),
62
+ z
63
+ .object({
64
+ id: z.string().describe("The variant name."),
65
+ neverRequireSignedURLs: z.boolean().optional().describe("Whether it ignores signing."),
66
+ })
67
+ .describe("One variant as the API returns it."),
68
+ )
69
+ .optional()
70
+ .describe("Every variant, keyed by name."),
71
+ })
72
+ .describe("The Images variants response.");
73
+
74
+ /** Parse an Images response the SDK types loosely, refusing a shape we cannot read. */
75
+ function parseResponse<T>(schema: z.ZodType<T>, response: unknown, what: string): T {
76
+ const result = schema.safeParse(response);
77
+ if (!result.success) {
78
+ throw new CloudflareInvalidResponseError({
79
+ message: `An Images ${what} response had an unexpected shape.`,
80
+ detail: result.error.message,
81
+ });
82
+ }
83
+ return result.data;
84
+ }
85
+
32
86
  /**
33
87
  * Out-of-Worker Cloudflare Images access over the REST API: upload, fetch, edit, delete, list, and
34
88
  * direct-upload URLs from a CLI/CI/provisioning context. Image metadata is an arbitrary
@@ -109,6 +163,44 @@ export class CloudflareImageManager extends CloudflareManager {
109
163
  });
110
164
  }
111
165
 
166
+ /**
167
+ * The account's URL-signing keys — what a signed delivery URL for an image uploaded with
168
+ * `requireSignedURLs` is minted with. Cloudflare provisions a `default` key per account.
169
+ */
170
+ async signingKeys(): Promise<ImagesSigningKey[]> {
171
+ return cloudflareRequest("Images list signing keys", async () => {
172
+ const response = await this.getClient().images.v1.keys.list({ account_id: this.accountId }, requestOptions);
173
+ return parseResponse(SigningKeysResponse, response, "signing keys").keys ?? [];
174
+ });
175
+ }
176
+
177
+ /** Create a named signing key, or roll it if it exists. Answers with every key the account then holds. */
178
+ async createSigningKey(name: string): Promise<ImagesSigningKey[]> {
179
+ return cloudflareRequest(`Images create signing key '${name}'`, async () => {
180
+ const response = await this.getClient().images.v1.keys.update(
181
+ name,
182
+ { account_id: this.accountId },
183
+ requestOptions,
184
+ );
185
+ return parseResponse(SigningKeysResponse, response, "signing keys").keys ?? [];
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Every variant, with whether it serves unsigned. A variant set to `neverRequireSignedURLs` delivers a
191
+ * private image to anyone holding its URL, which is why media's provisioning reads this.
192
+ */
193
+ async listVariants(): Promise<ImagesVariant[]> {
194
+ return cloudflareRequest("Images list variants", async () => {
195
+ const response = await this.getClient().images.v1.variants.list({ account_id: this.accountId }, requestOptions);
196
+ const variants = parseResponse(VariantsResponse, response, "variants").variants ?? {};
197
+ return Object.values(variants).map((variant) => ({
198
+ id: variant.id,
199
+ neverRequireSignedURLs: variant.neverRequireSignedURLs === true,
200
+ }));
201
+ });
202
+ }
203
+
112
204
  getServiceType(): string {
113
205
  return "Cloudflare Images";
114
206
  }
@@ -68,6 +68,19 @@ function parseDownloadStatus(response: unknown): StreamDownloadStatus {
68
68
  return result.data;
69
69
  }
70
70
 
71
+ /**
72
+ * A Stream signing key: what a signed playback token for a video uploaded with `requireSignedURLs` is
73
+ * signed with. The JWK is base64 of the key's JSON, as Cloudflare returns it — a single line, so it is
74
+ * the form stored, where the PEM is not.
75
+ */
76
+ export const StreamSigningKey = z
77
+ .object({
78
+ id: z.string().min(1).describe("The key id — the `kid` a signed token names."),
79
+ jwk: z.string().min(1).describe("The RSA private key as base64-encoded JWK, as Cloudflare returns it."),
80
+ })
81
+ .describe("One Cloudflare Stream signing key.");
82
+ export type StreamSigningKey = z.infer<typeof StreamSigningKey>;
83
+
71
84
  /**
72
85
  * Out-of-Worker Cloudflare Stream access over the REST API: delete, fetch, edit, direct uploads,
73
86
  * and download (MP4 + audio-only) management from a CLI/CI/provisioning context. Video metadata is
@@ -182,6 +195,27 @@ export class CloudflareStreamManager extends CloudflareManager {
182
195
  );
183
196
  }
184
197
 
198
+ /**
199
+ * Create a signing key. Cloudflare returns the private half once, on creation, and never again — so the
200
+ * caller stores it, and a lost key is replaced by creating another.
201
+ */
202
+ async createSigningKey(): Promise<StreamSigningKey> {
203
+ return cloudflareRequest("Stream create signing key", async () => {
204
+ const response = await this.getClient().stream.keys.create(
205
+ { account_id: this.accountId, body: {} },
206
+ requestOptions,
207
+ );
208
+ const parsed = StreamSigningKey.safeParse(response);
209
+ if (!parsed.success) {
210
+ throw new CloudflareInvalidResponseError({
211
+ message: "A Stream signing-key response had an unexpected shape.",
212
+ detail: parsed.error.message,
213
+ });
214
+ }
215
+ return parsed.data;
216
+ });
217
+ }
218
+
185
219
  getServiceType(): string {
186
220
  return "Cloudflare Stream";
187
221
  }
@@ -0,0 +1,70 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { commandName } from "@pithy-sh/core/src/cli/command";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+
7
+ /** What a callback said: a code to redeem, or Cloudflare's refusal. */
8
+ export type CallbackAnswer = { kind: "code"; code: string } | { kind: "error"; error: string; description: string };
9
+
10
+ /** What the address must match: the `state` and `redirect_uri` this sign-in sent. */
11
+ export interface CallbackExpectation {
12
+ /** The `state` in the authorize URL. */
13
+ state: string;
14
+ /** The `redirect_uri` in the authorize URL, verbatim. */
15
+ redirectUri: string;
16
+ }
17
+
18
+ /**
19
+ * Read the address a browser landed on — from the loopback listener or pasted by hand — into an answer.
20
+ *
21
+ * **One reader for both paths, so they cannot check different things.** A remote session pastes what its
22
+ * browser shows, because the redirect lands on the browser's own loopback interface, where nothing is
23
+ * listening (measured over SSH, 2026-10-15). The pasted address is held to exactly what a delivered
24
+ * callback is: the same origin and path this sign-in sent, the same `state`, and a `code` or an `error`.
25
+ *
26
+ * Accepting a paste is safe because of PKCE, not because of this check — a code is worthless without the
27
+ * verifier, and the verifier never left this process. The `state` check is what stops a code from a
28
+ * *different* sign-in being redeemed into this one.
29
+ */
30
+ export function readCallback(address: string, expect: CallbackExpectation): CallbackAnswer {
31
+ const text = address.trim().replace(/^["']|["']$/g, "");
32
+ let url: URL;
33
+ try {
34
+ url = new URL(text);
35
+ } catch {
36
+ throw notTheAddress(expect, "it is not an address");
37
+ }
38
+ const sent = new URL(expect.redirectUri);
39
+ if (url.origin !== sent.origin || url.pathname !== sent.pathname) {
40
+ throw notTheAddress(expect, `it is ${url.origin}${url.pathname}`);
41
+ }
42
+ if (url.searchParams.get("state") !== expect.state) {
43
+ throw new ValidationError({
44
+ message: "That address belongs to a different sign-in: its state does not match.",
45
+ action: `Paste the address from the browser tab this sign-in opened, or run ${commandName("cloudflare connect")} again.`,
46
+ detail: "callback state did not match the state this sign-in sent",
47
+ });
48
+ }
49
+ const error = url.searchParams.get("error");
50
+ if (error !== null) {
51
+ return { kind: "error", error, description: url.searchParams.get("error_description") ?? error };
52
+ }
53
+ const code = url.searchParams.get("code");
54
+ if (code === null || code === "") {
55
+ throw new ValidationError({
56
+ message: "That address carries no code and no error.",
57
+ action: "Paste the whole address from the browser's address bar, after you approve.",
58
+ detail: "callback had neither `code` nor `error`",
59
+ });
60
+ }
61
+ return { kind: "code", code };
62
+ }
63
+
64
+ function notTheAddress(expect: CallbackExpectation, why: string): ValidationError {
65
+ return new ValidationError({
66
+ message: `That is not the address this sign-in sent the browser to: ${why}.`,
67
+ action: `Paste the whole address your browser landed on. It starts with ${expect.redirectUri}.`,
68
+ detail: `expected ${expect.redirectUri}`,
69
+ });
70
+ }
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { cloudflareRequest, decodeResponse } from "../client/errors";
6
+
7
+ /** One OAuth scope, as Cloudflare's catalog lists it. */
8
+ export const OAuthScope = z
9
+ .object({
10
+ id: z.string().describe("The scope a client requests: `d1.write`."),
11
+ name: z.string().describe("Its display name, which is the permission group's own: `D1 Write`."),
12
+ category: z.string().optional().describe("Cloudflare's grouping, e.g. `developer_platform`."),
13
+ })
14
+ .describe("One scope in `GET /oauth/scopes`.");
15
+ export type OAuthScope = z.output<typeof OAuthScope>;
16
+
17
+ const ScopeList = z.object({ result: z.array(OAuthScope) });
18
+
19
+ /** The catalog's endpoint. Global: it needs authentication and no account. */
20
+ export const OAUTH_SCOPES_URL = "https://api.cloudflare.com/client/v4/oauth/scopes";
21
+
22
+ /**
23
+ * **Cloudflare's OAuth scope catalog: every scope a third-party client may register (#759).**
24
+ *
25
+ * The third column of the permission table is checked against this the way the group names are checked
26
+ * against the account, because a scope id that drifts from it is a sign-in Cloudflare refuses with
27
+ * `invalid_scope` — at the consent screen of an adopter, rather than in this repository's CI.
28
+ */
29
+ export async function listOAuthScopes(options: {
30
+ apiToken: string;
31
+ fetch?: (url: string, init: { headers: Record<string, string> }) => Promise<Response>;
32
+ }): Promise<OAuthScope[]> {
33
+ const send = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
34
+ const raw = await cloudflareRequest("list OAuth scopes", async () => {
35
+ const response = await send(OAUTH_SCOPES_URL, { headers: { authorization: `Bearer ${options.apiToken}` } });
36
+ return (await response.json()) as unknown;
37
+ });
38
+ return decodeResponse(ScopeList, raw, "oauth scopes list").result;
39
+ }