wiki-formant 0.47.1 → 0.49.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.
Files changed (79) hide show
  1. package/README.md +95 -26
  2. package/bin/maintenance-log.mjs +198 -0
  3. package/bin/mark-verified.mjs +75 -0
  4. package/bin/prisma-models.mjs +53 -0
  5. package/dist/analytics.d.ts +0 -25
  6. package/dist/analytics.js +10 -49
  7. package/dist/base.css +23 -2
  8. package/dist/blocks.d.ts +7 -6
  9. package/dist/blocks.js +11 -3
  10. package/dist/chart.d.ts +17 -8
  11. package/dist/chart.js +53 -6
  12. package/dist/combobox.d.ts +0 -4
  13. package/dist/combobox.js +2 -0
  14. package/dist/crawlers.d.ts +0 -24
  15. package/dist/crawlers.js +9 -3
  16. package/dist/dom.d.ts +0 -5
  17. package/dist/dom.js +2 -0
  18. package/dist/editor.d.ts +0 -43
  19. package/dist/editor.js +7 -1
  20. package/dist/feed.d.ts +0 -14
  21. package/dist/feed.js +4 -0
  22. package/dist/freshness.d.ts +0 -7
  23. package/dist/freshness.js +3 -0
  24. package/dist/headers.d.ts +0 -8
  25. package/dist/headers.js +3 -1
  26. package/dist/headings.d.ts +0 -8
  27. package/dist/headings.js +2 -0
  28. package/dist/history.d.ts +0 -6
  29. package/dist/history.js +2 -0
  30. package/dist/http.d.ts +0 -11
  31. package/dist/http.js +5 -0
  32. package/dist/license.d.ts +0 -8
  33. package/dist/license.js +2 -0
  34. package/dist/link-check.d.ts +0 -21
  35. package/dist/link-check.js +6 -2
  36. package/dist/links.d.ts +0 -5
  37. package/dist/links.js +2 -0
  38. package/dist/maintenance.d.ts +61 -0
  39. package/dist/maintenance.js +137 -0
  40. package/dist/maps.d.ts +0 -6
  41. package/dist/maps.js +2 -0
  42. package/dist/markdown.d.ts +0 -2
  43. package/dist/markdown.js +1 -1
  44. package/dist/mcp.d.ts +0 -14
  45. package/dist/mcp.js +1 -1
  46. package/dist/metadata.d.ts +14 -11
  47. package/dist/metadata.js +33 -0
  48. package/dist/pagination.d.ts +0 -5
  49. package/dist/pagination.js +0 -4
  50. package/dist/passkey.js +2 -15
  51. package/dist/rate-limit.d.ts +5 -16
  52. package/dist/rate-limit.js +10 -1
  53. package/dist/react-server.d.ts +0 -12
  54. package/dist/react-server.js +2 -0
  55. package/dist/revisions.d.ts +0 -7
  56. package/dist/revisions.js +2 -0
  57. package/dist/rola.d.ts +74 -0
  58. package/dist/rola.js +97 -0
  59. package/dist/search.d.ts +0 -12
  60. package/dist/search.js +4 -0
  61. package/dist/sidebar.d.ts +0 -16
  62. package/dist/sidebar.js +2 -0
  63. package/dist/stats.d.ts +0 -4
  64. package/dist/stats.js +2 -2
  65. package/dist/taxonomy.d.ts +0 -4
  66. package/dist/taxonomy.js +2 -2
  67. package/dist/text.d.ts +16 -22
  68. package/dist/text.js +39 -5
  69. package/dist/tiptap.d.ts +5 -4
  70. package/dist/tiptap.js +2 -1
  71. package/dist/upload.d.ts +31 -0
  72. package/dist/upload.js +91 -0
  73. package/dist/validation.d.ts +0 -6
  74. package/dist/validation.js +3 -3
  75. package/dist/well-known.d.ts +0 -20
  76. package/dist/well-known.js +3 -0
  77. package/package.json +37 -8
  78. package/prisma/analytics.prisma +57 -0
  79. package/prisma/passkey.prisma +21 -0
package/README.md CHANGED
@@ -219,6 +219,8 @@ export async function GET(request: Request) {
219
219
  }
220
220
  ```
221
221
 
222
+ `descriptorResponse` and `descriptorHandler` serve a JSON descriptor (an agent card, a manifest, an OpenAPI document) with its own ETag and `Access-Control-Allow-Origin: *`, since every descriptor is public and browser agents read them; six of nine routes had passed the header by hand.
223
+
222
224
  `corpusValidatorsFrom(seed, stamps)` is that pair from an aggregate: the newest of `stamps` (the epoch when every one is null), appended to `seed` for the tag. `corpusRoute(validators, build)` is the whole handler, and skips the build on a 304.
223
225
 
224
226
  ```ts
