breakaway 2.0.0-main.82 → 2.0.0-main.83

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.82",
3
+ "version": "2.0.0-main.83",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "breakaway",
9
- "version": "2.0.0-main.82",
9
+ "version": "2.0.0-main.83",
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.82",
3
+ "version": "2.0.0-main.83",
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",
@@ -58,9 +58,22 @@ export async function analyticsQuery(ctx, query, variables) {
58
58
  'Cloudflare’s rate limit was reached, so reading the analytics stopped: it refuses every call for 5 minutes, then try again',
59
59
  429,
60
60
  );
61
- if (res.status === 403)
62
- throw new AnalyticsError(`Cloudflare refused the analytics: the token needs ${PERMISSION}`, 403, PERMISSION);
63
61
  const json = await res.json().catch(() => null);
62
+ if (res.status === 403) {
63
+ // What Cloudflare said too, since a refusal isn't always the permission the board guesses (BRK-254).
64
+ const first = Array.isArray(json?.errors) ? json.errors[0] : null;
65
+ const said = first
66
+ ? [first.code, first.message]
67
+ .filter((x) => x != null && x !== '')
68
+ .join(': ')
69
+ .slice(0, 200)
70
+ : '';
71
+ throw new AnalyticsError(
72
+ `Cloudflare refused the analytics: the token needs ${PERMISSION}${said ? ` (Cloudflare said ${said})` : ''}`,
73
+ 403,
74
+ PERMISSION,
75
+ );
76
+ }
64
77
  if (!res.ok) throw new AnalyticsError(`Cloudflare answered ${res.status} to the analytics`, res.status);
65
78
  const errors = Array.isArray(json?.errors) ? json.errors : [];
66
79
  if (errors.length) {
@@ -71,6 +84,7 @@ export async function analyticsQuery(ctx, query, variables) {
71
84
  .slice(0, 200);
72
85
  throw new AnalyticsError(`Cloudflare’s analytics said: ${said || 'the query failed'}`, 400);
73
86
  }
87
+ ctx.reached?.add(PERMISSION);
74
88
  return json?.data ?? null;
75
89
  }
76
90
 
@@ -265,6 +265,16 @@ function said(json) {
265
265
  .slice(0, 200);
266
266
  }
267
267
 
268
+ /** What Cloudflare said, with each error's code (`10000: Authentication error`), in a line. */
269
+ function saidCodes(json) {
270
+ const errors = Array.isArray(json?.errors) ? json.errors : [];
271
+ return errors
272
+ .filter((e) => e?.message || e?.code != null)
273
+ .map((e) => (e.code != null ? `${e.code}${e.message ? `: ${e.message}` : ''}` : e.message))
274
+ .join('; ')
275
+ .slice(0, 200);
276
+ }
277
+
268
278
  /**
269
279
  * A reader for one call of the provider: GETs only, each checked against NEVER_CALLED, stopping on a 429.
270
280
  * @param {ProviderContext} ctx
@@ -294,16 +304,21 @@ export function reader(ctx) {
294
304
  );
295
305
  const json = await res.json().catch(() => null);
296
306
  if (res.status === 404 && missingOk) return null;
307
+ // Cloudflare's own code and message ride along with the permission the board guesses, so the real cause shows.
297
308
  if (res.status === 403)
298
309
  throw Object.assign(
299
- new CloudflareError(`Cloudflare refused GET ${path.split('?')[0]}: the token needs ${named(permission)}`, 403),
300
- { permission },
310
+ new CloudflareError(
311
+ `Cloudflare refused GET ${path.split('?')[0]}: the token needs ${named(permission)}${saidCodes(json) ? ` (Cloudflare said ${saidCodes(json)})` : ''}`,
312
+ 403,
313
+ ),
314
+ { permission, cloudflare: saidCodes(json) },
301
315
  );
302
316
  if (!res.ok || json?.success === false)
303
317
  throw new CloudflareError(
304
318
  `Cloudflare answered ${res.status} to GET ${path.split('?')[0]}${said(json) ? `: ${said(json)}` : ''}`,
305
319
  res.status,
306
320
  );
321
+ ctx.reached?.add(permission);
307
322
  return json;
308
323
  }
309
324
 
@@ -408,13 +423,26 @@ export function bindingTarget(b) {
408
423
  * @property {Array<{ hostname: string, worker: string }>} domains every custom domain on the account, likewise
409
424
  */
410
425
 
426
+ /**
427
+ * The zones whose routes the token can't read, as a quiet note for Connections. An account-owned token is listed every
428
+ * zone on the account, including ones it has no permission on, so a 403 on one is normal (BRK-254).
429
+ * @param {Array<{ zone: string, error: any }>} refused
430
+ */
431
+ function routesSkipped(refused) {
432
+ const zones = refused.map((r) => r.zone);
433
+ const list = zones.length === 1 ? zones[0] : `${zones.slice(0, -1).join(', ')} and ${zones.at(-1)}`;
434
+ return `routes on ${list} aren’t readable with this token, so discovery skipped ${zones.length === 1 ? 'it' : 'them'}`;
435
+ }
436
+
411
437
  /**
412
438
  * Discovers what the environment's target Worker runs on, as resources and relations. `missing` names a permission
413
439
  * the token lacks for a kind a repository may leave out (queues, containers): that kind isn't read, and discovery goes
414
- * on (BRK-188, "Tokens"). With `live`, it also returns the LiveAccount plan and apply work from.
440
+ * on (BRK-188, "Tokens"). `skipped` says, in words, what the token couldn't read and discovery went on without (a
441
+ * zone whose routes it can't read). Each permission a call answers with goes in `ctx.reached`. With `live`, it also
442
+ * returns the LiveAccount plan and apply work from.
415
443
  * @param {ProviderContext} ctx
416
444
  * @param {{ live?: boolean }} [options]
417
- * @returns {Promise<Discovery & { missing: string[], live?: LiveAccount }>}
445
+ * @returns {Promise<Discovery & { missing: string[], skipped?: string[], live?: LiveAccount }>}
418
446
  */
