wiki-formant 0.31.0 → 0.33.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/README.md CHANGED
@@ -170,6 +170,12 @@ process.exit(t.summary());
170
170
 
171
171
  What stays in your repo is fixtures: which tools you expect, what a good answer from each looks like, and which text surfaces you publish. `payloadBudget` weighs every listed call **and** every read-only tool that takes no required arguments, because the one tool nobody thought to list is the one that answers with 3.3 MB; pass a per-call `maxBytes` for a bulk-export tool that is deliberately large. It also asserts that a JSON answer arrived with `structuredContent`.
172
172
 
173
+ `standardVersionSources(t, openapiPaths)` is the `versionCoherence` map every S10 surface serves — both agent-card paths, `mcp.json`, the server card and each path the OpenAPI document answers at — so a suite names only the paths it adds. `t.rpcAt(endpoint, …)` and `t.callAt` drive a second MCP endpoint on the same surface with the same backoff, User-Agent and tally.
174
+
175
+ ```ts
176
+ await versionCoherence(t, server.version, standardVersionSources(t, ['/openapi.json', '/api/openapi.json']), init.result?.serverInfo?.version);
177
+ ```
178
+
173
179
  `descriptorChecks(t)` defaults to `S10_DESCRIPTORS`, the six JSON descriptors every surface here serves, and `distinctEtagChecks(t, ['llms.txt', 'llms-index.txt', 'llms-full.txt'])` asserts that depths projecting one corpus carry different ETags — every one present, which a bare Set-size check misses.
174
180
 
175
181
  ## Markdown twins