@@ -261,9 +263,9 @@ const nextConfig = {
261
263
 
262
264
  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
265
 
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.
266
+ `contentSecurityPolicy` starts from 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
267
 
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.
268
+ The default `frame-src` is `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 the sanitiser and states the same hosts in its `frame-src`.
267
269
 
268
270
  ## Pagination and versioning
269
271
 
@@ -394,9 +396,9 @@ and a crawler obeys only its most-specific matching group, so an agent with no
394
396
  group of its own falls through to `*`. Three wikis were measuring five crawlers
395
397
  they had never addressed.
396
398
 
397
- 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.
399
+ Every agent group also allows the agent surface — `/api/mcp`, the three `llms` exports, `/openapi.json`, `/.well-known/` — without being told. `aiAllow` is what an origin serves beyond that.
398
400
 
399
- `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.
401
+ `crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one for each search engine. 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. The `<Link>` prefetch payload (`?_rsc=`) is refused in every group.
400
402
 
401
403
  `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.
402
404
 
@@ -438,6 +440,12 @@ const faq = faqPageLd(questionPairs(html), url);
438
440
  {faq && <JsonLd data={faq} />}
439
441
  ```
440
442
 
443
+ `describe(text)` is a page's meta description from any prose, HTML, markdown or plain: tags, entities, link targets and bare URLs go, and the rest fits `DESCRIPTION_LIMIT` (160) as whole sentences where they fill half of it, else cut at a word with an ellipsis. Four sites had four rules, one cutting mid-word at 150 and one slicing at 160 with no mark. `openingText(blocks)` (`wiki-formant/text`) is what a page without an excerpt is described by: its first top-level `content` block with prose once the headings are out, since a page that opens with an infobox would otherwise be described by its metadata table.
444
+
445
+ ```ts
446
+ const description = describe(page.metadata?.excerpt || openingText(page.content));
447
+ ```
448
+
441
449
  `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.
442
450
 
443
451
  ```ts
@@ -506,7 +514,7 @@ each change on `data-action`; `base.css` only resets the list.
506
514
 
507
515
  Three repos, four feeds, 47 of 64 significant lines identical — including,
508
516
  verbatim in two of them, the comment explaining why the apostrophe escapes
509
- numerically. `renderFeed` is the union.
517
+ numerically. `feedResponse` is the union.
510
518
 
511
519
  ```ts
512
520
  export const GET = async (request: Request) => feedResponse(request, channel, await items());
@@ -554,11 +562,11 @@ In React, `useArticlePasses(ref, [blocks])` runs the three passes and `useTweetE
554
562
 
555
563
  `scrollTables(html)` is the one table pass that runs on the string, before render: it wraps each stored table in a `.table-scroll` box, and `base.css` lets that box scroll sideways. A table box ignores `overflow`, so a table with an address or a URL in a cell is as wide as that token whatever its column, and a scroll box added by an effect would arrive after the table had already been laid out too wide. Call it on the render path beside `injectHeadingIds`. A table already in a box keeps it, so it is safe on HTML that has been through it once.
556
564
 
557
- `TWITTER_ORIGIN` is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed.
565
+ The embed's origin is written down once. It is both the embed host and the allow-list `onTweetResize` checks before believing a posted height, and it had been spelled out at four call sites across two repos. Any page can `postMessage`; only the embed host may size the embed.
558
566
 
559
567
  ## Search
560
568
 
561
- `proseSql`, `searchTsvSql`, `HEADLINE_OPTIONS` and `FTS_RANK_NORMALIZATION` are what a literal tier and a full-text tier must agree on. `searchTsvDdl(table)` is the statements that build the generated `search_tsv` column and its index — dropped and re-added in one transaction every run, because a skip-if-present script is blind to a changed expression.
569
+ `proseSql`, `HEADLINE_OPTIONS` and `FTS_RANK_NORMALIZATION` are what a literal tier and a full-text tier must agree on. `searchTsvDdl(table)` is the statements that build the generated `search_tsv` column and its index — dropped and re-added in one transaction every run, because a skip-if-present script is blind to a changed expression.
562
570
 
563
571
  ## Block trees
564
572
 
@@ -574,7 +582,7 @@ leafBlocks(blocks, BLOCK_SHAPE.containers);
574
582
 
575
583
  `computeRevisionDiff` defaults to the same shape, path-addressed, so a repo whose containers are the core two passes none.
576
584
 
577
- The dispatch over a repo's own block union stays in that repo; the bodies come from here. `wiki-formant/text` has a prose body for every core leaf — `statsToText`, `linkGridToText` and `pageListToText` joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
585
+ The dispatch over a repo's own block union stays in that repo; the bodies come from here. `coreAtomicText` has a prose body for every core leaf — the stats, link-grid and page-list bodies joined the originals when two of the three wikis turned out to extract nothing from them, so their MCP `get_page` answered a hub page as nearly empty.
578
586
 
579
587
  The core leaves — `content`, `codeTabs`, `banner`, `references`, `stats`, `linkGrid`, `recentPages`, `pageList` — had the same case bodies in every wiki's text export, markdown twin, validator and new-block record. A repo's switch hands those cases over and keeps its own, so it stays exhaustive over its union:
580
588
 
@@ -620,7 +628,7 @@ A `codeTabs` tab's `code` is source text in every wiki here, and every view trea
620
628
  ```ts
621
629
  import { createHtmlSanitizer, sanitizeCoreLeaf } from 'wiki-formant/sanitize';
622
630
 
623
- const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // and frameSources(FRAME_HOSTS) in the CSP
631
+ const clean = createHtmlSanitizer({ iframeHosts: FRAME_HOSTS }); // and the same hosts in the CSP's frame-src
624
632
  const safe = mapBlockTree(blocks, b => sanitizeCoreLeaf(b, clean), BLOCK_SHAPE);
625
633
  ```
626
634
 
@@ -628,27 +636,41 @@ The default list is derived from what the editor nodes in `wiki-formant/tiptap`
628
636
 
629
637
  Classes pass by name only. Limiting `style` to layout and paint is worth nothing while `class` is free, because every site's own stylesheet ships `fixed inset-0 z-50`, and those draw a fake prompt over the chrome as well as `position` does.
630
638
 
639
+ ## Image uploads
640
+
641
+ `wiki-formant/upload` is the route an editor's image upload posts to. Two wikis had written it and their limits had drifted: one took 25 MB with no cap on decoded pixels and trusted the Content-Type the client sent, the other 4 MB, 40 megapixels and the file's own leading bytes. These are the second one's defaults.
642
+
643
+ ```ts
644
+ // src/app/api/upload/route.ts
645
+ export const runtime = 'nodejs';
646
+ export const POST = uploadHandler({
647
+ authorize: async () => (await currentUser())?.id ?? errors.unauthorized('Sign in to upload.'),
648
+ prefix: 'wiki/',
649
+ });
650
+ ```
651
+
652
+ The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget, keyed on `authorize`'s id and address. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers.
653
+
631
654
  ## Editor nodes
632
655
 
633
656
  `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. The four `@tiptap/*` packages are optional peers, so a consumer taking only the taxonomy still installs a package with no runtime dependencies.
634
657
 
635
658
  ```ts
636
659
  const CodeBlock = createCodeBlock({
637
- langs: CODE_LANGS, defaultLang: DEFAULT_LANG,
638
660
  classNames: { button: 'lang-btn', option: 'lang-option', optionActive: 'text-accent' },
639
661
  icons: { chevron: open => <ChevronDown className={open ? 'rotate-180' : ''} /> },
640
662
  });
641
663
  ```
642
664
 
643
- The ones that take config take it because that is exactly where the two copies differed — class tokens, the language list, and the API route a shortened map URL has to be resolved through. Injecting them is what lets one wiki keep `text-jupiter` and the other `text-accent` without either forking the node, and it keeps this file from dragging an icon library in behind it.
665
+ The language list defaults to `CODE_LANGS` and `DEFAULT_LANG` from `wiki-formant/blocks`, which two wikis had typed identically; a wiki with its own passes `langs` and `defaultLang`. The ones that take config take it because that is exactly where the two copies differed — class tokens, the language list, and the API route a shortened map URL has to be resolved through. Injecting them is what lets one wiki keep `text-jupiter` and the other `text-accent` without either forking the node, and it keeps this file from dragging an icon library in behind it.
644
666
 
645
667
  `createTabs` returns `TabGroup` and `TabItem` together: `tabGroup`'s content expression is `tabItem+`, so registering one without the other leaves a node type the schema cannot satisfy. A pasted short map link inserts immediately with `about:blank` and swaps its `src` when the redirect resolves — pasting must not block on a network hop, and the node has to exist for the reader to see anything happen.
646
668
 
647
669
  `createHeadingIds({ slug })` decorates each heading in the editor with the id its published copy will carry, through `uniqueHeadingId` — the dedupe `injectHeadingIds` uses — so a rail listing headings mid-edit links to the anchors readers will get. `uploadImageTo('/api/upload')` is the `uploadImage` two editors had written identically.
648
670
 
649
- `TOOLBAR_ACTIONS` are the formatting commands with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Pick yours in order with `toolbarActions([...])`, render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
671
+ `toolbarActions([...])` picks the formatting commands, in order, with the labels a screen reader announces — two toolbars titled buttons with the internal key, so one said "codeBlock". Render each through `ToolbarButton` (named, and `aria-pressed`, since these are toggles), and run a table command with `runTableAction`. Upload and embed stay with the caller; they are the parts that differ.
650
672
 
651
- `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you. `applyUrlPrompt` is what it runs, exported for a toolbar that keeps its own state.
673
+ `useUrlPrompt(editor, { resolveMapUrl })` is the link and embed address field under a toolbar, in place of a `window.prompt` that blocks the page and cannot show the link the cursor is in. Three editors had written it and one still used the prompt; only one copy both opened a link field on the current address and unlinked on an emptied one. The hook carries both, closes the field when the editor it serves changes (a toolbar shared between blocks would otherwise apply it to the wrong one), and leaves the markup to you.
652
674
 
653
675
  ```tsx
654
676
  const url = useUrlPrompt(editor, { resolveMapUrl });
@@ -656,7 +678,7 @@ const url = useUrlPrompt(editor, { resolveMapUrl });
656
678
  {url.kind && <input autoFocus value={url.value} onChange={e => url.setValue(e.target.value)} onKeyDown={url.onKeyDown} />}
657
679
  ```
658
680
 
659
- A link is stored as its `href` and the `link` class, nothing else (`wikiLink`). Stock Tiptap writes `target="_blank" rel="noopener noreferrer nofollow"` onto every link, internal ones included, which on a site throws away its own link equity and opens in-site navigation in new tabs. Whether a link is external depends on the host serving the page, so `normaliseLinks` decides it at render time. A legacy link loses both attributes the next time its page is saved.
681
+ A link is stored as its `href` and the `link` class, nothing else. Stock Tiptap writes `target="_blank" rel="noopener noreferrer nofollow"` onto every link, internal ones included, which on a site throws away its own link equity and opens in-site navigation in new tabs. Whether a link is external depends on the host serving the page, so `normaliseLinks` decides it at render time. A legacy link loses both attributes the next time its page is saved.
660
682
 
661
683
  The redirect resolves through the wiki's own route, because the editor cannot read it cross-origin. `resolveMapUrl` is the client half and `resolveMapHandler` the whole route: exact shortener hosts, one hop, an allowlisted landing host, a timeout, and your sign-in check as `authorize`. One of the two copies it replaced matched `goo.gl` as a substring and followed every redirect for anyone who asked.
662
684
 
@@ -672,8 +694,8 @@ address and browser under a salt that is replaced every UTC day, and the old
672
694
  salt is deleted, so a visitor is told apart within a day and never followed
673
695
  into the next. Rows are kept 400 days. The package owns the SQL and the app
674
696
  owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
675
- placeholders, and the four tables it needs are stated as Prisma models at the
676
- top of `src/analytics.ts`. The fourth, `ViewDay`, holds days a site counted
697
+ placeholders, and the four tables it needs ship as Prisma models in
698
+ `prisma/analytics.prisma` (see `prisma-models` below). The fourth, `ViewDay`, holds days a site counted
677
699
  elsewhere before it counted its own; `digest` adds them to any window that
678
700
  reaches back that far, and a site with no such history leaves it empty.
679
701
 
@@ -707,14 +729,12 @@ any other browser event. Underneath the tracker, server code calls
707
729
  its zero-dependency guarantee:
708
730
 
709
731
  ```ts
710
- const props = searchQueryProps({ query, results: rows.length, surface: 'wiki' });
711
- // null for an empty field, so a blank search cannot fire an event
712
- if (props) after(() => recordEvent(sql, { name: 'Search Query', props, url, headers }));
732
+ after(() => recordEvent(sql, { name: 'Signup', props: { plan }, url, headers }));
713
733
  ```
714
734
 
715
735
  `mcpCallProps` reads a tool name out of a JSON-RPC envelope that is untrusted
716
- and may be a batch; `searchQueryProps` normalises a search box's free text so
717
- the same question aggregates as one row. Pass the request's headers and the
736
+ and may be a batch; `trackSearch` normalises a search box's free text so
737
+ the same question aggregates as one row, and drops an empty one. Pass the request's headers and the
718
738
  event joins whoever sent it, person or agent.
719
739
 
720
740
  `digest(sql, { days })` reads a site back as one JSON object: visitors, page
@@ -735,8 +755,8 @@ its readers asked for. The queries that return zero rows are the valuable ones
735
755
  nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
736
756
  returns the whole of it. `gate.route` is the sign-in route,
737
757
  `gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
738
- while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) are stated as Prisma
739
- models at the top of `src/passkey.ts`, and every token is kept only as its hash.
758
+ while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
759
+ models in `prisma/passkey.prisma`, and every token is kept only as its hash.
740
760
  `@simplewebauthn/server` is an optional peer.
