@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,64 @@
1
+ import { activityEntry, activityPage } from "@worker-protocol/schemas";
2
+ import { ruleFor } from "../attribution.js";
3
+ import { verdicts } from "../report.js";
4
+ /**
5
+ * The `activity` Capability.
6
+ *
7
+ * Alerts with the words changed, which is what `spec/activity.md` says it is: one address, one
8
+ * page, items that carry a closed-vocabulary state and a summary addressed to a person. What these
9
+ * checks can reach is exactly what a program can act on — that the page has the shape and that
10
+ * every state is one of the three.
11
+ *
12
+ * ACTV-5 is absent because nothing can reach it. An activity that disappears may have finished,
13
+ * failed, or been dropped, and no tool outside the Worker can tell which — the same shape as ALRT-5
14
+ * and TASK-15. ACTV-6 is `H` and lives in `arranged.ts` with ALRT-6, because two credentials have
15
+ * to exist before two lists can be compared.
16
+ */
17
+ export const CLAIMS = ["ACTV-1", "ACTV-2", "ACTV-3", "ACTV-4"];
18
+ export async function checkActivity(entry, url, rules, attribution, transcript) {
19
+ const { results, say, allExcept } = verdicts(rules, CLAIMS);
20
+ if (entry === undefined) {
21
+ allExcept("notExercised", "the Worker declares no `activity`");
22
+ return results;
23
+ }
24
+ const declared = activityEntry.safeParse(entry);
25
+ if (!declared.success) {
26
+ const issue = declared.error.issues[0];
27
+ const id = ruleFor(attribution, "activity-entry", issue?.path ?? []) ?? "ACTV-1";
28
+ say(id, "fails", `${issue?.path.join(".") || "(root)"}: ${issue?.message}`);
29
+ allExcept("notExercised", "the `activity` entry did not validate", [id]);
30
+ return results;
31
+ }
32
+ say("ACTV-1", "passes");
33
+ if (url === null) {
34
+ allExcept("notExercised", "the declared address did not resolve", ["ACTV-1"]);
35
+ return results;
36
+ }
37
+ const answer = await transcript.send(url, "what the Worker is doing and has undertaken");
38
+ if (answer.status !== 200) {
39
+ say("ACTV-2", "fails", `the address answered ${answer.status}`);
40
+ allExcept("notExercised", "no page of activities was read", ["ACTV-1", "ACTV-2"]);
41
+ return results;
42
+ }
43
+ const page = activityPage.safeParse(answer.json);
44
+ if (!page.success) {
45
+ const issue = page.error.issues[0];
46
+ const id = ruleFor(attribution, "activity-page", issue?.path ?? []) ?? "ACTV-2";
47
+ say(id, "fails", `${issue?.path.join(".") || "(root)"}: ${issue?.message}`);
48
+ allExcept("notExercised", "the page did not validate", ["ACTV-1", id]);
49
+ return results;
50
+ }
51
+ say("ACTV-2", "passes");
52
+ // ACTV-3 and ACTV-4 are what validation established, said per rule because a report naming one
53
+ // of them is the point of having ids. A Worker holding nothing exercises neither, and that is
54
+ // `not exercised` rather than a pass: an empty page is conformant and proves nothing about the
55
+ // shape of an activity.
56
+ if (page.data.items.length === 0) {
57
+ say("ACTV-3", "notExercised", "the Worker holds nothing, so no activity was read");
58
+ say("ACTV-4", "notExercised", "the Worker holds nothing, so no state was read");
59
+ return results;
60
+ }
61
+ say("ACTV-3", "passes");
62
+ say("ACTV-4", "passes");
63
+ return results;
64
+ }
@@ -0,0 +1,16 @@
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 `alerts` Capability.
6
+ *
7
+ * The smallest surface in the protocol after `health`, and the only one whose whole content is
8
+ * addressed to a person: ALRT-3's summary is parsed by nothing. What a program acts on is the
9
+ * severity and the Actions, and both are closed vocabularies a console renders without knowing
10
+ * anything about the Worker — which is what these checks can therefore reach.
11
+ *
12
+ * ALRT-5 is absent because nothing can reach it. An Alert that disappears may have had its
13
+ * condition stop holding, or may have been dismissed by somebody this verifier never saw.
14
+ */
15
+ export declare const CLAIMS: readonly ["ALRT-1", "ALRT-2", "ALRT-3", "ALRT-4", "ALRT-7"];
16
+ export declare function checkAlerts(entry: Record<string, unknown> | undefined, url: string | null, actionNames: string[], rules: Map<string, Rule>, attribution: Attribution, transcript: Transcript): Promise<Result[]>;
@@ -0,0 +1,84 @@
1
+ import { alertPage, alertsEntry } from "@worker-protocol/schemas";
2
+ import { ruleFor } from "../attribution.js";
3
+ import { verdicts } from "../report.js";
4
+ /**
5
+ * The `alerts` Capability.
6
+ *
7
+ * The smallest surface in the protocol after `health`, and the only one whose whole content is
8
+ * addressed to a person: ALRT-3's summary is parsed by nothing. What a program acts on is the
9
+ * severity and the Actions, and both are closed vocabularies a console renders without knowing
10
+ * anything about the Worker — which is what these checks can therefore reach.
11
+ *
12
+ * ALRT-5 is absent because nothing can reach it. An Alert that disappears may have had its
13
+ * condition stop holding, or may have been dismissed by somebody this verifier never saw.
14
+ */
15
+ export const CLAIMS = ["ALRT-1", "ALRT-2", "ALRT-3", "ALRT-4", "ALRT-7"];
16
+ export async function checkAlerts(entry, url, actionNames, rules, attribution, transcript) {
17
+ const { results, say, allExcept } = verdicts(rules, CLAIMS);
18
+ if (entry === undefined) {
19
+ allExcept("notExercised", "the Worker declares no `alerts`");
20
+ return results;
21
+ }
22
+ const declared = alertsEntry.safeParse(entry);
23
+ if (!declared.success) {
24
+ const issue = declared.error.issues[0];
25
+ const id = ruleFor(attribution, "alerts-entry", issue?.path ?? []) ?? "ALRT-1";
26
+ say(id, "fails", `${issue?.path.join(".") || "(root)"}: ${issue?.message}`);
27
+ allExcept("notExercised", "the `alerts` entry did not validate", [id]);
28
+ return results;
29
+ }
30
+ say("ALRT-1", "passes");
31
+ if (url === null) {
32
+ allExcept("notExercised", "the declared address did not resolve", ["ALRT-1"]);
33
+ return results;
34
+ }
35
+ const answer = await transcript.send(url, "the Alerts whose conditions hold");
36
+ if (answer.status !== 200) {
37
+ say("ALRT-2", "fails", `the address answered ${answer.status}`);
38
+ allExcept("notExercised", "no page of Alerts was read", ["ALRT-1", "ALRT-2"]);
39
+ return results;
40
+ }
41
+ const page = alertPage.safeParse(answer.json);
42
+ if (!page.success) {
43
+ const issue = page.error.issues[0];
44
+ const id = ruleFor(attribution, "alert-page", issue?.path ?? []) ?? "ALRT-2";
45
+ say(id, "fails", `${issue?.path.join(".") || "(root)"}: ${issue?.message}`);
46
+ allExcept("notExercised", "the page did not validate", ["ALRT-1", id]);
47
+ return results;
48
+ }
49
+ say("ALRT-2", "passes");
50
+ // ALRT-3 and ALRT-4 are what validation established, said per rule because a report naming one
51
+ // of them is the point of having ids. A Worker with nothing to report exercises neither, and
52
+ // that is `not exercised` rather than a pass — an empty page is conformant and proves nothing
53
+ // about the shape of an Alert.
54
+ if (page.data.items.length === 0) {
55
+ say("ALRT-3", "notExercised", "no condition is holding, so no Alert was read");
56
+ say("ALRT-4", "notExercised", "no condition is holding, so no severity was read");
57
+ say("ALRT-7", "notExercised", "no condition is holding, so no Action was offered");
58
+ return results;
59
+ }
60
+ say("ALRT-3", "passes");
61
+ say("ALRT-4", "passes");
62
+ // ALRT-7: every Action an Alert offers is one this Worker's own `actions` entry accepts. An
63
+ // agreement between two entries, which is the part no schema reaches — and a Descriptor
64
+ // disagreeing with itself is DESC-18 one level down.
65
+ const accepted = new Set(actionNames);
66
+ const dangling = [];
67
+ for (const alert of page.data.items) {
68
+ for (const action of alert.actions) {
69
+ if (!accepted.has(action))
70
+ dangling.push(`${alert.id} offers \`${action}\``);
71
+ }
72
+ }
73
+ const offered = page.data.items.some((alert) => alert.actions.length > 0);
74
+ if (!offered) {
75
+ say("ALRT-7", "notExercised", "no Alert offers an Action");
76
+ }
77
+ else if (dangling.length === 0) {
78
+ say("ALRT-7", "passes");
79
+ }
80
+ else {
81
+ say("ALRT-7", "fails", `${dangling.join("; ")}, which its \`actions\` entry does not accept`);
82
+ }
83
+ return results;
84
+ }
@@ -0,0 +1,27 @@
1
+ import type { Arrangement } from "../index.ts";
2
+ import { type Result, type Rule } from "../report.ts";
3
+ import type { Transcript } from "../transcript.ts";
4
+ /**
5
+ * The rules that need something no Worker has by accident.
6
+ *
7
+ * A second credential, a boot window, settings its operators will let go of, an event it already
8
+ * published. `conformance/verifiability.md` classes these `H` because nothing a tool can do to an
9
+ * unarranged Worker will ever see a violation — and the arrangement cannot come from the protocol,
10
+ * because test scaffolding in a Descriptor would be carried by every Worker in the network to
11
+ * serve a tool most of them will never meet.
12
+ *
13
+ * Every one of them reports what was missing rather than a verdict, which is a gap somebody can
14
+ * close and not a claim about the Worker.
15
+ */
16
+ export declare const CLAIMS: readonly ["REG-8", "REG-28", "REG-32", "ALRT-6", "ACTV-6", "TASK-6", "HLTH-4", "ACT-14", "EVT-1"];
17
+ type Surfaces = {
18
+ descriptorUrl: string | null;
19
+ alertsUrl: string | null;
20
+ activityUrl: string | null;
21
+ tasksUrl: string | null;
22
+ settingsUrl: string | null;
23
+ workerId: string | null;
24
+ eventTypes: string[];
25
+ };
26
+ export declare function checkArranged(surfaces: Surfaces, arrangement: Arrangement, mayPerform: boolean, rules: Map<string, Rule>, transcript: Transcript): Promise<Result[]>;
27
+ export {};
@@ -0,0 +1,232 @@
1
+ import { health as healthSchema, taskPage } from "@worker-protocol/schemas";
2
+ import { verdicts } from "../report.js";
3
+ /**
4
+ * The rules that need something no Worker has by accident.
5
+ *
6
+ * A second credential, a boot window, settings its operators will let go of, an event it already
7
+ * published. `conformance/verifiability.md` classes these `H` because nothing a tool can do to an
8
+ * unarranged Worker will ever see a violation — and the arrangement cannot come from the protocol,
9
+ * because test scaffolding in a Descriptor would be carried by every Worker in the network to
10
+ * serve a tool most of them will never meet.
11
+ *
12
+ * Every one of them reports what was missing rather than a verdict, which is a gap somebody can
13
+ * close and not a claim about the Worker.
14
+ */
15
+ export const CLAIMS = [
16
+ "REG-8",
17
+ "REG-28",
18
+ "REG-32",
19
+ "ALRT-6",
20
+ "ACTV-6",
21
+ "TASK-6",
22
+ "HLTH-4",
23
+ "ACT-14",
24
+ "EVT-1",
25
+ ];
26
+ /**
27
+ * The surfaces where two credentials must be served one list, and the rule each one is.
28
+ *
29
+ * A table rather than a block each, because the rule is mechanical: any read-only surface whose
30
+ * audience is whoever operates the Worker gets one, and the two that exist derive from each other
31
+ * by substitution. TASK-6 is deliberately not here — the comment below says why it reads the
32
+ * other way.
33
+ */
34
+ const SAME_TO_BOTH = [
35
+ { rule: "ALRT-6", url: "alertsUrl", capability: "alerts", one: "the Alerts", many: "Alerts" },
36
+ {
37
+ rule: "ACTV-6",
38
+ url: "activityUrl",
39
+ capability: "activity",
40
+ one: "the activity",
41
+ many: "activities",
42
+ },
43
+ ];
44
+ export async function checkArranged(surfaces, arrangement, mayPerform, rules, transcript) {
45
+ const { results, say } = verdicts(rules, CLAIMS);
46
+ const asSecond = (url, intent) => transcript.send(url, intent, {
47
+ headers: { authorization: `Bearer ${arrangement.secondCredential}` },
48
+ });
49
+ // REG-28 (recommended), REG-8, ALRT-6 and ACTV-6 all need a second credential, and it is one
50
+ // arrangement because it is one thing an operator issues.
51
+ const second = arrangement.secondCredential;
52
+ if (second === undefined || surfaces.descriptorUrl === null) {
53
+ for (const id of ["REG-28", "REG-8", ...SAME_TO_BOTH.map((one) => one.rule)]) {
54
+ say(id, "notExercised", "no second credential was given to the verifier");
55
+ }
56
+ }
57
+ else {
58
+ // REG-28: without it rotation is a flag day — the old credential stops at the instant the new
59
+ // one starts, and every caller holding the old one fails in the window between.
60
+ const withSecond = await asSecond(surfaces.descriptorUrl, "the Descriptor, second credential");
61
+ if (withSecond.status === 200) {
62
+ say("REG-28", "passes");
63
+ }
64
+ else {
65
+ say("REG-28", "fails", `the second credential answered ${withSecond.status}`);
66
+ }
67
+ // REG-8: a Descriptor filtered per reader is a different document to every reader, and a
68
+ // consumer then cannot tell a Capability it may not use from one the Worker withdrew.
69
+ const withFirst = await transcript.send(surfaces.descriptorUrl, "the Descriptor, again");
70
+ if (withSecond.status !== 200 || withFirst.status !== 200) {
71
+ say("REG-8", "notExercised", "one of the two credentials did not read the Descriptor");
72
+ }
73
+ else if (withFirst.body === withSecond.body) {
74
+ say("REG-8", "passes");
75
+ }
76
+ else {
77
+ say("REG-8", "fails", "the two credentials were served different Descriptors");
78
+ }
79
+ // ALRT-6 and ACTV-6 are one rule read on two surfaces: both are for whoever OPERATES the
80
+ // Worker, which is a relationship of enrollment, so every credential it authenticates sees one
81
+ // list. A third read-only surface with that audience gets a row here rather than a block.
82
+ for (const { rule, url, capability, one, many } of SAME_TO_BOTH) {
83
+ const address = surfaces[url];
84
+ if (address === null) {
85
+ say(rule, "notExercised", `the Worker declares no \`${capability}\``);
86
+ continue;
87
+ }
88
+ const first = await transcript.send(address, `${one}, first credential`);
89
+ const second = await asSecond(address, `${one}, second credential`);
90
+ if (first.status !== 200 || second.status !== 200) {
91
+ say(rule, "notExercised", `one of the two credentials did not read ${one}`);
92
+ }
93
+ else if (first.body === second.body) {
94
+ say(rule, "passes");
95
+ }
96
+ else {
97
+ say(rule, "fails", `the two credentials were served different ${many}`);
98
+ }
99
+ }
100
+ }
101
+ // TASK-6 is the one that reads the other way. Alerts are for whoever operates the Worker, so
102
+ // two credentials seeing the same list is the rule holding; Tasks are what a Contract covers,
103
+ // so two credentials seeing the same list proves only that this Worker had nothing to filter.
104
+ // The credential compared against is a Contract's where one was arranged — that is the party
105
+ // the rule is about — and the second credential otherwise.
106
+ const consumer = arrangement.consumerCredential ?? second;
107
+ if (consumer === undefined) {
108
+ say("TASK-6", "notExercised", "no Contract credential was given to the verifier");
109
+ }
110
+ else if (surfaces.tasksUrl === null) {
111
+ say("TASK-6", "notExercised", "the Worker declares no `tasks`");
112
+ }
113
+ else {
114
+ const a = taskPage.safeParse((await transcript.send(surfaces.tasksUrl, "Tasks, first")).json);
115
+ const b = taskPage.safeParse((await transcript.send(surfaces.tasksUrl, "Tasks, Contract credential", {
116
+ headers: { authorization: `Bearer ${consumer}` },
117
+ })).json);
118
+ if (!a.success || !b.success) {
119
+ say("TASK-6", "notExercised", "one of the two reads did not validate");
120
+ }
121
+ else {
122
+ const ids = (page) => (page.success ? page.data.items.map((t) => t.id) : []);
123
+ const first = new Set(ids(a));
124
+ const other = ids(b);
125
+ if (other.length === first.size && other.every((id) => first.has(id))) {
126
+ say("TASK-6", "notExercised", "both credentials see the same Tasks, which is consistent with filtering and with not filtering");
127
+ }
128
+ else {
129
+ say("TASK-6", "passes");
130
+ }
131
+ }
132
+ }
133
+ // REG-32 (recommended): a refusal that explains itself is an oracle. A caller told *that key is
134
+ // expired* rather than *no* has learned the key exists.
135
+ const unprivileged = arrangement.unprivilegedCredential;
136
+ if (unprivileged === undefined || surfaces.descriptorUrl === null) {
137
+ say("REG-32", "notExercised", "no credential was given that authenticates and lacks a right");
138
+ }
139
+ else {
140
+ const lacking = await transcript.send(surfaces.descriptorUrl, "a credential that lacks a right", {
141
+ headers: { authorization: `Bearer ${unprivileged}` },
142
+ permanent: true,
143
+ });
144
+ const nonsense = await transcript.send(surfaces.descriptorUrl, "a credential never issued", {
145
+ headers: { authorization: "Bearer never-issued-7a2c" },
146
+ permanent: true,
147
+ });
148
+ if (lacking.status < 400 || nonsense.status < 400) {
149
+ say("REG-32", "notExercised", "the Worker refused neither, so there is nothing to compare");
150
+ }
151
+ else if (lacking.body === nonsense.body || lacking.status !== nonsense.status) {
152
+ // Either the two refusals say the same thing, or they are different KINDS of refusal, which
153
+ // ENDP-29 divides at whether the credential could be read. Neither tells a caller which
154
+ // credential or scope would have changed the answer.
155
+ say("REG-32", "passes");
156
+ }
157
+ else {
158
+ say("REG-32", "fails", "two refusals of the same status said different things");
159
+ }
160
+ }
161
+ // HLTH-4: a Worker that has not yet established its state answers `unhealthy`, never `healthy`.
162
+ // The window is between a process starting and its first evaluation, and only whoever started it
163
+ // knows the verifier is inside one.
164
+ if (arrangement.justStarted !== true) {
165
+ say("HLTH-4", "notExercised", "the verifier was not told the Worker had just started");
166
+ }
167
+ else if (arrangement.healthUrl === undefined) {
168
+ say("HLTH-4", "notExercised", "no health address was resolved");
169
+ }
170
+ else {
171
+ const polled = await transcript.send(arrangement.healthUrl, "health, immediately after start");
172
+ const parsed = healthSchema.safeParse(polled.json);
173
+ if (!parsed.success) {
174
+ say("HLTH-4", "notExercised", "the health answer did not validate");
175
+ }
176
+ else if (parsed.data.status === "healthy") {
177
+ say("HLTH-4", "fails", "it answered `healthy` before establishing its state");
178
+ }
179
+ else {
180
+ say("HLTH-4", "passes");
181
+ }
182
+ }
183
+ // ACT-14: the input of `configure` is the complete settings document. What a check establishes
184
+ // is that the whole document round-trips — over a closed schema that requires every member,
185
+ // replacing and merging are the same operation, and this is the honest limit of the witness.
186
+ if (arrangement.replaceableSettings !== true || !mayPerform) {
187
+ say("ACT-14", "notExercised", "the verifier was not permitted to replace this Worker's settings");
188
+ }
189
+ else if (surfaces.settingsUrl === null || arrangement.actionsUrl === undefined) {
190
+ say("ACT-14", "notExercised", "no `configure` reading address was resolved");
191
+ }
192
+ else {
193
+ const before = await transcript.send(surfaces.settingsUrl, "the settings, before");
194
+ const written = await transcript.send(`${arrangement.actionsUrl}?action=configure`, "configure, with what it already holds", {
195
+ method: "POST",
196
+ body: before.body,
197
+ });
198
+ const after = await transcript.send(surfaces.settingsUrl, "the settings, after");
199
+ if (written.status >= 400) {
200
+ say("ACT-14", "fails", `configure refused its own document with ${written.status}`);
201
+ }
202
+ else if (before.body === after.body) {
203
+ say("ACT-14", "passes");
204
+ }
205
+ else {
206
+ say("ACT-14", "fails", "the settings changed when the Worker was handed what it already held");
207
+ }
208
+ }
209
+ // EVT-1: `source` is the Worker's id and `type` is one the entry declares. The verifier holds no
210
+ // broker and sees no event, so the only way to reach this is to be handed one.
211
+ const published = arrangement.publishedEvent;
212
+ if (published === undefined) {
213
+ say("EVT-1", "notExercised", "no published event was given to the verifier");
214
+ }
215
+ else {
216
+ const event = published;
217
+ const faults = [];
218
+ if (typeof event.id !== "string" || event.id.length === 0)
219
+ faults.push("no `id`");
220
+ if (event.source !== surfaces.workerId) {
221
+ faults.push(`\`source\` is ${JSON.stringify(event.source)} and the Worker's id is ${JSON.stringify(surfaces.workerId)}`);
222
+ }
223
+ if (typeof event.type !== "string" || !surfaces.eventTypes.includes(event.type)) {
224
+ faults.push(`\`type\` ${JSON.stringify(event.type)} is not one the entry declares`);
225
+ }
226
+ if (faults.length === 0)
227
+ say("EVT-1", "passes");
228
+ else
229
+ say("EVT-1", "fails", faults.join("; "));
230
+ }
231
+ return results;
232
+ }
@@ -0,0 +1,34 @@
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
+ * What the Descriptor document alone establishes.
6
+ *
7
+ * Every rule here is one `conformance/verifiability.md` classifies `W`, and every one of them is
8
+ * reached without calling a single declared address — which is why this is the slice the verifier
9
+ * started with: it is the only surface a Worker is required to have at all (DESC-1).
10
+ *
11
+ * DESC-18 is deliberately absent. It is `W`, but its witness is a declared address answering 404,
12
+ * so it belongs with the checks that call one.
13
+ */
14
+ export declare const CLAIMS: readonly ["DESC-1", "DESC-3", "DESC-5", "DESC-6", "DESC-8", "DESC-9", "DESC-12", "DESC-14", "DESC-22", "DESC-23", "TASK-31"];
15
+ export type Descriptor = {
16
+ id: string;
17
+ edition: string;
18
+ /** TASK-31. Absent for a Worker with no Skill, which DESC-2 admits of anything it does not do. */
19
+ skills?: Record<string, unknown>;
20
+ capabilities: Record<string, Record<string, unknown>>;
21
+ };
22
+ export type DescriptorReading = {
23
+ results: Result[];
24
+ /** The document, where one was read and validated. Later checks need its addresses. */
25
+ document: Descriptor | null;
26
+ /** The route it was read from, which later checks probe again. */
27
+ url: string | null;
28
+ /** Each declared address resolved against that route (DESC-12). */
29
+ surfaces: {
30
+ capability: string;
31
+ url: string;
32
+ }[];
33
+ };
34
+ export declare function readDescriptor(baseUrl: string, rules: Map<string, Rule>, attribution: Attribution, transcript: Transcript): Promise<DescriptorReading>;
@@ -0,0 +1,179 @@
1
+ import { capabilityName, descriptor as descriptorSchema, vendorCapabilityName, } from "@worker-protocol/schemas";
2
+ import { ruleFor } from "../attribution.js";
3
+ import { verdicts } from "../report.js";
4
+ /**
5
+ * What the Descriptor document alone establishes.
6
+ *
7
+ * Every rule here is one `conformance/verifiability.md` classifies `W`, and every one of them is
8
+ * reached without calling a single declared address — which is why this is the slice the verifier
9
+ * started with: it is the only surface a Worker is required to have at all (DESC-1).
10
+ *
11
+ * DESC-18 is deliberately absent. It is `W`, but its witness is a declared address answering 404,
12
+ * so it belongs with the checks that call one.
13
+ */
14
+ export const CLAIMS = [
15
+ "DESC-1",
16
+ "DESC-3",
17
+ "DESC-5",
18
+ "DESC-6",
19
+ "DESC-8",
20
+ "DESC-9",
21
+ "DESC-12",
22
+ "DESC-14",
23
+ "DESC-22",
24
+ "DESC-23",
25
+ // TASK-31 lives here rather than in the tasks check because `skills` is on the ROOT: a Worker
26
+ // that only ANSWERS Tasks declares a Skill and no `tasks` Capability at all, and a verdict
27
+ // reached only through that Capability would have been silent about exactly that Worker.
28
+ "TASK-31",
29
+ ];
30
+ const ROUTE = ".well-known/worker-protocol";
31
+ export async function readDescriptor(baseUrl, rules, attribution, transcript) {
32
+ const { results, say } = verdicts(rules, CLAIMS);
33
+ const nothingRead = (why, except) => {
34
+ for (const id of CLAIMS)
35
+ if (!except.includes(id))
36
+ say(id, "notExercised", why);
37
+ };
38
+ // DESC-3 is answered before anything is called, because its subject is the enrolled base URL and
39
+ // not the document. A verifier that reached the Worker over plaintext has already established
40
+ // the fault, and says so without pretending a call proved it.
41
+ let base;
42
+ try {
43
+ base = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
44
+ }
45
+ catch {
46
+ say("DESC-3", "fails", `not a URL: ${baseUrl}`);
47
+ nothingRead("no Descriptor could be read", ["DESC-3"]);
48
+ return { results, document: null, url: null, surfaces: [] };
49
+ }
50
+ if (base.protocol === "https:")
51
+ say("DESC-3", "passes");
52
+ else
53
+ say("DESC-3", "fails", `the base URL is \`${base.protocol}\` and DESC-3 fixes \`https\``);
54
+ const url = new URL(ROUTE, base).toString();
55
+ let first;
56
+ try {
57
+ first = await transcript.send(url, "the Descriptor route");
58
+ }
59
+ catch (cause) {
60
+ const why = cause instanceof Error ? cause.message : String(cause);
61
+ say("DESC-1", "fails", `${url} could not be reached: ${why}`);
62
+ nothingRead("no Descriptor could be read", ["DESC-1", "DESC-3"]);
63
+ return { results, document: null, url, surfaces: [] };
64
+ }
65
+ if (first.status < 200 || first.status >= 300) {
66
+ say("DESC-1", "fails", `${url} answered ${first.status}`);
67
+ nothingRead("no Descriptor could be read", ["DESC-1", "DESC-3"]);
68
+ return { results, document: null, url, surfaces: [] };
69
+ }
70
+ if (first.json === null) {
71
+ say("DESC-1", "fails", `${url} did not answer JSON`);
72
+ nothingRead("the Descriptor did not parse", ["DESC-1", "DESC-3"]);
73
+ return { results, document: null, url, surfaces: [] };
74
+ }
75
+ // DESC-5: a GET changes nothing a later reader could observe, so the same request answers the
76
+ // same document. This is the weakest form of the check and it is the honest one — a verifier
77
+ // cannot prove the absence of a side effect, only catch a Worker whose read has one it shows.
78
+ const second = await transcript.send(url, "the Descriptor route, a second time");
79
+ if (second.status === first.status && second.body === first.body)
80
+ say("DESC-5", "passes");
81
+ else
82
+ say("DESC-5", "fails", "a second GET of the Descriptor answered something else");
83
+ const validation = descriptorSchema.safeParse(first.json);
84
+ /**
85
+ * DESC-8 and DESC-14, judged before the document as a whole.
86
+ *
87
+ * The attribution map cannot reach these, and the reason is worth stating rather than working
88
+ * around: the key schema is a union of the reserved enumeration and the vendor pattern, and a
89
+ * key that fails it matched neither — so nothing in `schemas/` says which branch it was reaching
90
+ * for. Attribution would land on DESC-22, and DESC-8 would become a rule that can pass and never
91
+ * fail, which is a check that only knows how to say yes.
92
+ *
93
+ * What tells them apart is DESC-14 itself: a name containing a `.` is the Worker's own, and a
94
+ * name without one is reserved to the edition or is nothing. That is the rule, applied — not the
95
+ * verifier deciding anything — and the enumeration it compares against is `capabilityName`,
96
+ * which is the normative list DESC-8 points at.
97
+ */
98
+ const keys = Object.keys(first.json?.capabilities ?? {});
99
+ const reserved = new Set(capabilityName.options);
100
+ const unknownReserved = keys.filter((key) => !key.includes(".") && !reserved.has(key));
101
+ const malformedVendor = keys.filter((key) => key.includes(".") && !vendorCapabilityName.safeParse(key).success);
102
+ const judgedKeys = new Set();
103
+ if (unknownReserved.length > 0) {
104
+ judgedKeys.add("DESC-8");
105
+ const names = unknownReserved.map((k) => `\`${k}\``).join(", ");
106
+ say("DESC-8", "fails", `${names}: undotted, and this edition defines no such Capability`);
107
+ }
108
+ if (malformedVendor.length > 0) {
109
+ judgedKeys.add("DESC-14");
110
+ const names = malformedVendor.map((k) => `\`${k}\``).join(", ");
111
+ say("DESC-14", "fails", `${names}: dotted, and not a well-formed vendor Capability name`);
112
+ }
113
+ if (!validation.success) {
114
+ // A report that answered "the Descriptor is invalid" would be the failure the rule ids exist
115
+ // to prevent: an operator cannot act on it, and two very different faults read identically.
116
+ // The rule each issue belongs to is read off `schemas/` rather than judged here — see
117
+ // `src/attribution.ts` for why that distinction is worth the machinery.
118
+ const blamed = new Set(judgedKeys);
119
+ // Where a key was already judged precisely above, the map's coarser verdict on the same fault
120
+ // is suppressed: DESC-22 is about the map's shape, and reporting it beside DESC-8 would give
121
+ // an operator two findings for one mistake and send them to the wrong one first.
122
+ if (judgedKeys.size > 0)
123
+ blamed.add("DESC-22");
124
+ for (const issue of validation.error.issues) {
125
+ const id = ruleFor(attribution, "descriptor", issue.path) ?? "DESC-1";
126
+ if (blamed.has(id))
127
+ continue;
128
+ blamed.add(id);
129
+ say(id, "fails", `${issue.path.join(".") || "(root)"}: ${issue.message}`);
130
+ }
131
+ nothingRead("the Descriptor did not validate, so this could not be judged", [
132
+ "DESC-3",
133
+ "DESC-5",
134
+ ...blamed,
135
+ ]);
136
+ return { results, document: null, url, surfaces: [] };
137
+ }
138
+ const document = validation.data;
139
+ say("DESC-1", "passes");
140
+ // TASK-31: what this Worker answers, which is what a Tower catalogs it by. Absent is conformant
141
+ // and is not a pass — a Worker with no Skill exercised nothing, and saying so is the difference
142
+ // between a report that was checked and one that had nothing to check.
143
+ if (document.skills === undefined) {
144
+ say("TASK-31", "notExercised", "the Worker declares no Skill");
145
+ }
146
+ else {
147
+ say("TASK-31", "passes");
148
+ }
149
+ // DESC-6: the id is not the URL it is served from. DESC-27 and DESC-28 carry the clauses nothing
150
+ // outside can reach, and the report marks those unverified rather than passing them here.
151
+ const addresses = [base.toString(), base.toString().replace(/\/$/, ""), url];
152
+ if (addresses.includes(document.id)) {
153
+ say("DESC-6", "fails", "the id is the URL the Descriptor is served from");
154
+ }
155
+ else {
156
+ say("DESC-6", "passes");
157
+ }
158
+ // The rest are what validation established. Saying so per rule rather than once is the whole
159
+ // point of the ids: a reader learns which obligations were actually judged.
160
+ for (const id of ["DESC-8", "DESC-9", "DESC-12", "DESC-14", "DESC-22", "DESC-23"]) {
161
+ if (!judgedKeys.has(id))
162
+ say(id, "passes");
163
+ }
164
+ // DESC-12: an address is an absolute https URL, or a relative reference resolved against the URL
165
+ // the Descriptor was read from — which is this route, not the base URL.
166
+ const surfaces = [];
167
+ for (const [capability, entry] of Object.entries(document.capabilities)) {
168
+ if (typeof entry.address !== "string")
169
+ continue;
170
+ try {
171
+ surfaces.push({ capability, url: new URL(entry.address, url).toString() });
172
+ }
173
+ catch {
174
+ // Unreachable while validation passed, and swallowing it silently would be the one thing
175
+ // this file exists against — so it is left to DESC-12, which validation already judged.
176
+ }
177
+ }
178
+ return { results, document, url, surfaces };
179
+ }
@@ -0,0 +1,17 @@
1
+ import { type Result, type Rule } from "../report.ts";
2
+ import { type Exchange } from "../transcript.ts";
3
+ /**
4
+ * The rules that are about every response rather than about one surface.
5
+ *
6
+ * These run last, over the whole transcript, because several of them are statements no single
7
+ * exchange can break: ENDP-26 forbids one code arriving under two statuses, which is a fact about
8
+ * a set. A check that asked it inside one surface would be asking a question it could not answer.
9
+ */
10
+ export declare const CLAIMS: readonly ["ENDP-1", "ENDP-11", "ENDP-4", "ENDP-5", "ENDP-19", "ENDP-25", "ENDP-26", "ENDP-29"];
11
+ /** The status and class each code fixes, generated from endpoints.md into rules.json (ENDP-26). */
12
+ export type Code = {
13
+ code: string;
14
+ status: number;
15
+ class: "reject" | "retry";
16
+ };
17
+ export declare function judgeTranscript(exchanges: Exchange[], codes: Code[], declared: Set<string>, rules: Map<string, Rule>): Result[];