@worker-protocol/conformance 0.1.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.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/dist/attribution.d.ts +13 -0
- package/dist/attribution.js +27 -0
- package/dist/checks/actions.d.ts +24 -0
- package/dist/checks/actions.js +345 -0
- package/dist/checks/activity.d.ts +18 -0
- package/dist/checks/activity.js +64 -0
- package/dist/checks/alerts.d.ts +16 -0
- package/dist/checks/alerts.js +84 -0
- package/dist/checks/arranged.d.ts +27 -0
- package/dist/checks/arranged.js +232 -0
- package/dist/checks/descriptor.d.ts +34 -0
- package/dist/checks/descriptor.js +179 -0
- package/dist/checks/endpoints.d.ts +17 -0
- package/dist/checks/endpoints.js +139 -0
- package/dist/checks/events.d.ts +16 -0
- package/dist/checks/events.js +58 -0
- package/dist/checks/health.d.ts +13 -0
- package/dist/checks/health.js +85 -0
- package/dist/checks/metrics.d.ts +14 -0
- package/dist/checks/metrics.js +422 -0
- package/dist/checks/nudges.d.ts +20 -0
- package/dist/checks/nudges.js +77 -0
- package/dist/checks/surfaces.d.ts +14 -0
- package/dist/checks/surfaces.js +249 -0
- package/dist/checks/tasks.d.ts +23 -0
- package/dist/checks/tasks.js +171 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +120 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +128 -0
- package/dist/report.d.ts +87 -0
- package/dist/report.js +71 -0
- package/dist/transcript.d.ts +44 -0
- package/dist/transcript.js +49 -0
- package/package.json +51 -0
- package/rules.json +1242 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { metricPage, metricsEntry } from "@worker-protocol/schemas";
|
|
2
|
+
import { ruleFor } from "../attribution.js";
|
|
3
|
+
import { verdicts } from "../report.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `metrics` Capability.
|
|
6
|
+
*
|
|
7
|
+
* The most checkable file in the specification — nineteen of its twenty rules have a witness a
|
|
8
|
+
* tool holding one ordinary credential can reach — and the one that needs a Worker with numbers in
|
|
9
|
+
* it. Several checks below report `notExercised` against a Worker that has accumulated nothing,
|
|
10
|
+
* which is not a weaker verdict than `passes`: it says a check exists and this run did not reach
|
|
11
|
+
* it, which is a gap somebody can close by pointing the tool at a Worker that has been running.
|
|
12
|
+
*/
|
|
13
|
+
export const CLAIMS = [
|
|
14
|
+
"MET-1",
|
|
15
|
+
"MET-21",
|
|
16
|
+
"MET-3",
|
|
17
|
+
"MET-4",
|
|
18
|
+
"MET-5",
|
|
19
|
+
"MET-6",
|
|
20
|
+
"MET-7",
|
|
21
|
+
"MET-8",
|
|
22
|
+
"MET-9",
|
|
23
|
+
"MET-10",
|
|
24
|
+
"MET-11",
|
|
25
|
+
"MET-12",
|
|
26
|
+
"MET-13",
|
|
27
|
+
"MET-14",
|
|
28
|
+
"MET-16",
|
|
29
|
+
"MET-17",
|
|
30
|
+
"MET-18",
|
|
31
|
+
"MET-19",
|
|
32
|
+
"MET-20",
|
|
33
|
+
"NAME-1",
|
|
34
|
+
// Judged here because this check already holds a valid page. A generic probe cannot construct a
|
|
35
|
+
// read for an arbitrary surface — which parameters a surface requires is each Capability file's
|
|
36
|
+
// to say — and a metric read is the collection that needs the most of them. Tasks and Alerts are
|
|
37
|
+
// collections too and answer a bare GET, so `checks/surfaces.ts` reaches those; this is the one
|
|
38
|
+
// that would otherwise go unjudged.
|
|
39
|
+
"ENDP-20",
|
|
40
|
+
"ENDP-23",
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* MET-5: the parameters this protocol defines on a read, which a dimension may not be named after.
|
|
44
|
+
*
|
|
45
|
+
* The list is here rather than generated because metrics.md states it in prose and nothing in
|
|
46
|
+
* `schemas/` can: the rule exists precisely because expressing *not one of these names* in a JSON
|
|
47
|
+
* Schema pattern needs a negative lookahead that RE2-backed validators refuse.
|
|
48
|
+
*
|
|
49
|
+
* `dimensions` joined the list when `openapi/` gained a parameter of that name to carry the
|
|
50
|
+
* dimension filters — which is MET-5's own warning arriving: *the list will grow, and when it does
|
|
51
|
+
* it may strand a Worker that declared the name first.* Nothing declares it today, and this is
|
|
52
|
+
* where a Worker that did would be told.
|
|
53
|
+
*/
|
|
54
|
+
const OWN_PARAMETERS = ["metric", "granularity", "from", "to", "by", "cursor", "dimensions"];
|
|
55
|
+
/** The wall-clock reading of an instant in a zone, for judging where a boundary was cut. */
|
|
56
|
+
function localParts(iso, zone) {
|
|
57
|
+
const format = new Intl.DateTimeFormat("en-CA", {
|
|
58
|
+
timeZone: zone,
|
|
59
|
+
hour12: false,
|
|
60
|
+
year: "numeric",
|
|
61
|
+
month: "2-digit",
|
|
62
|
+
day: "2-digit",
|
|
63
|
+
hour: "2-digit",
|
|
64
|
+
minute: "2-digit",
|
|
65
|
+
second: "2-digit",
|
|
66
|
+
weekday: "short",
|
|
67
|
+
});
|
|
68
|
+
const parts = {};
|
|
69
|
+
for (const part of format.formatToParts(new Date(iso)))
|
|
70
|
+
parts[part.type] = part.value;
|
|
71
|
+
// `en-CA` renders midnight as `24` rather than `00` in some ICU versions, which is the same
|
|
72
|
+
// instant under another name and would otherwise read as a boundary cut in the wrong place.
|
|
73
|
+
if (parts.hour === "24")
|
|
74
|
+
parts.hour = "00";
|
|
75
|
+
return parts;
|
|
76
|
+
}
|
|
77
|
+
export async function checkMetrics(entry, url, rules, attribution, transcript) {
|
|
78
|
+
const { results, say, allExcept } = verdicts(rules, CLAIMS);
|
|
79
|
+
if (entry === undefined) {
|
|
80
|
+
allExcept("notExercised", "the Worker declares no `metrics`");
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
// MET-1 through MET-6 are read off the Descriptor, and a verifier fails the Worker on them
|
|
84
|
+
// without calling anything. The rule each failure belongs to is read off `schemas/`.
|
|
85
|
+
const declared = metricsEntry.safeParse(entry);
|
|
86
|
+
if (!declared.success) {
|
|
87
|
+
const blamed = new Set();
|
|
88
|
+
for (const issue of declared.error.issues) {
|
|
89
|
+
const id = ruleFor(attribution, "metrics-entry", issue.path) ?? "MET-1";
|
|
90
|
+
if (blamed.has(id))
|
|
91
|
+
continue;
|
|
92
|
+
blamed.add(id);
|
|
93
|
+
say(id, "fails", `${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
94
|
+
}
|
|
95
|
+
allExcept("notExercised", "the `metrics` entry did not validate", [...blamed]);
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
const { timeZone, publishes } = declared.data;
|
|
99
|
+
for (const id of ["MET-1", "MET-21", "MET-3", "MET-4", "MET-6"])
|
|
100
|
+
say(id, "passes");
|
|
101
|
+
// MET-5: a dimension named after a parameter this protocol defines on a read would be
|
|
102
|
+
// unreachable — the Worker could never tell the filter from the parameter.
|
|
103
|
+
const collisions = [];
|
|
104
|
+
for (const [metric, declaration] of Object.entries(publishes)) {
|
|
105
|
+
for (const dimension of Object.keys(declaration.dimensions)) {
|
|
106
|
+
if (OWN_PARAMETERS.includes(dimension))
|
|
107
|
+
collisions.push(`${metric}.${dimension}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (collisions.length > 0) {
|
|
111
|
+
say("MET-5", "fails", `${collisions.join(", ")}: named after a parameter of a read`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
say("MET-5", "passes");
|
|
115
|
+
}
|
|
116
|
+
if (url === null) {
|
|
117
|
+
allExcept("notExercised", "the declared address did not resolve", [
|
|
118
|
+
"MET-1",
|
|
119
|
+
"MET-21",
|
|
120
|
+
"MET-3",
|
|
121
|
+
"MET-4",
|
|
122
|
+
"MET-5",
|
|
123
|
+
"MET-6",
|
|
124
|
+
]);
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
const readUrl = (parameters) => {
|
|
128
|
+
const target = new URL(url);
|
|
129
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
130
|
+
for (const one of Array.isArray(value) ? value : [value])
|
|
131
|
+
target.searchParams.append(key, one);
|
|
132
|
+
}
|
|
133
|
+
return target.toString();
|
|
134
|
+
};
|
|
135
|
+
// The metric with the most to say: the most granularities, then the most dimensions. A Worker
|
|
136
|
+
// that declares one metric with one granularity and no dimension is checked on less, and the
|
|
137
|
+
// report says which rules that left unexercised rather than passing them.
|
|
138
|
+
const [name, declaration] = Object.entries(publishes).sort(([, a], [, b]) => b.granularities.length - a.granularities.length ||
|
|
139
|
+
Object.keys(b.dimensions).length - Object.keys(a.dimensions).length)[0] ?? [];
|
|
140
|
+
if (name === undefined || declaration === undefined) {
|
|
141
|
+
allExcept("notExercised", "the entry declares no metric to read", [
|
|
142
|
+
"MET-1",
|
|
143
|
+
"MET-21",
|
|
144
|
+
"MET-3",
|
|
145
|
+
"MET-4",
|
|
146
|
+
"MET-5",
|
|
147
|
+
"MET-6",
|
|
148
|
+
]);
|
|
149
|
+
return results;
|
|
150
|
+
}
|
|
151
|
+
const granularity = declaration.granularities.includes("day")
|
|
152
|
+
? "day"
|
|
153
|
+
: declaration.granularities[0];
|
|
154
|
+
// MET-9: a metric the entry does not declare is 404, with the code `not_found`. A name no
|
|
155
|
+
// Worker would declare is the only way to ask without a Worker having to cooperate.
|
|
156
|
+
const absent = await transcript.send(readUrl({ metric: "no-such-metric-a4f1c7", granularity }), "a metric the entry does not declare", { permanent: true });
|
|
157
|
+
const absentCode = absent.json?.code;
|
|
158
|
+
if (absent.status === 404 && absentCode === "not_found")
|
|
159
|
+
say("MET-9", "passes");
|
|
160
|
+
else
|
|
161
|
+
say("MET-9", "fails", `answered ${absent.status} with \`${absentCode ?? "no code"}\``);
|
|
162
|
+
// MET-10: a granularity the metric does not declare is 400 with `invalid_parameter`, and so is
|
|
163
|
+
// an omitted one where the metric declares more than one — which is also MET-8's condition.
|
|
164
|
+
const wrongGrain = await transcript.send(readUrl({ metric: name, granularity: "fortnight" }), "a granularity the metric does not declare", { permanent: true });
|
|
165
|
+
const wrongCode = wrongGrain.json?.code;
|
|
166
|
+
if (wrongGrain.status === 400 && wrongCode === "invalid_parameter")
|
|
167
|
+
say("MET-10", "passes");
|
|
168
|
+
else
|
|
169
|
+
say("MET-10", "fails", `answered ${wrongGrain.status} with \`${wrongCode ?? "no code"}\``);
|
|
170
|
+
if (declaration.granularities.length > 1) {
|
|
171
|
+
const omitted = await transcript.send(readUrl({ metric: name }), "a read with no granularity, where the metric declares more than one");
|
|
172
|
+
if (omitted.status === 400)
|
|
173
|
+
say("MET-8", "passes");
|
|
174
|
+
else
|
|
175
|
+
say("MET-8", "fails", `answered ${omitted.status} rather than refusing`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const omitted = await transcript.send(readUrl({ metric: name }), "a read with no granularity, where the metric declares exactly one");
|
|
179
|
+
if (omitted.status === 200)
|
|
180
|
+
say("MET-8", "passes");
|
|
181
|
+
else
|
|
182
|
+
say("MET-8", "fails", `refused an omitted granularity with ${omitted.status}`);
|
|
183
|
+
}
|
|
184
|
+
// A window wide enough to hold something, and explicit so that two reads can be compared.
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const span = 30 * 24 * 3_600_000;
|
|
187
|
+
const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
188
|
+
const whole = await transcript.send(readUrl({ metric: name, granularity, from: iso(now - span), to: iso(now + 3_600_000) }), "the metric over a month");
|
|
189
|
+
const page = metricPage.safeParse(whole.json);
|
|
190
|
+
if (whole.status !== 200 || !page.success) {
|
|
191
|
+
const why = whole.status !== 200
|
|
192
|
+
? `the read answered ${whole.status}`
|
|
193
|
+
: `the answer did not validate: ${page.success ? "" : page.error.issues[0]?.message}`;
|
|
194
|
+
allExcept("notExercised", why, [
|
|
195
|
+
"MET-1",
|
|
196
|
+
"MET-21",
|
|
197
|
+
"MET-3",
|
|
198
|
+
"MET-4",
|
|
199
|
+
"MET-5",
|
|
200
|
+
"MET-6",
|
|
201
|
+
"MET-8",
|
|
202
|
+
"MET-9",
|
|
203
|
+
"MET-10",
|
|
204
|
+
]);
|
|
205
|
+
if (whole.status === 200)
|
|
206
|
+
say("MET-13", "fails", why);
|
|
207
|
+
return results;
|
|
208
|
+
}
|
|
209
|
+
const buckets = page.data.items;
|
|
210
|
+
say("MET-13", "passes");
|
|
211
|
+
// ENDP-20: every surface that answers a list answers it in the shared page envelope. `metricPage`
|
|
212
|
+
// narrows that envelope's items, which is the narrowing ENDP-20 says each surface performs, so
|
|
213
|
+
// the page having validated above is the witness.
|
|
214
|
+
say("ENDP-20", "passes");
|
|
215
|
+
// ENDP-23: a collection declares an order and holds it, so that paging terminates. The witness
|
|
216
|
+
// is an unchanged read answering the same items in the same order — a collection whose order
|
|
217
|
+
// moves between two reads has none for a cursor to resume from.
|
|
218
|
+
const repeated = await transcript.send(readUrl({ metric: name, granularity, from: iso(now - span), to: iso(now + 3_600_000) }), "the same read again, to see whether the order holds");
|
|
219
|
+
const second = metricPage.safeParse(repeated.json);
|
|
220
|
+
if (!second.success) {
|
|
221
|
+
say("ENDP-23", "notExercised", "the repeated read did not validate");
|
|
222
|
+
}
|
|
223
|
+
else if (JSON.stringify(second.data.items) !== JSON.stringify(buckets)) {
|
|
224
|
+
say("ENDP-23", "fails", "an unchanged read answered its buckets in a different order");
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
say("ENDP-23", "passes");
|
|
228
|
+
}
|
|
229
|
+
if (buckets.length === 0) {
|
|
230
|
+
allExcept("notExercised", "the Worker has accumulated nothing over the last month", [
|
|
231
|
+
"MET-1",
|
|
232
|
+
"MET-21",
|
|
233
|
+
"MET-3",
|
|
234
|
+
"MET-4",
|
|
235
|
+
"MET-5",
|
|
236
|
+
"MET-6",
|
|
237
|
+
"MET-8",
|
|
238
|
+
"MET-9",
|
|
239
|
+
"MET-10",
|
|
240
|
+
"MET-13",
|
|
241
|
+
]);
|
|
242
|
+
return results;
|
|
243
|
+
}
|
|
244
|
+
// MET-14: buckets ascend by start.
|
|
245
|
+
const starts = buckets.map((b) => Date.parse(b.start));
|
|
246
|
+
const ascending = starts.every((at, i) => i === 0 || at >= starts[i - 1]);
|
|
247
|
+
if (ascending)
|
|
248
|
+
say("MET-14", "passes");
|
|
249
|
+
else
|
|
250
|
+
say("MET-14", "fails", "buckets are not ascending by start");
|
|
251
|
+
// MET-20, and MET-7 for a week. Every boundary is cut in the zone the entry declares — so the
|
|
252
|
+
// wall-clock reading of a bucket's start in that zone is midnight, or the top of an hour.
|
|
253
|
+
const misaligned = buckets.filter((bucket) => {
|
|
254
|
+
const at = localParts(bucket.start, timeZone);
|
|
255
|
+
if (granularity === "hour")
|
|
256
|
+
return at.minute !== "00" || at.second !== "00";
|
|
257
|
+
return at.hour !== "00" || at.minute !== "00" || at.second !== "00";
|
|
258
|
+
});
|
|
259
|
+
if (misaligned.length === 0) {
|
|
260
|
+
say("MET-20", "passes");
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const at = localParts(misaligned[0].start, timeZone);
|
|
264
|
+
say("MET-20", "fails", `${misaligned[0].start} is ${at.hour}:${at.minute} in ${timeZone}`);
|
|
265
|
+
}
|
|
266
|
+
if (granularity === "week") {
|
|
267
|
+
const notMonday = buckets.filter((b) => localParts(b.start, timeZone).weekday !== "Mon");
|
|
268
|
+
if (notMonday.length === 0)
|
|
269
|
+
say("MET-7", "passes");
|
|
270
|
+
else
|
|
271
|
+
say("MET-7", "fails", `${notMonday[0].start} does not begin a Monday in ${timeZone}`);
|
|
272
|
+
}
|
|
273
|
+
else if (declaration.granularities.includes("week")) {
|
|
274
|
+
const weekly = await transcript.send(readUrl({ metric: name, granularity: "week", from: iso(now - span), to: iso(now) }), "the metric by week");
|
|
275
|
+
const weeks = metricPage.safeParse(weekly.json);
|
|
276
|
+
const notMonday = weeks.success
|
|
277
|
+
? weeks.data.items.filter((b) => localParts(b.start, timeZone).weekday !== "Mon")
|
|
278
|
+
: [];
|
|
279
|
+
if (!weeks.success)
|
|
280
|
+
say("MET-7", "notExercised", "the weekly read did not validate");
|
|
281
|
+
else if (weeks.data.items.length === 0)
|
|
282
|
+
say("MET-7", "notExercised", "no week accumulated");
|
|
283
|
+
else if (notMonday.length === 0)
|
|
284
|
+
say("MET-7", "passes");
|
|
285
|
+
else
|
|
286
|
+
say("MET-7", "fails", `${notMonday[0].start} does not begin a Monday in ${timeZone}`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
say("MET-7", "notExercised", "the metric declares no `week` granularity");
|
|
290
|
+
}
|
|
291
|
+
// MET-12: an interval that begins inside a bucket never cuts one. Asking from the midpoint of
|
|
292
|
+
// the first whole bucket must not return a bucket whose start is before that midpoint.
|
|
293
|
+
const first = buckets[0];
|
|
294
|
+
const midpoint = Date.parse(first.start) + (Date.parse(first.end) - Date.parse(first.start)) / 2;
|
|
295
|
+
const fromMid = await transcript.send(readUrl({ metric: name, granularity, from: iso(midpoint), to: iso(now + 3_600_000) }), "an interval beginning inside a bucket");
|
|
296
|
+
const mid = metricPage.safeParse(fromMid.json);
|
|
297
|
+
if (!mid.success) {
|
|
298
|
+
say("MET-12", "notExercised", "the read from a midpoint did not validate");
|
|
299
|
+
say("MET-11", "notExercised", "the read from a midpoint did not validate");
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const cut = mid.data.items.filter((b) => Date.parse(b.start) < midpoint);
|
|
303
|
+
if (cut.length === 0)
|
|
304
|
+
say("MET-12", "passes");
|
|
305
|
+
else
|
|
306
|
+
say("MET-12", "fails", `${cut[0].start} begins before the interval did`);
|
|
307
|
+
// MET-11: the interval is half-open, so a bucket starting exactly at `to` is excluded and the
|
|
308
|
+
// two halves of a split interval share a boundary without sharing a bucket.
|
|
309
|
+
const boundary = buckets[Math.floor(buckets.length / 2)];
|
|
310
|
+
const before = await transcript.send(readUrl({ metric: name, granularity, from: iso(now - span), to: boundary.start }), "the interval up to a bucket's own start");
|
|
311
|
+
const head = metricPage.safeParse(before.json);
|
|
312
|
+
if (!head.success) {
|
|
313
|
+
say("MET-11", "notExercised", "the half-open read did not validate");
|
|
314
|
+
}
|
|
315
|
+
else if (head.data.items.some((b) => b.start === boundary.start)) {
|
|
316
|
+
say("MET-11", "fails", `\`to\` of ${boundary.start} returned the bucket starting there`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
say("MET-11", "passes");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const closed = Object.entries(declaration.dimensions).find(([, d]) => d.values !== undefined);
|
|
323
|
+
const free = Object.entries(declaration.dimensions).find(([, d]) => d.values === undefined);
|
|
324
|
+
// MET-17: a value outside a declared set is 400 with `invalid_parameter`.
|
|
325
|
+
if (closed) {
|
|
326
|
+
const [dimension] = closed;
|
|
327
|
+
const bad = await transcript.send(readUrl({ metric: name, granularity, [dimension]: "no-such-value-9c2e" }), "a dimension value outside the declared set", { permanent: true });
|
|
328
|
+
const code = bad.json?.code;
|
|
329
|
+
if (bad.status === 400 && code === "invalid_parameter")
|
|
330
|
+
say("MET-17", "passes");
|
|
331
|
+
else
|
|
332
|
+
say("MET-17", "fails", `answered ${bad.status} with \`${code ?? "no code"}\``);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
say("MET-17", "notExercised", "no dimension declares a closed set of values");
|
|
336
|
+
}
|
|
337
|
+
// NAME-1: two names are the same name when their bytes are identical — no case folding, no
|
|
338
|
+
// normalisation, no trimming. Its witness is any declared name carrying an uppercase letter,
|
|
339
|
+
// sent back folded where the Worker matches names: a dimension whose parameter is `taskType`
|
|
340
|
+
// must not answer to `tasktype`. A Worker that folds passes every test anybody writes until the
|
|
341
|
+
// day somebody declares a name with a capital in it, which is exactly why this is worth a check.
|
|
342
|
+
const foldable = Object.keys(declaration.dimensions).find((d) => d !== d.toLowerCase());
|
|
343
|
+
if (foldable === undefined) {
|
|
344
|
+
say("NAME-1", "notExercised", "no declared dimension name carries an uppercase letter");
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
const folded = await transcript.send(readUrl({ metric: name, granularity, [foldable.toLowerCase()]: "anything" }), "a declared dimension name, folded to lowercase", { permanent: true });
|
|
348
|
+
const code = folded.json?.code;
|
|
349
|
+
if (folded.status === 400 && code === "unknown_filter") {
|
|
350
|
+
say("NAME-1", "passes");
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
say("NAME-1", "fails", `\`${foldable.toLowerCase()}\` was matched against \`${foldable}\``);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// MET-16 and MET-18: a dimension is fixed with a parameter of its own name, and one a read
|
|
357
|
+
// neither fixes nor groups by is accumulated over — so on a metric that only accumulates upward
|
|
358
|
+
// the unfiltered total is never smaller than any slice of it.
|
|
359
|
+
const total = buckets.reduce((sum, b) => sum + (b.value ?? 0), 0);
|
|
360
|
+
if (closed && declaration.additive) {
|
|
361
|
+
const [dimension, { values }] = closed;
|
|
362
|
+
const slice = await transcript.send(readUrl({
|
|
363
|
+
metric: name,
|
|
364
|
+
granularity,
|
|
365
|
+
from: iso(now - span),
|
|
366
|
+
to: iso(now + 3_600_000),
|
|
367
|
+
[dimension]: values[0],
|
|
368
|
+
}), "the metric fixed to one value of a dimension");
|
|
369
|
+
const sliced = metricPage.safeParse(slice.json);
|
|
370
|
+
if (slice.status !== 200 || !sliced.success) {
|
|
371
|
+
say("MET-16", "fails", `fixing \`${dimension}\` answered ${slice.status}`);
|
|
372
|
+
say("MET-18", "notExercised", "the filtered read did not answer");
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
say("MET-16", "passes");
|
|
376
|
+
const part = sliced.data.items.reduce((sum, b) => sum + (b.value ?? 0), 0);
|
|
377
|
+
if (part <= total)
|
|
378
|
+
say("MET-18", "passes");
|
|
379
|
+
else
|
|
380
|
+
say("MET-18", "fails", `fixing \`${dimension}\` answered ${part} against a total of ${total}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
say("MET-16", "notExercised", "no dimension declares a closed set to filter by");
|
|
385
|
+
say("MET-18", "notExercised", "no additive metric with a closed dimension to compare over");
|
|
386
|
+
}
|
|
387
|
+
// MET-19: a read breaks down with `by`, and only by a dimension that declared its values. Each
|
|
388
|
+
// bucket then carries what it is broken down by, and the same period appears once per
|
|
389
|
+
// combination.
|
|
390
|
+
if (closed) {
|
|
391
|
+
const [dimension] = closed;
|
|
392
|
+
const grouped = await transcript.send(readUrl({
|
|
393
|
+
metric: name,
|
|
394
|
+
granularity,
|
|
395
|
+
from: iso(now - span),
|
|
396
|
+
to: iso(now + 3_600_000),
|
|
397
|
+
by: dimension,
|
|
398
|
+
}), "the metric broken down by a declared dimension");
|
|
399
|
+
const parts = metricPage.safeParse(grouped.json);
|
|
400
|
+
const freeRefused = free
|
|
401
|
+
? await transcript
|
|
402
|
+
.send(readUrl({ metric: name, granularity, by: free[0] }), "a breakdown by a free dimension")
|
|
403
|
+
.then((r) => r.status === 400)
|
|
404
|
+
: true;
|
|
405
|
+
if (grouped.status !== 200 || !parts.success) {
|
|
406
|
+
say("MET-19", "fails", `a breakdown by \`${dimension}\` answered ${grouped.status}`);
|
|
407
|
+
}
|
|
408
|
+
else if (parts.data.items.some((b) => b.dimensions?.[dimension] === undefined)) {
|
|
409
|
+
say("MET-19", "fails", "a broken-down bucket carries no value for the dimension");
|
|
410
|
+
}
|
|
411
|
+
else if (!freeRefused) {
|
|
412
|
+
say("MET-19", "fails", `a breakdown by the free dimension \`${free?.[0]}\` was not refused`);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
say("MET-19", "passes");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
say("MET-19", "notExercised", "no dimension declares a closed set to group by");
|
|
420
|
+
}
|
|
421
|
+
return results;
|
|
422
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Attribution } from "../attribution.ts";
|
|
2
|
+
import { type Result, type Rule } from "../report.ts";
|
|
3
|
+
import type { Transcript } from "../transcript.ts";
|
|
4
|
+
/**
|
|
5
|
+
* The `nudges` Capability: being told there is work of a Task type.
|
|
6
|
+
*
|
|
7
|
+
* The only surface in this protocol whose body is fixed by the protocol rather than by the Worker,
|
|
8
|
+
* which is what a check gets to use: it can write a nudge without reading anything, and both of the
|
|
9
|
+
* answers below are shapes `spec/nudges.md` states rather than shapes this Worker chose.
|
|
10
|
+
*
|
|
11
|
+
* **NDG-3 is asked first and NDG-2 second, and the order is the same one `spec/` argues for.** A
|
|
12
|
+
* nudge for a type the Worker declares no Skill for is refused, so nothing happened; a nudge it
|
|
13
|
+
* accepts sends it to read somebody's Tasks. The second is why the register classes NDG-2 `H` — it
|
|
14
|
+
* needs a Worker whose operators agreed it could be told — and the first is `W` for the reason
|
|
15
|
+
* ACT-6 is: the witness is a refusal.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CLAIMS: readonly ["NDG-1", "NDG-2", "NDG-3"];
|
|
18
|
+
export declare function checkNudges(entry: Record<string, unknown> | undefined, url: string | null,
|
|
19
|
+
/** TASK-31's declaration, read off the Descriptor root: the types this Worker may be nudged for. */
|
|
20
|
+
skills: string[], rules: Map<string, Rule>, attribution: Attribution, transcript: Transcript, mayPerform: boolean): Promise<Result[]>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { nudgesEntry } from "@worker-protocol/schemas";
|
|
2
|
+
import { ruleFor } from "../attribution.js";
|
|
3
|
+
import { verdicts } from "../report.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `nudges` Capability: being told there is work of a Task type.
|
|
6
|
+
*
|
|
7
|
+
* The only surface in this protocol whose body is fixed by the protocol rather than by the Worker,
|
|
8
|
+
* which is what a check gets to use: it can write a nudge without reading anything, and both of the
|
|
9
|
+
* answers below are shapes `spec/nudges.md` states rather than shapes this Worker chose.
|
|
10
|
+
*
|
|
11
|
+
* **NDG-3 is asked first and NDG-2 second, and the order is the same one `spec/` argues for.** A
|
|
12
|
+
* nudge for a type the Worker declares no Skill for is refused, so nothing happened; a nudge it
|
|
13
|
+
* accepts sends it to read somebody's Tasks. The second is why the register classes NDG-2 `H` — it
|
|
14
|
+
* needs a Worker whose operators agreed it could be told — and the first is `W` for the reason
|
|
15
|
+
* ACT-6 is: the witness is a refusal.
|
|
16
|
+
*/
|
|
17
|
+
export const CLAIMS = ["NDG-1", "NDG-2", "NDG-3"];
|
|
18
|
+
/** A type namespaced under a domain nobody owns, so no Worker could honestly declare a Skill for it. */
|
|
19
|
+
const NO_SUCH_TYPE = "tech.rowing.no-such.task-type-4c1f";
|
|
20
|
+
export async function checkNudges(entry, url,
|
|
21
|
+
/** TASK-31's declaration, read off the Descriptor root: the types this Worker may be nudged for. */
|
|
22
|
+
skills, rules, attribution, transcript, mayPerform) {
|
|
23
|
+
const { results, say, allExcept } = verdicts(rules, CLAIMS);
|
|
24
|
+
if (entry === undefined) {
|
|
25
|
+
allExcept("notExercised", "the Worker declares no `nudges`");
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
const declared = nudgesEntry.safeParse(entry);
|
|
29
|
+
if (!declared.success) {
|
|
30
|
+
const issue = declared.error.issues[0];
|
|
31
|
+
const id = ruleFor(attribution, "nudges-entry", issue?.path ?? []) ?? "NDG-1";
|
|
32
|
+
say(id, "fails", `${issue?.path.join(".") || "(root)"}: ${issue?.message}`);
|
|
33
|
+
allExcept("notExercised", "the `nudges` entry did not validate", [id]);
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
36
|
+
say("NDG-1", "passes");
|
|
37
|
+
if (url === null) {
|
|
38
|
+
allExcept("notExercised", "the declared address did not resolve", ["NDG-1"]);
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
41
|
+
if (!mayPerform) {
|
|
42
|
+
allExcept("notExercised", "the verifier was not permitted to POST to this Worker", ["NDG-1"]);
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
const nudge = (type, intent, permanent = false) => transcript.send(url, intent, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
body: JSON.stringify({ type }),
|
|
48
|
+
headers: { "content-type": "application/json" },
|
|
49
|
+
permanent,
|
|
50
|
+
});
|
|
51
|
+
// NDG-3: a Worker that took a nudge for a type it does not answer would be telling an owner it
|
|
52
|
+
// had been told, and the owner would stop nudging whoever could actually help.
|
|
53
|
+
const refused = await nudge(NO_SUCH_TYPE, "a nudge for a Task type no Worker answers", true);
|
|
54
|
+
const code = refused.json?.code;
|
|
55
|
+
if (refused.status === 404 && code === "not_found")
|
|
56
|
+
say("NDG-3", "passes");
|
|
57
|
+
else
|
|
58
|
+
say("NDG-3", "fails", `answered ${refused.status} with \`${code ?? "no code"}\``);
|
|
59
|
+
// NDG-2: `204` and no body, because there is nothing to say. This is the one call here that the
|
|
60
|
+
// Worker acts on, and a type it declares a Skill for is the only one it may be sent for.
|
|
61
|
+
const answerable = skills[0];
|
|
62
|
+
if (answerable === undefined) {
|
|
63
|
+
say("NDG-2", "notExercised", "the Worker declares no Skill, so there is no type to nudge it for");
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
const told = await nudge(answerable, `a nudge for \`${answerable}\`, which it answers`);
|
|
67
|
+
if (told.status !== 204) {
|
|
68
|
+
say("NDG-2", "fails", `answered ${told.status} rather than 204`);
|
|
69
|
+
}
|
|
70
|
+
else if (told.body.length > 0) {
|
|
71
|
+
say("NDG-2", "fails", "answered 204 with a body");
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
say("NDG-2", "passes");
|
|
75
|
+
}
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Result, type Rule } from "../report.ts";
|
|
2
|
+
import type { Transcript } from "../transcript.ts";
|
|
3
|
+
/**
|
|
4
|
+
* What calling each declared address establishes, before any Capability's own file is consulted.
|
|
5
|
+
*
|
|
6
|
+
* DESC-18 is here rather than beside the Descriptor because its witness is a declared address
|
|
7
|
+
* answering `404`, which needs the call the Descriptor check deliberately does not make.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CLAIMS: readonly ["DESC-18", "REG-3", "REG-7", "REG-21", "ENDP-2", "ENDP-6", "ENDP-24"];
|
|
10
|
+
export type Surface = {
|
|
11
|
+
capability: string;
|
|
12
|
+
url: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function callSurfaces(surfaces: Surface[], descriptorUrl: string, writeAddresses: string[], rules: Map<string, Rule>, transcript: Transcript, credential: string | undefined, mayPerform: boolean): Promise<Result[]>;
|