741
761
 
742
762
  There is no sign-up. While no passkey is saved, the page saves the first one
@@ -786,6 +806,22 @@ counted itself, says so, and drops the comparison with the window before. The ch
786
806
  axis from zero, so `lightweight-charts` is an optional peer that a stats page
787
807
  needs installed.
788
808
 
809
+ ## ROLA sign-in
810
+
811
+ `wiki-formant/rola` is the part of a Radix wallet sign-in two sites had each written: a single-use challenge, the wallet's proof of it, and the HS256 token a verified address is handed. How a site keeps the session stays with it, because the two keep it differently: radix-wiki a `Session` row behind a seven-day cookie, caper a stateless token it slides forward, with a short one for agents.
812
+
813
+ ```ts
814
+ const rola = createRola({ expectedOrigin, dAppDefinitionAddress, networkId, applicationName, challenges: prisma.challenge });
815
+ const { challenge } = await rola.generateChallenge();
816
+ const v = await rola.verifySignedChallenge(signed); // { isValid, error? }
817
+
818
+ const secret = sessionSecret(process.env.JWT_SECRET); // throws under 32 characters
819
+ const token = await signSession({ id: signed.address }, secret, 7 * 86400);
820
+ const claims = await verifySession<{ id: string }>(bearerToken(request.headers) ?? cookie, secret);
821
+ ```
822
+
823
+ Where the two copies differed, this takes the stricter. A challenge is spent by one conditional delete, so two requests racing with one signature cannot both pass, and it is spent whatever the outcome; making one purges the expired. Only an account proof signs in. Verification pins HS256. `jose` and `@radixdlt/rola` are optional peers.
824
+
789
825
  ## Charts