@@ -220,6 +226,45 @@ const agg = await prisma.page.aggregate({ _count: true, _max: { updatedAt: true
220
226
  corpusValidatorsFrom([depth, agg._count], [agg._max.updatedAt]);
221
227
  ```
222
228
 
229
+ ### Route errors
230
+
231
+ `json`, `errors` and `handleRoute` are the answers a route handler gives when it is not answering with data, as web-standard `Response`s a Next handler returns as they are. Every error body is `{ error, ...extra }`: one origin had these helpers, one had a second convention with a third copy inside a single route, and two answered with whatever `Response.json` was nearest, so one mistake came back three ways depending on the origin.
232
+
233
+ ```ts
234
+ import { errors, handleRoute, HttpError } from 'wiki-formant/http';
235
+
236
+ export const GET = (req: Request) => handleRoute(async () => {
237
+ const q = new URL(req.url).searchParams.get('q');
238
+ if (!q) return errors.badRequest('q is required');
239
+ if (q.length > 200) throw new HttpError(400, 'q is too long', { code: 'INVALID_PARAMS' });
240
+ return Response.json(await search(q));
241
+ }, 'Search failed');
242
+ ```
243
+
244
+ A thrown `HttpError` answers with its own status and message. Anything else thrown is logged and answered with a 500 carrying only `errorMsg`, so an exception's text — a database error naming a table — never reaches the caller. `errors.tooManyRequests(sec)` states `Retry-After`.
245
+
246
+ ## Response headers
247
+
248
+ `wiki-formant/headers` holds the six headers every route sends and the Content-Security-Policy they share, for `next.config.ts` to import:
249
+
250
+ ```ts
251
+ import { contentSecurityPolicy, securityHeaders } from 'wiki-formant/headers';
252
+
253
+ const csp = contentSecurityPolicy({ 'connect-src': ["'self'", 'https://*.radixdlt.com'] });
254
+
255
+ const nextConfig = {
256
+ async headers() {
257
+ return [{ source: '/:path*', headers: securityHeaders(process.env.NODE_ENV === 'production' ? csp : null) }];
258
+ },
259
+ };
260
+ ```
261
+
262
+ Four configs had restated these by hand on the grounds that a config could not import an ESM-only package. It could not because no subpath had a `default` condition, and Next resolves a config's imports the way `require` does; every subpath has one now, and Node 24 loads the module there unchanged.
263
+
264
+ `contentSecurityPolicy` starts from `BASE_CSP`, the policy all four origins shared line for line, and each directive passed replaces its default rather than appending, so the call site reads as the whole list. An empty list drops a directive.
265
+
266
+ The default `frame-src` is `frameSources(DEFAULT_IFRAME_HOSTS)` — the same list the sanitiser's `iframeHosts` defaults to. The sanitiser's comment had asked every config to keep the two matched by hand, and one did not: its policy lacked the bare `youtube.com` and `youtube-nocookie.com` its sanitiser kept, so those embeds survived cleaning and then rendered blank. A site with its own host list passes it to both.
267
+
223
268
  ## Pagination and versioning
224
269
 
225
270
  `parsePagination` clamps `page ≥ 1` and `pageSize` to 1–100; `paginatedResponse` always carries `totalPages`. Reshaping that response is a breaking change to every client that pages, which is why it lives here rather than being re-typed per repo.
@@ -333,10 +378,10 @@ group. Both copies were byte-identical across three repos, and they were
333
378
  different lists.
334
379
 
335
380
  ```ts
336
- import { detectAiBot, aiCrawlerRules } from 'wiki-formant/crawlers';
381
+ import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';
337
382
 
338
- const bot = detectAiBot(request.headers.get('user-agent')); // label, or null
339
- const rules = aiCrawlerRules({ allow: '/', disallow, aiAllow });
383
+ trackAiBot(request, event, () => import('@/lib/track')); // in proxy.ts: label, or null
384
+ const rules = crawlerRules({ allow: '/', disallow, aiAllow });
340
385
  ```
341
386
 
342
387
  Only one direction of that difference was deliberate: `Applebot-Extended` never
@@ -347,7 +392,11 @@ and a crawler obeys only its most-specific matching group, so an agent with no
347
392
  group of its own falls through to `*`. Three wikis were measuring five crawlers
348
393
  they had never addressed.
349
394
 
350
- Every group also allows `AGENT_SURFACE_PATHS` — `/api/mcp`, the three `llms` exports, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
395
+ Every agent group also allows `AGENT_SURFACE_PATHS` — `/api/mcp`, the three `llms` exports, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
396
+
397
+ `crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one each for `SEARCH_ENGINES`. Search engines get the HTML site and none of its twins — `/*.md$` always, plus `searchDisallow` — because the `*` group keeps the machine surface open to agents, and for Google a twin is a page it already has. Two origins wrote that group by hand and the third never did, so Google crawled its twins. `RSC_DISALLOW`, the `<Link>` prefetch payload, is refused in every group.
398
+
399
+ `trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
351
400
 
352
401
  ## Page metadata
353
402
 
@@ -366,6 +415,14 @@ The schema.org nodes sit beside it, because they state the same facts to a diffe
366
415
  <JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: { '@id': `${SITE_URL}/#organization` }, citation: citationsFromReferences(refs) })} />
367
416
  ```
368
417
 
418
+ `sitePageMetadata({ siteName, handle })` binds the three fields Next drops from every page that sets its own card, with `locale` defaulting to `en_US`. `siteGraphLd` is the Organization, WebSite (with its `SearchAction`) and `WebAPI` as one `@graph` for the root layout, every node carrying an `@id`, and `siteRefs(url)` hands out the same ids for a page's `publisher` and `isPartOf` — so no page writes an inline copy of the publisher that can disagree with the layout's.
419
+
420
+ ```ts
421
+ export const card = sitePageMetadata({ siteName: SITE_NAME, handle: '@example' });
422
+ export const SITE = siteRefs(SITE_URL);
423
+ <JsonLd data={siteGraphLd({ name, url: SITE_URL, logo, sameAs, searchUrl, api })} />
424
+ ```
425
+
369
426
  ## Revisions
370
427
 
371
428
  What changed between two versions of a page, and therefore which semver bump to
@@ -540,7 +597,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
540
597
  ```ts
541
598
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
542
599
 
543
- const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // pair with CSP frame-src
600
+ const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // and frameSources(FRAME_HOSTS) in the CSP
544
601
  const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
545
602
  ```
546
603
 
@@ -644,15 +701,16 @@ its readers asked for. The queries that return zero rows are the valuable ones
644
701
  `wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
645
702
  nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
646
703
  returns the whole of it. `gate.route` is the sign-in route,
647
- `gate.signedIn(cookieValue)` is the check a page makes, and `gate.cookie` is the
648
- cookie's name. The two tables (`Passkey`, `PasskeyToken`) are stated as Prisma
704
+ `gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
705
+ while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) are stated as Prisma
649
706
  models at the top of `src/passkey.ts`, and every token is kept only as its hash.
650
707
  `@simplewebauthn/server` is an optional peer.
651
708
 
652
- There is no sign-up. The first passkey is saved from a single-use link that the
653
- `passkey-invite` bin prints from a machine holding `DATABASE_URL`, so the only
654
- way in is through someone who can already reach the database. A passkey is bound
655
- to the host it was saved on, so localhost needs its own.
709
+ There is no sign-up. While no passkey is saved, the page saves the first one
710
+ from whoever opens it, so the owner should do that as soon as the door ships.
711
+ Every passkey after that is saved from a single-use link that the
712
+ `passkey-invite` bin prints from a machine holding `DATABASE_URL`. A passkey is
713
+ bound to the host it was saved on, so localhost needs its own.
656
714
 
657
715
  ```ts
658
716
  // src/lib/admin.ts
@@ -663,7 +721,7 @@ export const POST = gate.route;
663
721
 
664
722
  // src/app/stats/page.tsx
665
723
  if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
666
- return <PasskeyButton endpoint="/api/passkey" invite={invite} />;
724
+ return <PasskeyButton endpoint="/api/passkey" invite={invite} create={await gate.open()} />;
667
725
  const days = statsDays((await searchParams).days); // 1, 7, 30 or 90, else 30
668
726
  return <Stats digest={await digest(sql, { days })} days={days} />;
669
727
  ```
@@ -676,7 +734,7 @@ ranked list, and `?days=` links between windows. It ships no JavaScript.
676
734
 
677
735
  ## Base stylesheet
678
736
 
679
- `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a stored table's scroll box, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid and bars. No colour and no scale, so a design system's own rules override it at equal specificity.
737
+ `wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a table's scroll box, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid and bars. It also carries the shape and motion of the primitives every site re-implemented and forked — `.spinner` (a `currentColor` ring sized by `--spinner-size`), `.skeleton`'s pulse, `.empty-state`, the wrap rule for long tokens in inline `code` — and the reduced-motion guard none of them had. No colour and no scale, so a design system's own rules override it at equal specificity.
680
738
 
681
739
  ```css
682
740
  @import "wiki-formant/base.css" layer(components);
@@ -693,6 +751,12 @@ already re-exported one module twice. The emitted `.d.ts` files are the referenc
693
751
  There is no hand-maintained symbol list here, because the one that used to be here
694
752
  drifted from them.
695
753
 
754
+ `wiki-formant/tsconfig.base.json` is the compiler floor: an app's `tsconfig.json` extends it and keeps only `paths`, `include`, `exclude` and any licensed extra, since tsconfig resolves those against the file that declares them.
755
+
756
+ ```json
757
+ { "extends": "wiki-formant/tsconfig.base.json", "compilerOptions": { "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/app/.well-known/**/*.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], "exclude": ["node_modules"] }
758
+ ```
759
+
696
760
  `wiki-formant/db` holds `pgPoolConfig(url)`, the options every app here hands to
697
761
  `new pg.Pool()`: ten clients on Supabase's 6543 transaction pooler, three on the
698
762
  5432 session pooler, whose fifteen-client cap a build's workers share.
@@ -722,12 +786,35 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
722
786
  npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
723
787
  ```
724
788
 
725
- `passkey-invite` prints a single-use link that saves a passkey for the page it names, good for a day (`--days n` for longer). Run it the same way:
789
+ `passkey-invite` prints a single-use link that saves another passkey for the page it names, good for a day (`--days n` for longer). Run it the same way:
726
790
 
727
791
  ```sh
728
792
  npx passkey-invite https://caper.network/stats
729
793
  ```
730
794
 
795
+ `fts-ddl <table>` rebuilds a table's `search_tsv` generated column and its GIN index from the expression `wiki-formant/search` queries, over `DIRECT_URL` (else `DATABASE_URL` on 5432). Run it before declaring the column in `schema.prisma`; the bin's header says why.
796
+
797
+ ```sh
798
+ npx fts-ddl pages
799
+ ```
800
+
801
+ ## A reusable workflow
802
+
803
+ `.github/workflows/publish-mcp-registry.yml` republishes an origin's server to the MCP registry when the version in its `server.json` moves. Each app keeps its own trigger and calls it with its domain:
804
+
805
+ ```yaml
806
+ on:
807
+ push: { branches: [main], paths: ['server.json'] }
808
+ workflow_dispatch:
809
+ jobs:
810
+ publish:
811
+ uses: tutmoses/wiki-formant/.github/workflows/publish-mcp-registry.yml@main
812
+ with:
813
+ domain: example.com
814
+ secrets:
815
+ MCP_REGISTRY_PRIVATE_KEY: ${{ secrets.MCP_REGISTRY_PRIVATE_KEY }}
816
+ ```
817
+
731
818
  ## Licence
732
819
 
733
820
  MIT.
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // bin/fts-ddl.mjs – (re)build a table's `search_tsv` generated column and its
3
+ // GIN index, from the same expression `wiki-formant/search` queries.
4
+ //
5
+ // Run from the app's root, where `pg` is installed and `.env` holds the
6
+ // database URL. Two wikis carried this script with nothing between them but
7
+ // the table name.
8
+ //
9
+ // npx fts-ddl pages
10
+ // npx fts-ddl wiki_pages
11
+ //
12
+ // The column is rebuilt every run rather than skipped when it exists: a skip
13
+ // is blind to a changed EXPRESSION, and Postgres normalises the stored one, so
14
+ // comparing it to the string can only produce false rebuilds. DROP+ADD in one
15
+ // transaction means no reader sees the table without it. Run this BEFORE
16
+ // declaring the column in schema.prisma, never after:
17
+ //
18
+ // 1. `prisma db push` drops the column if it is not declared
19
+ // (`Unsupported("tsvector")?`), and drops the index if
20
+ // `@@index([searchTsv], type: Gin)` is not declared either.
21
+ // 2. Push must never create the column: Prisma emits a plain
22
+ // `ADD COLUMN search_tsv tsvector` with no generation expression, which
23
+ // stays empty forever and fails silently.
24
+ // 3. With both declared, push still wants `ALTER COLUMN "search_tsv" DROP
25
+ // DEFAULT`, and Postgres refuses it (42601, "is a generated column"). That
26
+ // error is the expected outcome, not a problem to fix.
27
+ //
28
+ // Connection: DIRECT_URL (the session pooler on 5432) when set, else
29
+ // DATABASE_URL moved from 6543 to 5432. Both run DDL; the session port is the
30
+ // one that never hangs on a long statement.
31
+ import pg from 'pg';
32
+ import { searchTsvDdl } from '../dist/search.js';
33
+
34
+ const table = process.argv[2];
35
+ if (!table || !/^[a-z_][a-z0-9_]*$/.test(table)) {
36
+ console.error('usage: npx fts-ddl <table>');
37
+ process.exit(1);
38
+ }
39
+
40
+ if (!process.env.DIRECT_URL && !process.env.DATABASE_URL) {
41
+ try {
42
+ process.loadEnvFile('.env');
43
+ } catch {
44
+ // reported below
45
+ }
46
+ }
47
+ const url = process.env.DIRECT_URL || process.env.DATABASE_URL?.replace(':6543', ':5432');
48
+ if (!url) {
49
+ console.error('no DIRECT_URL or DATABASE_URL in the environment or ./.env');
50
+ process.exit(1);
51
+ }
52
+
53
+ // A bad route hangs rather than erroring without a connect timeout.
54
+ const client = new pg.Client({
55
+ connectionString: url.replace(/\?.*$/, ''),
56
+ ssl: { rejectUnauthorized: false },
57
+ connectionTimeoutMillis: 15000,
58
+ });
59
+ await client.connect();
60
+ const q = s => client.query(s).then(r => r.rows);
61
+ try {
62
+ const ddl = searchTsvDdl(table);
63
+ await q('BEGIN');
64
+ for (const statement of ddl.column) await q(statement);
65
+ await q('COMMIT');
66
+ console.log('rebuilt generated column search_tsv');
67
+ await q(ddl.index);
68
+ console.log('GIN index present');
69
+ console.table(await q(`select column_name, data_type, is_generated from information_schema.columns
70
+ where table_name='${table}' and column_name='search_tsv'`));
71
+ console.table(await q(`select count(*)::int rows, count(*) filter (where search_tsv is null)::int null_tsv from ${table}`));
72
+ console.table(await q(`select pg_size_pretty(pg_relation_size('${table}_search_tsv_idx')) index_size`));
73
+ } catch (e) {
74
+ await q('ROLLBACK').catch(() => {});
75
+ console.error(e.message);
76
+ process.exitCode = 1;
77
+ } finally {
78
+ await client.end();
79
+ }
@@ -196,9 +196,9 @@ export interface Tracker {
196
196
  * The tracker holds `sql`, so the module that builds it imports the app's
197
197
  * database client. A proxy must keep reaching it through a dynamic import
198
198
  * inside `waitUntil`, never a static one, or every request the proxy sees
199
- * loads Prisma:
199
+ * loads Prisma — `trackAiBot` (`wiki-formant/crawlers`) is that call:
200
200
  *
201
- * event.waitUntil(import('@/lib/track').then(m => m.trackEvent('AI Bot Visit', url, { bot }, request.headers)));
201
+ * trackAiBot(request, event, () => import('@/lib/track'));
202
202
  */
203
203
  export declare function createTracker({ sql, defer, enabled, siteUrl, }: TrackerOptions): Tracker;
204
204
  export {};
package/dist/analytics.js CHANGED
@@ -272,9 +272,9 @@ export function searchQueryProps({ query, results, surface, }) {
272
272
  * The tracker holds `sql`, so the module that builds it imports the app's
273
273
  * database client. A proxy must keep reaching it through a dynamic import
274
274
  * inside `waitUntil`, never a static one, or every request the proxy sees
275
- * loads Prisma:
275
+ * loads Prisma — `trackAiBot` (`wiki-formant/crawlers`) is that call:
276
276
  *
277
- * event.waitUntil(import('@/lib/track').then(m => m.trackEvent('AI Bot Visit', url, { bot }, request.headers)));
277
+ * trackAiBot(request, event, () => import('@/lib/track'));
278
278
  */
279
279
  export function createTracker({ sql, defer, enabled = typeof process !== 'undefined' && process.env.VERCEL_ENV === 'production', siteUrl = '/', }) {
280
280
  const trackEvent = (name, url, props, headers) => enabled ? recordEvent(sql, { name, url, props, headers }) : Promise.resolve();
package/dist/base.css CHANGED
@@ -46,10 +46,63 @@
46
46
  .tab-panel:not(.active) { display: none; }
47
47
  [data-tabs]:not([data-tabs-init]) [data-tab-item] ~ [data-tab-item] { display: none; }
48
48
 
49
- /* ---- table scroll boxes (scrollTables) -----------------------------------
50
- A stored table wider than its column scrolls inside its own box, at every
51
- width, instead of painting past the column. */
49
+ /* ---- table scroll boxes (scrollTables, React tables) -----------------------
50
+ A table wider than its column scrolls inside its own box, at every width,
51
+ instead of painting past the column. */
52
52
  .table-scroll { overflow-x: auto; }
53
+ .data-table-wrap { overflow-x: auto; }
54
+
55
+ /* ---- Breadcrumbs ------------------------------------------------------------
56
+ The trail is a <nav> around an <ol>; the row is the list, not the nav.
57
+ Three sites restated this row, each a little differently. */
58
+ .breadcrumbs ol {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ align-items: center;
62
+ gap: 0.25rem;
63
+ margin: 0;
64
+ padding-left: 0;
65
+ list-style: none;
66
+ }
67
+ .breadcrumbs .separator { user-select: none; }
68
+
69
+ /* ---- spinner, skeleton, empty state ----------------------------------------
70
+ The shape and the motion; colour, size and surface are the site's. The
71
+ spinner is a ring in currentColor sized by `--spinner-size`, the skeleton a
72
+ pulse over whatever background the site gives it. Four stylesheets had
73
+ forked the spinner three ways: 0.6s and 0.8s, currentColor and a hard-coded
74
+ accent, one with no keyframe in any layer. */
75
+ @keyframes spin { to { transform: rotate(360deg); } }
76
+ @keyframes pulse { 50% { opacity: 0.5; } }
77
+ .spinner {
78
+ display: inline-block;
79
+ flex: none;
80
+ width: var(--spinner-size, 1em);
81
+ height: var(--spinner-size, 1em);
82
+ border: 2px solid currentColor;
83
+ border-top-color: transparent;
84
+ border-radius: 9999px;
85
+ animation: spin 0.6s linear infinite;
86
+ }
87
+ .skeleton { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
88
+ .empty-state { padding-block: 3rem; text-align: center; }
89
+
90
+ /* ---- long tokens ----------------------------------------------------------
91
+ An address or hash is one word, so its min-content width is the whole
92
+ string and it runs off a phone. A <pre> keeps scrolling: wrapping a listing
93
+ is not reading it. */
94
+ :not(pre) > code { overflow-wrap: anywhere; word-break: break-word; }
95
+
96
+ /* ---- reduced motion ---------------------------------------------------------
97
+ No site here honoured it. The spinner slows rather than stops, because a
98
+ still ring no longer says anything is happening; the pulse and smooth
99
+ scrolling stop. `!important` because a site's own later rule in the same
100
+ layer would otherwise put the motion back. */
101
+ @media (prefers-reduced-motion: reduce) {
102
+ html { scroll-behavior: auto !important; }
103
+ .spinner { animation-duration: 2.4s !important; }
104
+ .skeleton { animation: none !important; }
105
+ }
53
106
 
54
107
  /* ---- code copy buttons (addCopyButtons) -----------------------------------
55
108
  The script positions the <pre>; this pins the button in its corner. Shown
@@ -70,6 +70,14 @@ export interface Tester {
70
70
  rpc(method: string, params?: unknown): Promise<Rpc>;
71
71
  /** `tools/call` shorthand. */
72
72
  call(name: string, args?: Record<string, unknown>): Promise<Rpc>;
73
+ /**
74
+ * `rpc` against another MCP endpoint on the same surface — a per-tenant
75
+ * server beside the main one — with the same backoff, User-Agent and tally.
76
+ * The one surface with a second endpoint had re-implemented the backoff for
77
+ * it, without the User-Agent.
78
+ */
79
+ rpcAt(endpoint: string, method: string, params?: unknown): Promise<Rpc>;
80
+ callAt(endpoint: string, name: string, args?: Record<string, unknown>): Promise<Rpc>;
73
81
  /** The decoded body of a tool result: parsed JSON, raw text, or the error. */
74
82
  payload(r: Rpc): unknown;
75
83
  /** Record an assertion and print it as it happens. */
@@ -107,6 +115,13 @@ export declare function versionCoherence(t: Tester, expected: string, sources: R
107
115
  url: string;
108
116
  at: (json: Record<string, unknown>) => unknown;
109
117
  }>, initVersion?: string): Promise<void>;
118
+ /** The version sources every S10 surface serves, for `versionCoherence`. */
119
+ export declare function standardVersionSources(t: Pick<Tester, 'base' | 'endpoint'>,
120
+ /** Every path the OpenAPI document is served at. */
121
+ openapi?: readonly string[]): Record<string, {
122
+ url: string;
123
+ at: (json: Record<string, unknown>) => unknown;
124
+ }>;
110
125
  /**
111
126
  * Both A2A well-known paths must serve the identical card. v0.3 renamed
112
127
  * agent.json to agent-card.json and defined no fallback either way, so an origin
@@ -45,10 +45,10 @@ export function createTester(opts) {
45
45
  // bounded number of times. This used to say "once" and recurse forever at a
46
46
  // fixed 5s, so a suite pointed at a budget it could never get under hung.
47
47
  const RETRIES = 4;
48
- const rpc = async (method, params) => {
48
+ const rpcAt = async (url, method, params) => {
49
49
  for (let attempt = 1;; attempt++) {
50
50
  calls++;
51
- const res = await fetch(endpoint, {
51
+ const res = await fetch(url, {
52
52
  method: 'POST',
53
53
  headers: { 'Content-Type': 'application/json', 'User-Agent': opts.clientName },
54
54
  body: JSON.stringify({ jsonrpc: '2.0', id: calls, method, params }),
@@ -61,11 +61,14 @@ export function createTester(opts) {
61
61
  await new Promise(r => setTimeout(r, Math.min(60, Number.isFinite(wait) && wait > 0 ? wait : 5) * 1000));
62
62
  }
63
63
  };
64
+ const rpc = (method, params) => rpcAt(endpoint, method, params);
64
65
  return {
65
66
  base,
66
67
  endpoint,
67
68
  rpc,
68
69
  call: (name, args = {}) => rpc('tools/call', { name, arguments: args }),
70
+ rpcAt,
71
+ callAt: (url, name, args = {}) => rpcAt(url, 'tools/call', { name, arguments: args }),
69
72
  payload(r) {
70
73
  const text = r.result?.content?.[0]?.text;
71
74
  if (text == null)
@@ -241,6 +244,22 @@ export async function versionCoherence(t, expected, sources, initVersion) {
241
244
  const all = Object.values(found).filter(v => v !== undefined);
242
245
  t.check('version coherence', all.every(v => v === expected), `expected=${expected} ` + Object.entries(found).map(([k, v]) => `${k}=${v}`).join(' '));
243
246
  }
247
+ /** The version sources every S10 surface serves, for `versionCoherence`. */
248
+ export function standardVersionSources(t,
249
+ /** Every path the OpenAPI document is served at. */
250
+ openapi = ['/openapi.json', '/.well-known/openapi.json']) {
251
+ const version = (j) => j.version;
252
+ return {
253
+ card: { url: `${t.base}/.well-known/agent-card.json`, at: version },
254
+ legacyCard: { url: `${t.base}/.well-known/agent.json`, at: version },
255
+ mcpManifest: { url: `${t.base}/.well-known/mcp.json`, at: version },
256
+ serverCard: { url: `${t.endpoint}/server-card`, at: version },
257
+ ...Object.fromEntries(openapi.map(path => [
258
+ `openapi:${path}`,
259
+ { url: `${t.base}${path}`, at: (j) => j.info?.version },
260
+ ])),
261
+ };
262
+ }
244
263
  /**
245
264
  * Both A2A well-known paths must serve the identical card. v0.3 renamed
246
265
  * agent.json to agent-card.json and defined no fallback either way, so an origin
@@ -1,3 +1,5 @@
1
+ import type { Tracker } from './analytics.js';
2
+ import type { HeaderReader } from './rate-limit.js';
1
3
  export interface AiCrawler {
2
4
  /** Matched as a substring of the User-Agent, and the robots.txt token. */
3
5
  token: string;
@@ -36,16 +38,48 @@ export interface RobotsGroup {
36
38
  */
37
39
  export declare const AGENT_SURFACE_PATHS: readonly string[];
38
40
  /**
39
- * The wildcard group followed by one group per crawler.
41
+ * The React Server Components payload a `<Link>` prefetches. A crawler that
42
+ * renders a page runs those prefetches too — 43% of Googlebot's requests on
43
+ * one wiki, against 10% for HTML — and nothing that reads robots.txt can use
44
+ * one. Every group gets it.
45
+ */
46
+ export declare const RSC_DISALLOW: readonly string[];
47
+ /** The search engines that get the HTML site and none of its twins. */
48
+ export declare const SEARCH_ENGINES: readonly string[];
49
+ /**
50
+ * Every group robots.txt needs: the wildcard, one per AI crawler, one per
51
+ * search engine.
40
52
  *
41
53
  * Every named agent gets an explicit `disallow`. Omitting it is the failure the
42
54
  * comment in all three `robots.ts` files warned about and all three then
43
55
  * committed for five agents: a named group that disallows nothing grants that
44
56
  * agent everything, because it stops matching `*` the moment it matches itself.
57
+ *
58
+ * The search-engine groups exist because `*` keeps the machine surface open to
59
+ * an agent the origin has never heard of, which is right for an agent and wrong
60
+ * for Google: a `.md` twin or its JSON is a page it already has in another
61
+ * format. Two origins wrote that group by hand and the third never did, so
62
+ * Google crawled its twins.
45
63
  */
46
- export declare function aiCrawlerRules(opts: {
64
+ export declare function crawlerRules(opts: {
47
65
  allow: string | string[];
48
66
  disallow: string | string[];
49
67
  /** Beyond `AGENT_SURFACE_PATHS`, which every group gets regardless. */
50
68
  aiAllow?: string | string[];
69
+ /** What search engines are refused beyond `disallow`. `/*.md$` always is. */
70
+ searchDisallow?: string | string[];
51
71
  }): RobotsGroup[];
72
+ /**
73
+ * Count an AI crawler's visit from a proxy, without holding up its response.
74
+ *
75
+ * `load` is a dynamic import of the module that records it, because that
76
+ * module pulls in a database client, and a static import would load it for
77
+ * every request the proxy sees, bots or not. Three proxies wrote these lines.
78
+ * Returns the bot's label, or null.
79
+ */
80
+ export declare function trackAiBot(request: {
81
+ url: string;
82
+ headers: HeaderReader;
83
+ }, event: {
84
+ waitUntil(promise: Promise<unknown>): void;
85
+ }, load: () => Promise<Pick<Tracker, 'trackEvent'>>): string | null;
package/dist/crawlers.js CHANGED
@@ -67,15 +67,33 @@ export const AGENT_SURFACE_PATHS = [
67
67
  '/.well-known/',
68
68
  ];
69
69
  /**
70
- * The wildcard group followed by one group per crawler.
70
+ * The React Server Components payload a `<Link>` prefetches. A crawler that
71
+ * renders a page runs those prefetches too — 43% of Googlebot's requests on
72
+ * one wiki, against 10% for HTML — and nothing that reads robots.txt can use
73
+ * one. Every group gets it.
74
+ */
75
+ export const RSC_DISALLOW = ['/*?_rsc=', '/*&_rsc='];
76
+ /** The search engines that get the HTML site and none of its twins. */
77
+ export const SEARCH_ENGINES = ['Googlebot', 'Bingbot'];
78
+ /**
79
+ * Every group robots.txt needs: the wildcard, one per AI crawler, one per
80
+ * search engine.
71
81
  *
72
82
  * Every named agent gets an explicit `disallow`. Omitting it is the failure the
73
83
  * comment in all three `robots.ts` files warned about and all three then
74
84
  * committed for five agents: a named group that disallows nothing grants that
75
85
  * agent everything, because it stops matching `*` the moment it matches itself.
86
+ *
87
+ * The search-engine groups exist because `*` keeps the machine surface open to
88
+ * an agent the origin has never heard of, which is right for an agent and wrong
89
+ * for Google: a `.md` twin or its JSON is a page it already has in another
90
+ * format. Two origins wrote that group by hand and the third never did, so
91
+ * Google crawled its twins.
76
92
  */
77
- export function aiCrawlerRules(opts) {
93
+ export function crawlerRules(opts) {
78
94
  const aiAllow = [...new Set([...[opts.aiAllow ?? []].flat(), ...AGENT_SURFACE_PATHS])];
95
+ const disallow = [...new Set([...[opts.disallow].flat(), ...RSC_DISALLOW])];
96
+ const searchDisallow = [...new Set([...disallow, '/*.md$', ...[opts.searchDisallow ?? []].flat()])];
79
97
  return [
80
98
  // `aiAllow` rides on the default group too, not only on the named roster.
81
99
  // The agent surface an origin advertises has to be reachable by a caller it
@@ -83,11 +101,22 @@ export function aiCrawlerRules(opts) {
83
101
  // their own agent card named `/api/mcp`, so an MCP client that identified
84
102
  // honestly as itself was told the endpoint was off limits. The roster is
85
103
  // for indexing policy, not for hiding a documented endpoint.
86
- { userAgent: '*', allow: [...new Set([...[opts.allow].flat(), ...aiAllow])], disallow: opts.disallow },
87
- ...aiCrawlerTokens().map(userAgent => ({
88
- userAgent,
89
- allow: aiAllow,
90
- disallow: opts.disallow,
91
- })),
104
+ { userAgent: '*', allow: [...new Set([...[opts.allow].flat(), ...aiAllow])], disallow },
105
+ ...aiCrawlerTokens().map(userAgent => ({ userAgent, allow: aiAllow, disallow })),
106
+ ...SEARCH_ENGINES.map(userAgent => ({ userAgent, allow: '/', disallow: searchDisallow })),
92
107
  ];
93
108
  }
109
+ /**
110
+ * Count an AI crawler's visit from a proxy, without holding up its response.
111
+ *
112
+ * `load` is a dynamic import of the module that records it, because that
113
+ * module pulls in a database client, and a static import would load it for
114
+ * every request the proxy sees, bots or not. Three proxies wrote these lines.
115
+ * Returns the bot's label, or null.
116
+ */
117
+ export function trackAiBot(request, event, load) {
118
+ const bot = detectAiBot(request.headers.get('user-agent'));
119
+ if (bot)
120
+ event.waitUntil(load().then(m => m.trackEvent('AI Bot Visit', request.url, { bot }, request.headers)));
121
+ return bot;
122
+ }
@@ -0,0 +1,35 @@
1
+ /** A header as Next's `headers()` wants it. */
2
+ export interface HeaderEntry {
3
+ key: string;
4
+ value: string;
5
+ }
6
+ /**
7
+ * The embed hosts the editor's iframe, YouTube, tweet and map nodes produce:
8
+ * the sanitiser's default `iframeHosts` and the default `frame-src`, so an
9
+ * embed that clears one clears the other.
10
+ */
11
+ export declare const DEFAULT_IFRAME_HOSTS: readonly string[];
12
+ /** `frame-src` sources for a host list — the one a sanitiser was given. */
13
+ export declare const frameSources: (hosts?: readonly string[]) => string[];
14
+ /** CSP directive names this builder writes, in the order it writes them. */
15
+ export type CspDirective = 'default-src' | 'base-uri' | 'object-src' | 'frame-ancestors' | 'form-action' | 'script-src' | 'style-src' | 'font-src' | 'img-src' | 'media-src' | 'connect-src' | 'frame-src' | 'worker-src' | 'manifest-src';
16
+ /**
17
+ * The policy all four origins shared line for line. Everything a site adds is
18
+ * a source list it hands `contentSecurityPolicy`, which replaces the default
19
+ * for that directive rather than appending, so a reader sees the whole list.
20
+ */
21
+ export declare const BASE_CSP: Readonly<Partial<Record<CspDirective, readonly string[]>>>;
22
+ /**
23
+ * The policy string: `BASE_CSP` with each directive in `directives` replacing
24
+ * its default. An empty list drops the directive.
25
+ */
26
+ export declare function contentSecurityPolicy(directives?: Partial<Record<CspDirective, readonly string[]>>): string;
27
+ /**
28
+ * The six headers every route sends, plus the policy when one is given.
29
+ *
30
+ * Pass the policy only where it is enforced — production. Next's dev runtime
31
+ * is inline and `eval`, so every origin here exempts dev, and the switch is the
32
+ * caller's because only the caller knows what dev means for it. An origin that
33
+ * sets its policy per request (a nonce) passes none.
34
+ */
35
+ export declare function securityHeaders(csp?: string | null): HeaderEntry[];