@pithy-sh/cloudflare 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/analytics/analyticsManager.d.ts +78 -0
  2. package/dist/analytics/analyticsManager.d.ts.map +1 -0
  3. package/dist/analytics/analyticsManager.js +206 -0
  4. package/dist/analytics/datasets.d.ts +26 -0
  5. package/dist/analytics/datasets.d.ts.map +1 -0
  6. package/dist/analytics/datasets.js +26 -0
  7. package/dist/analytics/window.d.ts +50 -0
  8. package/dist/analytics/window.d.ts.map +1 -0
  9. package/dist/analytics/window.js +67 -0
  10. package/dist/artifacts/artifactsManager.d.ts +52 -0
  11. package/dist/artifacts/artifactsManager.d.ts.map +1 -0
  12. package/dist/artifacts/artifactsManager.js +150 -0
  13. package/dist/client/clients.d.ts +13 -1
  14. package/dist/client/clients.d.ts.map +1 -1
  15. package/dist/client/clients.js +22 -1
  16. package/dist/media/imageManager.d.ts +25 -0
  17. package/dist/media/imageManager.d.ts.map +1 -1
  18. package/dist/media/imageManager.js +58 -2
  19. package/dist/media/streamManager.d.ts +15 -0
  20. package/dist/media/streamManager.d.ts.map +1 -1
  21. package/dist/media/streamManager.js +28 -1
  22. package/dist/oauth/scopes.d.ts +2 -2
  23. package/dist/oauth/scopes.js +2 -2
  24. package/dist/queue/queueProvisioner.d.ts +30 -0
  25. package/dist/queue/queueProvisioner.d.ts.map +1 -0
  26. package/dist/queue/queueProvisioner.js +72 -0
  27. package/dist/r2/r2Manager.d.ts +11 -1
  28. package/dist/r2/r2Manager.d.ts.map +1 -1
  29. package/dist/r2/r2Manager.js +18 -1
  30. package/dist/tokens/accountTokensManager.d.ts +1 -0
  31. package/dist/tokens/accountTokensManager.d.ts.map +1 -1
  32. package/dist/tokens/accountTokensManager.js +2 -1
  33. package/dist/tokens/permissions.d.ts +10 -0
  34. package/dist/tokens/permissions.d.ts.map +1 -1
  35. package/dist/tokens/permissions.js +12 -2
  36. package/dist/tokens/profiles.d.ts +6 -30
  37. package/dist/tokens/profiles.d.ts.map +1 -1
  38. package/dist/tokens/profiles.js +44 -36
  39. package/dist/tokens/runtime.d.ts +16 -0
  40. package/dist/tokens/runtime.d.ts.map +1 -0
  41. package/dist/tokens/runtime.js +18 -0
  42. package/package.json +3 -3
  43. package/src/analytics/analyticsManager.ts +311 -0
  44. package/src/analytics/datasets.ts +30 -0
  45. package/src/analytics/window.ts +101 -0
  46. package/src/artifacts/artifactsManager.ts +199 -0
  47. package/src/client/clients.ts +28 -1
  48. package/src/media/imageManager.ts +93 -1
  49. package/src/media/streamManager.ts +34 -0
  50. package/src/oauth/scopes.ts +2 -2
  51. package/src/queue/queueProvisioner.ts +79 -0
  52. package/src/r2/r2Manager.ts +29 -2
  53. package/src/tokens/accountTokensManager.ts +12 -0
  54. package/src/tokens/permissions.ts +27 -0
  55. package/src/tokens/profiles.ts +54 -70
  56. package/src/tokens/runtime.ts +18 -0
@@ -4,6 +4,8 @@
4
4
  import { CloudflareAIManager } from "../ai/aiManager";
5
5
  import { CloudflareVectorizeManager } from "../ai/vectorizeManager";
6
6
  import { CloudflareVectorizeProvisioner } from "../ai/vectorizeProvisioner";
7
+ import { CloudflareAnalyticsManager } from "../analytics/analyticsManager";
8
+ import { CloudflareArtifactsManager } from "../artifacts/artifactsManager";
7
9
  import { CloudflareD1Manager } from "../d1/d1Manager";
8
10
  import { CloudflareD1Provisioner } from "../d1/d1Provisioner";
9
11
  import { CloudflareEmailRoutingManager } from "../email/emailRoutingManager";
@@ -14,6 +16,7 @@ import { CloudflareKVProvisioner } from "../kv/kvProvisioner";
14
16
  import { CloudflareImageManager } from "../media/imageManager";
15
17
  import { CloudflareStreamManager } from "../media/streamManager";
16
18
  import { CloudflareQueueManager } from "../queue/queueManager";
19
+ import { CloudflareQueueProvisioner } from "../queue/queueProvisioner";
17
20
  import type { R2Credentials } from "../r2/r2Credentials";