790
826
 
791
827
  `TimeChart` (`wiki-formant/chart`) is every time-series chart on the four sites:
@@ -819,14 +855,25 @@ straight between closes and a count's is curved.
819
855
  `aggregate` is how a week or month is made from its days: `last` for a price,
820
856
  `mean` for a count, so the week in progress keeps its level instead of reading
821
857
  as a collapse. A single range draws no range buttons and shows every point.
822
- `bucketChart` and `chartSteps` are exported for a chart drawn elsewhere.
858
+ `chartSteps` is exported for a chart drawn elsewhere.
859
+
860
+ `markers` are events, `{ time, label }`, each drawn as an arrow on the last
861
+ point at or before it, so one mid-week lands on its week and two on one point
862
+ share it. `step` opens on a step where the
863
+ range can make it, and `onView` hears the range and step on mount and on every
864
+ change, for a page that keeps them in its URL:
865
+
866
+ ```tsx
867
+ <TimeChart series={days} label="Transactions" aggregate="mean" fromZero
868
+ markers={[{ time: haltAt, label: 'Halt' }]} step="month" onView={v => keep(v)} />
869
+ ```
823
870
  `base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
824
871
  canvas's top-left corner, rules a line between groups and dims a disabled step;
825
872
  the site's stylesheet gives them and the box a look.
826
873
 
827
874
  ## Base stylesheet
828
875
 
829
- `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`, the stats page's grid and bars, and the chart's height and pinned buttons. 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.
876
+ `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 stats block's grid, the code-tab strip, the banner's row and the infobox's wrapping, the breadcrumb row, the `aria-sort` arrow as a mask over `currentColor`, the stats page's grid and bars, and the chart's height and pinned buttons. 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.
830
877
 
