breakaway 2.0.0-main.82 → 2.0.0-main.84
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 +2 -2
- package/package.json +1 -1
- package/src/infra-cloudflare-analytics.js +16 -2
- package/src/infra-cloudflare.js +130 -23
- package/src/infra-provider.js +12 -0
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.84",
|
|
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.84",
|
|
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.84",
|
|
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
|
|
package/src/infra-cloudflare.js
CHANGED
|
@@ -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(
|
|
300
|
-
|
|
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").
|
|
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
|
-
|
|
685
|
-
|
|
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 ?? [];
|
|
@@ -1118,38 +1162,83 @@ export async function observe(ctx) {
|
|
|
1118
1162
|
}
|
|
1119
1163
|
|
|
1120
1164
|
/**
|
|
1121
|
-
* A Cloudflare alert, cut down to
|
|
1122
|
-
* (its text, data, account and policy IDs) is left out. The board's alert webhook
|
|
1123
|
-
* alert history (`events`) both read alerts with this, so an alert heard both ways
|
|
1165
|
+
* A Cloudflare alert, cut down to what places it: its name, when it fired, and the Worker, zone, or hostname it names.
|
|
1166
|
+
* Everything else (its text, the rest of its data, account and policy IDs) is left out. The board's alert webhook
|
|
1167
|
+
* (src/store-routines.js) and the alert history (`events`) both read alerts with this, so an alert heard both ways
|
|
1168
|
+
* reads the same. `zoneId` is only for finding the zone's name; it's never stored.
|
|
1124
1169
|
* @param {any} body a notification webhook's body, or an alert history entry's `alert_body`
|
|
1125
|
-
* @returns {{ alert: string | null, at: string | null, worker: string | null
|
|
1170
|
+
* @returns {{ alert: string | null, at: string | null, worker: string | null, zone: string | null,
|
|
1171
|
+
* zoneId: string | null, hostname: string | null }}
|
|
1126
1172
|
*/
|
|
1127
1173
|
export function alertFields(body) {
|
|
1128
|
-
const pick = (...values) => values.find((v) => typeof v === 'string' && v.trim()) ?? null;
|
|
1174
|
+
const pick = (...values) => values.find((v) => typeof v === 'string' && v.trim())?.trim() ?? null;
|
|
1175
|
+
const first = (v) => (Array.isArray(v) ? v.find((x) => typeof x === 'string') : v);
|
|
1129
1176
|
const data = body?.data && typeof body.data === 'object' && !Array.isArray(body.data) ? body.data : {};
|
|
1130
1177
|
const ts = Number(body?.ts);
|
|
1131
1178
|
const at =
|
|
1132
1179
|
Number.isFinite(ts) && ts > 0
|
|
1133
1180
|
? new Date(ts < 1e11 ? ts * 1000 : ts)
|
|
1134
1181
|
: new Date(pick(body?.timestamp, body?.time) ?? Number.NaN);
|
|
1182
|
+
const host = pick(data.hostname, data.host, data.domain, first(data.hostnames), first(data.hosts));
|
|
1135
1183
|
return {
|
|
1136
1184
|
alert: pick(body?.alert_name, body?.policy_name, body?.name, body?.alert_type),
|
|
1137
1185
|
at: Number.isNaN(at.getTime()) ? null : at.toISOString(),
|
|
1138
1186
|
worker: pick(data.script_name, data.worker_name, data.worker, data.service, data.script),
|
|
1187
|
+
zone: pick(data.zone_name, data.zone)?.toLowerCase() ?? null,
|
|
1188
|
+
zoneId: pick(data.zone_tag, data.zone_id),
|
|
1189
|
+
hostname: host ? host.toLowerCase().replace(/^\*\./u, '').replace(/\.$/u, '') : null,
|
|
1139
1190
|
};
|
|
1140
1191
|
}
|
|
1141
1192
|
|
|
1142
|
-
/** An alert signal's text: the alert's name and
|
|
1143
|
-
export function alertText(alert,
|
|
1193
|
+
/** An alert signal's text: the alert's name and what it's on (a Worker, a zone, or a hostname). */
|
|
1194
|
+
export function alertText(alert, on) {
|
|
1144
1195
|
const name = String(alert ?? '').slice(0, 200) || 'unnamed alert';
|
|
1145
|
-
return `Cloudflare alert: ${name}${
|
|
1196
|
+
return `Cloudflare alert: ${name}${on ? ` on ${String(on).slice(0, 100)}` : ''}`;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** The zones an environment's resources use: the zone of each of its routes and custom domains. */
|
|
1200
|
+
export function zonesOf(resources) {
|
|
1201
|
+
const zones = new Set();
|
|
1202
|
+
for (const r of resources ?? []) {
|
|
1203
|
+
if (r.kind !== 'route' && r.kind !== 'custom-domain') continue;
|
|
1204
|
+
const zone = /** @type {any} */ (r.attrs)?.zone;
|
|
1205
|
+
if (typeof zone === 'string' && zone) zones.add(zone.toLowerCase());
|
|
1206
|
+
}
|
|
1207
|
+
return zones;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Where an alert goes in one environment (BRK-255), from its fields and the environment's resources: on the Worker it
|
|
1212
|
+
* names when that Worker is the environment's; on the whole environment when the zone it names (or its hostname's
|
|
1213
|
+
* zone) is one the environment's routes or custom domains use; `account` when it names no Worker, zone, or hostname
|
|
1214
|
+
* (it's about the account or the platform, and the board keeps one per provider, not one per environment); or null
|
|
1215
|
+
* when it's another environment's, or no environment's.
|
|
1216
|
+
* @param {ReturnType<typeof alertFields>} fields
|
|
1217
|
+
* @param {Array<{ kind: string, name: string, attrs?: Record<string, unknown> }>} resources
|
|
1218
|
+
* @returns {{ resource: string | null, account: boolean, on: string | null } | null}
|
|
1219
|
+
*/
|
|
1220
|
+
export function alertPlace(fields, resources) {
|
|
1221
|
+
if (fields.worker) {
|
|
1222
|
+
const mine = (resources ?? []).some((r) => r.kind === 'worker' && r.name === fields.worker);
|
|
1223
|
+
return mine ? { resource: rid('worker', fields.worker), account: false, on: fields.worker } : null;
|
|
1224
|
+
}
|
|
1225
|
+
if (!fields.zone && !fields.hostname && !fields.zoneId) return { resource: null, account: true, on: null };
|
|
1226
|
+
const zones = zonesOf(resources);
|
|
1227
|
+
const host = fields.hostname;
|
|
1228
|
+
const zone =
|
|
1229
|
+
(fields.zone && zones.has(fields.zone) ? fields.zone : null) ??
|
|
1230
|
+
(host ? [...zones].find((z) => host === z || host.endsWith(`.${z}`)) : null) ??
|
|
1231
|
+
null;
|
|
1232
|
+
return zone ? { resource: null, account: false, on: host ?? zone } : null;
|
|
1146
1233
|
}
|
|
1147
1234
|
|
|
1148
1235
|
/**
|
|
1149
1236
|
* The account's alerts since `since`, from its alert history (`GET …/alerting/v3/history`, Notifications Read), as
|
|
1150
|
-
* `alert` signals:
|
|
1151
|
-
* when it names
|
|
1152
|
-
*
|
|
1237
|
+
* `alert` signals placed by `alertPlace` (BRK-255): on the Worker it names when that Worker is in the environment, on
|
|
1238
|
+
* the whole environment when it names a zone or hostname the environment uses, and marked `account` when it names
|
|
1239
|
+
* none of them; an alert about another environment's Worker or zone is left out. A zone named only by its ID is
|
|
1240
|
+
* looked up in the account's zones (Zone Read). Oldest first. The board's alert webhook reports the same alerts as
|
|
1241
|
+
* they fire; the store keeps one of each, and one of each account-wide alert across the provider's environments.
|
|
1153
1242
|
* @param {ProviderContext} ctx
|
|
1154
1243
|
* @param {string} since ISO 8601
|
|
1155
1244
|
* @returns {Promise<import('./infra-provider.js').Signal[]>}
|
|
@@ -1160,11 +1249,25 @@ export async function events(ctx, since) {
|
|
|
1160
1249
|
const now = Date.now();
|
|
1161
1250
|
if (from > now) return [];
|
|
1162
1251
|
const resources = ctx.resources ?? (await discover(ctx)).resources;
|
|
1163
|
-
const workers = new Set(resources.filter((r) => r.kind === 'worker').map((r) => r.name));
|
|
1164
1252
|
const cf = reader(ctx);
|
|
1165
|
-
const
|
|
1253
|
+
const account = await accountOf(cf, ctx);
|
|
1254
|
+
const a = enc(account);
|
|
1166
1255
|
const window = `since=${enc(new Date(from).toISOString())}&before=${enc(new Date(now).toISOString())}`;
|
|
1167
1256
|
const history = await cf.all(`/accounts/${a}/alerting/v3/history?${window}`, { permission: 'Notifications Read' });
|
|
1257
|
+
/** @type {Map<string, string> | null} zone names by ID, read only when an alert names a zone by its ID alone */
|
|
1258
|
+
let zoneNames = null;
|
|
1259
|
+
const zoneName = async (id) => {
|
|
1260
|
+
if (!zoneNames) {
|
|
1261
|
+
zoneNames = new Map();
|
|
1262
|
+
try {
|
|
1263
|
+
for (const z of await cf.all(`/zones?account.id=${a}`, { permission: 'Zone Read' }, 50))
|
|
1264
|
+
zoneNames.set(String(z.id), String(z.name).toLowerCase());
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
if (error?.status !== 403) throw error;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return zoneNames.get(id) ?? null;
|
|
1270
|
+
};
|
|
1168
1271
|
/** @type {import('./infra-provider.js').Signal[]} */
|
|
1169
1272
|
const signals = [];
|
|
1170
1273
|
for (const entry of history) {
|
|
@@ -1178,16 +1281,20 @@ export async function events(ctx, since) {
|
|
|
1178
1281
|
const fields = alertFields(body);
|
|
1179
1282
|
const at = fields.at ?? (Number.isNaN(Date.parse(entry?.sent)) ? null : new Date(entry.sent).toISOString());
|
|
1180
1283
|
if (!at || Date.parse(at) < from) continue;
|
|
1181
|
-
if (fields.worker && !
|
|
1284
|
+
if (!fields.worker && !fields.zone && !fields.hostname && fields.zoneId)
|
|
1285
|
+
fields.zone = await zoneName(fields.zoneId);
|
|
1286
|
+
const place = alertPlace(fields, resources);
|
|
1287
|
+
if (!place) continue;
|
|
1182
1288
|
signals.push({
|
|
1183
1289
|
source: 'cloudflare',
|
|
1184
1290
|
environment: ctx.environment,
|
|
1185
|
-
resource:
|
|
1291
|
+
resource: place.resource,
|
|
1186
1292
|
kind: 'alert',
|
|
1187
1293
|
level: 'warning',
|
|
1188
1294
|
value: null,
|
|
1189
1295
|
at,
|
|
1190
|
-
text: alertText(fields.alert ?? entry?.name ?? entry?.alert_type,
|
|
1296
|
+
text: alertText(fields.alert ?? entry?.name ?? entry?.alert_type, place.on),
|
|
1297
|
+
...(place.account ? { account: true } : {}),
|
|
1191
1298
|
});
|
|
1192
1299
|
}
|
|
1193
1300
|
return signals.sort((x, y) => Date.parse(x.at) - Date.parse(y.at));
|
package/src/infra-provider.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -121,6 +123,8 @@ export const COST_NOTE_MAX = 500;
|
|
|
121
123
|
* @property {number | null} value
|
|
122
124
|
* @property {string} at ISO 8601
|
|
123
125
|
* @property {string} text short, at most SIGNAL_TEXT_MAX characters
|
|
126
|
+
* @property {boolean} [account] true for an alert about the provider's account or platform as a whole, not this
|
|
127
|
+
* environment (BRK-255): its resource is null, and the board keeps one per provider, not one per environment
|
|
124
128
|
*/
|
|
125
129
|
|
|
126
130
|
/**
|
|
@@ -139,6 +143,8 @@ export const COST_NOTE_MAX = 500;
|
|
|
139
143
|
* @property {typeof fetch} [fetch] the fetch to call the platform with; tests pass a mock
|
|
140
144
|
* @property {Resource[]} [resources] what `discover` just found in the environment's scope: the store passes it to
|
|
141
145
|
* `observe` and `cost` so they needn't discover again
|
|
146
|
+
* @property {Set<string>} [reached] the store's collector: the provider adds a permission's name when a call answers
|
|
147
|
+
* with it, so Connections puts back one an earlier refusal struck (BRK-254)
|
|
142
148
|
*/
|
|
143
149
|
|
|
144
150
|
/**
|
|
@@ -350,6 +356,8 @@ export function checkDiscovery(provider, discovery) {
|
|
|
350
356
|
}
|
|
351
357
|
if (discovery.missing !== undefined && (!Array.isArray(discovery.missing) || !discovery.missing.every(text)))
|
|
352
358
|
fail(what, 'missing is not a list of permission names');
|
|
359
|
+
if (discovery.skipped !== undefined && (!Array.isArray(discovery.skipped) || !discovery.skipped.every(text)))
|
|
360
|
+
fail(what, 'skipped is not a list of sentences');
|
|
353
361
|
return discovery;
|
|
354
362
|
}
|
|
355
363
|
|
|
@@ -489,6 +497,10 @@ export function checkSignals(provider, ctx, since, signals) {
|
|
|
489
497
|
if (s.source !== provider.id) fail(what, `a signal is from ${s.source}, not ${provider.id}`);
|
|
490
498
|
if (s.environment !== ctx.environment) fail(what, `a signal is for ${s.environment}, not ${ctx.environment}`);
|
|
491
499
|
if (s.resource !== null && !text(s.resource)) fail(what, 'a signal has a resource that is neither an id nor null');
|
|
500
|
+
if (s.account !== undefined && typeof s.account !== 'boolean')
|
|
501
|
+
fail(what, 'a signal has an account that isn’t true or false');
|
|
502
|
+
if (s.account && (s.resource !== null || s.kind !== 'alert'))
|
|
503
|
+
fail(what, 'an account signal is an alert on no resource');
|
|
492
504
|
if (!SIGNAL_KINDS.includes(s.kind)) fail(what, `a signal has unknown kind "${s.kind}"`);
|
|
493
505
|
if (!SIGNAL_LEVELS.includes(s.level)) fail(what, `a signal has unknown level "${s.level}"`);
|
|
494
506
|
if (s.value !== null && (typeof s.value !== 'number' || !Number.isFinite(s.value)))
|