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.
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "2.0.0-main.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/tasks/infra.js
CHANGED
|
@@ -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
|
-
*
|
|
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:
|
|
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:
|
|
624
|
-
if (c.op === 'delete') return call('DELETE', p,
|
|
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`, {
|
|
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:
|
|
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:
|
|
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 })),
|
package/src/infra-cloudflare.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
327
|
-
)
|
|
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:
|
|
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:
|
|
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:
|
|
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({
|
package/src/infra-provider.js
CHANGED
|
@@ -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
|
|
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
|
}
|