@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,249 @@
1
+ import { verdicts } from "../report.js";
2
+ /**
3
+ * What calling each declared address establishes, before any Capability's own file is consulted.
4
+ *
5
+ * DESC-18 is here rather than beside the Descriptor because its witness is a declared address
6
+ * answering `404`, which needs the call the Descriptor check deliberately does not make.
7
+ */
8
+ export const CLAIMS = [
9
+ "DESC-18",
10
+ "REG-3",
11
+ "REG-7",
12
+ "REG-21",
13
+ "ENDP-2",
14
+ "ENDP-6",
15
+ "ENDP-24",
16
+ ];
17
+ /**
18
+ * Capabilities whose surface is written rather than read.
19
+ *
20
+ * DESC-18's witness is a declared address answering `404`, and this sweep finds it with a GET —
21
+ * which is the right question for every surface but one. ENDP-3 puts everything that changes state
22
+ * behind a POST, so the Actions address answers `404` to a GET while serving perfectly well, and a
23
+ * check that read that as an undeclared surface would fail a conformant Worker on the one
24
+ * Capability that does anything. `checks/actions.ts` judges that address instead, with the POST it
25
+ * had to ask permission for, and `checks/nudges.ts` does the same for the other one.
26
+ */
27
+ const WRITTEN = new Set(["actions", "nudges"]);
28
+ export async function callSurfaces(surfaces, descriptorUrl, writeAddresses, rules, transcript, credential, mayPerform) {
29
+ const { results, say } = verdicts(rules, CLAIMS);
30
+ if (surfaces.length === 0) {
31
+ say("DESC-18", "passes", "the Descriptor declares no address, so none can be missing");
32
+ }
33
+ const missing = [];
34
+ const refused = [];
35
+ const answered = [];
36
+ for (const surface of surfaces) {
37
+ if (WRITTEN.has(surface.capability))
38
+ continue;
39
+ let answer;
40
+ try {
41
+ answer = await transcript.send(surface.url, `the \`${surface.capability}\` address`);
42
+ }
43
+ catch (cause) {
44
+ const why = cause instanceof Error ? cause.message : String(cause);
45
+ missing.push(`\`${surface.capability}\` at ${surface.url} could not be reached: ${why}`);
46
+ continue;
47
+ }
48
+ // DESC-18: a Descriptor that declares a Capability the Worker does not serve is a fault in the
49
+ // Descriptor, not in the surface — so the finding is reported against the document.
50
+ if (answer.status === 404) {
51
+ missing.push(`\`${surface.capability}\` is declared at ${surface.url} and answers 404`);
52
+ }
53
+ // REG-21: a Worker accepts the credential recorded for it on every address this protocol
54
+ // defines. A surface that refuses the recorded credential is not a surface a Tower can poll.
55
+ if (answer.status === 401 || answer.status === 403) {
56
+ refused.push(`\`${surface.capability}\` at ${surface.url} answered ${answer.status}`);
57
+ }
58
+ if (answer.status === 200) {
59
+ answered.push({ capability: surface.capability, url: surface.url, body: answer.body });
60
+ }
61
+ }
62
+ if (surfaces.length > 0) {
63
+ if (missing.length === 0)
64
+ say("DESC-18", "passes");
65
+ else
66
+ say("DESC-18", "fails", missing.join("; "));
67
+ }
68
+ await probeReads(answered, writeAddresses, mayPerform, transcript, say);
69
+ if (credential === undefined) {
70
+ // Without one there is nothing to present, and a Worker that reads openly is conformant —
71
+ // registration.md says in as many words that a Worker may answer more than REG-21 requires.
72
+ for (const id of ["REG-3", "REG-7", "REG-21"]) {
73
+ say(id, "notExercised", "no credential was given to the verifier");
74
+ }
75
+ return results;
76
+ }
77
+ if (refused.length === 0) {
78
+ say("REG-21", "passes");
79
+ // REG-3 fixes only how a credential is presented. What establishes it is that the Worker read
80
+ // one arriving as `Authorization: Bearer <token>` — which is what every call above did.
81
+ say("REG-3", "passes");
82
+ }
83
+ else {
84
+ say("REG-21", "fails", refused.join("; "));
85
+ say("REG-3", "notExercised", "the recorded credential was refused, so nothing read it");
86
+ }
87
+ // REG-7: a Worker does not answer 404 in place of 401 or 403 on an address it serves. The
88
+ // Descriptor's route is the one address every Worker has, and it answered above, so it is served
89
+ // by definition — which is exactly the precondition the rule needs.
90
+ const wrong = `${credential}-is-not-this`;
91
+ let probe;
92
+ try {
93
+ probe = await transcript.send(descriptorUrl, "the Descriptor with a credential it never issued", { headers: { authorization: `Bearer ${wrong}` }, permanent: true });
94
+ }
95
+ catch {
96
+ say("REG-7", "notExercised", "the Descriptor route could not be reached a second time");
97
+ return results;
98
+ }
99
+ if (probe.status === 404) {
100
+ say("REG-7", "fails", "the Descriptor route answered 404 to a credential it does not accept");
101
+ }
102
+ else if (probe.status === 401 || probe.status === 403) {
103
+ say("REG-7", "passes");
104
+ }
105
+ else {
106
+ // A Worker that serves its Descriptor openly is conformant (REG-31 only recommends a
107
+ // credential on addresses that change state), and there is then no refusal to judge.
108
+ say("REG-7", "notExercised", `the Worker answered ${probe.status} rather than refusing`);
109
+ }
110
+ return results;
111
+ }
112
+ /**
113
+ * What a declared surface can be asked, once it is known to answer.
114
+ *
115
+ * These are the last rules in [endpoints](../../../spec/endpoints.md) with a witness against an
116
+ * ordinary Worker, and each needs a request nothing else in a run would send: a parameter the
117
+ * Worker cannot know, a Capability version it cannot answer, a read repeated to see whether it
118
+ * changed anything.
119
+ */
120
+ async function probeReads(answered, writeAddresses, mayPerform, transcript, say) {
121
+ // A Worker whose only declared surface is written — a thin proxy that accepts an operation and
122
+ // passes it on, which DESC-1's argument names as a Worker worth admitting — has nothing to read
123
+ // twice and nothing to hand an unknown filter to. ENDP-6 is not in that position: it is about a
124
+ // REQUEST, and a write is one, so it goes on below with whatever write addresses there are.
125
+ if (answered.length === 0) {
126
+ for (const id of ["ENDP-2", "ENDP-24"]) {
127
+ say(id, "notExercised", "no declared surface answered a read");
128
+ }
129
+ if (writeAddresses.length === 0 || !mayPerform) {
130
+ say("ENDP-6", "notExercised", "no surface was reachable to state a version at");
131
+ return;
132
+ }
133
+ }
134
+ // ENDP-2: a GET changes nothing a later reader could observe.
135
+ //
136
+ // **What is compared is which items came back, not which bytes.** Byte equality was the first
137
+ // form of this check and it was wrong in a way that only shows against a real Worker: a health
138
+ // `detail` carrying an observed value, or any surface whose facts moved between two reads,
139
+ // answers different bytes having changed nothing. Failing that Worker would be this tool
140
+ // inventing an obligation nobody wrote.
141
+ //
142
+ // What the rule is actually about is stated in its own argument: listing a Worker's open Tasks
143
+ // does not consume them, reading its Alerts does not dismiss them. The witness is items that
144
+ // were there and are gone, with none arriving — a read that empties what it read. That is not
145
+ // airtight either, because a Task may close on its own between two calls, and the detail says
146
+ // what was compared so a reader can weigh it.
147
+ const items = (payload) => {
148
+ try {
149
+ const parsed = JSON.parse(payload);
150
+ if (!Array.isArray(parsed.items))
151
+ return null;
152
+ return parsed.items.map((item) => typeof item === "object" && item !== null && "id" in item
153
+ ? String(item.id)
154
+ : JSON.stringify(item));
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ };
160
+ const emptied = [];
161
+ let collections = 0;
162
+ for (const surface of answered) {
163
+ const again = await transcript.send(surface.url, `the \`${surface.capability}\` address, again`);
164
+ if (again.status !== 200) {
165
+ emptied.push(`\`${surface.capability}\` answered ${again.status} on a second read`);
166
+ continue;
167
+ }
168
+ const before = items(surface.body);
169
+ const after = items(again.body);
170
+ if (before === null || after === null)
171
+ continue;
172
+ collections += 1;
173
+ const present = new Set(after);
174
+ const gone = before.filter((id) => !present.has(id));
175
+ const arrived = after.filter((id) => !new Set(before).has(id));
176
+ if (gone.length > 0 && arrived.length === 0) {
177
+ emptied.push(`\`${surface.capability}\` lost ${gone.length} item(s) to being read`);
178
+ }
179
+ }
180
+ if (answered.length > 0) {
181
+ if (emptied.length > 0)
182
+ say("ENDP-2", "fails", emptied.join("; "));
183
+ else if (collections === 0) {
184
+ say("ENDP-2", "notExercised", "no declared surface answered a collection to read twice");
185
+ }
186
+ else {
187
+ say("ENDP-2", "passes", "every collection answered the same items on a second read");
188
+ }
189
+ }
190
+ // ENDP-24: an unrecognized filter parameter is 400, and is never ignored.
191
+ const ignored = [];
192
+ for (const surface of answered) {
193
+ const probed = new URL(surface.url);
194
+ probed.searchParams.set("no-such-filter-8e31", "1");
195
+ const answer = await transcript.send(probed.toString(), "a filter the Worker cannot know", {
196
+ permanent: true,
197
+ });
198
+ const code = answer.json?.code;
199
+ if (answer.status !== 400 || code !== "unknown_filter") {
200
+ ignored.push(`\`${surface.capability}\` answered ${answer.status} with \`${code ?? "no code"}\``);
201
+ }
202
+ }
203
+ if (answered.length > 0) {
204
+ if (ignored.length === 0)
205
+ say("ENDP-24", "passes");
206
+ else
207
+ say("ENDP-24", "fails", ignored.join("; "));
208
+ }
209
+ // ENDP-6: a caller may state the Capability version it expects, and a Worker that cannot answer
210
+ // it refuses the request WHOLE rather than substituting its own. A version no edition will reach
211
+ // soon is the only way to ask without a Worker having to cooperate.
212
+ //
213
+ // The write addresses are probed too, and they are where the rule matters most: a Worker that
214
+ // refuses an unanswerable version on a read and PERFORMS one on a write has done the thing the
215
+ // rule exists to prevent, on the calls where being wrong costs something. They are POSTs, so
216
+ // they need `mayPerform` — and a Worker that obeys the rule performs nothing, which is why
217
+ // asking is safe once permission is given.
218
+ const unanswerable = [];
219
+ const probes = [
220
+ ...answered.map((surface) => ({
221
+ what: `\`${surface.capability}\``,
222
+ url: surface.url,
223
+ init: { headers: { "worker-protocol-capability-version": "99999" }, permanent: true },
224
+ })),
225
+ ...(mayPerform
226
+ ? writeAddresses.map((url) => ({
227
+ what: `the write address ${url}`,
228
+ url,
229
+ init: {
230
+ method: "POST",
231
+ body: "{}",
232
+ headers: { "worker-protocol-capability-version": "99999" },
233
+ permanent: true,
234
+ },
235
+ }))
236
+ : []),
237
+ ];
238
+ for (const probe of probes) {
239
+ const answer = await transcript.send(probe.url, "a Capability version the Worker cannot answer", probe.init);
240
+ const code = answer.json?.code;
241
+ if (answer.status !== 400 || code !== "unsupported_version") {
242
+ unanswerable.push(`${probe.what} answered ${answer.status} with \`${code ?? "no code"}\``);
243
+ }
244
+ }
245
+ if (unanswerable.length === 0)
246
+ say("ENDP-6", "passes");
247
+ else
248
+ say("ENDP-6", "fails", unanswerable.join("; "));
249
+ }
@@ -0,0 +1,23 @@
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 `tasks` Capability, which is now reading and nothing else.
6
+ *
7
+ * Every check here is a GET, so none of them needs the operators' permission and none leaves the
8
+ * Worker changed. That was not true while a Claim existed: taking one was the most consequential
9
+ * thing a caller could do through this protocol, and half of this file was the sequence that took
10
+ * one carefully and gave it back. The lease is withdrawn and the sequence with it.
11
+ */
12
+ export declare const CLAIMS: readonly ["TASK-27", "TASK-32", "TASK-4", "TASK-5", "TASK-8", "TASK-28", "NAME-7"];
13
+ export declare function checkTasks(entry: Record<string, unknown> | undefined, url: string | null,
14
+ /** TASK-31, read off the Descriptor ROOT. Its names are Task types, so NAME-7 and TASK-4 reach
15
+ * them — and reach them even for a Worker that declares a Skill and no `tasks` entry. */
16
+ skills: string[],
17
+ /** The `actions` entry's own map, because TASK-32 is about the Action's name AND its input. */
18
+ accepts: Record<string, {
19
+ input?: unknown;
20
+ }>, rules: Map<string, Rule>, attribution: Attribution, transcript: Transcript): Promise<{
21
+ results: Result[];
22
+ addresses: string[];
23
+ }>;
@@ -0,0 +1,171 @@
1
+ import { taskPage, tasksEntry } from "@worker-protocol/schemas";
2
+ import { ruleFor } from "../attribution.js";
3
+ import { verdicts } from "../report.js";
4
+ /**
5
+ * The `tasks` Capability, which is now reading and nothing else.
6
+ *
7
+ * Every check here is a GET, so none of them needs the operators' permission and none leaves the
8
+ * Worker changed. That was not true while a Claim existed: taking one was the most consequential
9
+ * thing a caller could do through this protocol, and half of this file was the sequence that took
10
+ * one carefully and gave it back. The lease is withdrawn and the sequence with it.
11
+ */
12
+ export const CLAIMS = [
13
+ "TASK-27",
14
+ "TASK-32",
15
+ "TASK-4",
16
+ "TASK-5",
17
+ "TASK-8",
18
+ "TASK-28",
19
+ "NAME-7",
20
+ ];
21
+ /**
22
+ * Whether a union's variants are told apart by a discriminator — TASK-32's second obligation.
23
+ *
24
+ * A member that every variant fixes to a constant of its own, which is what a discriminated union
25
+ * writes and what an answerer needs in order to say which ending it is producing. Without one a
26
+ * consumer reading the schema cannot name the ending it can reach, and the mapping TASK-32 exists
27
+ * to dissolve comes back as a conversation between two parties.
28
+ *
29
+ * The same walk `packages/client`'s `skills.ts` does when it decides whether one Worker can answer
30
+ * another's Tasks. It is six lines and it is written twice rather than imported, because a verifier
31
+ * depending on a consumer library would make the tool's verdicts turn on a package it is supposed
32
+ * to be able to judge.
33
+ */
34
+ function told(variants) {
35
+ const first = variants[0];
36
+ if (first === undefined)
37
+ return false;
38
+ return Object.keys(first.properties ?? {}).some((member) => {
39
+ const fixed = variants.map((one) => one.properties?.[member]?.const);
40
+ return fixed.every((one) => one !== undefined) && new Set(fixed).size === variants.length;
41
+ });
42
+ }
43
+ export async function checkTasks(entry, url,
44
+ /** TASK-31, read off the Descriptor ROOT. Its names are Task types, so NAME-7 and TASK-4 reach
45
+ * them — and reach them even for a Worker that declares a Skill and no `tasks` entry. */
46
+ skills,
47
+ /** The `actions` entry's own map, because TASK-32 is about the Action's name AND its input. */
48
+ accepts, rules, attribution, transcript) {
49
+ const { results, say, allExcept } = verdicts(rules, CLAIMS);
50
+ const addresses = [];
51
+ // NAME-7 and TASK-4 are about the NAMES, wherever they were declared. A Worker that only answers
52
+ // Tasks has them under `skills` alone, and judging them only through a `tasks` entry would have
53
+ // reported nothing about the one Worker the root declaration exists for.
54
+ const crossing = (raised) => {
55
+ if (raised + skills.length === 0) {
56
+ say("NAME-7", "notExercised", "the Worker neither raises nor answers a Task type");
57
+ say("TASK-4", "notExercised", "the Worker declares no Task type");
58
+ }
59
+ else {
60
+ say("NAME-7", "passes");
61
+ say("TASK-4", "passes");
62
+ }
63
+ };
64
+ if (entry === undefined) {
65
+ crossing(0);
66
+ allExcept("notExercised", "the Worker declares no `tasks`", ["NAME-7", "TASK-4"]);
67
+ return { results, addresses };
68
+ }
69
+ const declared = tasksEntry.safeParse(entry);
70
+ if (!declared.success) {
71
+ const blamed = new Set();
72
+ for (const issue of declared.error.issues) {
73
+ const id = ruleFor(attribution, "tasks-entry", issue.path) ?? "TASK-27";
74
+ if (blamed.has(id))
75
+ continue;
76
+ blamed.add(id);
77
+ say(id, "fails", `${issue.path.join(".") || "(root)"}: ${issue.message}`);
78
+ }
79
+ allExcept("notExercised", "the `tasks` entry did not validate", [...blamed]);
80
+ return { results, addresses };
81
+ }
82
+ const { raises } = declared.data;
83
+ say("TASK-27", "passes");
84
+ // NAME-7: a name this protocol expects one party to match against a name that came from
85
+ // somewhere else is namespaced. A Task type is the first such name the protocol actually serves,
86
+ // and TASK-4 is that rule applied — so the schema's qualified-name key carries both, and this
87
+ // says so per rule rather than once, because a report naming one of them is the point.
88
+ crossing(Object.keys(raises).length);
89
+ // TASK-32 has two obligations and neither is a shape inside one entry, which is why the schema
90
+ // reaches neither and both are here.
91
+ //
92
+ // The first is the name: answering a Task is performing one of the OWNER's own Actions, so a
93
+ // Task type naming an Action the Worker does not accept is a Descriptor disagreeing with itself.
94
+ //
95
+ // The second is the input of the Action it names. Where a Task can end more than one way the
96
+ // endings are variants of that input, told apart by a discriminator — and without one, a
97
+ // consumer holding a schema it can satisfy cannot say WHICH ending it is reporting, so the
98
+ // mapping this rule dissolved comes back as something two parties have to agree out of band.
99
+ // Unions of one are not judged: an input that is not a union is a Task that ends one way.
100
+ const dangling = [];
101
+ const untold = [];
102
+ for (const [type, declaration] of Object.entries(raises)) {
103
+ const declared = accepts[declaration.answeredBy];
104
+ if (declared === undefined) {
105
+ dangling.push(`${type} names \`${declaration.answeredBy}\`, which its \`actions\` entry does not accept`);
106
+ continue;
107
+ }
108
+ const input = declared.input;
109
+ const variants = input?.anyOf ?? input?.oneOf;
110
+ if (variants !== undefined && variants.length > 1 && !told(variants)) {
111
+ untold.push(`${type} is answered by \`${declaration.answeredBy}\`, whose input is a union of ${variants.length} with no member fixed to a different constant in each`);
112
+ }
113
+ }
114
+ const faults = [...dangling, ...untold];
115
+ if (Object.keys(raises).length === 0) {
116
+ say("TASK-32", "notExercised", "the Worker raises no Task type");
117
+ }
118
+ else if (faults.length === 0) {
119
+ say("TASK-32", "passes");
120
+ }
121
+ else {
122
+ say("TASK-32", "fails", faults.join("; "));
123
+ }
124
+ if (url === null) {
125
+ allExcept("notExercised", "the reading address did not resolve", [
126
+ "TASK-27",
127
+ "TASK-32",
128
+ "TASK-4",
129
+ "NAME-7",
130
+ ]);
131
+ return { results, addresses };
132
+ }
133
+ // TASK-5, TASK-28: the Tasks whose conditions hold, in the page envelope, each carrying what a
134
+ // consumer needs in order to decide whether to try.
135
+ const answer = await transcript.send(url, "the Tasks whose conditions hold");
136
+ if (answer.status !== 200) {
137
+ say("TASK-5", "fails", `the reading address answered ${answer.status}`);
138
+ say("TASK-28", "notExercised", "no page of Tasks was read");
139
+ }
140
+ else {
141
+ const page = taskPage.safeParse(answer.json);
142
+ if (page.success) {
143
+ say("TASK-5", "passes");
144
+ if (page.data.items.length === 0) {
145
+ say("TASK-28", "notExercised", "no condition is holding, so no Task was read");
146
+ }
147
+ else {
148
+ say("TASK-28", "passes");
149
+ }
150
+ }
151
+ else {
152
+ const issue = page.error.issues[0];
153
+ const id = ruleFor(attribution, "task-page", issue?.path ?? []) ?? "TASK-5";
154
+ say(id, "fails", `${issue?.path.join(".") || "(root)"}: ${issue?.message}`);
155
+ say(id === "TASK-5" ? "TASK-28" : "TASK-5", "notExercised", "the page did not validate");
156
+ }
157
+ }
158
+ // TASK-8: a type the entry does not declare. A name no Worker would raise is the only way to ask
159
+ // without a Worker having to cooperate, and a read refuses without changing anything.
160
+ const probe = new URL(url);
161
+ probe.searchParams.set("type", "tech.rowing.no-such.task-type-4c1f");
162
+ const refused = await transcript.send(probe.toString(), "a Task type the entry does not declare", {
163
+ permanent: true,
164
+ });
165
+ const code = refused.json?.code;
166
+ if (refused.status === 400 && code === "invalid_parameter")
167
+ say("TASK-8", "passes");
168
+ else
169
+ say("TASK-8", "fails", `answered ${refused.status} with \`${code ?? "no code"}\``);
170
+ return { results, addresses };
171
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { verify } from "./index.js";
4
+ import { tally } from "./report.js";
5
+ /**
6
+ * `npx @worker-protocol/conformance <base-url>` — the verifier, from a shell.
7
+ *
8
+ * `docs/roadmap.md` says each language's repository ships its own reference Worker and runs this
9
+ * package against it in CI, and that the report is what ties those repositories together: an SDK is
10
+ * right because its Worker passes, not because somebody read the prose carefully. That promise was
11
+ * being made to repositories in Python, C# and Go, and what it offered them was a TypeScript
12
+ * function — so honouring it began with writing a Node program, which is a toolchain a Python
13
+ * repository should not have to acquire in order to find out whether it conforms. This file is the
14
+ * difference between that and one line of YAML.
15
+ *
16
+ * **Its exit code is the whole point and is the one thing here that must not be clever.** Zero when
17
+ * no rule failed, 1 when one did, 2 when no verdict was reached.
18
+ *
19
+ * Which of those an unreachable Worker is, `verify` has already decided and this file does not
20
+ * revisit: it fails DESC-1, naming what could not be fetched. That is a verdict and a correct one —
21
+ * a Worker is not conformant at an address that does not answer — so it exits 1 like any other
22
+ * failure. What exits 2 is the case where this tool is the one that is behind (DESC-25), and
23
+ * whatever `verify` does not turn into a verdict at all.
24
+ *
25
+ * `notExercised` never fails the run. `conformance/README.md` spends a paragraph on why it is not
26
+ * `fails`: the Worker declares no such Capability, or nobody arranged what the check needs to see.
27
+ * Both are gaps somebody can close, and neither is the Worker breaking an obligation.
28
+ */
29
+ const USAGE = `worker-protocol-conformance <base-url> [options]
30
+
31
+ Verify a Worker against the edition of worker-protocol this package encodes.
32
+
33
+ Options:
34
+ --credential <token> Presented as \`Authorization: Bearer <token>\` (REG-3).
35
+ Prefer WORKER_PROTOCOL_CREDENTIAL: argv is visible to
36
+ every process on the machine, and a CI log often keeps it.
37
+ --may-perform Allow POSTs to Actions. Off by default: an Action is an
38
+ operation somebody's operators chose to expose, and a tool
39
+ pointed at a Worker to inspect it does not perform work on
40
+ it uninvited. Rules needing one report notExercised.
41
+ --json Write the report to stdout as JSON, and nothing else.
42
+ -h, --help This.
43
+
44
+ Exit: 0 nothing failed, 1 a rule failed, 2 the run could not be made.`;
45
+ const { values, positionals } = parseArgs({
46
+ allowPositionals: true,
47
+ options: {
48
+ credential: { type: "string" },
49
+ "may-perform": { type: "boolean", default: false },
50
+ json: { type: "boolean", default: false },
51
+ help: { type: "boolean", short: "h", default: false },
52
+ },
53
+ });
54
+ if (values.help) {
55
+ console.log(USAGE);
56
+ process.exit(0);
57
+ }
58
+ const [baseUrl, ...extra] = positionals;
59
+ if (baseUrl === undefined || extra.length > 0) {
60
+ console.error(baseUrl === undefined
61
+ ? "A Worker's base URL is required.\n"
62
+ : `Expected one base URL, got ${positionals.length}.\n`);
63
+ console.error(USAGE);
64
+ process.exit(2);
65
+ }
66
+ /** The order `tally` fixes, which is the order a reader of `conformance/README.md` meets them in. */
67
+ const ORDER = ["passes", "fails", "notExercised", "unverified", "otherSubject"];
68
+ function print(report) {
69
+ const counts = tally(report.results);
70
+ console.log(`\n${report.baseUrl}`);
71
+ console.log(`edition ${report.edition ?? "(none declared)"}, verifier ${report.verifierEdition}\n`);
72
+ for (const verdict of ORDER) {
73
+ console.log(` ${verdict.padEnd(14)}${String(counts[verdict]).padStart(4)}`);
74
+ }
75
+ const failures = report.results.filter((result) => result.verdict === "fails");
76
+ if (failures.length === 0)
77
+ return;
78
+ console.log("\nfailed:\n");
79
+ for (const { rule, detail } of failures) {
80
+ // The class is printed beside the id and never folded into the verdict. A `recommended` rule
81
+ // that is not met is still reported — spec/README.md says this specification has standing to
82
+ // give advice — and it is this column that keeps a reader from reading advice as a contract.
83
+ console.log(` ${rule.id.padEnd(10)} ${rule.class.padEnd(12)} ${rule.file}`);
84
+ if (detail !== undefined)
85
+ console.log(` ${detail}`);
86
+ }
87
+ }
88
+ let report;
89
+ try {
90
+ report = await verify({
91
+ baseUrl,
92
+ credential: values.credential ?? process.env.WORKER_PROTOCOL_CREDENTIAL,
93
+ mayPerform: values["may-perform"],
94
+ });
95
+ }
96
+ catch (thrown) {
97
+ // A backstop, and it should stay empty. `verify` turns an unreachable Worker, an unresolvable
98
+ // host and a base URL that is not a URL into verdicts of their own, so anything arriving here is
99
+ // this tool failing rather than the Worker — and reporting that as a conformance failure would be
100
+ // the one mistake a CI could not see through.
101
+ console.error(`Could not verify ${baseUrl}: ${thrown.message}`);
102
+ process.exit(2);
103
+ }
104
+ if (values.json) {
105
+ console.log(JSON.stringify(report, null, 2));
106
+ }
107
+ else {
108
+ print(report);
109
+ }
110
+ // DESC-25 binds a verifier rather than a Worker: one that does not hold the declared MAJOR has
111
+ // verified nothing and says so. Exit 2, for the same reason as the catch above — the Worker has
112
+ // not been judged, and the thing that is behind is this tool.
113
+ if (report.older) {
114
+ if (!values.json) {
115
+ console.error(`\nThis verifier encodes edition ${report.verifierEdition} and the Worker declares ` +
116
+ `${report.edition}. Nothing was judged. Install a newer @worker-protocol/conformance.`);
117
+ }
118
+ process.exit(2);
119
+ }
120
+ process.exit(report.results.some((result) => result.verdict === "fails") ? 1 : 0);
@@ -0,0 +1,95 @@
1
+ import type { Attribution } from "./attribution.ts";
2
+ import { type Code } from "./checks/endpoints.ts";
3
+ import { type Report, type Rule } from "./report.ts";
4
+ export type { Attribution } from "./attribution.ts";
5
+ export type { Report, Result, Rule, Verdict } from "./report.ts";
6
+ export { tally } from "./report.ts";
7
+ export type { Exchange } from "./transcript.ts";
8
+ /**
9
+ * `@worker-protocol/conformance` — point it at a Worker's base URL, get a report of what it
10
+ * complies with.
11
+ *
12
+ * Its subject is a Worker and nothing else. Twenty-seven rules in `spec/` bind a verifier, a Control
13
+ * Tower, a consumer, an issuer or the specification itself; this tool reports those as
14
+ * `otherSubject` rather than passing them, because it never contacted the party they oblige.
15
+ * `conformance/verifiability.md` is where that classification is decided and `conformance/
16
+ * README.md` holds the verdict vocabulary.
17
+ *
18
+ * Nothing here carries behaviour of its own. Every check reports against a rule id, and a check
19
+ * that observed something `spec/` does not require would be this package making the standard.
20
+ */
21
+ export type VerifyOptions = {
22
+ /** The Worker's enrolled base URL, with or without a path. */
23
+ baseUrl: string;
24
+ /** Presented as `Authorization: Bearer <token>` (REG-3). */
25
+ credential?: string;
26
+ /**
27
+ * Whether the verifier may POST to this Worker.
28
+ *
29
+ * Every surface but `actions` is read, and a read establishes what it establishes and leaves the
30
+ * Worker as it found it. An Action is an operation somebody's operators chose to expose, so the
31
+ * default is `false`: a tool pointed at a Worker to inspect it does not perform work on it
32
+ * uninvited, and the rules that need a POST report `notExercised` with that as the reason.
33
+ */
34
+ mayPerform?: boolean;
35
+ /**
36
+ * What the Worker's operators arranged so that a rule with no ordinary witness can be observed.
37
+ *
38
+ * `conformance/verifiability.md` classes a rule `H` when nothing a tool can do to an unarranged
39
+ * Worker will ever see a violation — a performance that succeeds, an input refused on content, a
40
+ * second credential. The arrangement cannot come from the protocol, because putting test
41
+ * scaffolding into a Descriptor would make every Worker in the network carry it. So it arrives
42
+ * the way the base URL and the credential do: out of band, from the person who set it up.
43
+ *
44
+ * Anything not arranged reports `notExercised` naming what was missing, which is a gap somebody
45
+ * can close rather than a verdict.
46
+ */
47
+ arrangement?: Arrangement;
48
+ /** For tests and for a caller that needs its own agent. Defaults to the global `fetch`. */
49
+ fetch?: typeof globalThis.fetch;
50
+ };
51
+ export type Arrangement = {
52
+ /** An Action that is safe to perform, and an input its declared schema accepts. */
53
+ safeAction?: {
54
+ name: string;
55
+ input: unknown;
56
+ };
57
+ /** An input that is schema-valid and that the Worker refuses on its own rules (ACT-9). */
58
+ refusedInput?: {
59
+ name: string;
60
+ input: unknown;
61
+ };
62
+ /** An Action that declares it does not complete within the call, and an input for it (ACT-11). */
63
+ asyncAction?: {
64
+ name: string;
65
+ input: unknown;
66
+ };
67
+ /** A second credential issued to the same holder (REG-8, REG-28, ALRT-6). */
68
+ secondCredential?: string;
69
+ /**
70
+ * A credential issued under a Contract rather than recorded at enrollment (TASK-6).
71
+ *
72
+ * TASK-6 compares what it sees against what the recorded credential sees. Where none is given it
73
+ * falls back to the second credential.
74
+ */
75
+ consumerCredential?: string;
76
+ /** A credential the Worker authenticates and that carries no right here (REG-32). */
77
+ unprivilegedCredential?: string;
78
+ /** That this Worker was started moments ago, so HLTH-4's window is still open. */
79
+ justStarted?: boolean;
80
+ /** That the operators will let the verifier write this Worker's settings back (ACT-14). */
81
+ replaceableSettings?: boolean;
82
+ /** An event this Worker published, since a verifier holds no broker and sees none (EVT-1). */
83
+ publishedEvent?: unknown;
84
+ /** Resolved by `verify` and not by a caller: the addresses the arranged checks need. */
85
+ healthUrl?: string;
86
+ actionsUrl?: string;
87
+ };
88
+ type Universe = {
89
+ rules: Rule[];
90
+ codes: Code[];
91
+ attribution: Attribution;
92
+ };
93
+ /** Generated from `spec/` by `src/generate-rules.ts` and committed beside the source. */
94
+ export declare function universe(): Promise<Universe>;
95
+ export declare function verify(options: VerifyOptions): Promise<Report>;