@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.
- package/README.md +3 -3
- package/dist/ai/aiManager.d.ts +15 -1
- package/dist/ai/aiManager.d.ts.map +1 -1
- package/dist/ai/aiManager.js +39 -6
- package/dist/analytics/analyticsManager.d.ts +78 -0
- package/dist/analytics/analyticsManager.d.ts.map +1 -0
- package/dist/analytics/analyticsManager.js +206 -0
- package/dist/analytics/datasets.d.ts +26 -0
- package/dist/analytics/datasets.d.ts.map +1 -0
- package/dist/analytics/datasets.js +26 -0
- package/dist/analytics/window.d.ts +50 -0
- package/dist/analytics/window.d.ts.map +1 -0
- package/dist/analytics/window.js +67 -0
- package/dist/artifacts/artifactsManager.d.ts +52 -0
- package/dist/artifacts/artifactsManager.d.ts.map +1 -0
- package/dist/artifacts/artifactsManager.js +150 -0
- package/dist/client/clients.d.ts +13 -1
- package/dist/client/clients.d.ts.map +1 -1
- package/dist/client/clients.js +22 -1
- package/dist/client/manager.d.ts +13 -2
- package/dist/client/manager.d.ts.map +1 -1
- package/dist/client/manager.js +18 -5
- package/dist/d1/d1Manager.d.ts.map +1 -1
- package/dist/d1/d1Manager.js +3 -3
- package/dist/d1/d1PreparedStatement.d.ts +12 -0
- package/dist/d1/d1PreparedStatement.d.ts.map +1 -1
- package/dist/d1/d1PreparedStatement.js +13 -3
- package/dist/media/imageManager.d.ts +25 -0
- package/dist/media/imageManager.d.ts.map +1 -1
- package/dist/media/imageManager.js +58 -2
- package/dist/media/streamManager.d.ts +15 -0
- package/dist/media/streamManager.d.ts.map +1 -1
- package/dist/media/streamManager.js +28 -1
- package/dist/oauth/callback.d.ts +32 -0
- package/dist/oauth/callback.d.ts.map +1 -0
- package/dist/oauth/callback.js +58 -0
- package/dist/oauth/catalog.d.ts +26 -0
- package/dist/oauth/catalog.d.ts.map +1 -0
- package/dist/oauth/catalog.js +30 -0
- package/dist/oauth/client.d.ts +126 -0
- package/dist/oauth/client.d.ts.map +1 -0
- package/dist/oauth/client.js +200 -0
- package/dist/oauth/scopes.d.ts +62 -0
- package/dist/oauth/scopes.d.ts.map +1 -0
- package/dist/oauth/scopes.js +79 -0
- package/dist/queue/queueProvisioner.d.ts +30 -0
- package/dist/queue/queueProvisioner.d.ts.map +1 -0
- package/dist/queue/queueProvisioner.js +72 -0
- package/dist/r2/r2Manager.d.ts +11 -1
- package/dist/r2/r2Manager.d.ts.map +1 -1
- package/dist/r2/r2Manager.js +18 -1
- package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
- package/dist/secrets/secretsStoreManager.js +3 -2
- package/dist/test-utils/settle.d.ts +79 -0
- package/dist/test-utils/settle.d.ts.map +1 -0
- package/dist/test-utils/settle.js +105 -0
- package/dist/tokens/accountTokensManager.d.ts +8 -7
- package/dist/tokens/accountTokensManager.d.ts.map +1 -1
- package/dist/tokens/accountTokensManager.js +2 -1
- package/dist/tokens/permissions.d.ts +49 -0
- package/dist/tokens/permissions.d.ts.map +1 -1
- package/dist/tokens/permissions.js +56 -2
- package/dist/tokens/profiles.d.ts +10 -30
- package/dist/tokens/profiles.d.ts.map +1 -1
- package/dist/tokens/profiles.js +48 -36
- package/dist/tokens/runtime.d.ts +16 -0
- package/dist/tokens/runtime.d.ts.map +1 -0
- package/dist/tokens/runtime.js +18 -0
- package/dist/workers/buildsManager.js +1 -1
- package/dist/workflows/stepFailure.js +1 -1
- package/dist/workflows/workflowsClient.js +1 -1
- package/package.json +3 -3
- package/src/ai/aiManager.ts +44 -5
- package/src/analytics/analyticsManager.ts +311 -0
- package/src/analytics/datasets.ts +30 -0
- package/src/analytics/window.ts +101 -0
- package/src/artifacts/artifactsManager.ts +199 -0
- package/src/client/clients.ts +28 -1
- package/src/client/manager.ts +24 -6
- package/src/d1/d1Manager.ts +7 -3
- package/src/d1/d1PreparedStatement.ts +21 -2
- package/src/media/imageManager.ts +93 -1
- package/src/media/streamManager.ts +34 -0
- package/src/oauth/callback.ts +70 -0
- package/src/oauth/catalog.ts +39 -0
- package/src/oauth/client.ts +265 -0
- package/src/oauth/scopes.ts +91 -0
- package/src/queue/queueProvisioner.ts +79 -0
- package/src/r2/r2Manager.ts +29 -2
- package/src/secrets/secretsStoreManager.ts +2 -1
- package/src/test-utils/settle.ts +139 -0
- package/src/tokens/accountTokensManager.ts +12 -0
- package/src/tokens/permissions.ts +81 -0
- package/src/tokens/profiles.ts +58 -70
- package/src/tokens/runtime.ts +18 -0
package/src/client/clients.ts
CHANGED
|
@@ -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);
|
package/src/client/manager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|
package/src/d1/d1Manager.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
+
}
|