@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.
@@ -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[]>;