@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,139 @@
|
|
|
1
|
+
import { error as errorSchema } from "@worker-protocol/schemas";
|
|
2
|
+
import { verdicts } from "../report.js";
|
|
3
|
+
import { isJson } from "../transcript.js";
|
|
4
|
+
/**
|
|
5
|
+
* The rules that are about every response rather than about one surface.
|
|
6
|
+
*
|
|
7
|
+
* These run last, over the whole transcript, because several of them are statements no single
|
|
8
|
+
* exchange can break: ENDP-26 forbids one code arriving under two statuses, which is a fact about
|
|
9
|
+
* a set. A check that asked it inside one surface would be asking a question it could not answer.
|
|
10
|
+
*/
|
|
11
|
+
export const CLAIMS = [
|
|
12
|
+
"ENDP-1",
|
|
13
|
+
"ENDP-11",
|
|
14
|
+
"ENDP-4",
|
|
15
|
+
"ENDP-5",
|
|
16
|
+
"ENDP-19",
|
|
17
|
+
"ENDP-25",
|
|
18
|
+
"ENDP-26",
|
|
19
|
+
"ENDP-29",
|
|
20
|
+
];
|
|
21
|
+
export function judgeTranscript(exchanges, codes, declared, rules) {
|
|
22
|
+
const { results, say } = verdicts(rules, CLAIMS);
|
|
23
|
+
if (exchanges.length === 0) {
|
|
24
|
+
for (const id of CLAIMS)
|
|
25
|
+
say(id, "notExercised", "no response was collected");
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* ENDP-1 is about an address, not about a URL.
|
|
30
|
+
*
|
|
31
|
+
* A read carries its parameters in the query string — MET-16 spells a dimension into one — and
|
|
32
|
+
* comparing whole URLs would report every filtered read as an undeclared address. What the
|
|
33
|
+
* Descriptor declares is where a surface answers; what a caller puts after the `?` is the
|
|
34
|
+
* question it asks there, and each surface's own file says which parameters those are.
|
|
35
|
+
*/
|
|
36
|
+
const address = (url) => {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
39
|
+
};
|
|
40
|
+
const addresses = new Set([...declared].map(address));
|
|
41
|
+
const byCode = new Map(codes.map((c) => [c.code, c]));
|
|
42
|
+
const failures = new Map();
|
|
43
|
+
const fail = (id, why) => {
|
|
44
|
+
const already = failures.get(id);
|
|
45
|
+
if (already)
|
|
46
|
+
already.push(why);
|
|
47
|
+
else
|
|
48
|
+
failures.set(id, [why]);
|
|
49
|
+
};
|
|
50
|
+
for (const exchange of exchanges) {
|
|
51
|
+
const where = `${exchange.method} ${exchange.url} → ${exchange.status}`;
|
|
52
|
+
// ENDP-1: every address other than the Descriptor's own route is declared in the Descriptor.
|
|
53
|
+
// The verifier can only judge its own behaviour here: it reaches an address because it read
|
|
54
|
+
// one, so what this establishes is that nothing it called was undeclared.
|
|
55
|
+
if (!addresses.has(address(exchange.url))) {
|
|
56
|
+
fail("ENDP-1", `${where} — an address the Descriptor did not declare`);
|
|
57
|
+
}
|
|
58
|
+
// ENDP-4: bodies and responses are JSON, UTF-8, `application/json`.
|
|
59
|
+
//
|
|
60
|
+
// A response with no body is not judged, and that is the rule read as written rather than a
|
|
61
|
+
// concession. A content type is a claim ABOUT a body; ACT-10 and ACT-11 have a Worker answer
|
|
62
|
+
// `204` and `202` with none, and requiring one there would be this verifier inventing an
|
|
63
|
+
// obligation out of a sentence that constrains bodies.
|
|
64
|
+
if (exchange.body.length > 0) {
|
|
65
|
+
if (!isJson(exchange.headers)) {
|
|
66
|
+
const got = exchange.headers.get("content-type") ?? "(none)";
|
|
67
|
+
fail("ENDP-4", `${where} — content-type ${got}`);
|
|
68
|
+
}
|
|
69
|
+
else if (exchange.json === null) {
|
|
70
|
+
fail("ENDP-4", `${where} — said application/json and did not parse`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ENDP-5: every protocol response carries both headers, stating what produced it.
|
|
74
|
+
for (const header of ["worker-protocol-edition", "worker-protocol-capability-version"]) {
|
|
75
|
+
if (!exchange.headers.has(header))
|
|
76
|
+
fail("ENDP-5", `${where} — no ${header}`);
|
|
77
|
+
}
|
|
78
|
+
if (exchange.status >= 200 && exchange.status < 300)
|
|
79
|
+
continue;
|
|
80
|
+
// ENDP-29: a response that is not a success carries one of the statuses the table lists. The
|
|
81
|
+
// success side is deliberately open, which is why only this branch is judged.
|
|
82
|
+
const statuses = new Set(codes.map((c) => c.status));
|
|
83
|
+
if (!statuses.has(exchange.status)) {
|
|
84
|
+
fail("ENDP-29", `${where} — a status no code in spec/endpoints.md names`);
|
|
85
|
+
}
|
|
86
|
+
// ENDP-25: every response that is not a success carries the shared envelope.
|
|
87
|
+
const envelope = errorSchema.safeParse(exchange.json);
|
|
88
|
+
if (!envelope.success) {
|
|
89
|
+
fail("ENDP-25", `${where} — ${envelope.error.issues[0]?.message ?? "no error envelope"}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// ENDP-26: a Worker answers a code with the status that code names. The class is already
|
|
93
|
+
// carried with the code by schemas/error.json, which is the half a schema can assert; this is
|
|
94
|
+
// the other half, and it is the reason the status table is generated rather than retyped.
|
|
95
|
+
const expected = byCode.get(envelope.data.code);
|
|
96
|
+
if (expected && expected.status !== exchange.status) {
|
|
97
|
+
fail("ENDP-26", `${where} — the code \`${envelope.data.code}\` fixes ${expected.status}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ENDP-11: a Worker does not answer 5xx for a condition that will not change. A bad body
|
|
101
|
+
// answered with a 500 is an instruction to redeliver an unusable payload forever, and the
|
|
102
|
+
// sender will comply. The witness is ordinarily out of reach — nothing outside can tell a
|
|
103
|
+
// transient fault from a permanent one — but the verifier knows which of its OWN requests were
|
|
104
|
+
// deliberately and permanently wrong, because it made them that way.
|
|
105
|
+
const permanent = exchanges.filter((exchange) => exchange.permanent);
|
|
106
|
+
const wrongly = permanent.filter((exchange) => exchange.status >= 500);
|
|
107
|
+
if (permanent.length === 0) {
|
|
108
|
+
say("ENDP-11", "notExercised", "the run provoked no condition that will not change");
|
|
109
|
+
}
|
|
110
|
+
else if (wrongly.length === 0) {
|
|
111
|
+
say("ENDP-11", "passes");
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const first = wrongly[0];
|
|
115
|
+
say("ENDP-11", "fails", `${first.intent} answered ${first.status}`);
|
|
116
|
+
}
|
|
117
|
+
// ENDP-19 recommends that a Worker cap the page size rather than negotiating it. The witness is
|
|
118
|
+
// a collection longer than the cap, which is a cursor coming back; short of that there is
|
|
119
|
+
// nothing to see, and a Worker whose collections all fit in one page has not been observed
|
|
120
|
+
// either following it or not.
|
|
121
|
+
const capped = exchanges.some((exchange) => {
|
|
122
|
+
const cursor = exchange.json?.nextCursor;
|
|
123
|
+
return typeof cursor === "string" && cursor.length > 0;
|
|
124
|
+
});
|
|
125
|
+
if (capped)
|
|
126
|
+
say("ENDP-19", "passes");
|
|
127
|
+
else
|
|
128
|
+
say("ENDP-19", "notExercised", "no collection was long enough to be capped");
|
|
129
|
+
for (const id of CLAIMS) {
|
|
130
|
+
if (id === "ENDP-11" || id === "ENDP-19")
|
|
131
|
+
continue;
|
|
132
|
+
const why = failures.get(id);
|
|
133
|
+
if (why === undefined)
|
|
134
|
+
say(id, "passes");
|
|
135
|
+
else
|
|
136
|
+
say(id, "fails", why.length === 1 ? why[0] : `${why.length} responses: ${why[0]}, …`);
|
|
137
|
+
}
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Attribution } from "../attribution.ts";
|
|
2
|
+
import { type Result, type Rule } from "../report.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The `events` Capability, which is as far as a Descriptor reaches.
|
|
5
|
+
*
|
|
6
|
+
* **Nothing here calls anything, because there is nothing to call.** An event travels over a broker
|
|
7
|
+
* this protocol declines to name, so DESC-22 leaves the shared entry's address optional for this
|
|
8
|
+
* one Capability and a verifier has no surface to point at. Four of its nine rules are read off the
|
|
9
|
+
* Descriptor and the other five are somebody else's or nobody's — a protocol that declines to own
|
|
10
|
+
* a transport declines to see what crosses it, and that is a property of the design rather than a
|
|
11
|
+
* gap in this file.
|
|
12
|
+
*
|
|
13
|
+
* It takes no transcript for the same reason. A check that sent nothing is the honest shape here.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CLAIMS: readonly ["EVT-11", "EVT-12", "EVT-4", "EVT-8"];
|
|
16
|
+
export declare function checkEvents(entry: Record<string, unknown> | undefined, rules: Map<string, Rule>, attribution: Attribution): Result[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { eventsEntry } from "@worker-protocol/schemas";
|
|
2
|
+
import { ruleFor } from "../attribution.js";
|
|
3
|
+
import { verdicts } from "../report.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `events` Capability, which is as far as a Descriptor reaches.
|
|
6
|
+
*
|
|
7
|
+
* **Nothing here calls anything, because there is nothing to call.** An event travels over a broker
|
|
8
|
+
* this protocol declines to name, so DESC-22 leaves the shared entry's address optional for this
|
|
9
|
+
* one Capability and a verifier has no surface to point at. Four of its nine rules are read off the
|
|
10
|
+
* Descriptor and the other five are somebody else's or nobody's — a protocol that declines to own
|
|
11
|
+
* a transport declines to see what crosses it, and that is a property of the design rather than a
|
|
12
|
+
* gap in this file.
|
|
13
|
+
*
|
|
14
|
+
* It takes no transcript for the same reason. A check that sent nothing is the honest shape here.
|
|
15
|
+
*/
|
|
16
|
+
export const CLAIMS = ["EVT-11", "EVT-12", "EVT-4", "EVT-8"];
|
|
17
|
+
export function checkEvents(entry, rules, attribution) {
|
|
18
|
+
const { results, say } = verdicts(rules, CLAIMS);
|
|
19
|
+
if (entry === undefined) {
|
|
20
|
+
for (const id of CLAIMS)
|
|
21
|
+
say(id, "notExercised", "the Worker declares no `events`");
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
24
|
+
const declared = eventsEntry.safeParse(entry);
|
|
25
|
+
if (!declared.success) {
|
|
26
|
+
const blamed = new Set();
|
|
27
|
+
for (const issue of declared.error.issues) {
|
|
28
|
+
const id = ruleFor(attribution, "events-entry", issue.path) ?? "EVT-11";
|
|
29
|
+
if (blamed.has(id))
|
|
30
|
+
continue;
|
|
31
|
+
blamed.add(id);
|
|
32
|
+
say(id, "fails", `${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
33
|
+
}
|
|
34
|
+
for (const id of CLAIMS) {
|
|
35
|
+
if (!blamed.has(id))
|
|
36
|
+
say(id, "notExercised", "the `events` entry did not validate");
|
|
37
|
+
}
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
const { publishes } = declared.data;
|
|
41
|
+
// EVT-11 and EVT-8 are what validation established: a broker, a protocol binding, a destination
|
|
42
|
+
// and the window a consumer sizes its deduplication store against. Neither string is parsed —
|
|
43
|
+
// this protocol names no broker and fixes no protocol binding — so a check says both are there.
|
|
44
|
+
say("EVT-11", "passes");
|
|
45
|
+
say("EVT-8", "passes");
|
|
46
|
+
// EVT-12 and EVT-4: every event type it publishes, each with the schema of its data, under a
|
|
47
|
+
// qualified name. A Worker that publishes none exercises neither, and an empty map is
|
|
48
|
+
// conformant — DESC-2 needs no qualification for a Capability declared with nothing in it.
|
|
49
|
+
if (Object.keys(publishes).length === 0) {
|
|
50
|
+
say("EVT-12", "notExercised", "the entry declares no event type");
|
|
51
|
+
say("EVT-4", "notExercised", "the entry declares no event type");
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
say("EVT-12", "passes");
|
|
55
|
+
say("EVT-4", "passes");
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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 `health` Capability.
|
|
6
|
+
*
|
|
7
|
+
* HLTH-4 is not here and is not missing: a Worker that has not yet established its state answers
|
|
8
|
+
* `unhealthy`, and the only window in which that is observable is between a process starting and
|
|
9
|
+
* its first evaluation. Only whoever started the Worker knows a poll is inside one, so it is
|
|
10
|
+
* classified `H` and lives in `checks/arranged.ts` with the rest of what an arrangement reaches.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CLAIMS: readonly ["HLTH-1", "HLTH-2", "HLTH-3", "HLTH-5"];
|
|
13
|
+
export declare function checkHealth(entry: Record<string, unknown> | undefined, url: string | null, rules: Map<string, Rule>, attribution: Attribution, transcript: Transcript): Promise<Result[]>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { healthEntry, health as healthSchema } from "@worker-protocol/schemas";
|
|
2
|
+
import { ruleFor } from "../attribution.js";
|
|
3
|
+
import { verdicts } from "../report.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `health` Capability.
|
|
6
|
+
*
|
|
7
|
+
* HLTH-4 is not here and is not missing: a Worker that has not yet established its state answers
|
|
8
|
+
* `unhealthy`, and the only window in which that is observable is between a process starting and
|
|
9
|
+
* its first evaluation. Only whoever started the Worker knows a poll is inside one, so it is
|
|
10
|
+
* classified `H` and lives in `checks/arranged.ts` with the rest of what an arrangement reaches.
|
|
11
|
+
*/
|
|
12
|
+
export const CLAIMS = ["HLTH-1", "HLTH-2", "HLTH-3", "HLTH-5"];
|
|
13
|
+
export async function checkHealth(entry, url, rules, attribution, transcript) {
|
|
14
|
+
const { results, say } = verdicts(rules, CLAIMS);
|
|
15
|
+
if (entry === undefined) {
|
|
16
|
+
// DESC-2: a Worker that declares no `health` is conformant, and DESC-1's argument says what
|
|
17
|
+
// stands in for it — the Tower fetches the Descriptor on a schedule anyway.
|
|
18
|
+
for (const id of CLAIMS)
|
|
19
|
+
say(id, "notExercised", "the Worker declares no `health`");
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
// HLTH-1: a `health` entry declares an address. A verifier sees this in the Descriptor and fails
|
|
23
|
+
// the Worker without calling anything.
|
|
24
|
+
//
|
|
25
|
+
// It parses `healthEntry` and attributes the issue rather than reaching for `entry.address` by
|
|
26
|
+
// hand, which is what every other Capability here does. A check that read the shape itself would
|
|
27
|
+
// be the verifier holding an opinion about a document `schemas/` already describes.
|
|
28
|
+
const declared = healthEntry.safeParse(entry);
|
|
29
|
+
if (!declared.success) {
|
|
30
|
+
const issue = declared.error.issues[0];
|
|
31
|
+
const id = ruleFor(attribution, "health-entry", issue?.path ?? []) ?? "HLTH-1";
|
|
32
|
+
say(id, "fails", `${issue?.path.join(".") || "(root)"}: ${issue?.message}`);
|
|
33
|
+
for (const other of CLAIMS) {
|
|
34
|
+
if (other !== id)
|
|
35
|
+
say(other, "notExercised", "the `health` entry did not validate");
|
|
36
|
+
}
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
say("HLTH-1", "passes");
|
|
40
|
+
if (url === null) {
|
|
41
|
+
for (const id of ["HLTH-2", "HLTH-3", "HLTH-5"]) {
|
|
42
|
+
say(id, "notExercised", "the declared address did not resolve");
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
let answer;
|
|
47
|
+
try {
|
|
48
|
+
answer = await transcript.send(url, "the `health` address");
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
const why = cause instanceof Error ? cause.message : String(cause);
|
|
52
|
+
for (const id of ["HLTH-2", "HLTH-3", "HLTH-5"]) {
|
|
53
|
+
say(id, "notExercised", `${url} could not be reached: ${why}`);
|
|
54
|
+
}
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
// HLTH-5: a Worker answers its health address 200 whatever it reports. Anything else means the
|
|
58
|
+
// Worker did not answer, not that it is unwell — which is the one distinction a health surface
|
|
59
|
+
// exists to draw, and the one `503` destroys.
|
|
60
|
+
if (answer.status === 200) {
|
|
61
|
+
say("HLTH-5", "passes");
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
say("HLTH-5", "fails", `answered ${answer.status}; the status is read from the body`);
|
|
65
|
+
}
|
|
66
|
+
const parsed = healthSchema.safeParse(answer.json);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
const issue = parsed.error.issues[0];
|
|
69
|
+
say("HLTH-2", "fails", `${issue?.path.join(".") || "(root)"}: ${issue?.message}`);
|
|
70
|
+
say("HLTH-3", "notExercised", "the answer did not validate, so it could not be judged");
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
say("HLTH-2", "passes");
|
|
74
|
+
// HLTH-3: a Worker does not answer `healthy` while any check it reports is not. It is what stops
|
|
75
|
+
// the summary from being decorative — the one field every console renders first.
|
|
76
|
+
const unwell = Object.entries(parsed.data.checks).filter(([, c]) => c.status !== "healthy");
|
|
77
|
+
if (parsed.data.status === "healthy" && unwell.length > 0) {
|
|
78
|
+
const names = unwell.map(([name, c]) => `${name} is ${c.status}`).join(", ");
|
|
79
|
+
say("HLTH-3", "fails", `the Worker answers \`healthy\` while ${names}`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
say("HLTH-3", "passes");
|
|
83
|
+
}
|
|
84
|
+
return results;
|
|
85
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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 `metrics` Capability.
|
|
6
|
+
*
|
|
7
|
+
* The most checkable file in the specification — nineteen of its twenty rules have a witness a
|
|
8
|
+
* tool holding one ordinary credential can reach — and the one that needs a Worker with numbers in
|
|
9
|
+
* it. Several checks below report `notExercised` against a Worker that has accumulated nothing,
|
|
10
|
+
* which is not a weaker verdict than `passes`: it says a check exists and this run did not reach
|
|
11
|
+
* it, which is a gap somebody can close by pointing the tool at a Worker that has been running.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CLAIMS: readonly ["MET-1", "MET-21", "MET-3", "MET-4", "MET-5", "MET-6", "MET-7", "MET-8", "MET-9", "MET-10", "MET-11", "MET-12", "MET-13", "MET-14", "MET-16", "MET-17", "MET-18", "MET-19", "MET-20", "NAME-1", "ENDP-20", "ENDP-23"];
|
|
14
|
+
export declare function checkMetrics(entry: Record<string, unknown> | undefined, url: string | null, rules: Map<string, Rule>, attribution: Attribution, transcript: Transcript): Promise<Result[]>;
|