18
21
  import { CloudflareR2Manager } from "../r2/r2Manager";
19
22
  import { CloudflareR2Provisioner } from "../r2/r2Provisioner";
@@ -35,7 +38,7 @@ import type { CloudflareManagerConfig } from "./manager";
35
38
  *
36
39
  * Resource-scoped managers (KV, D1, queues, Vectorize indexes, secret stores, hostname zones, R2
37
40
  * buckets) are keyed by their resource id, so repeated calls for the same id return one shared
38
- * instance. Account-scoped managers (AI, Images, Stream, Workers, Builds, Turnstile) are singletons.
41
+ * instance. Account-scoped managers (AI, Images, Stream, Analytics, Workers, Builds, Turnstile) are singletons.
39
42
  */
40
43
  export class CloudflareClients {
41
44
  private readonly config: CloudflareManagerConfig;
@@ -62,6 +65,8 @@ export class CloudflareClients {
62
65
 
63
66
  private streamManager?: CloudflareStreamManager;
64
67
 
68
+ private analyticsManager?: CloudflareAnalyticsManager;
69
+
65
70
  private workersManager?: CloudflareWorkersManager;
66
71
 
67
72
  private buildsManager?: CloudflareBuildsManager;
@@ -81,6 +86,10 @@ export class CloudflareClients {
81
86
 
82
87
  private r2ProvisionerManager?: CloudflareR2Provisioner;
83
88
 
89
+ private queueProvisionerManager?: CloudflareQueueProvisioner;
90
+
91
+ private artifactsManager?: CloudflareArtifactsManager;
92
+
84
93
  private vectorizeProvisionerManager?: CloudflareVectorizeProvisioner;
85
94
 
86
95
  constructor(config: CloudflareManagerConfig) {
@@ -167,6 +176,12 @@ export class CloudflareClients {
167
176
  return this.streamManager;
168
177
  }
169
178
 
179
+ /** The account-scoped GraphQL Analytics manager. */
180
+ analytics(): CloudflareAnalyticsManager {
181
+ this.analyticsManager ??= new CloudflareAnalyticsManager(this.config);
182
+ return this.analyticsManager;
183
+ }
184
+
170
185
  /** The account-scoped Workers (scripts/deployments) manager. */
171
186
  workers(): CloudflareWorkersManager {
172
187
  this.workersManager ??= new CloudflareWorkersManager(this.config);
@@ -209,6 +224,18 @@ export class CloudflareClients {
209
224
  return this.r2ProvisionerManager;
210
225
  }
211
226
 
227
+ /** The account-scoped Queues control plane — create, find, and delete queues. */
228
+ queueProvisioner(): CloudflareQueueProvisioner {
229
+ this.queueProvisionerManager ??= new CloudflareQueueProvisioner(this.config);
230
+ return this.queueProvisionerManager;
231
+ }
232
+
233
+ /** The account-scoped Artifacts control plane — list and delete the repos in a namespace (#673). */
234
+ artifacts(): CloudflareArtifactsManager {
235
+ this.artifactsManager ??= new CloudflareArtifactsManager(this.config);
236
+ return this.artifactsManager;
237
+ }
238
+
212
239
  /** The account-scoped Vectorize control plane — create, find, list, and delete indexes and their metadata indexes. */
213
240
  vectorizeProvisioner(): CloudflareVectorizeProvisioner {
214
241
  this.vectorizeProvisionerManager ??= new CloudflareVectorizeProvisioner(this.config);
@@ -8,7 +8,8 @@ import type {
8
8
  DirectUploadCreateResponse,
9
9
  } from "cloudflare/resources/images/v2/direct-uploads";
10
10
  import type { V2ListParams } from "cloudflare/resources/images/v2/v2";
11
- import { cloudflareRequest } from "../client/errors";
11
+ import { z } from "zod";
12
+ import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors";
12
13
  import { CloudflareManager } from "../client/manager";
13
14
 
14
15
  /**
@@ -29,6 +30,59 @@ function encodeMetadata(metadata: unknown): unknown {
29
30
  return metadata === undefined || metadata === null ? metadata : JSON.stringify(metadata);
30
31
  }
31
32
 
33
+ /** One of the account's Images URL-signing keys. The value is the HMAC secret signed URLs are minted with. */
34
+ export const ImagesSigningKey = z
35
+ .object({
36
+ name: z.string().min(1).describe("The key's name. Cloudflare provisions one named `default`."),
37
+ value: z.string().min(1).describe("The key itself — the HMAC-SHA256 secret a signed delivery URL is minted with."),
38
+ })
39
+ .describe("One Cloudflare Images URL-signing key.");
40
+ export type ImagesSigningKey = z.infer<typeof ImagesSigningKey>;
41
+
42
+ const SigningKeysResponse = z
43
+ .object({ keys: z.array(ImagesSigningKey).optional().describe("The account's signing keys.") })
44
+ .describe("The Images signing-keys response.");
45
+
46
+ /** One Images variant, and whether it serves an image without a signature whatever the image requires. */
47
+ export const ImagesVariant = z
48
+ .object({
49
+ id: z.string().describe("The variant's name, as it appears in a delivery URL."),
50
+ neverRequireSignedURLs: z
51
+ .boolean()
52
+ .describe("Whether this variant serves every image unsigned, even one uploaded with requireSignedURLs."),
53
+ })
54
+ .describe("One Cloudflare Images variant's signing posture.");
55
+ export type ImagesVariant = z.infer<typeof ImagesVariant>;
56
+
57
+ const VariantsResponse = z
58
+ .object({
59
+ variants: z
60
+ .record(
61
+ z.string(),
62
+ z
63
+ .object({
64
+ id: z.string().describe("The variant name."),
65
+ neverRequireSignedURLs: z.boolean().optional().describe("Whether it ignores signing."),
66
+ })
67
+ .describe("One variant as the API returns it."),
68
+ )
69
+ .optional()
70
+ .describe("Every variant, keyed by name."),
71
+ })
72
+ .describe("The Images variants response.");
73
+
74
+ /** Parse an Images response the SDK types loosely, refusing a shape we cannot read. */
75
+ function parseResponse<T>(schema: z.ZodType<T>, response: unknown, what: string): T {
76
+ const result = schema.safeParse(response);
77
+ if (!result.success) {
78
+ throw new CloudflareInvalidResponseError({
79
+ message: `An Images ${what} response had an unexpected shape.`,
80
+ detail: result.error.message,
81
+ });
82
+ }
83
+ return result.data;
84
+ }
85
+
32
86
  /**
33
87
  * Out-of-Worker Cloudflare Images access over the REST API: upload, fetch, edit, delete, list, and
34
88
  * direct-upload URLs from a CLI/CI/provisioning context. Image metadata is an arbitrary
@@ -109,6 +163,44 @@ export class CloudflareImageManager extends CloudflareManager {
109
163
  });
110
164
  }
111
165
 
166
+ /**
167
+ * The account's URL-signing keys — what a signed delivery URL for an image uploaded with
168
+ * `requireSignedURLs` is minted with. Cloudflare provisions a `default` key per account.
169
+ */
170
+ async signingKeys(): Promise<ImagesSigningKey[]> {
171
+ return cloudflareRequest("Images list signing keys", async () => {
172
+ const response = await this.getClient().images.v1.keys.list({ account_id: this.accountId }, requestOptions);
173
+ return parseResponse(SigningKeysResponse, response, "signing keys").keys ?? [];
174
+ });
175
+ }
176
+
177
+ /** Create a named signing key, or roll it if it exists. Answers with every key the account then holds. */
178
+ async createSigningKey(name: string): Promise<ImagesSigningKey[]> {
179
+ return cloudflareRequest(`Images create signing key '${name}'`, async () => {
180
+ const response = await this.getClient().images.v1.keys.update(
181
+ name,
182
+ { account_id: this.accountId },
183
+ requestOptions,
184
+ );
185
+ return parseResponse(SigningKeysResponse, response, "signing keys").keys ?? [];
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Every variant, with whether it serves unsigned. A variant set to `neverRequireSignedURLs` delivers a
191
+ * private image to anyone holding its URL, which is why media's provisioning reads this.
192
+ */
193
+ async listVariants(): Promise<ImagesVariant[]> {
194
+ return cloudflareRequest("Images list variants", async () => {
195
+ const response = await this.getClient().images.v1.variants.list({ account_id: this.accountId }, requestOptions);
196
+ const variants = parseResponse(VariantsResponse, response, "variants").variants ?? {};
197
+ return Object.values(variants).map((variant) => ({
198
+ id: variant.id,
199
+ neverRequireSignedURLs: variant.neverRequireSignedURLs === true,
200
+ }));
201
+ });
202
+ }
203
+
112
204
  getServiceType(): string {
113
205
  return "Cloudflare Images";
114
206
  }
@@ -68,6 +68,19 @@ function parseDownloadStatus(response: unknown): StreamDownloadStatus {
68
68
  return result.data;
69
69
  }
70
70
 
71
+ /**
72
+ * A Stream signing key: what a signed playback token for a video uploaded with `requireSignedURLs` is
73
+ * signed with. The JWK is base64 of the key's JSON, as Cloudflare returns it — a single line, so it is
74
+ * the form stored, where the PEM is not.
75
+ */
76
+ export const StreamSigningKey = z
77
+ .object({
78
+ id: z.string().min(1).describe("The key id — the `kid` a signed token names."),
79
+ jwk: z.string().min(1).describe("The RSA private key as base64-encoded JWK, as Cloudflare returns it."),
80
+ })
81
+ .describe("One Cloudflare Stream signing key.");
82
+ export type StreamSigningKey = z.infer<typeof StreamSigningKey>;
83
+
71
84
  /**
72
85
  * Out-of-Worker Cloudflare Stream access over the REST API: delete, fetch, edit, direct uploads,
73
86
  * and download (MP4 + audio-only) management from a CLI/CI/provisioning context. Video metadata is
@@ -182,6 +195,27 @@ export class CloudflareStreamManager extends CloudflareManager {
182
195
  );
183
196
  }
184
197
 
198
+ /**
199
+ * Create a signing key. Cloudflare returns the private half once, on creation, and never again — so the
200
+ * caller stores it, and a lost key is replaced by creating another.
201
+ */
202
+ async createSigningKey(): Promise<StreamSigningKey> {
203
+ return cloudflareRequest("Stream create signing key", async () => {
204
+ const response = await this.getClient().stream.keys.create(
205
+ { account_id: this.accountId, body: {} },
206
+ requestOptions,
207
+ );
208
+ const parsed = StreamSigningKey.safeParse(response);
209
+ if (!parsed.success) {
210
+ throw new CloudflareInvalidResponseError({
211
+ message: "A Stream signing-key response had an unexpected shape.",
212
+ detail: parsed.error.message,
213
+ });
214
+ }
215
+ return parsed.data;
216
+ });
217
+ }
218
+
185
219
  getServiceType(): string {
186
220
  return "Cloudflare Stream";
187
221
  }
@@ -10,8 +10,8 @@ import { oauthScopesFor } from "../tokens/permissions";
10
10
  * the person who signed in.
11
11
  * - `offline_access` — the only way Cloudflare issues a refresh token. Without it a session dies at the
12
12
  * hour and every command after it opens a browser (measured 2026-10-15).
13
- * - `account-api-tokens.write` — the mint. A session mints `ci-system` and every worker-consumer token
14
- * the project declares, so the hand-made bootstrap token stops being a setup step.
13
+ * - `account-api-tokens.write` — the mint. A session mints `ci-system` and `runtime` as the project
14
+ * composes them, so the hand-made bootstrap token stops being a setup step.
15
15
  * - `account-settings.read` — `GET /accounts`, so the account is discovered rather than stored. On its own
16
16
  * it lists the account; `d1.write` alone listed nothing (both measured 2026-10-15), so discovery must not
17
17
  * lean on whichever other scope a project happens to request.
@@ -0,0 +1,79 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { cloudflareRequest, decodeResponse, isNotFoundError } from "../client/errors";
6
+ import { CloudflareManager } from "../client/manager";
7
+
8
+ /** A queue's identity, decoded from the Queues API's `queue_id` and `queue_name`. */
9
+ export const QueueInfo = z
10
+ .object({
11
+ id: z.string().describe("The CF-assigned queue id, which the REST API addresses a queue by."),
12
+ name: z.string().describe("The queue name a producer binding names it by."),
13
+ })
14
+ .describe("A Cloudflare Queue's identity, as the Queues API returns it.");
15
+ export type QueueInfo = z.output<typeof QueueInfo>;
16
+
17
+ /** The Queues API's own spelling of a queue's identity, mapped onto {@link QueueInfo}. */
18
+ const QueueRecord = z
19
+ .object({
20
+ queue_id: z.string().describe("The CF-assigned queue id."),
21
+ queue_name: z.string().describe("The queue name."),
22
+ })
23
+ .transform(({ queue_id, queue_name }) => ({ id: queue_id, name: queue_name }))
24
+ .describe("A queue as the Queues API returns it.");
25
+
26
+ /**
27
+ * Account-level Queues control plane: **find, create and delete queues** (#662).
28
+ *
29
+ * `pithy provision` creates the queue behind every `queue` binding a composition declares, and `pithy feature
30
+ * destroy` deletes a feature's. A `queues.producers` entry names a queue, and `wrangler deploy` refuses one that
31
+ * does not exist, so the entry is only honest because this stands the queue up. Addressed by account — unlike
32
+ * {@link CloudflareQueueManager}, which targets one queue to send into.
33
+ */
34
+ export class CloudflareQueueProvisioner extends CloudflareManager {
35
+ getServiceType(): string {
36
+ return "Cloudflare Queues (control plane)";
37
+ }
38
+
39
+ /** Prove access by listing queues — a read, never a create or a delete. Never throws. */
40
+ async validateServiceAccess(): Promise<boolean> {
41
+ try {
42
+ await this.getClient().queues.list({ account_id: this.accountId });
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /** Find a queue by exact name in the account, or `null` — for idempotent provisioning. */
50
+ async findQueueByName(name: string): Promise<QueueInfo | null> {
51
+ return cloudflareRequest(`find queue ${name}`, async () => {
52
+ for await (const queue of this.getClient().queues.list({ account_id: this.accountId })) {
53
+ const parsed = QueueRecord.safeParse(queue);
54
+ if (parsed.success && parsed.data.name === name) return parsed.data;
55
+ }
56
+ return null;
57
+ });
58
+ }
59
+
60
+ /** Create a queue by name; returns its id and name. */
61
+ async createQueue(name: string): Promise<QueueInfo> {
62
+ const response = await cloudflareRequest(`create queue ${name}`, () =>
63
+ this.getClient().queues.create({ account_id: this.accountId, queue_name: name }),
64
+ );
65
+ return decodeResponse(QueueRecord, response, "queue create");
66
+ }
67
+
68
+ /** Delete a queue by id. Idempotent — a missing queue is not an error, so teardown can re-run safely. */
69
+ async deleteQueue(queueId: string): Promise<void> {
70
+ await cloudflareRequest(`delete queue ${queueId}`, async () => {
71
+ try {
72
+ await this.getClient().queues.delete(queueId, { account_id: this.accountId });
73
+ } catch (error) {
74
+ if (isNotFoundError(error)) return;
75
+ throw error;
76
+ }
77
+ });
78
+ }
79
+ }
@@ -33,11 +33,29 @@ const LIST_PARTS_PAGE_SIZE = 1000;
33
33
  * the bucket it targets. R2 signs presigned URLs with the S3 keys, not the CF API token — both
34
34
  * come from config (no environment coupling).
35
35
  */
36
- export interface R2ManagerConfig extends CloudflareManagerConfig, R2Credentials {
36
+ export interface R2ManagerConfig extends Omit<CloudflareManagerConfig, "apiToken">, R2Credentials {
37
37
  /** The R2 bucket all object operations target. */
38
38
  bucketName: string;
39
+ /**
40
+ * A Cloudflare API token, for the one call here that is not S3: {@link CloudflareR2Manager.validateServiceAccess}.
41
+ *
42
+ * **Absent in a Worker (#728).** Every object operation is S3, signed with the key pair, so a Worker's
43
+ * object store holds no API token: the application's one is `runtime`, and R2 needs none of it. Without
44
+ * one, `validateServiceAccess` answers `false` and nothing calls the Cloudflare API. An empty string is
45
+ * still refused, because that is an unresolved value rather than a decision.
46
+ */
47
+ apiToken?: string;
39
48
  }
40
49
 
50
+ /**
51
+ * What the base client is built with when the config carries no API token. Never sent: {@link noApiToken}
52
+ * is the provider, and the base reads the token through it at every call and refuses an empty one.
53
+ */
54
+ const S3_ONLY = "r2-s3-only";
55
+
56
+ /** The token provider of an S3-only manager: no token, so any Cloudflare API call is refused before it is sent. */
57
+ const noApiToken = (): string => "";
58
+
41
59
  /** How long a presigned URL stays valid. Shared by every presign method so the option reads the same. */
42
60
  export interface PresignOptions {
43
61
  /** Lifetime in seconds. Defaults to one hour — long enough for a large upload, short enough to leak little. */
@@ -168,8 +186,15 @@ export class CloudflareR2Manager extends CloudflareManager {
168
186
 
169
187
  private s3Client: S3Client | null = null;
170
188
 
189
+ private readonly s3Only: boolean;
190
+
171
191
  constructor(config: R2ManagerConfig) {
172
- super(config);
192
+ super(
193
+ config.apiToken === undefined
194
+ ? { ...config, apiToken: S3_ONLY, tokenProvider: noApiToken }
195
+ : { ...config, apiToken: config.apiToken },
196
+ );
197
+ this.s3Only = config.apiToken === undefined;
173
198
  if (!config.bucketName) {
174
199
  throw new CloudflareNotConfiguredError({ detail: "Missing bucketName for R2 access." });
175
200
  }
@@ -538,6 +563,8 @@ export class CloudflareR2Manager extends CloudflareManager {
538
563
  * not merely that an S3 client could be built. Never throws.
539
564
  */
540
565
  async validateServiceAccess(): Promise<boolean> {
566
+ // An S3-only manager holds no API token to read the bucket record with.
567
+ if (this.s3Only) return false;
541
568
  try {
542
569
  await this.getClient().r2.buckets.get(this.bucketName, { account_id: this.accountId });
543
570
  return true;
@@ -185,6 +185,18 @@ export const AccountTokenSummary = z
185
185
  .describe(
186
186
  "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.",
187
187
  ),
188
+ // **Not `nullAsAbsent`, and that is the field.** Everywhere else a `null` means the response did not say;
189
+ // here it is Cloudflare's answer — nothing has ever authenticated with this token. Absent stays "did
190
+ // not say", and a shape this does not model degrades to absent rather than dropping the token, for the
191
+ // reason `policies` gives.
192
+ last_used_on: z
193
+ .string()
194
+ .nullable()
195
+ .optional()
196
+ .catch(undefined)
197
+ .describe(
198
+ "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.",
199
+ ),
188
200
  })
189
201
  .describe("An existing account-owned API token's metadata (never its secret value).");
190
202
  export type AccountTokenSummary = z.output<typeof AccountTokenSummary>;
@@ -51,6 +51,28 @@ export const PERMISSION_GROUPS = {
51
51
  // domain — a proxied DNS record and a certificate — on any zone the account holds (measured, #709;
52
52
  // docs/TOKENS.md). So a token carrying the base can reach every zone's hostnames whatever this scopes.
53
53
  "routes:write": ["Workers Routes Write"],
54
+ // **Reading the account's analytics, and nothing else (#728).** The GraphQL Analytics API's account-level
55
+ // datasets — Stream minutes viewed, Images delivered — answer to this group. Cloudflare also has
56
+ // "Analytics Read", which is the **zone** group and reaches none of them.
57
+ //
58
+ // A second credential class for the kit: every other key here acts on resources the project owns,
59
+ // and this one reads the Cloudflare account. Its grain is the whole account, never one environment, so
60
+ // a capability reading it narrows the answer itself — media joins it to this environment's own assets.
61
+ "analytics:read": ["Account Analytics Read"],
62
+ // **Media's Cloudflare products, so the catalog can mint what an operator used to make by hand (#728).**
63
+ // A direct upload, a signing change and a delete on Cloudflare Images and on Stream. Both groups are
64
+ // account-scoped and edit only the account's Images and Stream libraries; they are carried by the one
65
+ // `runtime` token a deployed application holds, never by `ci-system`.
66
+ "images:write": ["Images Write"],
67
+ "stream:write": ["Stream Write"],
68
+ // **Creating and deleting the queue behind a `queue` binding (#662).** `pithy provision` creates one per
69
+ // binding and `pithy feature destroy` deletes a feature's, so `ci-system` carries it wherever a composition
70
+ // binds a queue. A Worker sends through its producer binding and needs no API token for it.
71
+ "queues:write": ["Queues Write"],
72
+ // **Listing and deleting the repos in a feature's Artifacts namespace, then the namespace (#673).** `pithy feature
73
+ // destroy` reaps them, because a repo costs storage and nothing else deletes one, so `ci-system` carries it wherever
74
+ // a composition binds `artifacts`. The account catalog names the group Write; the API schema's label says Edit. A Worker creates, reads and mints through its binding and needs no API token for any of it.
75
+ "artifacts:write": ["Artifacts Write"],
54
76
  } as const;
55
77
 
56
78
  /** A known permission key — one of {@link PERMISSION_GROUPS}'s keys. */
@@ -89,6 +111,11 @@ export const OAUTH_SCOPES = {
89
111
  "ai:read": ["ai.read"],
90
112
  "zone:read": ["zone.read"],
91
113
  "routes:write": ["workers-routes.write"],
114
+ "analytics:read": ["account-analytics.read"],
115
+ "images:write": ["images.write"],
116
+ "stream:write": ["stream.write"],
117
+ "queues:write": ["queues.write"],
118
+ "artifacts:write": ["artifacts.write"],
92
119
  } as const satisfies Record<PermissionKey, readonly string[]>;
93
120
 
94
121
  /**
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import type { Capability, TokenProfileSeam } from "@pithy-sh/core/src/capability/capability";
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
5
  import { CloudflareNotConfiguredError } from "../client/errors";
6
6
  import { accountResource, type TokenPermission, zoneResources } from "./accountTokensManager";
7
7
  import {
@@ -11,12 +11,13 @@ import {
11
11
  type PermissionKey,
12
12
  resolvePermissionKeys,
13
13
  } from "./permissions";
14
+ import { RUNTIME_PROFILE, RUNTIME_TOKEN_SECRET } from "./runtime";
14
15
 
15
16
  /**
16
17
  * Where a minted token's value is written.
17
18
  *
18
- * - `secrets-store` — the CF Secrets Store; a Worker reads it via its binding. The destination for a
19
- * worker-consumer token; resolved from the token's declared secret (its registry backend) unless a
19
+ * - `secrets-store` — the CF Secrets Store; a Worker reads it via its binding. The destination for the
20
+ * `runtime` token; resolved from the token's declared secret (its registry backend) unless a
20
21
  * profile or `--store` names it directly.
21
22
  * - `dev-vars` — `<config>/<project>/tokens.json`, keyed by environment and outside every checkout.
22
23
  * **Read by an operator, never by a command** — nothing resolves a credential from it. The store for
@@ -62,26 +63,12 @@ export interface TokenProfile {
62
63
  resources?: "account" | Record<string, string>;
63
64
  /** The secret-registry name the minted value is stored under; its declared backend is the destination. */
64
65
  secret: string;
65
- /**
66
- * Whether the value behind {@link secret} differs per environment (the default) or is one value every
67
- * environment shares. It decides the environment segment of the **CF Secrets Store entry name** the
68
- * minted value is written to, and nothing else — never the variable key, which is a variable name
69
- * and stays verbatim. A `global` profile writes one entry with the literal `global` in that slot,
70
- * matching what provisioning wrote; an `environment` profile writes one entry per environment.
71
- */
72
- secretScope?: TokenSecretScope;
73
66
  /** A built-in destination override (`dev-vars`/`ephemeral`/`secrets-store`); else the secret's backend. */
74
67
  defaultStore?: TokenStore;
75
68
  /** Why this profile exists / what job consumes it. */
76
69
  description: string;
77
70
  }
78
71
 
79
- /** Whether a profile's secret is one value per environment or one value shared by all of them. */
80
- export type TokenSecretScope = "environment" | "global";
81
-
82
- /** The valid secret scopes, for validating a capability's declaration. */
83
- const TOKEN_SECRET_SCOPES: readonly TokenSecretScope[] = ["environment", "global"];
84
-
85
72
  /** An adopter's override of a profile's defaults (from `pithy.config.ts` or CLI flags). Every field optional. */
86
73
  export interface ProfileOverride {
87
74
  /** Replace the profile's permission keys. */
@@ -112,15 +99,21 @@ function assertPermissionKeys(keys: readonly string[], where: string): Permissio
112
99
  if (!isPermissionKey(key)) {
113
100
  throw new CloudflareNotConfiguredError({
114
101
  message: `${where} declares an unknown permission key: ${key}.`,
115
- action:
116
- "Use a known permission key (d1:read, d1:write, workers:write, secrets:read, secrets:write, email:routing, kv:write).",
102
+ action: `Use a known permission key: ${Object.keys(PERMISSION_GROUPS).join(", ")}.`,
117
103
  });
118
104
  }
119
105
  }
120
106
  return keys as PermissionKey[];
121
107
  }
122
108
 
123
- /** Build the aggregating `ci-system` profile: the base permissions ∪ every capability's `ciPermissions`. */
109
+ /**
110
+ * Build the aggregating `ci-system` profile: the base permissions ∪ every capability's `ciPermissions` ∪
111
+ * `queues:write` where a composed capability binds a queue ∪ `artifacts:write` where one binds Artifacts.
112
+ *
113
+ * **The queue permission is derived from the binding, not declared (#662).** `pithy provision` creates the queue
114
+ * behind every `queue` binding it composes, the adopter's own app's included, and the app has no `ciPermissions`
115
+ * anyone would think to write. A permission read off the binding cannot be forgotten by whoever declares it.
116
+ */
124
117
  function ciSystemProfile(capabilities: readonly Capability[]): TokenProfile {
125
118
  const permissions: PermissionKey[] = [...CI_SYSTEM_BASE];
126
119
  for (const capability of capabilities) {
@@ -128,6 +121,16 @@ function ciSystemProfile(capabilities: readonly Capability[]): TokenProfile {
128
121
  if (!permissions.includes(key)) permissions.push(key);
129
122
  }
130
123
  }
124
+ const bindsQueue = capabilities.some((capability) =>
125
+ capability.requiredBindings.some((binding) => binding.type === "queue"),
126
+ );
127
+ if (bindsQueue && !permissions.includes("queues:write")) permissions.push("queues:write");
128
+ // **And `artifacts:write` where one binds Artifacts (#673)**, for the same reason: `pithy feature destroy` reaps
129
+ // every repo in the feature's namespace under this token, and the app that binds it declares no `ciPermissions`.
130
+ const bindsArtifacts = capabilities.some((capability) =>
131
+ capability.requiredBindings.some((binding) => binding.type === "artifacts"),
132
+ );
133
+ if (bindsArtifacts && !permissions.includes("artifacts:write")) permissions.push("artifacts:write");
131
134
  return {
132
135
  name: CI_SYSTEM_PROFILE,
133
136
  permissions,
@@ -139,66 +142,47 @@ function ciSystemProfile(capabilities: readonly Capability[]): TokenProfile {
139
142
  }
140
143
 
141
144
  /**
142
- * The seam fields this package reads. Core's {@link TokenProfileSeam} is the structural contract every
143
- * capability declares against; `secretScope` is additive on top of it, so it is named here rather than
144
- * in core — a seam value that omits it is still assignable, and one that carries it is read as declared.
145
+ * Build the aggregating `runtime` profile: every composed capability's `runtimePermissions`, each once,
146
+ * in first-declared order — or nothing, when the composition declares none.
145
147
  *
146
- * Exported so a capability can type its `tokenProfiles` entry against it. Declaring `secretScope` as an
147
- * inline object literal directly in `tokenProfiles` still trips TypeScript's excess-property check
148
- * against core's narrower `TokenProfileSeam`; declare the entry as its own `const` (with `as const` or
149
- * `satisfies TokenProfileSeamInput`) and it flows through.
148
+ * **One token per deployed application, the way `ci-system` is one per pipeline.** A capability names
149
+ * what its Workers call Cloudflare with and never a token of its own, so a second credential is not
150
+ * something a capability can ask for. The token is minted per environment into the Secrets Store, so
151
+ * revoking staging's leaves production reading. A key whose group is zone-scoped is refused here: a
152
+ * Worker's token is account-scoped, and its zones would have to be chosen by hand.
150
153
  */
151
- export interface TokenProfileSeamInput extends TokenProfileSeam {
152
- readonly secretScope?: string;
153
- }
154
-
155
- /** Build a concrete, validated worker-consumer {@link TokenProfile} from a capability's structural seam entry. */
156
- function profileFromSeam(name: string, seam: TokenProfileSeamInput, capability: string): TokenProfile {
157
- const store = seam.defaultStore;
158
- if (store !== undefined && !(TOKEN_STORES as readonly string[]).includes(store)) {
159
- throw new CloudflareNotConfiguredError({
160
- message: `Token profile "${name}" (capability "${capability}") declares an unknown store: ${store}.`,
161
- action: `Use one of: ${TOKEN_STORES.join(", ")}.`,
162
- });
163
- }
164
- const scope = seam.secretScope;
165
- if (scope !== undefined && !(TOKEN_SECRET_SCOPES as readonly string[]).includes(scope)) {
166
- throw new CloudflareNotConfiguredError({
167
- message: `Token profile "${name}" (capability "${capability}") declares an unknown secret scope: ${scope}.`,
168
- action: `Use one of: ${TOKEN_SECRET_SCOPES.join(", ")}.`,
169
- });
154
+ function runtimeProfile(capabilities: readonly Capability[]): TokenProfile | undefined {
155
+ const permissions: PermissionKey[] = [];
156
+ for (const capability of capabilities) {
157
+ const where = `Capability "${capability.name}"`;
158
+ const keys = assertPermissionKeys(capability.runtimePermissions ?? [], where);
159
+ assertAccountScoped(keys, `${where}'s runtimePermissions`);
160
+ for (const key of keys) {
161
+ if (!permissions.includes(key)) permissions.push(key);
162
+ }
170
163
  }
164
+ if (permissions.length === 0) return undefined;
171
165
  return {
172
- name,
173
- permissions: assertPermissionKeys(seam.permissions, `Token profile "${name}"`),
174
- resources: seam.resources,
175
- secret: seam.secret ?? tokenSecretName(name),
176
- secretScope: scope as TokenSecretScope | undefined,
177
- defaultStore: store as TokenStore | undefined,
178
- description: seam.description ?? name,
166
+ name: RUNTIME_PROFILE,
167
+ permissions,
168
+ secret: RUNTIME_TOKEN_SECRET,
169
+ defaultStore: "secrets-store",
170
+ description:
171
+ "The one Cloudflare API token a deployed application holds: every composed capability's runtime needs, per environment. Analytics reads are account-wide by Cloudflare's grain.",
179
172
  };
180
173
  }
181
174
 
182
175
  /**
183
- * The project's token-profile registry: the aggregating `ci-system` profile plus every composed
184
- * capability's worker-consumer {@link Capability.tokenProfiles} slice. A capability profile that
185
- * clashes with `ci-system` (or another capability's name) fails loudly at assembly. This is the one
186
- * registry `pithy token` reads.
176
+ * The project's token-profile registry: the aggregating `ci-system` profile and, when any composed
177
+ * capability declares {@link Capability.runtimePermissions}, the aggregating `runtime` profile. Nothing
178
+ * else — a capability cannot add a profile. This is the one registry `pithy token` reads.
187
179
  */
188
180
  export function resolveTokenProfiles(capabilities: readonly Capability[]): Record<string, TokenProfile> {
189
- const profiles: Record<string, TokenProfile> = { [CI_SYSTEM_PROFILE]: ciSystemProfile(capabilities) };
190
- for (const capability of capabilities) {
191
- for (const [name, seam] of Object.entries(capability.tokenProfiles ?? {})) {
192
- if (profiles[name]) {
193
- throw new CloudflareNotConfiguredError({
194
- message: `Token profile "${name}" is declared more than once (capability "${capability.name}").`,
195
- action: "Give each token profile a unique name.",
196
- });
197
- }
198
- profiles[name] = profileFromSeam(name, seam, capability.name);
199
- }
200
- }
201
- return profiles;
181
+ const runtime = runtimeProfile(capabilities);
182
+ return {
183
+ [CI_SYSTEM_PROFILE]: ciSystemProfile(capabilities),
184
+ ...(runtime ? { [RUNTIME_PROFILE]: runtime } : {}),
185
+ };
202
186
  }
203
187
 
204
188
  /**