@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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* The scopes every session asks for, whatever the project composes (#759).
|
|
5
|
+
*
|
|
6
|
+
* - `user-details.read` — `GET /user`, which is what turns an audit row's actor from a token's name into
|
|
7
|
+
* the person who signed in.
|
|
8
|
+
* - `offline_access` — the only way Cloudflare issues a refresh token. Without it a session dies at the
|
|
9
|
+
* hour and every command after it opens a browser (measured 2026-10-15).
|
|
10
|
+
* - `account-api-tokens.write` — the mint. A session mints `ci-system` and `runtime` as the project
|
|
11
|
+
* composes them, so the hand-made bootstrap token stops being a setup step.
|
|
12
|
+
* - `account-settings.read` — `GET /accounts`, so the account is discovered rather than stored. On its own
|
|
13
|
+
* it lists the account; `d1.write` alone listed nothing (both measured 2026-10-15), so discovery must not
|
|
14
|
+
* lean on whichever other scope a project happens to request.
|
|
15
|
+
* - `zone.read` — `pithy init` and `pithy worker add` offer the account's real zones, and `init` asks
|
|
16
|
+
* before there is a project whose capabilities could say so. Read-only, as {@link OAUTH_SCOPES} says of it.
|
|
17
|
+
*
|
|
18
|
+
* Everything else comes from the project's own token permissions, through {@link sessionScopes}.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SESSION_BASE_SCOPES: readonly ["user-details.read", "offline_access", "account-api-tokens.write", "account-settings.read", "zone.read"];
|
|
21
|
+
/**
|
|
22
|
+
* The scopes Cloudflare's consent screen lists under **"Sensitive scopes"**, switched off, by the name it
|
|
23
|
+
* shows them under.
|
|
24
|
+
*
|
|
25
|
+
* **Approving with one switched off does not grant the rest.** The dashboard answers *"We could not
|
|
26
|
+
* complete the authorization. Please check your network connection and try again."* and no callback
|
|
27
|
+
* ever arrives (measured 2026-10-15). So `connect` names these before it opens the browser, and its
|
|
28
|
+
* timeout names them as the likeliest cause.
|
|
29
|
+
*
|
|
30
|
+
* **The list is ours to keep, because Cloudflare's is not published.** `GET /oauth/scopes` carries an
|
|
31
|
+
* id, a name, a category and nothing that marks a scope sensitive; only the consent screen says. Add to
|
|
32
|
+
* it when a consent screen shows another.
|
|
33
|
+
*/
|
|
34
|
+
export declare const SENSITIVE_SCOPES: Readonly<Record<string, string>>;
|
|
35
|
+
/**
|
|
36
|
+
* The scopes a session needs to act for a project, and to mint every token it declares: the base, then
|
|
37
|
+
* the scope of each permission key, de-duped in first-seen order.
|
|
38
|
+
*
|
|
39
|
+
* **Derived, never listed.** Cloudflare's delegation rule binds the session as it binds a token — a mint
|
|
40
|
+
* whose groups the session does not hold is refused — so the request is exactly the union of what the
|
|
41
|
+
* project's tokens carry. A capability the project does not compose contributes nothing to it, and so
|
|
42
|
+
* nothing to the consent screen the adopter reads.
|
|
43
|
+
*/
|
|
44
|
+
export declare function sessionScopes(permissionKeys: readonly string[]): string[];
|
|
45
|
+
/**
|
|
46
|
+
* The requested scopes a grant does not carry, in request order.
|
|
47
|
+
*
|
|
48
|
+
* `granted` is the token endpoint's `scope`: one space-separated string. Today Cloudflare fails rather
|
|
49
|
+
* than grant a subset, so this should always be empty; it is checked because that is Cloudflare's
|
|
50
|
+
* behavior to change, and a session missing `account-api-tokens.write` would fail at the first mint.
|
|
51
|
+
*/
|
|
52
|
+
export declare function missingScopes(requested: readonly string[], granted: string): string[];
|
|
53
|
+
/** One sensitive scope a request carries, by its id and the name the consent screen shows. */
|
|
54
|
+
export interface SensitiveScope {
|
|
55
|
+
/** The catalog id: `account-api-tokens.write`. */
|
|
56
|
+
id: string;
|
|
57
|
+
/** What the consent screen calls it: "Account API Tokens Write". */
|
|
58
|
+
name: string;
|
|
59
|
+
}
|
|
60
|
+
/** The sensitive scopes in a request, in request order. See {@link SENSITIVE_SCOPES}. */
|
|
61
|
+
export declare function sensitiveScopesIn(scopes: readonly string[]): SensitiveScope[];
|
|
62
|
+
//# sourceMappingURL=scopes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scopes.d.ts","sourceRoot":"","sources":["../../src/oauth/scopes.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,mBAAmB,YAC9B,mBAAmB,EACnB,gBAAgB,EAChB,0BAA0B,EAC1B,uBAAuB,EACvB,WAAW,CACH,CAAC;AAEX;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAE7D,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAMzE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAGrF;AAED,8FAA8F;AAC9F,MAAM,WAAW,cAAc;IAC7B,kDAAkD;IAClD,EAAE,EAAE,MAAM,CAAC;IACX,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;CACd;AAED,yFAAyF;AACzF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,cAAc,EAAE,CAK7E"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { oauthScopesFor } from "../tokens/permissions.js";
|
|
4
|
+
//#region src/oauth/scopes.ts
|
|
5
|
+
/**
|
|
6
|
+
* The scopes every session asks for, whatever the project composes (#759).
|
|
7
|
+
*
|
|
8
|
+
* - `user-details.read` — `GET /user`, which is what turns an audit row's actor from a token's name into
|
|
9
|
+
* the person who signed in.
|
|
10
|
+
* - `offline_access` — the only way Cloudflare issues a refresh token. Without it a session dies at the
|
|
11
|
+
* hour and every command after it opens a browser (measured 2026-10-15).
|
|
12
|
+
* - `account-api-tokens.write` — the mint. A session mints `ci-system` and `runtime` as the project
|
|
13
|
+
* composes them, so the hand-made bootstrap token stops being a setup step.
|
|
14
|
+
* - `account-settings.read` — `GET /accounts`, so the account is discovered rather than stored. On its own
|
|
15
|
+
* it lists the account; `d1.write` alone listed nothing (both measured 2026-10-15), so discovery must not
|
|
16
|
+
* lean on whichever other scope a project happens to request.
|
|
17
|
+
* - `zone.read` — `pithy init` and `pithy worker add` offer the account's real zones, and `init` asks
|
|
18
|
+
* before there is a project whose capabilities could say so. Read-only, as {@link OAUTH_SCOPES} says of it.
|
|
19
|
+
*
|
|
20
|
+
* Everything else comes from the project's own token permissions, through {@link sessionScopes}.
|
|
21
|
+
*/
|
|
22
|
+
const SESSION_BASE_SCOPES = [
|
|
23
|
+
"user-details.read",
|
|
24
|
+
"offline_access",
|
|
25
|
+
"account-api-tokens.write",
|
|
26
|
+
"account-settings.read",
|
|
27
|
+
"zone.read"
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* The scopes Cloudflare's consent screen lists under **"Sensitive scopes"**, switched off, by the name it
|
|
31
|
+
* shows them under.
|
|
32
|
+
*
|
|
33
|
+
* **Approving with one switched off does not grant the rest.** The dashboard answers *"We could not
|
|
34
|
+
* complete the authorization. Please check your network connection and try again."* and no callback
|
|
35
|
+
* ever arrives (measured 2026-10-15). So `connect` names these before it opens the browser, and its
|
|
36
|
+
* timeout names them as the likeliest cause.
|
|
37
|
+
*
|
|
38
|
+
* **The list is ours to keep, because Cloudflare's is not published.** `GET /oauth/scopes` carries an
|
|
39
|
+
* id, a name, a category and nothing that marks a scope sensitive; only the consent screen says. Add to
|
|
40
|
+
* it when a consent screen shows another.
|
|
41
|
+
*/
|
|
42
|
+
const SENSITIVE_SCOPES = { "account-api-tokens.write": "Account API Tokens Write" };
|
|
43
|
+
/**
|
|
44
|
+
* The scopes a session needs to act for a project, and to mint every token it declares: the base, then
|
|
45
|
+
* the scope of each permission key, de-duped in first-seen order.
|
|
46
|
+
*
|
|
47
|
+
* **Derived, never listed.** Cloudflare's delegation rule binds the session as it binds a token — a mint
|
|
48
|
+
* whose groups the session does not hold is refused — so the request is exactly the union of what the
|
|
49
|
+
* project's tokens carry. A capability the project does not compose contributes nothing to it, and so
|
|
50
|
+
* nothing to the consent screen the adopter reads.
|
|
51
|
+
*/
|
|
52
|
+
function sessionScopes(permissionKeys) {
|
|
53
|
+
const scopes = [...SESSION_BASE_SCOPES];
|
|
54
|
+
for (const scope of oauthScopesFor(permissionKeys)) if (!scopes.includes(scope)) scopes.push(scope);
|
|
55
|
+
return scopes;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The requested scopes a grant does not carry, in request order.
|
|
59
|
+
*
|
|
60
|
+
* `granted` is the token endpoint's `scope`: one space-separated string. Today Cloudflare fails rather
|
|
61
|
+
* than grant a subset, so this should always be empty; it is checked because that is Cloudflare's
|
|
62
|
+
* behavior to change, and a session missing `account-api-tokens.write` would fail at the first mint.
|
|
63
|
+
*/
|
|
64
|
+
function missingScopes(requested, granted) {
|
|
65
|
+
const held = new Set(granted.split(/\s+/).filter(Boolean));
|
|
66
|
+
return requested.filter((scope) => !held.has(scope));
|
|
67
|
+
}
|
|
68
|
+
/** The sensitive scopes in a request, in request order. See {@link SENSITIVE_SCOPES}. */
|
|
69
|
+
function sensitiveScopesIn(scopes) {
|
|
70
|
+
return scopes.flatMap((id) => {
|
|
71
|
+
const name = SENSITIVE_SCOPES[id];
|
|
72
|
+
return name === void 0 ? [] : [{
|
|
73
|
+
id,
|
|
74
|
+
name
|
|
75
|
+
}];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { SENSITIVE_SCOPES, SESSION_BASE_SCOPES, missingScopes, sensitiveScopesIn, sessionScopes };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CloudflareManager } from "../client/manager";
|
|
5
|
+
/** A queue's identity, decoded from the Queues API's `queue_id` and `queue_name`. */
|
|
6
|
+
export declare const QueueInfo: z.ZodObject<{
|
|
7
|
+
id: z.ZodString;
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
export type QueueInfo = z.output<typeof QueueInfo>;
|
|
11
|
+
/**
|
|
12
|
+
* Account-level Queues control plane: **find, create and delete queues** (#662).
|
|
13
|
+
*
|
|
14
|
+
* `pithy provision` creates the queue behind every `queue` binding a composition declares, and `pithy feature
|
|
15
|
+
* destroy` deletes a feature's. A `queues.producers` entry names a queue, and `wrangler deploy` refuses one that
|
|
16
|
+
* does not exist, so the entry is only honest because this stands the queue up. Addressed by account — unlike
|
|
17
|
+
* {@link CloudflareQueueManager}, which targets one queue to send into.
|
|
18
|
+
*/
|
|
19
|
+
export declare class CloudflareQueueProvisioner extends CloudflareManager {
|
|
20
|
+
getServiceType(): string;
|
|
21
|
+
/** Prove access by listing queues — a read, never a create or a delete. Never throws. */
|
|
22
|
+
validateServiceAccess(): Promise<boolean>;
|
|
23
|
+
/** Find a queue by exact name in the account, or `null` — for idempotent provisioning. */
|
|
24
|
+
findQueueByName(name: string): Promise<QueueInfo | null>;
|
|
25
|
+
/** Create a queue by name; returns its id and name. */
|
|
26
|
+
createQueue(name: string): Promise<QueueInfo>;
|
|
27
|
+
/** Delete a queue by id. Idempotent — a missing queue is not an error, so teardown can re-run safely. */
|
|
28
|
+
deleteQueue(queueId: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=queueProvisioner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"queueProvisioner.d.ts","sourceRoot":"","sources":["../../src/queue/queueProvisioner.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEtD,qFAAqF;AACrF,eAAO,MAAM,SAAS;;;iBAKqD,CAAC;AAC5E,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,SAAS,CAAC,CAAC;AAWnD;;;;;;;GAOG;AACH,qBAAa,0BAA2B,SAAQ,iBAAiB;IAC/D,cAAc,IAAI,MAAM,CAEvB;IAED,yFAAyF;IACnF,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED,0FAA0F;IACpF,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAQ7D;IAED,uDAAuD;IACjD,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAKlD;IAED,yGAAyG;IACnG,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAShD;CACF"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { cloudflareRequest, decodeResponse, isNotFoundError } from "../client/errors.js";
|
|
4
|
+
import { CloudflareManager } from "../client/manager.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/queue/queueProvisioner.ts
|
|
7
|
+
/** A queue's identity, decoded from the Queues API's `queue_id` and `queue_name`. */
|
|
8
|
+
const QueueInfo = z.object({
|
|
9
|
+
id: z.string().describe("The CF-assigned queue id, which the REST API addresses a queue by."),
|
|
10
|
+
name: z.string().describe("The queue name a producer binding names it by.")
|
|
11
|
+
}).describe("A Cloudflare Queue's identity, as the Queues API returns it.");
|
|
12
|
+
/** The Queues API's own spelling of a queue's identity, mapped onto {@link QueueInfo}. */
|
|
13
|
+
const QueueRecord = z.object({
|
|
14
|
+
queue_id: z.string().describe("The CF-assigned queue id."),
|
|
15
|
+
queue_name: z.string().describe("The queue name.")
|
|
16
|
+
}).transform(({ queue_id, queue_name }) => ({
|
|
17
|
+
id: queue_id,
|
|
18
|
+
name: queue_name
|
|
19
|
+
})).describe("A queue as the Queues API returns it.");
|
|
20
|
+
/**
|
|
21
|
+
* Account-level Queues control plane: **find, create and delete queues** (#662).
|
|
22
|
+
*
|
|
23
|
+
* `pithy provision` creates the queue behind every `queue` binding a composition declares, and `pithy feature
|
|
24
|
+
* destroy` deletes a feature's. A `queues.producers` entry names a queue, and `wrangler deploy` refuses one that
|
|
25
|
+
* does not exist, so the entry is only honest because this stands the queue up. Addressed by account — unlike
|
|
26
|
+
* {@link CloudflareQueueManager}, which targets one queue to send into.
|
|
27
|
+
*/
|
|
28
|
+
var CloudflareQueueProvisioner = class extends CloudflareManager {
|
|
29
|
+
getServiceType() {
|
|
30
|
+
return "Cloudflare Queues (control plane)";
|
|
31
|
+
}
|
|
32
|
+
/** Prove access by listing queues — a read, never a create or a delete. Never throws. */
|
|
33
|
+
async validateServiceAccess() {
|
|
34
|
+
try {
|
|
35
|
+
await this.getClient().queues.list({ account_id: this.accountId });
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Find a queue by exact name in the account, or `null` — for idempotent provisioning. */
|
|
42
|
+
async findQueueByName(name) {
|
|
43
|
+
return cloudflareRequest(`find queue ${name}`, async () => {
|
|
44
|
+
for await (const queue of this.getClient().queues.list({ account_id: this.accountId })) {
|
|
45
|
+
const parsed = QueueRecord.safeParse(queue);
|
|
46
|
+
if (parsed.success && parsed.data.name === name) return parsed.data;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Create a queue by name; returns its id and name. */
|
|
52
|
+
async createQueue(name) {
|
|
53
|
+
const response = await cloudflareRequest(`create queue ${name}`, () => this.getClient().queues.create({
|
|
54
|
+
account_id: this.accountId,
|
|
55
|
+
queue_name: name
|
|
56
|
+
}));
|
|
57
|
+
return decodeResponse(QueueRecord, response, "queue create");
|
|
58
|
+
}
|
|
59
|
+
/** Delete a queue by id. Idempotent — a missing queue is not an error, so teardown can re-run safely. */
|
|
60
|
+
async deleteQueue(queueId) {
|
|
61
|
+
await cloudflareRequest(`delete queue ${queueId}`, async () => {
|
|
62
|
+
try {
|
|
63
|
+
await this.getClient().queues.delete(queueId, { account_id: this.accountId });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isNotFoundError(error)) return;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
72
|
+
export { CloudflareQueueProvisioner, QueueInfo };
|
package/dist/r2/r2Manager.d.ts
CHANGED
|
@@ -8,9 +8,18 @@ import { R2Credentials } from "./r2Credentials";
|
|
|
8
8
|
* the bucket it targets. R2 signs presigned URLs with the S3 keys, not the CF API token — both
|
|
9
9
|
* come from config (no environment coupling).
|
|
10
10
|
*/
|
|
11
|
-
export interface R2ManagerConfig extends CloudflareManagerConfig, R2Credentials {
|
|
11
|
+
export interface R2ManagerConfig extends Omit<CloudflareManagerConfig, "apiToken">, R2Credentials {
|
|
12
12
|
/** The R2 bucket all object operations target. */
|
|
13
13
|
bucketName: string;
|
|
14
|
+
/**
|
|
15
|
+
* A Cloudflare API token, for the one call here that is not S3: {@link CloudflareR2Manager.validateServiceAccess}.
|
|
16
|
+
*
|
|
17
|
+
* **Absent in a Worker (#728).** Every object operation is S3, signed with the key pair, so a Worker's
|
|
18
|
+
* object store holds no API token: the application's one is `runtime`, and R2 needs none of it. Without
|
|
19
|
+
* one, `validateServiceAccess` answers `false` and nothing calls the Cloudflare API. An empty string is
|
|
20
|
+
* still refused, because that is an unresolved value rather than a decision.
|
|
21
|
+
*/
|
|
22
|
+
apiToken?: string;
|
|
14
23
|
}
|
|
15
24
|
/** How long a presigned URL stays valid. Shared by every presign method so the option reads the same. */
|
|
16
25
|
export interface PresignOptions {
|
|
@@ -97,6 +106,7 @@ export declare class CloudflareR2Manager extends CloudflareManager {
|
|
|
97
106
|
private readonly accessKeyId;
|
|
98
107
|
private readonly secretAccessKey;
|
|
99
108
|
private s3Client;
|
|
109
|
+
private readonly s3Only;
|
|
100
110
|
constructor(config: R2ManagerConfig);
|
|
101
111
|
/** The S3-compatible client for R2, lazily created and cached for the manager's lifetime. */
|
|
102
112
|
private getS3Client;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"r2Manager.d.ts","sourceRoot":"","sources":["../../src/r2/r2Manager.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AACpF,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAQhD;;;;GAIG;AACH,MAAM,WAAW,eAAgB,SAAQ,uBAAuB,EAAE,aAAa;
|
|
1
|
+
{"version":3,"file":"r2Manager.d.ts","sourceRoot":"","sources":["../../src/r2/r2Manager.ts"],"names":[],"mappings":"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"}
|
package/dist/r2/r2Manager.js
CHANGED
|
@@ -11,6 +11,13 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
|
11
11
|
const PRESIGN_EXPIRY_SECONDS = 3600;
|
|
12
12
|
/** How many parts one `ListParts` page returns. S3's own cap; the drain loop below relies on it. */
|
|
13
13
|
const LIST_PARTS_PAGE_SIZE = 1e3;
|
|
14
|
+
/**
|
|
15
|
+
* What the base client is built with when the config carries no API token. Never sent: {@link noApiToken}
|
|
16
|
+
* is the provider, and the base reads the token through it at every call and refuses an empty one.
|
|
17
|
+
*/
|
|
18
|
+
const S3_ONLY = "r2-s3-only";
|
|
19
|
+
/** The token provider of an S3-only manager: no token, so any Cloudflare API call is refused before it is sent. */
|
|
20
|
+
const noApiToken = () => "";
|
|
14
21
|
/** An object's metadata as R2 reports it, without fetching the body. */
|
|
15
22
|
const R2ObjectHead = z.object({
|
|
16
23
|
size: z.number().int().nonnegative().describe("The object's size in bytes."),
|
|
@@ -70,8 +77,17 @@ var CloudflareR2Manager = class extends CloudflareManager {
|
|
|
70
77
|
accessKeyId;
|
|
71
78
|
secretAccessKey;
|
|
72
79
|
s3Client = null;
|
|
80
|
+
s3Only;
|
|
73
81
|
constructor(config) {
|
|
74
|
-
super(config
|
|
82
|
+
super(config.apiToken === void 0 ? {
|
|
83
|
+
...config,
|
|
84
|
+
apiToken: S3_ONLY,
|
|
85
|
+
tokenProvider: noApiToken
|
|
86
|
+
} : {
|
|
87
|
+
...config,
|
|
88
|
+
apiToken: config.apiToken
|
|
89
|
+
});
|
|
90
|
+
this.s3Only = config.apiToken === void 0;
|
|
75
91
|
if (!config.bucketName) throw new CloudflareNotConfiguredError({ detail: "Missing bucketName for R2 access." });
|
|
76
92
|
const credentials = R2Credentials.safeParse(config);
|
|
77
93
|
if (!credentials.success) throw new CloudflareNotConfiguredError({ detail: `Invalid R2 credentials: ${credentials.error.message}` });
|
|
@@ -395,6 +411,7 @@ var CloudflareR2Manager = class extends CloudflareManager {
|
|
|
395
411
|
* not merely that an S3 client could be built. Never throws.
|
|
396
412
|
*/
|
|
397
413
|
async validateServiceAccess() {
|
|
414
|
+
if (this.s3Only) return false;
|
|
398
415
|
try {
|
|
399
416
|
await this.getClient().r2.buckets.get(this.bucketName, { account_id: this.accountId });
|
|
400
417
|
return true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secretsStoreManager.d.ts","sourceRoot":"","sources":["../../src/secrets/secretsStoreManager.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"secretsStoreManager.d.ts","sourceRoot":"","sources":["../../src/secrets/secretsStoreManager.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,KAAK,uBAAuB,EAAE,MAAM,mBAAmB,CAAC;AAKpF;;;;GAIG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;iBAeqE,CAAC;AAChG,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,aAAa,CAAC,CAAC;AAE3D,mGAAmG;AACnG,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AA4BxE,gGAAgG;AAChG,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACxE,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,qBAAa,6BAA8B,SAAQ,iBAAiB;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IAEjC,YAAY,MAAM,EAAE,yBAAyB,EAM5C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,iEAAiE;IAC3D,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;;;;;;;;;;;;;;;OAiBG;IACG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB5E;IAED;;;;;;;;;;;;;;;OAeG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAmBtG;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACG,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BzF;IAED,4FAA4F;IACtF,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAc9C;IAED;;;;;;;;;;;;;OAaG;IACG,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAU1D;IAED;;;OAGG;IACG,WAAW,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAU5C;IAED;;;;;;;;;;;;OAYG;IACG,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAEzD;IAED,0EAA0E;IACpE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE3C;IAED,kEAAkE;IAClE,mBAAmB,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAE5D;IAED,6GAA6G;YAC/F,YAAY;IAiB1B;;;;OAIG;YACW,UAAU;CAGzB"}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { CloudflareNotConfiguredError, cloudflareRequest, decodeResponse } from "../client/errors.js";
|
|
4
4
|
import { CloudflareManager } from "../client/manager.js";
|
|
5
|
-
import { z } from "zod";
|
|
6
5
|
import { ConflictError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { remedy } from "@pithy-sh/core/src/cli/command";
|
|
7
8
|
import { JsonDate } from "@pithy-sh/core/src/data/codecs";
|
|
8
9
|
//#region src/secrets/secretsStoreManager.ts
|
|
9
10
|
/** Scopes attached to every secret we create. CF requires at least one; "workers" is the bind target. */
|
|
@@ -172,7 +173,7 @@ var CloudflareSecretsStoreManager = class extends CloudflareManager {
|
|
|
172
173
|
const target = named[0];
|
|
173
174
|
if (!target) throw new NotFoundError({
|
|
174
175
|
message: `Secret '${name}' is not in the store, so there is nothing to update.`,
|
|
175
|
-
action: "
|
|
176
|
+
action: `${remedy("secrets provision").say((command) => `Run \`${command}\`.`)} It establishes the entry; then run this again.`,
|
|
176
177
|
detail: `update secret ${name}: no live entry of that name, and this verb never creates one`
|
|
177
178
|
});
|
|
178
179
|
if (named.length > 1) throw new ConflictError({
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* **Waiting on an eventually consistent control plane, for the live suites.** One budget and one rule, shared
|
|
5
|
+
* by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, because the cloudflare suite kept a
|
|
6
|
+
* 60s budget and a single confirming read of its own and failed on exactly that (#709).
|
|
7
|
+
*
|
|
8
|
+
* **The budget is 120s, and 300s for a metadata index.** A Vectorize index became visible 25-50s after the
|
|
9
|
+
* call that made it, measured live, and an upsert became queryable in about 30s. A metadata index is slower:
|
|
10
|
+
* about 97s on one run of #709 and over 120s on the next, so its waits take {@link METADATA_INDEX_SETTLE_MS}.
|
|
11
|
+
*
|
|
12
|
+
* **A metadata-index wait compares names and types, never a count.** {@link metadataIndexesSettled} is the
|
|
13
|
+
* predicate; a list of the right length can still be the wrong list.
|
|
14
|
+
*
|
|
15
|
+
* **One confirming read proves nothing.** The metadata-index list is eventually consistent *and not
|
|
16
|
+
* monotonic*: a 3-minute probe watched it report `[]` for 26s, then both properties, then one of the two 18s
|
|
17
|
+
* later with nothing else running, then both again. So {@link waitForStable} waits for {@link STABLE_READS}
|
|
18
|
+
* agreeing reads in a row and hands back that snapshot, and a caller asserts against the snapshot rather
|
|
19
|
+
* than a fresh read, which can disagree with the one before it.
|
|
20
|
+
*
|
|
21
|
+
* **A test that waits several times shares one deadline across them.** Each wait's own budget bounds that
|
|
22
|
+
* wait and nothing else, so a test making eight of them in a row could outrun its vitest timeout with every
|
|
23
|
+
* step inside budget. Vitest then abandoned the body mid-poll: the failure said only "Test timed out", and the
|
|
24
|
+
* teardown that deletes the index never ran (#709). Pass the same {@link SettleOptions.deadline} to every
|
|
25
|
+
* wait and the step in progress when it passes fails by name, inside the body, where the teardown still runs.
|
|
26
|
+
*/
|
|
27
|
+
/** Ceiling for any one wait. Measured at 25-50s for a control-plane change, and about 30s for an upsert. */
|
|
28
|
+
export declare const SETTLE_TIMEOUT_MS = 120000;
|
|
29
|
+
/**
|
|
30
|
+
* Ceiling for a metadata-index wait, and only for that. A metadata index is the slowest change Vectorize
|
|
31
|
+
* applies: on #709 one settled in about 97s and the next was still unsettled at 120s, so the shared budget
|
|
32
|
+
* undercut exactly this wait. `pithy vector provision` waits on the same measurement.
|
|
33
|
+
*/
|
|
34
|
+
export declare const METADATA_INDEX_SETTLE_MS = 300000;
|
|
35
|
+
/**
|
|
36
|
+
* Consecutive agreeing reads that count as settled. Three, because one confirming read can be a stale replica,
|
|
37
|
+
* and two in a row six seconds apart is a far weaker coincidence.
|
|
38
|
+
*/
|
|
39
|
+
export declare const STABLE_READS = 3;
|
|
40
|
+
/** How often a poll re-checks. Vectorize applies changes in tens of seconds, so a tighter loop only burns quota. */
|
|
41
|
+
export declare const POLL_INTERVAL_MS = 2000;
|
|
42
|
+
/** How long to wait and how often to look. Every field defaults to the shared budget above. */
|
|
43
|
+
export interface SettleOptions {
|
|
44
|
+
/** Give up after this long. Defaults to {@link SETTLE_TIMEOUT_MS}. */
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
/** Re-check this often. Defaults to {@link POLL_INTERVAL_MS}. */
|
|
47
|
+
intervalMs?: number;
|
|
48
|
+
/** Agreeing reads in a row that count as settled, for {@link waitForStable}. Defaults to {@link STABLE_READS}. */
|
|
49
|
+
stableReads?: number;
|
|
50
|
+
/**
|
|
51
|
+
* An absolute deadline (ms since the epoch) shared by every wait in a test. A wait gives up at whichever
|
|
52
|
+
* comes first, its own `timeoutMs` or this, and says which.
|
|
53
|
+
*/
|
|
54
|
+
deadline?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Poll until `ready`, or fail with a message naming what was being waited for. A bare timeout tells the next
|
|
58
|
+
* reader nothing; naming the step tells them whether Cloudflare was slow or something is actually broken.
|
|
59
|
+
*/
|
|
60
|
+
export declare function waitFor(what: string, ready: () => Promise<boolean>, options?: SettleOptions): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Poll until `settled` holds on {@link STABLE_READS} consecutive reads, and return the last value read. The
|
|
63
|
+
* caller asserts against the returned snapshot, never against a fresh read.
|
|
64
|
+
*/
|
|
65
|
+
export declare function waitForStable<T>(what: string, read: () => Promise<T>, settled: (value: T) => boolean, options?: SettleOptions): Promise<T>;
|
|
66
|
+
/**
|
|
67
|
+
* The settle predicate for a metadata-index list: every declared index is live, under its own name and its own
|
|
68
|
+
* type. The rule `compareMetadataIndexes` applies with nothing missing and nothing mismatched, restated here
|
|
69
|
+
* because this package cannot import `@pithy-sh/vector`. A count is not enough: a list of the right length
|
|
70
|
+
* with the wrong names passed the predicate this replaced. An undeclared extra does not block it.
|
|
71
|
+
*/
|
|
72
|
+
export declare function metadataIndexesSettled(declared: readonly {
|
|
73
|
+
propertyName: string;
|
|
74
|
+
indexType: string;
|
|
75
|
+
}[]): (live: readonly {
|
|
76
|
+
propertyName: string;
|
|
77
|
+
indexType: string;
|
|
78
|
+
}[]) => boolean;
|
|
79
|
+
//# sourceMappingURL=settle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settle.d.ts","sourceRoot":"","sources":["../../src/test-utils/settle.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,4GAA4G;AAC5G,eAAO,MAAM,iBAAiB,SAAU,CAAC;AAEzC;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,SAAU,CAAC;AAEhD;;;GAGG;AACH,eAAO,MAAM,YAAY,IAAI,CAAC;AAE9B,oHAAoH;AACpH,eAAO,MAAM,gBAAgB,OAAQ,CAAC;AAEtC,+FAA+F;AAC/F,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kHAAkH;IAClH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAmBD;;;GAGG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAYrH;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACtB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,EAC9B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,CAAC,CAAC,CAiBZ;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,SAAS;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,GAC/D,CAAC,IAAI,EAAE,SAAS;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,OAAO,CAK3E"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/test-utils/settle.ts
|
|
4
|
+
/**
|
|
5
|
+
* **Waiting on an eventually consistent control plane, for the live suites.** One budget and one rule, shared
|
|
6
|
+
* by `@pithy-sh/cloudflare`'s Vectorize suite and `@pithy-sh/vector`'s, because the cloudflare suite kept a
|
|
7
|
+
* 60s budget and a single confirming read of its own and failed on exactly that (#709).
|
|
8
|
+
*
|
|
9
|
+
* **The budget is 120s, and 300s for a metadata index.** A Vectorize index became visible 25-50s after the
|
|
10
|
+
* call that made it, measured live, and an upsert became queryable in about 30s. A metadata index is slower:
|
|
11
|
+
* about 97s on one run of #709 and over 120s on the next, so its waits take {@link METADATA_INDEX_SETTLE_MS}.
|
|
12
|
+
*
|
|
13
|
+
* **A metadata-index wait compares names and types, never a count.** {@link metadataIndexesSettled} is the
|
|
14
|
+
* predicate; a list of the right length can still be the wrong list.
|
|
15
|
+
*
|
|
16
|
+
* **One confirming read proves nothing.** The metadata-index list is eventually consistent *and not
|
|
17
|
+
* monotonic*: a 3-minute probe watched it report `[]` for 26s, then both properties, then one of the two 18s
|
|
18
|
+
* later with nothing else running, then both again. So {@link waitForStable} waits for {@link STABLE_READS}
|
|
19
|
+
* agreeing reads in a row and hands back that snapshot, and a caller asserts against the snapshot rather
|
|
20
|
+
* than a fresh read, which can disagree with the one before it.
|
|
21
|
+
*
|
|
22
|
+
* **A test that waits several times shares one deadline across them.** Each wait's own budget bounds that
|
|
23
|
+
* wait and nothing else, so a test making eight of them in a row could outrun its vitest timeout with every
|
|
24
|
+
* step inside budget. Vitest then abandoned the body mid-poll: the failure said only "Test timed out", and the
|
|
25
|
+
* teardown that deletes the index never ran (#709). Pass the same {@link SettleOptions.deadline} to every
|
|
26
|
+
* wait and the step in progress when it passes fails by name, inside the body, where the teardown still runs.
|
|
27
|
+
*/
|
|
28
|
+
/** Ceiling for any one wait. Measured at 25-50s for a control-plane change, and about 30s for an upsert. */
|
|
29
|
+
const SETTLE_TIMEOUT_MS = 12e4;
|
|
30
|
+
/**
|
|
31
|
+
* Ceiling for a metadata-index wait, and only for that. A metadata index is the slowest change Vectorize
|
|
32
|
+
* applies: on #709 one settled in about 97s and the next was still unsettled at 120s, so the shared budget
|
|
33
|
+
* undercut exactly this wait. `pithy vector provision` waits on the same measurement.
|
|
34
|
+
*/
|
|
35
|
+
const METADATA_INDEX_SETTLE_MS = 3e5;
|
|
36
|
+
/**
|
|
37
|
+
* Consecutive agreeing reads that count as settled. Three, because one confirming read can be a stale replica,
|
|
38
|
+
* and two in a row six seconds apart is a far weaker coincidence.
|
|
39
|
+
*/
|
|
40
|
+
const STABLE_READS = 3;
|
|
41
|
+
/** How often a poll re-checks. Vectorize applies changes in tens of seconds, so a tighter loop only burns quota. */
|
|
42
|
+
const POLL_INTERVAL_MS = 2e3;
|
|
43
|
+
/** When a wait gives up, and the clause its timeout message ends the step name with. */
|
|
44
|
+
function limitOf(options, started) {
|
|
45
|
+
const own = started + (options.timeoutMs ?? 12e4);
|
|
46
|
+
if (options.deadline !== void 0 && options.deadline < own) return {
|
|
47
|
+
deadline: options.deadline,
|
|
48
|
+
why: " (the shared deadline for this test ran out)"
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
deadline: own,
|
|
52
|
+
why: ""
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function seconds(ms) {
|
|
56
|
+
return Math.round(ms / 1e3);
|
|
57
|
+
}
|
|
58
|
+
function pause(ms) {
|
|
59
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Poll until `ready`, or fail with a message naming what was being waited for. A bare timeout tells the next
|
|
63
|
+
* reader nothing; naming the step tells them whether Cloudflare was slow or something is actually broken.
|
|
64
|
+
*/
|
|
65
|
+
async function waitFor(what, ready, options = {}) {
|
|
66
|
+
const started = Date.now();
|
|
67
|
+
const { deadline, why } = limitOf(options, started);
|
|
68
|
+
for (;;) {
|
|
69
|
+
if (await ready()) return;
|
|
70
|
+
if (Date.now() >= deadline) throw new Error(`Timed out after ${seconds(Date.now() - started)}s waiting for ${what}${why}. Cloudflare applies this change asynchronously — this is either a slower-than-usual apply or a real failure.`);
|
|
71
|
+
await pause(options.intervalMs ?? 2e3);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Poll until `settled` holds on {@link STABLE_READS} consecutive reads, and return the last value read. The
|
|
76
|
+
* caller asserts against the returned snapshot, never against a fresh read.
|
|
77
|
+
*/
|
|
78
|
+
async function waitForStable(what, read, settled, options = {}) {
|
|
79
|
+
const started = Date.now();
|
|
80
|
+
const needed = options.stableReads ?? 3;
|
|
81
|
+
const { deadline, why } = limitOf(options, started);
|
|
82
|
+
let agreed = 0;
|
|
83
|
+
let last = await read();
|
|
84
|
+
for (;;) {
|
|
85
|
+
agreed = settled(last) ? agreed + 1 : 0;
|
|
86
|
+
if (agreed >= needed) return last;
|
|
87
|
+
if (Date.now() >= deadline) throw new Error(`Timed out after ${seconds(Date.now() - started)}s waiting for ${what} to settle across ${needed} reads${why}. Last read: ${JSON.stringify(last)}.`);
|
|
88
|
+
await pause(options.intervalMs ?? 2e3);
|
|
89
|
+
last = await read();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The settle predicate for a metadata-index list: every declared index is live, under its own name and its own
|
|
94
|
+
* type. The rule `compareMetadataIndexes` applies with nothing missing and nothing mismatched, restated here
|
|
95
|
+
* because this package cannot import `@pithy-sh/vector`. A count is not enough: a list of the right length
|
|
96
|
+
* with the wrong names passed the predicate this replaced. An undeclared extra does not block it.
|
|
97
|
+
*/
|
|
98
|
+
function metadataIndexesSettled(declared) {
|
|
99
|
+
return (live) => {
|
|
100
|
+
const types = new Map(live.map((entry) => [entry.propertyName, entry.indexType]));
|
|
101
|
+
return declared.every((entry) => types.get(entry.propertyName) === entry.indexType);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
export { METADATA_INDEX_SETTLE_MS, POLL_INTERVAL_MS, SETTLE_TIMEOUT_MS, STABLE_READS, metadataIndexesSettled, waitFor, waitForStable };
|
|
@@ -57,7 +57,7 @@ export declare const MintedAccountToken: z.ZodObject<{
|
|
|
57
57
|
active: "active";
|
|
58
58
|
disabled: "disabled";
|
|
59
59
|
expired: "expired";
|
|
60
|
-
}
|
|
60
|
+
}>>, unknown>;
|
|
61
61
|
}, z.core.$strip>;
|
|
62
62
|
export type MintedAccountToken = z.output<typeof MintedAccountToken>;
|
|
63
63
|
/**
|
|
@@ -72,10 +72,10 @@ export declare const AccountTokenPolicy: z.ZodObject<{
|
|
|
72
72
|
effect: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
|
|
73
73
|
allow: "allow";
|
|
74
74
|
deny: "deny";
|
|
75
|
-
}
|
|
75
|
+
}>>, unknown>;
|
|
76
76
|
permission_groups: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
77
77
|
id: z.ZodString;
|
|
78
|
-
}, z.core.$strip
|
|
78
|
+
}, z.core.$strip>>>, unknown>;
|
|
79
79
|
resources: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
80
80
|
}, z.core.$strip>;
|
|
81
81
|
export type AccountTokenPolicy = z.output<typeof AccountTokenPolicy>;
|
|
@@ -96,17 +96,18 @@ export declare const AccountTokenSummary: z.ZodObject<{
|
|
|
96
96
|
active: "active";
|
|
97
97
|
disabled: "disabled";
|
|
98
98
|
expired: "expired";
|
|
99
|
-
}
|
|
99
|
+
}>>, unknown>;
|
|
100
100
|
policies: z.ZodCatch<z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
101
101
|
effect: z.ZodPreprocess<z.ZodOptional<z.ZodEnum<{
|
|
102
102
|
allow: "allow";
|
|
103
103
|
deny: "deny";
|
|
104
|
-
}
|
|
104
|
+
}>>, unknown>;
|
|
105
105
|
permission_groups: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
106
106
|
id: z.ZodString;
|
|
107
|
-
}, z.core.$strip
|
|
107
|
+
}, z.core.$strip>>>, unknown>;
|
|
108
108
|
resources: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
109
|
-
}, z.core.$strip
|
|
109
|
+
}, z.core.$strip>>>, unknown>>;
|
|
110
|
+
last_used_on: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
110
111
|
}, z.core.$strip>;
|
|
111
112
|
export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;
|
|
112
113
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"accountTokensManager.d.ts","sourceRoot":"","sources":["../../src/tokens/accountTokensManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAYxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAkB1D;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,kHAAkH;IAClH,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,kGAAkG;IAClG,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAEzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAIhF;AAeD;;;GAGG;AACH,eAAO,MAAM,iBAAiB;;;iBAK8E,CAAC;AAC7G,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEnE;;;;GAIG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;iBAY6D,CAAC;AAC7F,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;iBAwBkF,CAAC;AAClH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAErE;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB
|
|
1
|
+
{"version":3,"file":"accountTokensManager.d.ts","sourceRoot":"","sources":["../../src/tokens/accountTokensManager.ts"],"names":[],"mappings":"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"}
|
|
@@ -110,7 +110,8 @@ const AccountTokenSummary = z.object({
|
|
|
110
110
|
"disabled",
|
|
111
111
|
"expired"
|
|
112
112
|
]).describe("The lifecycle state Cloudflare holds for the token.").optional()).describe("The token's lifecycle status. Reported, because a `disabled` or `expired` token cannot attach a route however its policies read. `nullish` for the reason {@link AccountTokenPolicy} gives."),
|
|
113
|
-
policies: nullAsAbsent(z.array(AccountTokenPolicy).optional()).catch(void 0).describe("The token's access policies, when the list response carries them. **A live token's own scope is the record of how it was minted** — it is how a `ci-system` token minted before zone-scoped routes (#651) can be told from one minted after, without reading the token record (which needs a grant these least-privilege tokens deliberately lack). Absent and empty are different facts: a response that says nothing means the scope could not be read, never that the token has none.")
|
|
113
|
+
policies: nullAsAbsent(z.array(AccountTokenPolicy).optional()).catch(void 0).describe("The token's access policies, when the list response carries them. **A live token's own scope is the record of how it was minted** — it is how a `ci-system` token minted before zone-scoped routes (#651) can be told from one minted after, without reading the token record (which needs a grant these least-privilege tokens deliberately lack). Absent and empty are different facts: a response that says nothing means the scope could not be read, never that the token has none."),
|
|
114
|
+
last_used_on: z.string().nullable().optional().catch(void 0).describe("When something last authenticated with the token, as Cloudflare measures it against the live account. Read because `pithy token forget` acts on it (#762): `null` means never, so a local copy may be its only one. Absent means the response did not say, which is not a claim either way.")
|
|
114
115
|
}).describe("An existing account-owned API token's metadata (never its secret value).");
|
|
115
116
|
/**
|
|
116
117
|
* Out-of-Worker control plane for **account-owned** Cloudflare API tokens: mint scoped, least-privilege
|