@fangorn-network/westmarch 0.0.1-dev
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.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
package/consume/lint.js
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// What will the index be able to do with what you baked?
|
|
2
|
+
//
|
|
3
|
+
// Every capability on the consumer side is paid for at bake time, and the bill
|
|
4
|
+
// is invisible until a reader hits it. A corpus with no `coverage` can never be
|
|
5
|
+
// ranked — it is simply absent from every question anyone asks. One with no
|
|
6
|
+
// `role_map` renders as a column of filenames. One whose `paywall` names fields
|
|
7
|
+
// the free shard ships anyway puts a price on data it already gave away, which
|
|
8
|
+
// is the bug that shipped in this repo's own fixture and that nobody noticed for
|
|
9
|
+
// weeks because nothing looks broken.
|
|
10
|
+
//
|
|
11
|
+
// So this is the publisher's mirror of `directory.js`: the same fetches, read
|
|
12
|
+
// for what is MISSING. It is deliberately not a schema validator — a bundle is
|
|
13
|
+
// allowed to declare nothing, and the honest report for that is "your rows will
|
|
14
|
+
// appear as filenames and nobody will find them", not an error.
|
|
15
|
+
//
|
|
16
|
+
// ponytail: one pass, no caching, no severity config. Three levels, because
|
|
17
|
+
// there are three real outcomes — the reader cannot find you, they find you and
|
|
18
|
+
// cannot read you, or they read you and cannot go anywhere.
|
|
19
|
+
|
|
20
|
+
import { configure, loadShard, resetShard, trimView } from "./shard.js";
|
|
21
|
+
import { shapeOf } from "./ui.js";
|
|
22
|
+
import { inSample, rolesFrom, textOf } from "./roles.js";
|
|
23
|
+
|
|
24
|
+
/** The model every corpus on this network is baked with. Cosine between two
|
|
25
|
+
* models' vectors is noise that still looks like a score, so a mismatch is not
|
|
26
|
+
* a warning — it is exclusion from the directory. */
|
|
27
|
+
export const NETWORK_MODEL = "nomic-ai/nomic-embed-text-v1.5";
|
|
28
|
+
|
|
29
|
+
const F = (level, what, why, fix) => ({ level, what, why, fix });
|
|
30
|
+
|
|
31
|
+
async function readJson(url, timeoutMs = 8000) {
|
|
32
|
+
const ctl = new AbortController();
|
|
33
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(url, { signal: ctl.signal });
|
|
36
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
37
|
+
return await res.json();
|
|
38
|
+
} finally { clearTimeout(t); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Does this url actually answer? A HEAD where the host allows one, else a
|
|
42
|
+
* one-byte ranged GET — the point is the status code, never the body, and a
|
|
43
|
+
* paid shard is megabytes nobody should download to find out it was public. */
|
|
44
|
+
async function served(url, { bytes = null, timeoutMs = 8000 } = {}) {
|
|
45
|
+
const ctl = new AbortController();
|
|
46
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
47
|
+
try {
|
|
48
|
+
let res = await fetch(url, { method: "HEAD", signal: ctl.signal });
|
|
49
|
+
if (res.status === 405 || res.status === 501) res = await fetch(url, { headers: { range: "bytes=0-0" }, signal: ctl.signal });
|
|
50
|
+
if (!res.ok && res.status !== 206) return false;
|
|
51
|
+
// A status code alone proves nothing. Static hosts fall back to
|
|
52
|
+
// index.html for unknown paths — Cloudflare Pages does — so a deleted
|
|
53
|
+
// shard answers 200 with 10 KB of HTML, and a leak check that trusted
|
|
54
|
+
// `res.ok` would report every view hosted that way as leaking. The
|
|
55
|
+
// payload is gzipped ndjson; an HTML shell is the host saying no.
|
|
56
|
+
if (/text\/html/i.test(res.headers.get("content-type") ?? "")) return false;
|
|
57
|
+
// …and when the manifest says how big the file is, the host has to agree.
|
|
58
|
+
const len = Number(res.headers.get("content-length"));
|
|
59
|
+
if (bytes && Number.isFinite(len) && len > 0 && len !== bytes) return false;
|
|
60
|
+
return true;
|
|
61
|
+
} catch { return false; } // unreachable is not a leak
|
|
62
|
+
finally { clearTimeout(t); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lint one baked view.
|
|
67
|
+
*
|
|
68
|
+
* `rows` samples the free shard — the only way to catch a paywall that lies and
|
|
69
|
+
* vectors that are not there. It is the free index, the same bytes any reader
|
|
70
|
+
* downloads, but it is the slow part; pass `rows: false` for catalog-only.
|
|
71
|
+
*/
|
|
72
|
+
export async function lint(view, { rows = true, model = NETWORK_MODEL, timeoutMs } = {}) {
|
|
73
|
+
const at = trimView(view);
|
|
74
|
+
const found = [];
|
|
75
|
+
let cat;
|
|
76
|
+
try {
|
|
77
|
+
cat = await readJson(`${at}/cdn/catalog`, timeoutMs);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return {
|
|
80
|
+
view: at, reachable: false, domains: [],
|
|
81
|
+
findings: [F("blocks", "no catalog", `${at}/cdn/catalog — ${e.message}`,
|
|
82
|
+
"The directory reads this one file to learn you exist. Nothing works until it serves.")],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!cat.embedding_model) {
|
|
87
|
+
found.push(F("blocks", "no embedding_model in the catalog",
|
|
88
|
+
"The directory cannot tell whether your vectors are comparable to a reader's query.",
|
|
89
|
+
`Write "embedding_model": "${model}" at the top of the catalog.`));
|
|
90
|
+
} else if (cat.embedding_model !== model) {
|
|
91
|
+
found.push(F("blocks", `embedded with ${cat.embedding_model}`,
|
|
92
|
+
`The network shares ${model}. Cosine between two models is noise that still looks like a score, so readers exclude you rather than rank you wrong.`,
|
|
93
|
+
"Re-bake with the network model, or accept that only readers using yours will see this corpus."));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const domains = [];
|
|
97
|
+
for (const d of cat.domains ?? []) {
|
|
98
|
+
const dom = { name: d.name, rows: d.count ?? 0, findings: [] };
|
|
99
|
+
const add = (...a) => dom.findings.push(F(...a));
|
|
100
|
+
// Deferred until the rows are in hand — see its call site.
|
|
101
|
+
const deadEnd = (playable) => {
|
|
102
|
+
const doors = Object.keys(roles.externalUrl ?? {}).length + Object.keys(roles.actions ?? {}).length;
|
|
103
|
+
// A declared reference is a destination too, and the most important
|
|
104
|
+
// kind here: a corpus of subtitles carries no media of its own and is
|
|
105
|
+
// not a dead end, because every row of it resolves into the film it
|
|
106
|
+
// was transcribed from and plays there.
|
|
107
|
+
if (doors || roles.launch || playable || roles.refers) return;
|
|
108
|
+
add("degrades", "nowhere to go",
|
|
109
|
+
"With no presentation.externalUrl, no actions, no launch, and no row a reader could play where it stands, a hit is a dead end.",
|
|
110
|
+
"Either declare where a row lives — presentation.externalUrl per entity type — or ship a media role the rows actually carry, and the stage will play it here.");
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (!d.coverage?.vectors?.length) {
|
|
114
|
+
add("blocks", "no coverage centroids",
|
|
115
|
+
"find-corpora ranks corpora it has not downloaded by matching a question against ~4 KB of published centroids. Without them this corpus scores null — sorted last on every question, and never opened by an agent that has anything else to try.",
|
|
116
|
+
"Re-bake: coverage is spherical k-means over a sample of your own vectors. It is the cheapest thing in the pipeline and the only thing that makes you findable.");
|
|
117
|
+
}
|
|
118
|
+
if (!d.description) {
|
|
119
|
+
add("degrades", "no description",
|
|
120
|
+
"The one line a person reads to decide whether to open you.",
|
|
121
|
+
"One sentence saying what is in here and roughly when it is from.");
|
|
122
|
+
}
|
|
123
|
+
if (!(d.entity_types ?? []).length) {
|
|
124
|
+
add("degrades", "no entity_types",
|
|
125
|
+
"browse-collection has nothing to group by, so 'show me what's here' returns one undifferentiated list.",
|
|
126
|
+
"Declare the kinds your rows come in and their counts.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The manifest: the shape, the price and the door out.
|
|
130
|
+
let man = null;
|
|
131
|
+
try { man = await readJson(`${at}/cdn/domains/${encodeURIComponent(d.name)}/manifest`, timeoutMs); }
|
|
132
|
+
catch { /* reported below as an absent role_map */ }
|
|
133
|
+
const roles = rolesFrom(man ? [man] : []);
|
|
134
|
+
|
|
135
|
+
if (!roles.declared) {
|
|
136
|
+
add("degrades", "no role_map",
|
|
137
|
+
"Readers fall back to sniffing field names. Titles become whatever looked title-ish, and the text a search matches is a guess.",
|
|
138
|
+
"Declare title, text, subtitle and tags. It is the highest-value dozen lines in the manifest.");
|
|
139
|
+
} else {
|
|
140
|
+
if (!roles.text.length) {
|
|
141
|
+
add("blocks", "role_map declares no text",
|
|
142
|
+
"The prose a query matches is empty, so lexical search matches nothing and every hit rests on the vector alone.",
|
|
143
|
+
"Name the field(s) holding the describing prose.");
|
|
144
|
+
}
|
|
145
|
+
if (!roles.title.length) {
|
|
146
|
+
add("degrades", "role_map declares no title",
|
|
147
|
+
"Every result is labelled by id.",
|
|
148
|
+
"Name the field a person would call the title.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const pay = man?.paywall ?? d.paywall;
|
|
153
|
+
// The check this pipeline did not have, and the reason it now does.
|
|
154
|
+
//
|
|
155
|
+
// `archive-transcripts` shipped a paywall over six fields — text, cues,
|
|
156
|
+
// start, end, role, kind — and served the file holding them from the same
|
|
157
|
+
// public directory as the free index. A 200. Plain gzip. 20,916 rows of
|
|
158
|
+
// dialogue anyone could curl, next to a manifest saying they cost 0.05
|
|
159
|
+
// USDC. The manifest said locked and the bytes said otherwise, and
|
|
160
|
+
// nothing in the pipeline had ever compared the two.
|
|
161
|
+
//
|
|
162
|
+
// Every other paywall finding here reads the manifest and reasons about
|
|
163
|
+
// it. This one FETCHES, because that is the only way to learn the thing
|
|
164
|
+
// that actually matters: whether the payload is behind anything at all.
|
|
165
|
+
for (const f of pay?.files ?? []) {
|
|
166
|
+
if (!f?.file) continue;
|
|
167
|
+
const url = `${at}/cdn/domains/${encodeURIComponent(d.name)}/shards/${f.file}`;
|
|
168
|
+
if (!(await served(url, { bytes: f.bytes ?? null, timeoutMs }))) continue;
|
|
169
|
+
add("blocks", "the paid payload is served in the clear",
|
|
170
|
+
`${f.file} answers 200 on the same public view as the free index — ${(f.count ?? 0).toLocaleString()} rows `
|
|
171
|
+
+ `of the ${(pay.locked ?? []).length} field${(pay.locked ?? []).length === 1 ? "" : "s"} you are charging for, `
|
|
172
|
+
+ `fetchable by anyone who reads the manifest that names it. The price is decoration.`,
|
|
173
|
+
"Either serve this file from somewhere a payment gates, or drop the paywall and say the data is free. "
|
|
174
|
+
+ "Publishing both a price and the bytes is worse than either, because a reader who paid was charged for a public file.");
|
|
175
|
+
}
|
|
176
|
+
if (pay && !pay.resourceId) {
|
|
177
|
+
add("degrades", "priced but not purchasable",
|
|
178
|
+
"A paywall with no resourceId quotes a price nobody can pay.",
|
|
179
|
+
"Publish the payload and write its resourceId, or drop the paywall.");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (rows) {
|
|
183
|
+
resetShard();
|
|
184
|
+
// The text role is evaluated AT PARSE TIME, so a lint that does not
|
|
185
|
+
// wire it reads the default sniff and grades a corpus on prose the
|
|
186
|
+
// publisher never declared. (This file had that bug too, which is
|
|
187
|
+
// some evidence for how easy it is to have.)
|
|
188
|
+
configure({ rowText: (f) => textOf(f, roles), onManifests: () => {} });
|
|
189
|
+
let sample = [];
|
|
190
|
+
try { sample = (await loadShard(at)).slice(0, 2000); }
|
|
191
|
+
catch (e) { add("blocks", "the shard did not load", e.message, "The free index is what a reader streams. If it does not serve, nothing else matters."); }
|
|
192
|
+
|
|
193
|
+
if (sample.length) {
|
|
194
|
+
const withVec = sample.filter((r) => r.vector?.length).length;
|
|
195
|
+
if (!withVec) {
|
|
196
|
+
add("blocks", "no vectors on the sampled rows",
|
|
197
|
+
"Search silently degrades to word matching, and taste, recommend and similar-rows stop working entirely.",
|
|
198
|
+
"The bake writes packed vectors per row. Check they survived the shard write.");
|
|
199
|
+
} else if (withVec < sample.length) {
|
|
200
|
+
add("degrades", `${sample.length - withVec} of ${sample.length} sampled rows have no vector`,
|
|
201
|
+
"Those rows can only ever be found by word match, and never by taste.",
|
|
202
|
+
"Usually rows whose text role was empty at bake time.");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A text role that is present and useless. Two buying agents in the
|
|
206
|
+
// market simulation hit this from opposite directions: a corpus
|
|
207
|
+
// sold as "21,131 timed dialogue rows" had baked the FILENAME
|
|
208
|
+
// into its text field, so every question about what was said
|
|
209
|
+
// returned nothing, and both agents read that as "worthless
|
|
210
|
+
// corpus" rather than "broken index". A publisher cannot see it
|
|
211
|
+
// — their manifest is correct and their rows have a text field.
|
|
212
|
+
const texts = sample.map((r) => String(r.text ?? "")).filter(Boolean);
|
|
213
|
+
const titles = new Set(roles.title.flatMap((f) => sample.map((r) => String(r[f] ?? ""))));
|
|
214
|
+
// Two ways to have no readable text, with opposite fixes: the
|
|
215
|
+
// role points at nothing, or the role points at something you
|
|
216
|
+
// are selling. Only the second is legitimate, and only if the
|
|
217
|
+
// buyer gets enough of it free to tell what it is.
|
|
218
|
+
const gatedText = roles.text.length && roles.text.every((f) => (pay?.locked ?? []).includes(f));
|
|
219
|
+
const free = pay?.sample?.count ?? 0;
|
|
220
|
+
if (gatedText && !free) {
|
|
221
|
+
add("blocks", "the whole text role is behind the paywall",
|
|
222
|
+
`${roles.text.join(", ")} — every word this corpus is about costs money, so a reader can rank it but cannot read one line to check the ranking means anything. Buying is a coin flip, and agents that hit this walk away rather than gamble.`,
|
|
223
|
+
"Set paywall.sample (1% is the default) so a deterministic slice of rows ships whole. The seller does not pick them, so the sample is evidence rather than advertising.");
|
|
224
|
+
} else if (gatedText) {
|
|
225
|
+
add("degrades", `text is paid, ${free.toLocaleString()} rows free to read`,
|
|
226
|
+
"Working as intended — a reader ranks the whole corpus and reads the sample to check the ranking before paying.",
|
|
227
|
+
"Nothing to fix. Raise paywall.sample if buyers still say they cannot tell what they are getting.");
|
|
228
|
+
} else if (!texts.length) {
|
|
229
|
+
add("blocks", "no searchable text on the sampled rows",
|
|
230
|
+
"The text role resolved to nothing, so lexical search matches nothing and every hit rests on the vector alone.",
|
|
231
|
+
"Check the field your role_map names actually carries prose in the shard.");
|
|
232
|
+
} else {
|
|
233
|
+
const distinct = new Set(texts).size;
|
|
234
|
+
const echoes = texts.filter((t) => titles.has(t)).length;
|
|
235
|
+
if (echoes > texts.length * 0.8) {
|
|
236
|
+
add("blocks", "the text role is the title",
|
|
237
|
+
`${echoes} of ${texts.length} sampled rows have searchable text identical to their title. Readers can only find you by name — which means they cannot find you by what you are about, which is the only thing search is for.`,
|
|
238
|
+
"Point role_map.text at the prose, not the filename. If the prose is what you are selling, some of it has to be in the free index or nobody can rank you.");
|
|
239
|
+
} else if (distinct < texts.length / 20) {
|
|
240
|
+
add("degrades", `only ${distinct} distinct texts across ${texts.length} rows`,
|
|
241
|
+
"Most rows are indistinguishable to search, so they will rank identically and flood each other.",
|
|
242
|
+
"Usually a text role pointing at a shared field (a series name, a collection) rather than a per-row one.");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The one that only rows can catch, and the one that shipped here.
|
|
247
|
+
// A row is allowed to carry locked fields if and only if it is in
|
|
248
|
+
// the declared free sample, and that is not the seller's word —
|
|
249
|
+
// the slice is a hash of each row's own id, so it is recomputed
|
|
250
|
+
// here. Rows outside it carrying paid fields is a leak; a sample
|
|
251
|
+
// that does not match the hash is a seller who picked its own
|
|
252
|
+
// shop window, which is the failure the hash exists to prevent.
|
|
253
|
+
const locked = pay?.locked ?? [];
|
|
254
|
+
const rate = pay?.sample?.rate ?? 0;
|
|
255
|
+
const carrying = sample.filter((r) => locked.some((f) => r[f] != null && r[f] !== ""));
|
|
256
|
+
const picked = [];
|
|
257
|
+
for (const r of carrying) if (!(await inSample(r.id, rate))) picked.push(r);
|
|
258
|
+
if (picked.length) {
|
|
259
|
+
const given = [...new Set(locked.filter((f) => picked.some((r) => r[f] != null && r[f] !== "")))];
|
|
260
|
+
add("blocks", `paywall names ${given.length} field${given.length === 1 ? "" : "s"} the free shard ships anyway`,
|
|
261
|
+
`${given.join(", ")} on ${picked.length} of ${sample.length} sampled rows outside the declared free slice — readers are told these cost money and are handed them for free. Anything a reader believes about your price is now wrong.`,
|
|
262
|
+
rate ? "Those rows are not the ones the sample hash picks. Re-bake rather than editing the shard."
|
|
263
|
+
: "Either withhold them from the shard, or take them out of paywall.locked.");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A hit is a dead end when there is nowhere to go AND nothing to
|
|
268
|
+
// play. The second half of that used to not exist: an index handed
|
|
269
|
+
// the reader to an app, so a corpus with no link was a corpus with
|
|
270
|
+
// no destination. `ui.js:stage` presents a row where it stands, so a
|
|
271
|
+
// publisher who ships playable files owes nobody a link.
|
|
272
|
+
//
|
|
273
|
+
// Measured on the sampled rows, never on the declaration. This
|
|
274
|
+
// corpus's own sibling declares `media: "url"` and ships no url on
|
|
275
|
+
// any row, which is exactly the dead end the rule is looking for,
|
|
276
|
+
// and a manifest-only check would have passed it.
|
|
277
|
+
deadEnd(sample.filter((r) => shapeOf(r, roles) !== "read").length);
|
|
278
|
+
} else {
|
|
279
|
+
deadEnd(0);
|
|
280
|
+
}
|
|
281
|
+
domains.push(dom);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!domains.length) {
|
|
285
|
+
found.push(F("blocks", "the catalog declares no domains", "There is nothing here to find.", "Bake at least one domain into this view."));
|
|
286
|
+
}
|
|
287
|
+
return { view: at, reachable: true, model: cat.embedding_model ?? null, domains, findings: found };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The report as a person reads it. Blocking first — a corpus nobody can find
|
|
291
|
+
* has no second problem. */
|
|
292
|
+
export function format(r) {
|
|
293
|
+
const line = (f) => ` ${f.level === "blocks" ? "✗" : "·"} ${f.what}\n ${f.why}\n → ${f.fix}`;
|
|
294
|
+
const order = (a, b) => (a.level === b.level ? 0 : a.level === "blocks" ? -1 : 1);
|
|
295
|
+
const out = [`${r.view}${r.model ? ` · ${r.model}` : ""}`];
|
|
296
|
+
if (!r.reachable) return [...out, ...r.findings.map(line)].join("\n");
|
|
297
|
+
for (const f of [...r.findings].sort(order)) out.push(line(f));
|
|
298
|
+
for (const d of r.domains) {
|
|
299
|
+
const blocks = d.findings.filter((f) => f.level === "blocks").length;
|
|
300
|
+
out.push(`\n${d.name} — ${d.rows.toLocaleString()} rows — `
|
|
301
|
+
+ (blocks ? `${blocks} thing${blocks === 1 ? "" : "s"} stopping readers finding or reading this`
|
|
302
|
+
: d.findings.length ? "findable, with rough edges" : "nothing to fix"));
|
|
303
|
+
for (const f of [...d.findings].sort(order)) out.push(line(f));
|
|
304
|
+
}
|
|
305
|
+
return out.join("\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── cli + self-check: `node consume/lint.js [viewUrl]` ─────────────────────
|
|
309
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
310
|
+
const arg = process.argv.slice(2).find((a) => !a.startsWith("--"));
|
|
311
|
+
if (arg) {
|
|
312
|
+
console.log(format(await lint(arg, { rows: !process.argv.includes("--no-rows") })));
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const body = (o) => {
|
|
317
|
+
const b = Buffer.from(typeof o === "string" ? o : JSON.stringify(o));
|
|
318
|
+
return { ok: true, status: 200, headers: new Headers(),
|
|
319
|
+
json: async () => JSON.parse(b.toString()), text: async () => b.toString(), arrayBuffer: async () => b,
|
|
320
|
+
body: new ReadableStream({ start(c) { c.enqueue(new Uint8Array(b)); c.close(); } }) };
|
|
321
|
+
};
|
|
322
|
+
const vec = Array.from({ length: 8 }, (_, i) => (i ? 0 : 1));
|
|
323
|
+
const ndjson = (rows) => rows.map((r) => JSON.stringify(r)).join("\n");
|
|
324
|
+
|
|
325
|
+
let CATALOG = null, MANIFEST = null, ROWS = [], LOCKED_SERVED = true;
|
|
326
|
+
globalThis.fetch = async (url) => {
|
|
327
|
+
const { pathname } = new URL(String(url), "https://ok.test");
|
|
328
|
+
if (!CATALOG) return { ok: false, status: 404, headers: new Headers() };
|
|
329
|
+
if (pathname.endsWith("/cdn/catalog")) return body(CATALOG);
|
|
330
|
+
if (/\/cdn\/domains\/[^/]+\/manifest$/.test(pathname)) return MANIFEST ? body(MANIFEST) : { ok: false, status: 404, headers: new Headers() };
|
|
331
|
+
if (/\/cdn\/domains\/[^/]+\/shards\/locked-/.test(pathname)) {
|
|
332
|
+
if (LOCKED_SERVED === "html") {
|
|
333
|
+
// What a static host with an SPA fallback actually does with a
|
|
334
|
+
// path it no longer has: 200, and the index page.
|
|
335
|
+
return { ok: true, status: 200, headers: new Headers({ "content-type": "text/html; charset=utf-8" }),
|
|
336
|
+
text: async () => "<!doctype html>", json: async () => ({}), arrayBuffer: async () => new ArrayBuffer(0) };
|
|
337
|
+
}
|
|
338
|
+
return LOCKED_SERVED ? body(ndjson(ROWS)) : { ok: false, status: 404, headers: new Headers() };
|
|
339
|
+
}
|
|
340
|
+
if (/\/cdn\/domains\/[^/]+\/shards\//.test(pathname)) return body(ndjson(ROWS));
|
|
341
|
+
return { ok: false, status: 404, headers: new Headers() };
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const has = (r, what) => [...r.findings, ...r.domains.flatMap((d) => d.findings)].some((f) => f.what.includes(what));
|
|
345
|
+
|
|
346
|
+
// Unreachable is its own answer, not a crash.
|
|
347
|
+
let r = await lint("https://gone.test/q/v1", { rows: false });
|
|
348
|
+
if (r.reachable || !has(r, "no catalog")) throw new Error("an unreachable view must report, not throw");
|
|
349
|
+
|
|
350
|
+
// The bake that forgot the one thing that makes it findable.
|
|
351
|
+
CATALOG = {
|
|
352
|
+
embedding_model: NETWORK_MODEL,
|
|
353
|
+
domains: [{ name: "films", count: 10, description: "Films", entity_types: ["video"] }],
|
|
354
|
+
};
|
|
355
|
+
MANIFEST = {
|
|
356
|
+
name: "films", shards: [{ file: "shard-0000-films.ndjson" }],
|
|
357
|
+
role_map: { title: "name", text: ["desc"], subtitle: "creator" },
|
|
358
|
+
presentation: { externalUrl: { video: "https://x.test/{id}" } },
|
|
359
|
+
};
|
|
360
|
+
r = await lint("https://ok.test/q/v1", { rows: false });
|
|
361
|
+
if (!has(r, "no coverage centroids")) throw new Error("a corpus with no coverage must be told it is unfindable");
|
|
362
|
+
if ([...r.domains[0].findings].find((f) => f.what.includes("coverage")).level !== "blocks") {
|
|
363
|
+
throw new Error("unfindable is blocking, not cosmetic");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// …and the same bake with coverage is clean.
|
|
367
|
+
CATALOG.domains[0].coverage = { dim: 8, sampled: 10, vectors: [vec], counts: [10] };
|
|
368
|
+
r = await lint("https://ok.test/q/v1", { rows: false });
|
|
369
|
+
if (r.domains[0].findings.length) throw new Error(`a complete bake must report nothing: ${JSON.stringify(r.domains[0].findings)}`);
|
|
370
|
+
|
|
371
|
+
// A foreign model is exclusion from the directory, not a warning.
|
|
372
|
+
CATALOG.embedding_model = "some-other-model";
|
|
373
|
+
if (!has(await lint("https://ok.test/q/v1", { rows: false }), "embedded with some-other-model")) {
|
|
374
|
+
throw new Error("a foreign model must be reported as blocking");
|
|
375
|
+
}
|
|
376
|
+
CATALOG.embedding_model = NETWORK_MODEL;
|
|
377
|
+
|
|
378
|
+
// No role_map, and no door out.
|
|
379
|
+
MANIFEST = { name: "films", shards: [{ file: "shard-0000-films.ndjson" }] };
|
|
380
|
+
r = await lint("https://ok.test/q/v1", { rows: false });
|
|
381
|
+
if (!has(r, "no role_map")) throw new Error("a sniffed bundle must be told it is being guessed at");
|
|
382
|
+
if (!has(r, "nowhere to go")) throw new Error("a corpus with no link, action or launch is a dead end and must say so");
|
|
383
|
+
|
|
384
|
+
// …but a publisher who ships files a reader can play where they stand owes
|
|
385
|
+
// nobody a link, and the rule has to read the ROWS to tell the two apart:
|
|
386
|
+
// both of these declare a media role and only one of them carries it.
|
|
387
|
+
MANIFEST = { name: "films", shards: [{ file: "shard-0000-films.ndjson" }],
|
|
388
|
+
role_map: { title: "name", text: ["desc"], media: "url" } };
|
|
389
|
+
ROWS = [{ track_id: "a", owner: "0x1", fields: { name: "A", desc: "d", url: "https://x.test/a.mp4", mime: "video/mp4" } }];
|
|
390
|
+
if (has(await lint("https://ok.test/q/v1"), "nowhere to go")) throw new Error("a corpus of playable files is not a dead end — the stage plays it in place");
|
|
391
|
+
ROWS = [{ track_id: "a", owner: "0x1", fields: { name: "A", desc: "d" } }];
|
|
392
|
+
if (!has(await lint("https://ok.test/q/v1"), "nowhere to go")) throw new Error("…but declaring a media role the rows do not carry is exactly a dead end");
|
|
393
|
+
|
|
394
|
+
// A corpus of subtitles carries no media at all and is not a dead end: every
|
|
395
|
+
// row resolves into the film it was transcribed from and plays there.
|
|
396
|
+
MANIFEST = { name: "lines", shards: [{ file: "shard-0000-lines.ndjson" }],
|
|
397
|
+
role_map: { title: "name", text: ["text"], identity: "videoPath",
|
|
398
|
+
refers: { field: "videoPath", corpus: "films", to: "path" } } };
|
|
399
|
+
ROWS = [{ track_id: "a", owner: "0x1", fields: { name: "A", text: "a line", videoPath: "x/y.mp4" } }];
|
|
400
|
+
if (has(await lint("https://ok.test/q/v1"), "nowhere to go")) throw new Error("a corpus that resolves into a playable one is not a dead end");
|
|
401
|
+
|
|
402
|
+
// The one only rows can catch: a price on data already given away.
|
|
403
|
+
MANIFEST = {
|
|
404
|
+
name: "films", shards: [{ file: "shard-0000-films.ndjson" }],
|
|
405
|
+
role_map: { title: "name", text: ["desc"] },
|
|
406
|
+
presentation: { externalUrl: { video: "https://x.test/{id}" } },
|
|
407
|
+
paywall: { free: ["name"], locked: ["url", "path"], price: "50000", asset: "USDC", resourceId: "0xabc" },
|
|
408
|
+
};
|
|
409
|
+
// The wire form: a row's own fields live under `fields`, which is exactly why
|
|
410
|
+
// a lint has to read a real shard rather than trust the manifest.
|
|
411
|
+
ROWS = [
|
|
412
|
+
{ track_id: "a", owner: "0x1", fields: { name: "A", desc: "d", url: "https://x.test/a" } },
|
|
413
|
+
{ track_id: "b", owner: "0x1", fields: { name: "B", desc: "d", url: "https://x.test/b" } },
|
|
414
|
+
];
|
|
415
|
+
r = await lint("https://ok.test/q/v1");
|
|
416
|
+
if (!has(r, "the free shard ships anyway")) throw new Error("a paywall that lies must be caught — this is the bug that shipped here");
|
|
417
|
+
const lie = r.domains[0].findings.find((f) => f.what.includes("ships anyway"));
|
|
418
|
+
if (!lie.why.includes("url") || lie.why.includes("path")) throw new Error(`name the fields actually given away, not the whole list: ${lie.why}`);
|
|
419
|
+
if (!has(r, "no vectors on the sampled rows")) throw new Error("a shard with no vectors must be caught before a reader finds it by accident");
|
|
420
|
+
|
|
421
|
+
// A price over bytes anyone can fetch. This shipped: `archive-transcripts`
|
|
422
|
+
// sold six fields and served the file holding them from the same public
|
|
423
|
+
// directory, as a 200, in the clear.
|
|
424
|
+
MANIFEST = {
|
|
425
|
+
name: "films", shards: [{ file: "shard-0000-films.ndjson" }],
|
|
426
|
+
role_map: { title: "name", text: ["desc"] },
|
|
427
|
+
presentation: { externalUrl: { video: "https://x.test/{id}" } },
|
|
428
|
+
paywall: { free: ["name"], locked: ["text"], price: "50000", asset: "USDC", resourceId: "0xabc",
|
|
429
|
+
files: [{ file: "locked-0000-films.ndjson", count: 20916 }] },
|
|
430
|
+
};
|
|
431
|
+
ROWS = [{ track_id: "a", owner: "0x1", fields: { name: "A", desc: "d" } }];
|
|
432
|
+
LOCKED_SERVED = true;
|
|
433
|
+
r = await lint("https://ok.test/q/v1");
|
|
434
|
+
if (!has(r, "served in the clear")) throw new Error("a paid payload that answers 200 on the public view must be blocking");
|
|
435
|
+
const leak = [...r.domains[0].findings].find((f) => f.what.includes("served in the clear"));
|
|
436
|
+
if (!leak.why.includes("20,916")) throw new Error("say how much is public, or the finding reads as theoretical");
|
|
437
|
+
|
|
438
|
+
// …and the same manifest, with the file actually gated, is silent.
|
|
439
|
+
LOCKED_SERVED = false;
|
|
440
|
+
if (has(await lint("https://ok.test/q/v1"), "served in the clear")) throw new Error("a payload that does not answer must not be reported as leaking");
|
|
441
|
+
|
|
442
|
+
// The false positive this rule nearly shipped with. Cloudflare Pages answers
|
|
443
|
+
// 200 with the index page for a path it does not have, so `res.ok` is true
|
|
444
|
+
// for every url on the host and the check would have reported a leak on a
|
|
445
|
+
// file that was correctly deleted.
|
|
446
|
+
LOCKED_SERVED = "html";
|
|
447
|
+
if (has(await lint("https://ok.test/q/v1"), "served in the clear")) throw new Error("a static host's HTML fallback is not a leaked payload");
|
|
448
|
+
LOCKED_SERVED = true;
|
|
449
|
+
|
|
450
|
+
// A text role that is present, declared, and useless — the defect that cost
|
|
451
|
+
// two buying agents their confidence in a corpus that was fine underneath.
|
|
452
|
+
MANIFEST = { name: "films", shards: [{ file: "s0.ndjson" }], role_map: { title: "name", text: ["blurb"] },
|
|
453
|
+
presentation: { externalUrl: { video: "https://x.test/{id}" } } };
|
|
454
|
+
ROWS = [
|
|
455
|
+
{ track_id: "a", owner: "0x1", v: "AQID", fields: { name: "S01E01.mp4", blurb: "S01E01.mp4" } },
|
|
456
|
+
{ track_id: "b", owner: "0x1", v: "AQID", fields: { name: "S01E02.mp4", blurb: "S01E02.mp4" } },
|
|
457
|
+
{ track_id: "c", owner: "0x1", v: "AQID", fields: { name: "S01E03.mp4", blurb: "S01E03.mp4" } },
|
|
458
|
+
];
|
|
459
|
+
r = await lint("https://ok.test/q/v1");
|
|
460
|
+
if (!has(r, "the text role is the title")) throw new Error("a text role that only repeats the title must be caught — nobody can search it");
|
|
461
|
+
|
|
462
|
+
// …and real prose is not flagged.
|
|
463
|
+
ROWS = ROWS.map((x, i) => ({ ...x, fields: { ...x.fields, blurb: `a distinct sentence about episode ${i} and what happens in it` } }));
|
|
464
|
+
r = await lint("https://ok.test/q/v1");
|
|
465
|
+
if (has(r, "the text role is the title")) throw new Error("real prose must not be flagged");
|
|
466
|
+
|
|
467
|
+
// A text role sold whole, with no sample: rankable, unreadable, unbuyable.
|
|
468
|
+
// This is archive-transcripts as it actually shipped.
|
|
469
|
+
MANIFEST = { name: "subs", shards: [{ file: "s0.ndjson" }], role_map: { title: "name", text: ["text"] },
|
|
470
|
+
presentation: { externalUrl: { subtitles: "https://x.test/{id}" } },
|
|
471
|
+
paywall: { free: ["title"], locked: ["text"], price: "50000", resourceId: "0xabc" } };
|
|
472
|
+
ROWS = [{ track_id: "a", owner: "0x1", v: "AQID", fields: { name: "S01E01.mp4" } },
|
|
473
|
+
{ track_id: "b", owner: "0x1", v: "AQID", fields: { name: "S01E02.mp4" } }];
|
|
474
|
+
r = await lint("https://ok.test/q/v1");
|
|
475
|
+
if (!has(r, "the whole text role is behind the paywall")) throw new Error("a corpus you can rank but cannot read one line of must be caught");
|
|
476
|
+
if (has(r, "the text role is the title")) throw new Error("a locked text role is not a filename bug — the advice for the two is opposite");
|
|
477
|
+
if (has(r, "the free shard ships anyway")) throw new Error("the derived search text must not be mistaken for a field the shard carries");
|
|
478
|
+
|
|
479
|
+
// …and with a sample it is a working paid corpus, not a defect. `row-47` is
|
|
480
|
+
// one the bake's hash actually picks, so its paid text is not a leak.
|
|
481
|
+
MANIFEST.paywall.sample = { rate: 0.01, count: 211 };
|
|
482
|
+
ROWS = [{ track_id: "row-47", owner: "0x1", v: "AQID", fields: { name: "a.mp4", text: "a line of dialogue" } },
|
|
483
|
+
{ track_id: "row-0", owner: "0x1", v: "AQID", fields: { name: "b.mp4" } }];
|
|
484
|
+
r = await lint("https://ok.test/q/v1");
|
|
485
|
+
if (has(r, "behind the paywall")) throw new Error("a sampled paid corpus must not block");
|
|
486
|
+
if (!has(r, "211 rows free to read")) throw new Error("the reader must be told how much it can read before paying");
|
|
487
|
+
if (has(r, "ships anyway")) throw new Error("a row inside the free slice carrying its paid text is the sample working, not a leak");
|
|
488
|
+
|
|
489
|
+
// A seller who put paid text on a row the hash does NOT pick is either
|
|
490
|
+
// leaking or choosing its own shop window. Both are the same finding.
|
|
491
|
+
ROWS[1].fields.text = "a line the hash did not pick";
|
|
492
|
+
r = await lint("https://ok.test/q/v1");
|
|
493
|
+
if (!has(r, "ships anyway")) throw new Error("paid text outside the declared slice must be caught");
|
|
494
|
+
if (!r.domains[0].findings.find((f) => f.what.includes("ships anyway")).why.includes("1 of 2")) throw new Error("say how many rows leaked, not just which fields");
|
|
495
|
+
|
|
496
|
+
if (!format(r).includes("films")) throw new Error("the report must name the domain");
|
|
497
|
+
console.log("lint.js self-check ok — unreachable reported not thrown, missing coverage is blocking, a complete bake is silent, "
|
|
498
|
+
+ "foreign model excluded, sniffed shape flagged, a dead end judged on the rows rather than the declaration and waived by a reference into another corpus, a text role that is really the filename caught, "
|
|
499
|
+
+ "a paywall naming fields the free shard ships, a paid payload served in the clear from the public view without mistaking a host's HTML fallback for one, and a text role sold whole with no free sample");
|
|
500
|
+
}
|