419
447
  export async function discover(ctx, { live = false } = {}) {
420
448
  const target = typeof ctx.scope?.target === 'string' ? ctx.scope.target : null;
@@ -424,6 +452,8 @@ export async function discover(ctx, { live = false } = {}) {
424
452
  const relations = new Map();
425
453
  /** @type {string[]} */
426
454
  const missing = [];
455
+ /** @type {string[]} */
456
+ const skipped = [];
427
457
  /** @type {LiveAccount} */
428
458
  const seen = { account: '', scripts: [], bindings: {}, zones: {}, routes: [], domains: [] };
429
459
  const add = (r) => resources.set(r.id, r);
@@ -432,6 +462,7 @@ export async function discover(ctx, { live = false } = {}) {
432
462
  resources: [...resources.values()],
433
463
  relations: [...relations.values()].filter((r) => resources.has(r.from) && resources.has(r.to)),
434
464
  missing,
465
+ ...(skipped.length ? { skipped } : {}),
435
466
  ...(live ? { live: seen } : {}),
436
467
  });
437
468
  if (!target) return done();
@@ -677,12 +708,23 @@ export async function discover(ctx, { live = false } = {}) {
677
708
  missing.push('Containers Read');
678
709
  }
679
710
 
680
- // Routes, on the zones the token reaches, that send to a Worker in scope.
711
+ // Routes, on the zones the token reaches, that send to a Worker in scope. The zones list holds every zone on the
712
+ // account for an account-owned token, so a 403 on one zone's routes is skipped and named in `skipped`, never an
713
+ // error (BRK-254); only when every zone refuses does discovery fail, naming the permission.
681
714
  const zones = await cf.all(`/zones?account.id=${a}`, { permission: 'Zone Read' }, 50);
715
+ /** @type {Array<{ zone: string, error: any }>} */
716
+ const refused = [];
682
717
  for (const zone of zones) {
683
718
  seen.zones[String(zone.name)] = String(zone.id);
684
- const routes =
685
- (await cf.get(`/zones/${enc(zone.id)}/workers/routes`, { permission: 'Workers Routes Read' })).result ?? [];
719
+ let routes;
720
+ try {
721
+ routes =
722
+ (await cf.get(`/zones/${enc(zone.id)}/workers/routes`, { permission: 'Workers Routes Read' })).result ?? [];
723
+ } catch (error) {
724
+ if (error?.status !== 403) throw error;
725
+ refused.push({ zone: String(zone.name), error });
726
+ continue;
727
+ }
686
728
  for (const route of routes) {
687
729
  seen.routes.push({ pattern: String(route.pattern), zone: String(zone.name), worker: String(route.script ?? '') });
688
730
  if (!workers.has(String(route.script))) continue;
@@ -695,6 +737,8 @@ export async function discover(ctx, { live = false } = {}) {
695
737
  relate(rid('worker', String(route.script)), rid('route', route.id), 'serves');
696
738
  }
697
739
  }
740
+ if (refused.length && refused.length === zones.length) throw refused[0].error;
741
+ if (refused.length) skipped.push(routesSkipped(refused));
698
742
 
699
743
  // Custom domains attached to a Worker in scope.
700
744
  const domains = (await cf.get(`/accounts/${a}/workers/domains`, { permission: WORKERS_READ })).result ?? [];
@@ -53,6 +53,8 @@ export const COST_NOTE_MAX = 500;
53
53
  * @property {Relation[]} relations
54
54
  * @property {string[]} [missing] permissions the token lacks for a kind the provider skipped instead of failing, by
55
55
  * name, for the provider's Connections row
56
+ * @property {string[]} [skipped] what the token couldn't read and discovery went on without, in words, as a quiet note
57
+ * on the Connections row (a zone whose routes it can't read)
56
58
  */
57
59
 
58
60
  /**
@@ -139,6 +141,8 @@ export const COST_NOTE_MAX = 500;
139
141
  * @property {typeof fetch} [fetch] the fetch to call the platform with; tests pass a mock
140
142
  * @property {Resource[]} [resources] what `discover` just found in the environment's scope: the store passes it to
141
143
  * `observe` and `cost` so they needn't discover again
144
+ * @property {Set<string>} [reached] the store's collector: the provider adds a permission's name when a call answers
145
+ * with it, so Connections puts back one an earlier refusal struck (BRK-254)
142
146
  */
143
147
 
144
148
  /**
@@ -350,6 +354,8 @@ export function checkDiscovery(provider, discovery) {
350
354
  }
351
355
  if (discovery.missing !== undefined && (!Array.isArray(discovery.missing) || !discovery.missing.every(text)))
352
356
  fail(what, 'missing is not a list of permission names');
357
+ if (discovery.skipped !== undefined && (!Array.isArray(discovery.skipped) || !discovery.skipped.every(text)))
358
+ fail(what, 'skipped is not a list of sentences');
353
359
  return discovery;
354
360
  }
355
361