breakaway 2.0.0-main.57 → 2.0.0-main.59

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "2.0.0-main.57",
3
+ "version": "2.0.0-main.59",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "breakaway",
9
- "version": "2.0.0-main.57",
9
+ "version": "2.0.0-main.59",
10
10
  "license": "FSL-1.1-Apache-2.0",
11
11
  "bin": {
12
12
  "breakaway": "scripts/tasks.mjs"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "2.0.0-main.57",
3
+ "version": "2.0.0-main.59",
4
4
  "description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -164,7 +164,7 @@ function init(opts, { cwd, log, error, version = VERSION }) {
164
164
  }
165
165
  log(`${opts['dry-run'] ? 'Would write' : 'Wrote'} ${file.path}, for ${environments.join(', ')}, from ${branch}.`);
166
166
  log(
167
- `Before the board can apply a plan, the owner makes, for each environment, a GitHub environment of the same name that only ${branch} may use, with that environment's write token as the secret CLOUDFLARE_API_TOKEN; sets the repository variable BREAKAWAY_URL to the board's address; and gives the board's GitHub App Actions: read and write here. Agents never do these, and never run the workflow.`,
167
+ `Before the board can apply a plan, the owner makes, for each environment, a GitHub environment of the same name that only ${branch} may use, with that environment's write token as the secret CLOUDFLARE_API_TOKEN (the board's read permissions, plus Cloudflare's Workers Editor on that environment's Workers, or the legacy Workers Scripts Write, and only the Write permissions its file needs); sets the repository variable BREAKAWAY_URL to the board's address; and gives the board's GitHub App Actions: read and write here. Agents never do these, and never run the workflow.`,
168
168
  );
169
169
  return 0;
170
170
  }
@@ -9,7 +9,8 @@
9
9
  * a discovered resource the desired state doesn't list is deleted. Deleting a Worker, D1 database, KV namespace, R2
10
10
  * bucket, queue, or container application is marked irreversible, with why.
11
11
  *
12
- * Tokens can't be scoped to one Worker, so the provider keeps every change inside the environment's scope itself: it
12
+ * Only Workers can be scoped one by one (Workers Editor, BRK-243); a database, namespace, bucket, or queue permission
13
+ * reaches every one on the account. So the provider keeps every change inside the environment's scope itself: it
13
14
  * refuses to make a resource that already exists on the account outside the scope, a database, namespace, bucket, or
14
15
  * queue no Worker in the desired state binds, and a delete of something a remaining Worker still binds.
15
16
  *
@@ -53,9 +54,27 @@ export { MANAGED };
53
54
  /** Kinds Cloudflare names by their name, so a rename is a change of its own (KV's title, a route's pattern). */
54
55
  const RENAMES = new Set(['kv', 'route']);
55
56
 
57
+ /**
58
+ * What the write token needs to change a Worker (BRK-243): Cloudflare's Workers Editor role, scoped to the
59
+ * environment's Workers. It replaces the legacy Workers Scripts Write (Editor at the Workers product scope), and a
60
+ * token made with that still works. Editor can't make or delete a Worker: that's Admin at the Workers product scope,
61
+ * which the owner gives the write token by hand for the one apply that needs it. Custom domains have no per-Worker
62
+ * role yet, so an environment that declares them scopes Editor to the Workers product.
63
+ */
64
+ export const WORKERS_WRITE = {
65
+ edit: 'Workers Editor on this Worker (or the legacy Workers Scripts Write)',
66
+ create:
67
+ 'Workers Admin at the Workers product scope, because Workers Editor can’t make a Worker: give the write token Admin for this apply, then take it away',
68
+ delete:
69
+ 'Workers Admin, because Workers Editor can’t delete a Worker: give the write token Admin for this apply, then take it away',
70
+ domain:
71
+ 'Workers Editor at the Workers product scope (custom domains have no per-Worker role yet; or the legacy Workers Scripts Write) and Workers Routes Write on its zone',
72
+ };
73
+
56
74
  /** Why deleting each kind can't be undone. Routes and custom domains hold nothing, so they can be made again. */