831
878
  ```css
832
879
  @import "wiki-formant/base.css" layer(components);
@@ -841,7 +888,8 @@ and there is no root export: `import … from 'wiki-formant'` does not resolve.
841
888
  barrel loads every module it names for the one symbol a route wanted, and had
842
889
  already re-exported one module twice. The emitted `.d.ts` files are the reference.
843
890
  There is no hand-maintained symbol list here, because the one that used to be here
844
- drifted from them.
891
+ drifted from them. A function a module uses only itself is marked `@internal`: the
892
+ package's tests still reach it, and the `.d.ts` leaves it out.
845
893
 
846
894
  `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.
847
895
 
@@ -859,6 +907,8 @@ long, across every bucket that names it, rather than letting the refill trickle
859
907
  one token through at a time. `clientIp` reads `x-real-ip` first — Vercel sets it
860
908
  and overwrites any inbound copy — and the leftmost `x-forwarded-for` only where no
861
909
  `x-real-ip` was set, since a proxy that appends hands the client that slot.
910
+ `limitClient(prefix, headers, opts)` is the per-address gate, `rateLimit` over
911
+ `clientKey`, which a dozen call sites had written out.
862
912
 
863
913
  ## A bin
864
914
 
@@ -884,12 +934,31 @@ npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the
884
934
  npx passkey-invite https://caper.network/stats
885
935
  ```
886
936
 
937
+ `prisma-models` copies the models the analytics and passkey SQL reads, `prisma/analytics.prisma` and `prisma/passkey.prisma`, into an app's schema folder, and `--check` exits 1 when a copy is missing or has drifted. Prisma reads a schema split across one folder's files, so the app moves `prisma/schema.prisma` into `prisma/schema/`, points `prisma.config.ts`'s `schema` at the folder, and keeps its generator, datasource and own models there beside the shipped files. A generator `output` is relative to the file that declares it, so it gains a `../`.
938
+
939
+ ```sh
940
+ npx prisma-models # write both into prisma/schema
941
+ npx prisma-models --check # in the build, before prisma generate
942
+ ```
943
+
887
944
  `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.
888
945
 
889
946
  ```sh
890
947
  npx fts-ddl pages
891
948
  ```
892
949
 
