@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
|
@@ -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":"AAMA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,qCAAqC,CAAC;AAExE,0FAA0F;AAC1F,eAAO,MAAM,gBAAgB;aAC3B,SAAS,EAAE,yCAAyC;aACpD,KAAK,EAAE,0CAA0C;aACjD,MAAM,EAAE,2CAA2C;CAC3C,CAAC;AAKX,oGAAoG;AACpG,MAAM,MAAM,UAAU,GAAG,CACvB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,eAAe,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,KAClG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEvB,6GAA6G;AAC7G,MAAM,WAAW,QAAQ;IACvB,8FAA8F;IAC9F,QAAQ,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,IAAI,QAAQ,CAGnC;AAED,0FAA0F;AAC1F,wBAAgB,UAAU,IAAI,MAAM,CAEnC;AAED,2CAA2C;AAC3C,MAAM,WAAW,gBAAgB;IAC/B,0EAA0E;IAC1E,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,iEAAiE;IACjE,WAAW,EAAE,MAAM,CAAC;IACpB,0CAA0C;IAC1C,KAAK,EAAE,MAAM,CAAC;IACd,0BAA0B;IAC1B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,sDAAsD;AACtD,wBAAgB,YAAY,CAAC,OAAO,EAAE,gBAAgB,GAAG,MAAM,CAY9D;AAED,mEAAmE;AACnE,eAAO,MAAM,kBAAkB;;;;;;iBAY2C,CAAC;AAC3E,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAerE,kDAAkD;AAClD,MAAM,WAAW,UAAU;IACzB,wBAAwB;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,0EAA0E;IAC1E,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,6FAA6F;IAC7F,SAAS,EAAE,IAAI,CAAC;IAChB,qDAAqD;IACrD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,kEAAkE;AAClE,UAAU,eAAe;IACvB,sCAAsC;IACtC,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,sCAAsC;IACtC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AA6CD;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,eAAe,GACjF,OAAO,CAAC,UAAU,CAAC,CAuBrB;AAED,2FAA2F;AAC3F,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE;IAAE,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,eAAe,GAAG,OAAO,CAAC,aAAa,CAAC,CAa9G;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,cAAc,GAAG,eAAe,CAAA;CAAE,GAAG,eAAe,GACnF,OAAO,CAAC,IAAI,CAAC,CAef"}
|
|
@@ -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 };
|