@worker-protocol/conformance 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/dist/attribution.d.ts +13 -0
- package/dist/attribution.js +27 -0
- package/dist/checks/actions.d.ts +24 -0
- package/dist/checks/actions.js +345 -0
- package/dist/checks/activity.d.ts +18 -0
- package/dist/checks/activity.js +64 -0
- package/dist/checks/alerts.d.ts +16 -0
- package/dist/checks/alerts.js +84 -0
- package/dist/checks/arranged.d.ts +27 -0
- package/dist/checks/arranged.js +232 -0
- package/dist/checks/descriptor.d.ts +34 -0
- package/dist/checks/descriptor.js +179 -0
- package/dist/checks/endpoints.d.ts +17 -0
- package/dist/checks/endpoints.js +139 -0
- package/dist/checks/events.d.ts +16 -0
- package/dist/checks/events.js +58 -0
- package/dist/checks/health.d.ts +13 -0
- package/dist/checks/health.js +85 -0
- package/dist/checks/metrics.d.ts +14 -0
- package/dist/checks/metrics.js +422 -0
- package/dist/checks/nudges.d.ts +20 -0
- package/dist/checks/nudges.js +77 -0
- package/dist/checks/surfaces.d.ts +14 -0
- package/dist/checks/surfaces.js +249 -0
- package/dist/checks/tasks.d.ts +23 -0
- package/dist/checks/tasks.js +171 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +120 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +128 -0
- package/dist/report.d.ts +87 -0
- package/dist/report.js +71 -0
- package/dist/transcript.d.ts +44 -0
- package/dist/transcript.js +49 -0
- package/package.json +51 -0
- package/rules.json +1242 -0
|
@@ -0,0 +1,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
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);
|
package/dist/index.d.ts
ADDED
|
@@ -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>;
|