950
+ `mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page; a revision is written at most once a day. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
951
+
952
+ ```sh
953
+ npx mark-verified contents/tech/radix-engine scrypto # one page
954
+ npx mark-verified --all ecosystem --invalidate wiki # a tag path, then the cache tag
955
+ npx maintenance-log read --at contents/tech/operations/wiki-maintenance-log --routine wiki-sweep --author <id>
956
+ npx maintenance-log backlog-add '[{"item": "…"}]' --at operations/wiki-maintenance-log --routine caper-wiki-sweep \
957
+ --pages wiki_pages --revisions wiki_revisions --page-key wiki_page_id --scope caper_id
958
+ ```
959
+
960
+ `--scope column` keeps to rows where the column is NULL (`=value` for one value), `--table`/`--pages`/`--revisions` default to `pages` and `revisions`, and `--invalidate <tag>` runs `vercel cache invalidate` after a write.
961
+
893
962
  ## A reusable workflow
894
963
 
895
964
  `.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:
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ // bin/maintenance-log.mjs – state I/O for a wiki-sweep routine's log page.
3
+ //
4
+ // The routine's memory between runs lives in one wiki page's metadata.state,
5
+ // and the page shows it, re-rendered and capped on every write (the rules are
6
+ // `src/maintenance.ts`). radix-wiki and caper each carried this script with the
7
+ // same rules against a different table, so the tables are arguments. Run it
8
+ // from the app's root, where `pg` is installed and `.env` holds DATABASE_URL.
9
+ //
10
+ // npx maintenance-log read # print the state as JSON
11
+ // npx maintenance-log write '<json>' # replace it (or `write -` from stdin)
12
+ // npx maintenance-log compact [--dry-run] # re-apply the caps to what is stored
13
+ // npx maintenance-log backlog-add '<json>' # append items under a row lock
14
+ //
15
+ // with the page and its tables named every time:
16
+ //
17
+ // --at <tagPath>/<slug> the log page, e.g. contents/tech/operations/wiki-maintenance-log
18
+ // --routine <name> named on the page and in the revision message
19
+ // --pages <table> default pages
20
+ // --revisions <table> default revisions
21
+ // --page-key <column> the revisions column naming the page, default page_id
22
+ // --scope <column>[=<value>] only rows where the column is NULL, or equals the value
23
+ // --author <id> revisions.author_id, default NULL
24
+ // --invalidate <tag> `vercel cache invalidate --tag` after a write
25
+ //
26
+ // `write` takes the whole new state: read, change, write back. A revision is
27
+ // written at most once a day, and on every `compact`; other writes edit the
28
+ // page in place, because a full copy per run is what made radix-wiki's log
29
+ // over half its revisions table. `backlog-add` is how another routine files an
30
+ // item: `write` replaces the whole state, so an append through it could lose a
31
+ // concurrent run's, and the row lock serialises the two. A revision's version
32
+ // is the page's `version` column bumped a minor where the table has one, else
33
+ // the latest revision's.
34
+ import { execFileSync } from 'node:child_process';
35
+ import { randomUUID } from 'node:crypto';
36
+ import { addBacklog, compact, ident, render, scopeSql } from '../dist/maintenance.js';
37
+ import { bump } from '../dist/versioning.js';
38
+
39
+ const args = process.argv.slice(2);
40
+ const arg = name => {
41
+ const i = args.indexOf(name);
42
+ return i >= 0 ? args[i + 1] : undefined;
43
+ };
44
+ const [mode, payload] = args.filter((a, i) => !a.startsWith('--') && !args[i - 1]?.match(/^--(?!dry-run)/));
45
+ const dry = args.includes('--dry-run');
46
+ const at = arg('--at') ?? '';
47
+ const routine = arg('--routine');
48
+ const slugAt = at.lastIndexOf('/');
49
+
50
+ if (!['read', 'write', 'compact', 'backlog-add'].includes(mode) || slugAt < 1 || !routine) {
51
+ console.error('usage: maintenance-log <read|write|compact|backlog-add> [json|-] --at <tagPath>/<slug> --routine <name> [--pages t] [--revisions t] [--page-key c] [--scope c[=v]] [--author id] [--invalidate tag] [--dry-run]');
52
+ process.exit(2);
53
+ }
54
+ const pages = ident(arg('--pages') ?? 'pages', '--pages');
55
+ const revisions = ident(arg('--revisions') ?? 'revisions', '--revisions');
56
+ const pageKey = ident(arg('--page-key') ?? 'page_id', '--page-key');
57
+ const scope = scopeSql(arg('--scope'), 3);
58
+ const where = `tag_path = $1 AND slug = $2 AND ${scope.sql}`;
59
+ const address = [at.slice(0, slugAt), at.slice(slugAt + 1), ...scope.values];
60
+
61
+ if (!process.env.DATABASE_URL) {
62
+ try {
63
+ process.loadEnvFile('.env');
64
+ } catch {
65
+ // reported below
66
+ }
67
+ }
68
+ if (!process.env.DATABASE_URL) {
69
+ console.error('no DATABASE_URL in the environment or ./.env');
70
+ process.exit(1);
71
+ }
72
+
73
+ const readStdin = async () => {
74
+ let data = '';
75
+ for await (const chunk of process.stdin) data += chunk;
76
+ return data;
77
+ };
78
+
79
+ // Imported once the command is known good: `pg` is the app's, not this package's.
80
+ const { default: pg } = await import('pg');
81
+ const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
82
+ const q = (text, values) => client.query(text, values).then(r => r.rows);
83
+ let wrote = false;
84
+ try {
85
+ await client.connect();
86
+ const today = new Date().toISOString().slice(0, 10);
87
+
88
+ if (mode === 'read') {
89
+ const [page] = await q(`SELECT metadata FROM ${pages} WHERE ${where}`, address);
90
+ if (!page) throw new Error(`no log page at ${at}`);
91
+ process.stdout.write(`${JSON.stringify(page.metadata?.state ?? {}, null, 2)}\n`);
92
+ } else if (mode === 'backlog-add') {
93
+ const items = JSON.parse(payload ?? '[]');
94
+ if (!Array.isArray(items) || !items.length) throw new Error('backlog-add expects a non-empty JSON array of items');
95
+ await q('BEGIN');
96
+ const [page] = await q(`SELECT id, metadata FROM ${pages} WHERE ${where} FOR UPDATE`, address);
97
+ if (!page) throw new Error(`no log page at ${at}`);
98
+ const state = page.metadata?.state ?? {};
99
+ const added = addBacklog(state, items, today);
100
+ if (!added.length) {
101
+ await q('ROLLBACK');
102
+ console.log(' nothing to add (all duplicates)');
103
+ } else {
104
+ const next = compact({ ...state, backlog: [...(state.backlog ?? []), ...added] });
105
+ console.log(` ${dry ? '[dry] ' : ''}+${added.length} backlog item(s), ${next.backlog.length} open`);
106
+ for (const a of added) console.log(` ${a.item}`);
107
+ if (dry) await q('ROLLBACK');
108
+ else {
109
+ await q(`UPDATE ${pages} SET content = $1, metadata = $2, updated_at = now() WHERE id = $3`, [
110
+ JSON.stringify(render(next, routine)),
111
+ JSON.stringify({ state: next, lastVerified: today }),
112
+ page.id,
113
+ ]);
114
+ await q('COMMIT');
115
+ wrote = true;
116
+ }
117
+ }
118
+ } else {
119
+ const [page] = await q(`SELECT * FROM ${pages} WHERE ${where}`, address);
120
+ if (!page) throw new Error(`no log page at ${at}`);
121
+ let input = page.metadata?.state ?? {};
122
+ if (mode === 'write') {
123
+ const text = payload === undefined || payload === '-' ? await readStdin() : payload;
124
+ if (!text.trim()) throw new Error('write needs the state as JSON, as an argument or on stdin with `write -`');
125
+ input = JSON.parse(text);
126
+ }
127
+ const state = compact(input);
128
+ const content = render(state, routine);
129
+
130
+ const [last] = await q(
131
+ `SELECT version, created_at FROM ${revisions} WHERE ${pageKey} = $1 ORDER BY created_at DESC LIMIT 1`,
132
+ [page.id],
133
+ );
134
+ const revise = mode === 'compact' || !last || last.created_at.toISOString().slice(0, 10) !== today;
135
+ const was = 'version' in page ? page.version : last?.version;
136
+ const version = revise ? bump(was, 'minor') : was;
137
+
138
+ for (const key of ['backlog', 'runHistory', 'feedback']) {
139
+ const [before, after] = [(input[key] ?? []).length, state[key].length];
140
+ if (before !== after) console.log(` ${key}: ${before} → ${after}`);
141
+ }
142
+ console.log(
143
+ ` ${dry ? '[dry] ' : ''}state ${JSON.stringify(input).length} → ${JSON.stringify(state).length} B, ` +
144
+ `content ${JSON.stringify(content).length} B${revise ? `, revision v${version}` : ', no revision today'}`,
145
+ );
146
+
147
+ if (!dry) {
148
+ await q('BEGIN');
149
+ await q(
150
+ `UPDATE ${pages} SET content = $1, metadata = $2, updated_at = now()${'version' in page ? ', version = $4' : ''} WHERE id = $3`,
151
+ [JSON.stringify(content), JSON.stringify({ state, lastVerified: today }), page.id, ...('version' in page ? [version] : [])],
152
+ );
153
+ if (revise) {
154
+ // An id with no database default (a Prisma cuid) is supplied here.
155
+ const [{ has_default }] = await q(
156
+ `SELECT column_default IS NOT NULL AS has_default FROM information_schema.columns
157
+ WHERE table_schema = current_schema() AND table_name = $1 AND column_name = 'id'`,
158
+ [revisions.slice(1, -1)],
159
+ );
160
+ const row = {
161
+ ...(has_default ? {} : { id: randomUUID() }),
162
+ [pageKey.slice(1, -1)]: page.id,
163
+ title: page.title,
164
+ content: JSON.stringify(content),
165
+ version,
166
+ change_type: 'minor',
167
+ author_id: arg('--author') ?? null,
168
+ message: mode === 'compact'
169
+ ? 'Compact the log: drop closed and duplicated backlog items, cap the append-only lists'
170
+ : `${routine} run ${state.lastRun ?? today}`,
171
+ };
172
+ const columns = Object.keys(row);
173
+ await q(
174
+ `INSERT INTO ${revisions} (${columns.map(c => `"${c}"`).join(', ')}) VALUES (${columns.map((_, i) => `$${i + 1}`).join(', ')})`,
175
+ Object.values(row),
176
+ );
177
+ }
178
+ await q('COMMIT');
179
+ wrote = true;
180
+ console.log(`log page updated${revise ? ` → v${version}` : ''}`);
181
+ }
182
+ }
183
+ } catch (e) {
184
+ await client.query('ROLLBACK').catch(() => {});
185
+ console.error(e.message);
186
+ process.exitCode = 1;
187
+ } finally {
188
+ await client.end().catch(() => {});
189
+ const tag = arg('--invalidate');
190
+ if (wrote && tag) {
191
+ try {
192
+ execFileSync('vercel', ['cache', 'invalidate', '--tag', tag, '--yes'], { stdio: 'ignore' });
193
+ console.log(`cache: ${tag} tag invalidated`);
194
+ } catch (e) {
195
+ console.warn(`cache: ${tag} tag NOT invalidated (${e.message}); run \`vercel cache invalidate --tag ${tag}\``);
196
+ }
197
+ }
198
+ }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // bin/mark-verified.mjs – stamp last_verified_at on pages a sweep has checked
3
+ // against their sources and found current, so `freshnessBanner`
4
+ // (`wiki-formant/freshness`) stops calling them outdated. A page that needed an
5
+ // edit gets its revision instead; this is for one that did not.
6
+ //
7
+ // radix-wiki and caper each carried this script against their own table. Run
8
+ // it from the app's root, where `pg` is installed and `.env` holds DATABASE_URL.
9
+ //
10
+ // npx mark-verified <tagPath> <slug> one page ('' is a category hub)
11
+ // npx mark-verified --all <tagPath> every page at or under a tag path
12
+ //
13
+ // --table <table> default pages
14
+ // --scope <column>[=<value>] only rows where the column is NULL, or equals the value
15
+ // --invalidate <tag> `vercel cache invalidate --tag` after a write
16
+ import { execFileSync } from 'node:child_process';
17
+ import { ident, scopeSql } from '../dist/maintenance.js';
18
+
19
+ const args = process.argv.slice(2);
20
+ const arg = name => {
21
+ const i = args.indexOf(name);
22
+ return i >= 0 ? args[i + 1] : undefined;
23
+ };
24
+ const all = args.includes('--all');
25
+ const [tagPath, slug] = args.filter((a, i) => !a.startsWith('--') && !['--table', '--scope', '--invalidate'].includes(args[i - 1]));
26
+
27
+ if (!tagPath || (!all && slug === undefined)) {
28
+ console.error('usage: mark-verified <tagPath> <slug> | --all <tagPath> [--table t] [--scope c[=v]] [--invalidate tag]');
29
+ process.exit(2);
30
+ }
31
+ const table = ident(arg('--table') ?? 'pages', '--table');
32
+ const scope = scopeSql(arg('--scope'), 3);
33
+
34
+ if (!process.env.DATABASE_URL) {
35
+ try {
36
+ process.loadEnvFile('.env');
37
+ } catch {
38
+ // reported below
39
+ }
40
+ }
41
+ if (!process.env.DATABASE_URL) {
42
+ console.error('no DATABASE_URL in the environment or ./.env');
43
+ process.exit(1);
44
+ }
45
+
46
+ // Imported once the command is known good: `pg` is the app's, not this package's.
47
+ const { default: pg } = await import('pg');
48
+ const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
49
+ try {
50
+ await client.connect();
51
+ const { rowCount } = all
52
+ ? await client.query(
53
+ `UPDATE ${table} SET last_verified_at = now() WHERE (tag_path = $1 OR tag_path LIKE $2) AND ${scope.sql}`,
54
+ [tagPath, `${tagPath}/%`, ...scope.values],
55
+ )
56
+ : await client.query(
57
+ `UPDATE ${table} SET last_verified_at = now() WHERE tag_path = $1 AND slug = $2 AND ${scope.sql}`,
58
+ [tagPath, slug, ...scope.values],
59
+ );
60
+ console.log(`Stamped last_verified_at on ${rowCount} page(s).`);
61
+ const tag = arg('--invalidate');
62
+ if (rowCount && tag) {
63
+ try {
64
+ execFileSync('vercel', ['cache', 'invalidate', '--tag', tag, '--yes'], { stdio: 'ignore' });
65
+ console.log(`cache: ${tag} tag invalidated`);
66
+ } catch (e) {
67
+ console.warn(`cache: ${tag} tag NOT invalidated (${e.message}); run \`vercel cache invalidate --tag ${tag}\``);
68
+ }
69
+ }
70
+ } catch (e) {
71
+ console.error(e.message);
72
+ process.exitCode = 1;
73
+ } finally {
74
+ await client.end().catch(() => {});
75
+ }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // bin/prisma-models.mjs – copy the Prisma models wiki-formant's SQL reads into an
3
+ // app's schema folder, or check that the copies there have not drifted.
4
+ //
5
+ // `wiki-formant/analytics` and `wiki-formant/passkey` own their SQL and name
6
+ // their tables as written, so the models are the package's. Three apps carried
7
+ // them byte for byte, and the package kept a fourth copy as a comment.
8
+ //
9
+ // Prisma reads a schema split across the .prisma files of one folder. The app
10
+ // points prisma.config.ts at the folder (`schema: 'prisma/schema'`), keeps its
11
+ // generator, datasource and own models in prisma/schema/schema.prisma, and this
12
+ // writes analytics.prisma and passkey.prisma beside them. Run it from the app's
13
+ // root:
14
+ //
15
+ // npx prisma-models # write both into prisma/schema
16
+ // npx prisma-models --check # exit 1 if either is missing or differs
17
+ // npx prisma-models analytics # only the ones named
18
+ // npx prisma-models --dir db/schema # another folder
19
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+
22
+ const MODELS = ['analytics', 'passkey'];
23
+ const args = process.argv.slice(2);
24
+ const check = args.includes('--check');
25
+ const at = args.indexOf('--dir');
26
+ const dir = at >= 0 ? args[at + 1] : 'prisma/schema';
27
+ const named = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--dir');
28
+ const unknown = named.filter(n => !MODELS.includes(n));
29
+
30
+ if (unknown.length || !dir) {
31
+ console.error(`usage: prisma-models [${MODELS.join('|')}…] [--dir <folder>] [--check]`);
32
+ process.exit(2);
33
+ }
34
+ if (!existsSync(dir)) {
35
+ console.error(`prisma-models: no ${dir}/ here. Move prisma/schema.prisma into it and point prisma.config.ts's schema at the folder.`);
36
+ process.exit(2);
37
+ }
38
+
39
+ let drift = 0;
40
+ for (const name of named.length ? named : MODELS) {
41
+ const shipped = readFileSync(new URL(`../prisma/${name}.prisma`, import.meta.url), 'utf8');
42
+ const target = join(dir, `${name}.prisma`);
43
+ const local = existsSync(target) ? readFileSync(target, 'utf8') : null;
44
+ if (local === shipped) continue;
45
+ if (check) {
46
+ drift++;
47
+ console.error(`${target} ${local === null ? 'is missing' : 'differs from wiki-formant'}: run npx prisma-models`);
48
+ } else {
49
+ writeFileSync(target, shipped);
50
+ console.log(`wrote ${target}`);
51
+ }
52
+ }
53
+ process.exit(drift ? 1 : 0);