57
75
  const LOST = {
58
- worker: 'deleting a Worker deletes every version of it, so it can’t be rolled back',
76
+ worker:
77
+ 'deleting a Worker deletes every version of it, so it can’t be rolled back; it needs Workers Admin on the write token, which Workers Editor doesn’t have, so give it for this apply and take it away after',
59
78
  d1: 'deleting a D1 database deletes its data; Time Travel can’t restore a deleted database',
60
79
  kv: 'deleting a KV namespace deletes every key in it',
61
80
  r2: 'deleting an R2 bucket deletes it for good (Cloudflare refuses unless it’s empty)',
@@ -620,11 +639,11 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
620
639
  switch (c.kind) {
621
640
  case 'worker': {
622
641
  const p = `${a}/workers/scripts/${encodeURIComponent(c.name)}`;
623
- const perm = { permission: 'Workers Scripts Write' };
624
- if (c.op === 'delete') return call('DELETE', p, perm);
642
+ const perm = { permission: WORKERS_WRITE.edit };
643
+ if (c.op === 'delete') return call('DELETE', p, { permission: WORKERS_WRITE.delete });
625
644
  if (c.op === 'create') {
626
645
  // The Worker alone: its first version, with its code, comes from its deploy.
627
- await call('POST', `${a}/workers/workers`, { ...perm, json: { name: c.name } });
646
+ await call('POST', `${a}/workers/workers`, { permission: WORKERS_WRITE.create, json: { name: c.name } });
628
647
  if (Array.isArray(after.crons) && after.crons.length)
629
648
  await call('PUT', `${p}/schedules`, { ...perm, json: after.crons.map((cron) => ({ cron })) });
630
649
  return;
@@ -731,7 +750,7 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
731
750
  }
732
751
  case 'custom-domain': {
733
752
  // Attaching makes a DNS record and a certificate, so it needs Workers Routes Write on the zone too.
734
- const perm = { permission: 'Workers Scripts Write and Workers Routes Write on its zone' };
753
+ const perm = { permission: WORKERS_WRITE.domain };
735
754
  if (c.op === 'delete') return call('DELETE', `${a}/workers/domains/${id}`, perm);
736
755
  const zone = after.zone ?? before.zone;
737
756
  return call('PUT', `${a}/workers/domains`, {
@@ -824,7 +843,7 @@ export async function rollbackWorker(ctx, { worker, versions, force = false }) {
824
843
  const account = await accountOf(reader(ctx), ctx);
825
844
  const p = `/accounts/${encodeURIComponent(account)}/workers/scripts/${encodeURIComponent(worker)}/deployments${force ? '?force=true' : ''}`;
826
845
  return writer(ctx)('POST', p, {
827
- permission: 'Workers Scripts Write',
846
+ permission: WORKERS_WRITE.edit,
828
847
  json: {
829
848
  strategy: 'percentage',
830
849
  versions: versions.map((v) => ({ version_id: v.id, percentage: v.percentage })),
@@ -93,12 +93,23 @@ export function refuses(r, op) {
93
93
  return null;
94
94
  }
95
95
 
96
+ /**
97
+ * Cloudflare's Workers role the board reads Workers with (BRK-243): Metadata Read-Only at the Workers product scope,
98
+ * which reads settings and never a Worker's code. It replaces the legacy Workers Scripts Read, which Cloudflare maps to
99
+ * Content Read-Only (code included); a token made with that still works.
100
+ */
101
+ export const WORKERS_READ = 'Workers Metadata Read-Only';
102
+
96
103
  /**
97
104
  * The board's read-only token (BRK-188, "Tokens"; BRK-194 keeps it). Read only: no permission ends in Edit or Write.
98
105
  * @type {import('./infra-provider.js').ReadToken['permissions']}
99
106
  */
100
107
  export const READ_PERMISSIONS = [
101
- { name: 'Workers Scripts Read', for: 'Workers, their settings, deployments, secret names, and cron triggers' },
108
+ {
109
+ name: WORKERS_READ,
110
+ legacy: ['Workers Scripts Read'],
111
+ for: 'Workers, their settings, deployments, secret names, cron triggers, Durable Object namespaces, and custom domains, never their code',
112
+ },
102
113
  { name: 'Workers KV Storage Read', for: 'KV namespaces, never their values' },
103
114
  { name: 'Workers R2 Storage Read', for: 'R2 buckets and their settings, never their objects' },
104
115
  { name: 'D1 Read', for: 'D1 databases' },
@@ -131,6 +142,12 @@ export const NEVER_CALLED = [
131
142
  /\/secrets\/[^/]+$/u,
132
143
  ];
133
144
 
145
+ /** A permission as a 403 names it: with its legacy names in parentheses, for a token made before Cloudflare renamed it. */
146
+ export function named(permission) {
147
+ const legacy = READ_PERMISSIONS.find((p) => p.name === permission)?.legacy;
148
+ return legacy?.length ? `${permission} (or the legacy ${legacy.join(' or ')})` : permission;
149
+ }
150
+
134
151
  /** An answer Cloudflare refused, with what the token was missing when it was a 403. */
135
152
  export class CloudflareError extends Error {
136
153
  /** @param {string} message @param {number} status */
@@ -181,7 +198,7 @@ export function reader(ctx) {
181
198
  if (res.status === 404 && missingOk) return null;
182
199
  if (res.status === 403)
183
200
  throw Object.assign(
184
- new CloudflareError(`Cloudflare refused GET ${path.split('?')[0]}: the token needs ${permission}`, 403),
201
+ new CloudflareError(`Cloudflare refused GET ${path.split('?')[0]}: the token needs ${named(permission)}`, 403),
185
202
  { permission },
186
203
  );
187
204
  if (!res.ok || json?.success === false)
@@ -322,9 +339,9 @@ export async function discover(ctx, { live = false } = {}) {
322
339
  seen.account = await accountOf(cf, ctx);
323
340
  const a = enc(seen.account);
324
341
  const scripts = new Map(
325
- (
326
- await cf.get(`/accounts/${a}/workers/scripts`, { permission: 'Workers Scripts Read' }).then((j) => j.result ?? [])
327
- ).map((s) => [String(s.id), s]),
342
+ (await cf.get(`/accounts/${a}/workers/scripts`, { permission: WORKERS_READ }).then((j) => j.result ?? [])).map(
343
+ (s) => [String(s.id), s],
344
+ ),
328
345
  );
329
346
  seen.scripts = [...scripts.keys()];
330
347
  if (!scripts.has(target)) return done();
@@ -337,7 +354,7 @@ export async function discover(ctx, { live = false } = {}) {
337
354
  const name = /** @type {string} */ (queue.shift());
338
355
  if (bindingsOf.has(name) || !scripts.has(name)) continue;
339
356
  const p = `/accounts/${a}/workers/scripts/${enc(name)}`;
340
- const opts = { permission: 'Workers Scripts Read' };
357
+ const opts = { permission: WORKERS_READ };
341
358
  const settings = (await cf.get(`${p}/settings`, opts)).result ?? {};
342
359
  const deployments = (await cf.get(`${p}/deployments`, opts)).result ?? {};
343
360
  const secrets = (await cf.get(`${p}/secrets`, opts)).result ?? [];
@@ -501,7 +518,7 @@ export async function discover(ctx, { live = false } = {}) {
501
518
  // Durable Object namespaces: those a Worker in scope binds or defines, and the Worker whose class runs them.
502
519
  const doBound = scoped('durable_object_namespace');
503
520
  const namespaces = await cf.all(`/accounts/${a}/workers/durable_objects/namespaces`, {
504
- permission: 'Workers Scripts Read',
521
+ permission: WORKERS_READ,
505
522
  });
506
523
  for (const ns of namespaces) {
507
524
  const id = rid('durable-object', ns.id);
@@ -578,7 +595,7 @@ export async function discover(ctx, { live = false } = {}) {
578
595
  }
579
596
 
580
597
  // Custom domains attached to a Worker in scope.
581
- const domains = (await cf.get(`/accounts/${a}/workers/domains`, { permission: 'Workers Scripts Read' })).result ?? [];
598
+ const domains = (await cf.get(`/accounts/${a}/workers/domains`, { permission: WORKERS_READ })).result ?? [];
582
599
  for (const d of domains) {
583
600
  if (!workers.has(String(d.service))) continue;
584
601
  add({
@@ -158,8 +158,10 @@ export const COST_NOTE_MAX = 500;
158
158
  * One permission a read-only token needs, by the name the platform's token page gives it, and what the board reads
159
159
  * with it. Never a token's value.
160
160
  * @typedef {object} TokenPermission
161
- * @property {string} name like `Workers Scripts Read`
161
+ * @property {string} name like `Workers Metadata Read-Only`
162
162
  * @property {string} for what the board reads with it, in a few words
163
+ * @property {string[]} [legacy] older names for the same access (like `Workers Scripts Read`): a token made with one of
164
+ * them still counts as having it, and the board names them in parentheses
163
165
  */
164
166
 
165
167
  /**
@@ -263,8 +265,11 @@ export function checkProvider(provider) {
263
265
  function checkReadToken(what, token) {
264
266
  if (!isObject(token)) fail(what, 'readToken is not an object');
265
267
  if (!Array.isArray(token.permissions) || token.permissions.length === 0) fail(what, 'readToken names no permissions');
266
- for (const p of token.permissions)
268
+ for (const p of token.permissions) {
267
269
  if (!isObject(p) || !text(p.name) || !text(p.for)) fail(what, 'a readToken permission has no name or no for');
270
+ if (p.legacy !== undefined && (!Array.isArray(p.legacy) || !p.legacy.every(text)))
271
+ fail(what, 'a readToken permission’s legacy is not a list of names');
272
+ }
268
273
  if (typeof token.url !== 'string' || !token.url.startsWith('https://')) fail(what, 'readToken has no https url');
269
274
  if (token.check !== undefined && typeof token.check !== 'function') fail(what, 'readToken check is not a function');
270
275
  }