wiki-formant 0.97.1 → 0.98.1

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/dist/base.css CHANGED
@@ -276,6 +276,7 @@ tbody > tr:nth-child(even) > :is(th, td):not(.chart *) { background-image: linea
276
276
  .see-also-detail { font-size: var(--ui-small); color: var(--ui-muted); }
277
277
 
278
278
  .link-preview-card { position: fixed; z-index: 50; width: 20rem; max-width: calc(100vw - 1rem); overflow: hidden; background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius); box-shadow: var(--ui-shadow, 0 8px 24px -4px rgb(0 0 0 / 0.25)); }
279
+ .link-preview-banner { display: block; width: 100%; height: 7rem; object-fit: cover; }
279
280
  .link-preview-body { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.75rem; }
280
281
  .link-preview-title { font-weight: 600; }
281
282
  .link-preview-excerpt { display: -webkit-box; margin: 0; overflow: hidden; font-size: var(--ui-small); color: var(--ui-muted); -webkit-line-clamp: 4; -webkit-box-orient: vertical; }
@@ -33,8 +33,9 @@ export type CategoryQuery = Record<string, string | string[] | undefined>;
33
33
  /**
34
34
  * A category's pages narrowed by its facets and an A–Z letter, sorted A–Z or
35
35
  * newest first (by a `date` key where the category declares one), and a card
36
- * per visible child. A set small enough to need no index offers no sort: it is
37
- * A–Z.
36
+ * per visible child. The order is the category's own until the query asks for
37
+ * the other, whatever the set's size: a blog of thirty posts is still newest
38
+ * first, though it is too short for an A–Z index.
38
39
  */
39
40
  export declare function categoryView<P extends FacetablePage & {
40
41
  tagPath: string;
package/dist/category.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // taxonomy and the store.
3
3
  //
4
4
  // radix-wiki assembled this inside its route and miow copied it line for line:
5
- // the count, a sort that only shows once the set needs an A–Z index, the facets
5
+ // the count, a sort toggle for any set with two pages to order, the facets
6
6
  // its `select` keys give, a letter that exists, and a card per section listing
7
7
  // what it holds. acuiq2 kept a subset that had drifted (no sort, no cards). The
8
8
  // view is the app's; the arithmetic is here.
@@ -11,21 +11,20 @@ const one = (v) => (Array.isArray(v) ? v[0] : v);
11
11
  /**
12
12
  * A category's pages narrowed by its facets and an A–Z letter, sorted A–Z or
13
13
  * newest first (by a `date` key where the category declares one), and a card
14
- * per visible child. A set small enough to need no index offers no sort: it is
15
- * A–Z.
14
+ * per visible child. The order is the category's own until the query asks for
15
+ * the other, whatever the set's size: a blog of thirty posts is still newest
16
+ * first, though it is too short for an A–Z index.
16
17
  */
17
18
  export async function categoryView({ tree, taxonomy, pages: reader }, tagPath, query, { href = wikiPath, cardPages = 6, limit = 2000 } = {}) {
18
19
  const resolved = tree.resolve(tagPath);
19
20
  const order = (path, sort) => sort === 'recent' && tree.metadataKeys(path).some(k => k.key === 'date' && k.type === 'date') ? 'date' : sort;
20
21
  const fallback = (path) => (tree.resolve(path).sort === 'title' ? 'title' : 'recent');
21
22
  const asked = one(query.sort);
22
- const requested = asked === 'title' || asked === 'recent' ? asked : fallback(tagPath);
23
- const fetched = await reader.list(tagPath, { sort: order(tagPath, requested), limit });
24
- const sort = taxonomy.needsAlphaIndex(fetched.length) ? requested : 'title';
25
- const all = sort === requested ? fetched : await reader.list(tagPath, { sort: order(tagPath, sort), limit });
23
+ const sort = asked === 'title' || asked === 'recent' ? asked : fallback(tagPath);
24
+ const all = await reader.list(tagPath, { sort: order(tagPath, sort), limit });
26
25
  const filters = taxonomy.facetFilters(tagPath, query);
27
26
  const letter = taxonomy.resolveLetter(all, filters, one(query.letter)?.toUpperCase());
28
- const sortable = taxonomy.needsAlphaIndex(all.length);
27
+ const sortable = all.length > 1;
29
28
  // A link carries the sort only when it is not the category's own, so the bare
30
29
  // category and its default are one address, not two.
31
30
  const state = { sort: sortable && sort !== fallback(tagPath) ? sort : undefined, filters, letter };
@@ -66,6 +66,7 @@ export interface TesterOptions {
66
66
  export interface Tester {
67
67
  base: string;
68
68
  endpoint: string;
69
+ clientName: string;
69
70
  /** One JSON-RPC call. Waits out a 429 as `Retry-After` asks, up to four tries. */
70
71
  rpc(method: string, params?: unknown): Promise<Rpc>;
71
72
  /** `tools/call` shorthand. */
@@ -266,3 +267,65 @@ export declare function rolaOriginCheck(t: Tester, opts?: {
266
267
  endpoint?: string;
267
268
  tool?: string;
268
269
  }): Promise<void>;
270
+ export interface StandardSuiteConfig {
271
+ /** `server.json`'s version, which `initialize` and every descriptor must state. */
272
+ version: string;
273
+ /** The capability keys `initialize` must declare. Default `['tools']`. */
274
+ capabilities?: readonly string[];
275
+ /** Every path the OpenAPI document is served at. */
276
+ openapi?: readonly string[];
277
+ /** The tools that write, which must not be marked read-only. */
278
+ writes?: readonly string[];
279
+ /** `manifestParity`'s options. */
280
+ manifest?: Parameters<typeof manifestParity>[1];
281
+ /** The calls `payloadBudget` weighs beyond the no-argument ones it finds itself; `[]` for those alone. None skips it. */
282
+ budget?: Parameters<typeof payloadBudget>[1];
283
+ /** Surfaces an agent recrawls, for `conditionalGetChecks`. None skips it. */
284
+ conditionalGet?: readonly string[];
285
+ /** JSON descriptors, for `descriptorChecks`. Default `S10_DESCRIPTORS`. */
286
+ descriptors?: readonly string[];
287
+ /** Paths the `User-agent: *` group must reach, for `robotsChecks`. None skips it. */
288
+ robots?: readonly string[];
289
+ }
290
+ /**
291
+ * The suites every surface's `scripts/_mcp-test.ts` runs, in the order they
292
+ * run them: transport, version coherence, the agent card, annotations, the
293
+ * manifest, payload budgets, conditional GETs, descriptors and robots. Returns
294
+ * the `initialize` response for the surface's own assertions.
295
+ */
296
+ export declare function standardSuite(t: Tester, cfg: StandardSuiteConfig): Promise<Rpc>;
297
+ /** A row as `search_wiki` and `list_pages` return it. */
298
+ export interface WikiReadRow {
299
+ path: string;
300
+ tagPath?: string;
301
+ slug?: string;
302
+ id?: string;
303
+ url?: string;
304
+ }
305
+ export interface WikiReadCheckOptions {
306
+ /** A query with hits on this wiki. */
307
+ query: string;
308
+ /** `get_page`'s arguments for a row. Default `{ path }`. */
309
+ address?: (row: WikiReadRow) => Record<string, unknown>;
310
+ /** A row's markdown twin, for `twinParity`. None skips it. */
311
+ twin?: (row: WikiReadRow) => string;
312
+ /** The `maxChars` the corpus slice is asked for, at least `CORPUS_BUDGET.min`. Default 20,000. */
313
+ corpusChars?: number;
314
+ endpoint?: string;
315
+ label?: string;
316
+ }
317
+ /**
318
+ * The `wiki-formant/wiki-tools` read tools answer as an agent expects: the
319
+ * categories, a search and the page it finds, a listing, recent changes, a
320
+ * corpus preflight and a page-aligned slice that resumes, and the teaching
321
+ * answers to no hits, a missing argument, an unknown tool and a missing page.
322
+ * Returns the search rows and the page read, for the surface's own checks.
323
+ */
324
+ export declare function wikiReadChecks(t: Tester, opts: WikiReadCheckOptions): Promise<{
325
+ rows: WikiReadRow[];
326
+ first: WikiReadRow | undefined;
327
+ page: {
328
+ title?: string;
329
+ content?: string;
330
+ } | undefined;
331
+ }>;
@@ -65,6 +65,7 @@ export function createTester(opts) {
65
65
  return {
66
66
  base,
67
67
  endpoint,
68
+ clientName: opts.clientName,
68
69
  rpc,
69
70
  call: (name, args = {}) => rpc('tools/call', { name, arguments: args }),
70
71
  rpcAt,
@@ -625,3 +626,76 @@ export async function rolaOriginCheck(t, opts = {}) {
625
626
  const message = answer?.sign?.message ?? '';
626
627
  t.check('ROLA recipe names this origin', message.includes(`"${origin}"`), `expects "${origin}" in: …${message.slice(-90)}`);
627
628
  }
629
+ /**
630
+ * The suites every surface's `scripts/_mcp-test.ts` runs, in the order they
631
+ * run them: transport, version coherence, the agent card, annotations, the
632
+ * manifest, payload budgets, conditional GETs, descriptors and robots. Returns
633
+ * the `initialize` response for the surface's own assertions.
634
+ */
635
+ export async function standardSuite(t, cfg) {
636
+ const init = await transportChecks(t, t.clientName, cfg.capabilities);
637
+ await versionCoherence(t, cfg.version, standardVersionSources(t, cfg.openapi), init.result?.serverInfo?.version);
638
+ await agentCardParity(t);
639
+ await annotationChecks(t, { writes: cfg.writes });
640
+ await manifestParity(t, cfg.manifest);
641
+ if (cfg.budget)
642
+ await payloadBudget(t, cfg.budget);
643
+ if (cfg.conditionalGet)
644
+ await conditionalGetChecks(t, cfg.conditionalGet);
645
+ await descriptorChecks(t, cfg.descriptors);
646
+ if (cfg.robots)
647
+ await robotsChecks(t, cfg.robots);
648
+ return init;
649
+ }
650
+ /**
651
+ * The `wiki-formant/wiki-tools` read tools answer as an agent expects: the
652
+ * categories, a search and the page it finds, a listing, recent changes, a
653
+ * corpus preflight and a page-aligned slice that resumes, and the teaching
654
+ * answers to no hits, a missing argument, an unknown tool and a missing page.
655
+ * Returns the search rows and the page read, for the surface's own checks.
656
+ */
657
+ export async function wikiReadChecks(t, opts) {
658
+ const endpoint = opts.endpoint ?? t.endpoint;
659
+ const label = (s) => (opts.label ? `[${opts.label}] ${s}` : s);
660
+ const address = opts.address ?? (row => ({ path: row.path }));
661
+ const call = (name, args) => t.callAt(endpoint, name, args);
662
+ const read = async (name, args) => t.payload(await call(name, args));
663
+ const text = (r) => r.result?.content?.[0]?.text ?? '';
664
+ const cats = await read('get_categories');
665
+ t.check(label('get_categories'), (cats?.categories?.length ?? 0) > 0, `${cats?.categories?.length ?? 0} categories`);
666
+ const search = await read('search_wiki', { query: opts.query, pageSize: 5 });
667
+ const rows = search?.pages ?? [];
668
+ const first = rows[0];
669
+ t.check(label('search_wiki'), (search?.total ?? 0) > 0 && !!first?.path, `${search?.total ?? 0} hits for "${opts.query}", first="${first?.path}"`);
670
+ const page = first ? await read('get_page', address(first)) : undefined;
671
+ t.check(label('get_page'), !!page?.title, `"${page?.title}" ${page?.content?.length ?? 0} chars`);
672
+ if (first && opts.twin)
673
+ await twinParity(t, label('get_page is the .md twin'), opts.twin(first), page?.content ?? '');
674
+ const listed = await read('list_pages', { pageSize: 5 });
675
+ t.check(label('list_pages'), (listed?.pages?.length ?? 0) > 0, `${listed?.pages?.length ?? 0} of ${listed?.total ?? 0}`);
676
+ const recent = await read('get_recent_changes', { days: 30, limit: 5 });
677
+ t.check(label('get_recent_changes'), Array.isArray(recent?.pages), `${recent?.pages?.length ?? 0} pages changed in 30d`);
678
+ // A corpus under the budget comes back whole; one over it, cut on a page
679
+ // boundary with somewhere to resume from. The 1,000 is the heading and the
680
+ // separators the document carries beside its pages.
681
+ const maxChars = opts.corpusChars ?? 20_000;
682
+ const size = await read('get_full_corpus', { sizeOnly: true });
683
+ const characters = size?.characters ?? 0;
684
+ t.check(label('get_full_corpus(sizeOnly)'), characters > 0 && (size?.totalPages ?? 0) > 0, `${size?.totalPages} pages, ${characters.toLocaleString()} chars`);
685
+ const slice = await read('get_full_corpus', { maxChars });
686
+ const over = characters > maxChars;
687
+ t.check(label('get_full_corpus(bounded)'), (slice?.document?.length ?? Infinity) <= maxChars + 1_000 && slice?.truncated === over, `${(slice?.document?.length ?? 0).toLocaleString()} chars for maxChars=${maxChars.toLocaleString()}, ${slice?.includedPages} pages, truncated=${slice?.truncated}, nextSkip=${slice?.nextSkip}`);
688
+ if (typeof slice?.nextSkip === 'number') {
689
+ const resumed = await read('get_full_corpus', { maxChars, skip: slice.nextSkip });
690
+ t.check(label('get_full_corpus(resumes)'), resumed?.skip === slice.nextSkip && resumed?.document !== slice.document, `skip=${resumed?.skip} returns a different slice`);
691
+ }
692
+ const none = await read('search_wiki', { query: 'zzzqqqnotathing' });
693
+ t.check(label('no hits teach'), none?.total === 0 && !!none?.note, none?.note?.slice(0, 80) ?? 'no note on an empty result');
694
+ const missing = await call('search_wiki', {});
695
+ t.check(label('missing argument'), !!missing.result?.isError && text(missing).includes('"query"') && text(missing).includes('Expected schema'), text(missing).split('\n')[0] ?? '');
696
+ const unknown = await call('no_such_tool', {});
697
+ t.check(label('unknown tool'), unknown.error?.code === -32602 && (unknown.error.message ?? '').includes('search_wiki'), `${unknown.error?.code}: ${(unknown.error?.message ?? '').slice(0, 80)}`);
698
+ const miss = await call('get_page', address({ path: 'definitely/not/a-page', tagPath: 'definitely/not', slug: 'a-page' }));
699
+ t.check(label('get_page(404) teaches'), !!miss.result?.isError && text(miss).includes('didYouMean'), text(miss).slice(0, 80));
700
+ return { rows, first, page };
701
+ }
package/dist/corpus.d.ts CHANGED
@@ -7,9 +7,13 @@ export interface CorpusSection {
7
7
  /** Heading, URL, date and body, exactly as the document will carry them. */
8
8
  section: string;
9
9
  }
10
- /** `maxChars` bounds: the default, the floor and the ceiling. Quote them in the tool's schema. */
10
+ /**
11
+ * `maxChars` bounds: the default, the floor and the ceiling. Quote them in the
12
+ * tool's schema. The default slice, with its JSON around it, stays inside
13
+ * `payloadBudget`'s 120,000, since a call with no arguments is weighed there.
14
+ */
11
15
  export declare const CORPUS_BUDGET: {
12
- readonly default: 200000;
16
+ readonly default: 100000;
13
17
  readonly min: 1000;
14
18
  readonly max: 1000000;
15
19
  };
package/dist/corpus.js CHANGED
@@ -11,8 +11,12 @@
11
11
  //
12
12
  // The corpus itself — which rows, in what order, how a page becomes a section —
13
13
  // stays with each wiki. This is the arithmetic over the sections it hands in.
14
- /** `maxChars` bounds: the default, the floor and the ceiling. Quote them in the tool's schema. */
15
- export const CORPUS_BUDGET = { default: 200_000, min: 1_000, max: 1_000_000 };
14
+ /**
15
+ * `maxChars` bounds: the default, the floor and the ceiling. Quote them in the
16
+ * tool's schema. The default slice, with its JSON around it, stays inside
17
+ * `payloadBudget`'s 120,000, since a call with no arguments is weighed there.
18
+ */
19
+ export const CORPUS_BUDGET = { default: 100_000, min: 1_000, max: 1_000_000 };
16
20
  const CLIP_MARKER = '\n\n[…page clipped at maxChars…]';
17
21
  /**
18
22
  * A preflight (`sizeOnly`) or one page-aligned slice of `sections`.
package/dist/editor.js CHANGED
@@ -72,8 +72,8 @@ function cleanPastedHtml(html) {
72
72
  * stored HTML, so a page edited once kept them for good. Nofollow on a site's
73
73
  * own links throws away its link equity, and a new tab for in-site navigation
74
74
  * is a bug. Whether a link is external depends on the host it is served from,
75
- * which the editor cannot know, so the render path decides: `normaliseLinks`
76
- * from `wiki-formant/links`. Dropping both attributes from the mark also cleans
75
+ * which the editor cannot know, so the render path decides: `articleHtml`'s
76
+ * `links` pass in `wiki-formant/headings`. Dropping both attributes from the mark also cleans
77
77
  * a legacy link the next time its page is saved.
78
78
  *
79
79
  * @internal
package/dist/headers.d.ts CHANGED
@@ -39,7 +39,14 @@ interface WikiNextConfig {
39
39
  experimental?: object;
40
40
  images?: object;
41
41
  headers?: () => Promise<unknown[]> | unknown[];
42
+ rewrites?: () => Promise<WikiRewrites> | WikiRewrites;
42
43
  }
44
+ /** A Next `rewrites()` answer: a bare list (run after the file system) or the three phases. */
45
+ type WikiRewrites = unknown[] | {
46
+ beforeFiles?: unknown[];
47
+ afterFiles?: unknown[];
48
+ fallback?: unknown[];
49
+ };
43
50
  /**
44
51
  * An app's Next config with the settings every app here wrote out the same way:
45
52
  *
@@ -53,9 +60,13 @@ interface WikiNextConfig {
53
60
  * - `securityHeaders` on every route, with `csp` enforced in production only:
54
61
  * Next's dev runtime is inline and `eval`. An origin that sets its policy
55
62
  * per request (a nonce) passes none.
63
+ * - `/openapi.json` rewritten to `/.well-known/openapi.json` ahead of the file
64
+ * system, so the spec answers at both paths S10 publishes it at.
56
65
  *
57
- * Anything the app sets wins, and its own `headers()` follow these. Pass
58
- * `NextConfig` as `T` so the object is typed as Next's own config.
66
+ * Anything the app sets wins, and its own `headers()` follow these; its
67
+ * `rewrites()`, in either form, keep their phases with this one first in
68
+ * `beforeFiles`. Pass `NextConfig` as `T` so the object is typed as Next's own
69
+ * config.
59
70
  */
60
71
  export declare function withWikiDefaults<T extends WikiNextConfig>(config: T, { csp }?: {
61
72
  csp?: string;
package/dist/headers.js CHANGED
@@ -88,6 +88,8 @@ export function securityHeaders(csp) {
88
88
  { key: 'X-DNS-Prefetch-Control', value: 'on' },
89
89
  ];
90
90
  }
91
+ /** The OpenAPI document at the bare path an agent guesses beside `/.well-known/openapi.json`. */
92
+ const OPENAPI_REWRITE = { source: '/openapi.json', destination: '/.well-known/openapi.json' };
91
93
  /**
92
94
  * An app's Next config with the settings every app here wrote out the same way:
93
95
  *
@@ -101,9 +103,13 @@ export function securityHeaders(csp) {
101
103
  * - `securityHeaders` on every route, with `csp` enforced in production only:
102
104
  * Next's dev runtime is inline and `eval`. An origin that sets its policy
103
105
  * per request (a nonce) passes none.
106
+ * - `/openapi.json` rewritten to `/.well-known/openapi.json` ahead of the file
107
+ * system, so the spec answers at both paths S10 publishes it at.
104
108
  *
105
- * Anything the app sets wins, and its own `headers()` follow these. Pass
106
- * `NextConfig` as `T` so the object is typed as Next's own config.
109
+ * Anything the app sets wins, and its own `headers()` follow these; its
110
+ * `rewrites()`, in either form, keep their phases with this one first in
111
+ * `beforeFiles`. Pass `NextConfig` as `T` so the object is typed as Next's own
112
+ * config.
107
113
  */
108
114
  export function withWikiDefaults(config, { csp } = {}) {
109
115
  return {
@@ -117,5 +123,10 @@ export function withWikiDefaults(config, { csp } = {}) {
117
123
  ...((await config.headers?.()) ?? []),
118
124
  ];
119
125
  },
126
+ async rewrites() {
127
+ const own = (await config.rewrites?.()) ?? {};
128
+ const phases = Array.isArray(own) ? { afterFiles: own } : own;
129
+ return { ...phases, beforeFiles: [OPENAPI_REWRITE, ...(phases.beforeFiles ?? [])] };
130
+ },
120
131
  };
121
132
  }
package/dist/react.d.ts CHANGED
@@ -216,6 +216,17 @@ export interface LinkPreviewState<T> {
216
216
  * three things, and those three are the options above.
217
217
  */
218
218
  export declare function useLinkPreview<T>({ eligible, fetch, cardClassName, cardWidth, cardHeight, showDelay, hideDelay, margin, }: LinkPreviewOptions<T>): LinkPreviewState<T>;
219
+ /**
220
+ * The card `useLinkPreview` positions: an optional banner, the title and the
221
+ * excerpt, in base.css's `link-preview-*` classes. Spread `cardProps` onto it;
222
+ * a caller that has to move the card (out of a query container) passes its own
223
+ * `style`.
224
+ */
225
+ export declare function LinkPreviewCard({ title, excerpt, bannerImage, ...card }: LinkPreviewState<unknown>['cardProps'] & {
226
+ title: string;
227
+ excerpt?: string | null;
228
+ bannerImage?: string | null;
229
+ }): import("react").JSX.Element;
219
230
  export type SortDirection = 'asc' | 'desc';
220
231
  export interface TableSortState<T, K extends string> {
221
232
  sorted: T[];
package/dist/react.js CHANGED
@@ -520,6 +520,15 @@ export function useLinkPreview({ eligible, fetch, cardClassName = 'link-preview-
520
520
  },
521
521
  };
522
522
  }
523
+ /**
524
+ * The card `useLinkPreview` positions: an optional banner, the title and the
525
+ * excerpt, in base.css's `link-preview-*` classes. Spread `cardProps` onto it;
526
+ * a caller that has to move the card (out of a query container) passes its own
527
+ * `style`.
528
+ */
529
+ export function LinkPreviewCard({ title, excerpt, bannerImage, ...card }) {
530
+ return (_jsxs("div", { className: "link-preview-card", ...card, children: [bannerImage && _jsx("img", { src: bannerImage, alt: "", className: "link-preview-banner", loading: "lazy" }), _jsxs("div", { className: "link-preview-body", children: [_jsx("div", { className: "link-preview-title", children: title }), excerpt && _jsx("p", { className: "link-preview-excerpt", children: excerpt })] })] }));
531
+ }
523
532
  /**
524
533
  * One active column, direction toggling on re-click, every table the same.
525
534
  *
package/dist/rola.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type JWTPayload } from 'jose';
2
+ import { type McpTool } from './mcp.js';
2
3
  export interface SignedChallenge {
3
4
  challenge: string;
4
5
  address: string;
@@ -72,3 +73,41 @@ export declare function verifySession<T extends object = JWTPayload>(token: stri
72
73
  export declare function bearerToken(headers: {
73
74
  get(name: string): string | null;
74
75
  }): string | null;
76
+ export interface RolaLoginToolsOptions {
77
+ /** What a token writes to, as in "Step 1 of writing to the wiki". */
78
+ site: string;
79
+ dAppDefinitionAddress: string;
80
+ /** The origin the verifier checks, which the signed message names. */
81
+ origin: string;
82
+ generateChallenge: () => Promise<{
83
+ challenge: string;
84
+ expiresAt: Date | string;
85
+ }>;
86
+ /**
87
+ * Verify the proof and issue a token. Throw an `McpToolError` to refuse; the
88
+ * answer's other fields (the signed-in address) are passed through.
89
+ */
90
+ login: (signed: SignedChallenge) => Promise<{
91
+ token: string;
92
+ expiresAt: Date | string;
93
+ } & Record<string, unknown>>;
94
+ /** Default 300, as `createRola`'s. */
95
+ challengeTtlSec?: number;
96
+ tokenTtlSec: number;
97
+ /** The calls the token is sent on, as in "create_page / edit_page". Default "tool". */
98
+ writeTools?: string;
99
+ notes?: {
100
+ /** A sentence added to `get_challenge`'s description. */
101
+ challenge?: string;
102
+ /** Lines added to, or replacing lines of, the signing recipe the challenge carries. */
103
+ sign?: Record<string, string>;
104
+ /** `login`'s `address` argument description. */
105
+ address?: string;
106
+ };
107
+ }
108
+ /**
109
+ * `get_challenge` and `login`, the two MCP tools that turn an agent's own key
110
+ * into a Bearer token: the challenge carries the exact message to sign, and a
111
+ * refused login says to start again, since a challenge is spent either way.
112
+ */
113
+ export declare function rolaLoginTools(o: RolaLoginToolsOptions): Array<McpTool & Required<Pick<McpTool, 'title' | 'annotations'>>>;
package/dist/rola.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // `@radixdlt/rola` are optional peers, needed only by an app importing this.
18
18
  import { SignJWT, jwtVerify } from 'jose';
19
19
  import { Rola } from '@radixdlt/rola';
20
+ import { McpToolError } from './mcp.js';
20
21
  const hex = (bytes) => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
21
22
  export function createRola({ challenges, challengeTtlSec = 300, ...identity }) {
22
23
  const rola = Rola(identity);
@@ -101,3 +102,83 @@ export async function verifySession(token, secret) {
101
102
  export function bearerToken(headers) {
102
103
  return headers.get('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1] ?? null;
103
104
  }
105
+ /** "12h", or "7-day" for whole days past one. */
106
+ const span = (sec) => (sec % 86_400 === 0 && sec > 86_400 ? `${sec / 86_400}-day` : `${Math.round(sec / 3600)}h`);
107
+ /**
108
+ * `get_challenge` and `login`, the two MCP tools that turn an agent's own key
109
+ * into a Bearer token: the challenge carries the exact message to sign, and a
110
+ * refused login says to start again, since a challenge is spent either way.
111
+ */
112
+ export function rolaLoginTools(o) {
113
+ const minutes = (o.challengeTtlSec ?? 300) / 60;
114
+ const carries = o.writeTools ?? 'tool';
115
+ return [
116
+ {
117
+ name: 'get_challenge',
118
+ title: 'Start a login',
119
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
120
+ description: `Step 1 of writing to ${o.site}: a single-use ROLA challenge (${minutes}-minute expiry). ` +
121
+ 'The response spells out the exact recipe for the message your Ed25519 key must sign. ' +
122
+ (o.notes?.challenge ? `${o.notes.challenge} ` : '') +
123
+ 'You always sign with your OWN key — nothing here custodies anything. Takes no parameters.',
124
+ inputSchema: { type: 'object', properties: {} },
125
+ handler: async () => {
126
+ const { challenge, expiresAt } = await o.generateChallenge();
127
+ return {
128
+ challenge,
129
+ expiresAt: new Date(expiresAt).toISOString(),
130
+ sign: {
131
+ message: `blake2b-256 of: "R" (ascii) + challenge (hex-decoded) + one length byte of the dApp definition address "${o.dAppDefinitionAddress}" + that same address (utf-8) + the origin "${o.origin}" (utf-8)`,
132
+ curve: 'curve25519 (Ed25519); sign the 32-byte hash, hex-encode the signature',
133
+ address: 'your virtual account address, derived from your public key',
134
+ ...o.notes?.sign,
135
+ then: 'Call login with { challenge, address, publicKey, signature, curve }.',
136
+ },
137
+ };
138
+ },
139
+ },
140
+ {
141
+ name: 'login',
142
+ title: 'Exchange a signed proof for a token',
143
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
144
+ description: `Step 2: exchange the signed ROLA proof for a ${span(o.tokenTtlSec)} Bearer token — the same verification the human wallet flow runs. ` +
145
+ `Send the returned token as an HTTP \`Authorization: Bearer <token>\` header on every later ${carries} call; tool arguments never carry it.`,
146
+ inputSchema: {
147
+ type: 'object',
148
+ properties: {
149
+ challenge: { type: 'string', description: 'The challenge from get_challenge' },
150
+ address: { type: 'string', description: o.notes?.address ?? 'Your account address (virtual account of the signing key)' },
151
+ publicKey: { type: 'string', description: 'Ed25519 public key, hex' },
152
+ signature: { type: 'string', description: 'Signature over the ROLA message, hex' },
153
+ curve: { type: 'string', enum: ['curve25519', 'secp256k1'], description: 'Signing curve (Ed25519 = "curve25519")' },
154
+ },
155
+ required: ['challenge', 'address', 'publicKey', 'signature', 'curve'],
156
+ },
157
+ handler: async (args) => {
158
+ const signed = {
159
+ challenge: String(args.challenge),
160
+ address: String(args.address),
161
+ proof: { publicKey: String(args.publicKey), signature: String(args.signature), curve: args.curve },
162
+ };
163
+ try {
164
+ const { token, expiresAt, ...rest } = await o.login(signed);
165
+ return {
166
+ token,
167
+ tokenType: 'Bearer',
168
+ expiresAt: new Date(expiresAt).toISOString(),
169
+ ...rest,
170
+ note: `Send as an HTTP \`Authorization: Bearer <token>\` header on the POSTs carrying your ${carries} calls.`,
171
+ };
172
+ }
173
+ catch (err) {
174
+ if (!(err instanceof McpToolError))
175
+ throw err;
176
+ throw new McpToolError(err.message, {
177
+ hint: `Challenges are single-use and expire after ${minutes} minutes — call get_challenge again. The signing recipe is in its response.`,
178
+ ...err.details,
179
+ });
180
+ }
181
+ },
182
+ },
183
+ ];
184
+ }
@@ -150,9 +150,8 @@ export interface Taxonomy {
150
150
  metadata?: unknown;
151
151
  }) => MetadataRow[];
152
152
  /**
153
- * Whether a set has outgrown a grid: the one size rule a hub's browsing
154
- * controls read. `alphaControls` applies it; a sort toggle reads it too, with
155
- * the same unfiltered count, so the two appear and vanish together.
153
+ * Whether a set has outgrown a grid and needs an A–Z index. `alphaControls`
154
+ * applies it. The sort toggle does not: order matters to a short dated set too.
156
155
  */
157
156
  needsAlphaIndex: (pageCount: number) => boolean;
158
157
  href: (tagPath: string, state: CategoryState) => string;
@@ -3,6 +3,8 @@ import { type CorpusSection } from './corpus.js';
3
3
  import { type WikiNode, type WikiTree } from './tree.js';
4
4
  import type { WikiStore, WikiPageListing, WikiPageRow, PageMetadata } from './store.js';
5
5
  import type { DiffBlock } from './revisions.js';
6
+ /** The section a `corpus` callback returns, from the slicer this module keeps internal. */
7
+ export type { CorpusSection };
6
8
  type ToolName = 'search_wiki' | 'get_page' | 'get_pages' | 'list_pages' | 'get_categories' | 'get_recent_changes' | 'get_full_corpus' | 'get_page_history';
7
9
  export interface WikiToolsOptions<B extends DiffBlock> {
8
10
  store: WikiStore<B>;
@@ -75,4 +77,3 @@ export declare function searchWiki<B extends DiffBlock>(o: Pick<WikiToolsOptions
75
77
  total: number;
76
78
  }>;
77
79
  export declare function wikiReadTools<B extends DiffBlock>(o: WikiToolsOptions<B>): WikiTool[];
78
- export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.97.1",
3
+ "version": "0.98.1",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -157,11 +157,6 @@
157
157
  "import": "./dist/social.js",
158
158
  "default": "./dist/social.js"
159
159
  },
160
- "./links": {
161
- "types": "./dist/links.d.ts",
162
- "import": "./dist/links.js",
163
- "default": "./dist/links.js"
164
- },
165
160
  "./maps": {
166
161
  "types": "./dist/maps.d.ts",
167
162
  "import": "./dist/maps.js",
@@ -273,11 +268,6 @@
273
268
  "import": "./dist/upload.js",
274
269
  "default": "./dist/upload.js"
275
270
  },
276
- "./corpus": {
277
- "types": "./dist/corpus.d.ts",
278
- "import": "./dist/corpus.js",
279
- "default": "./dist/corpus.js"
280
- },
281
271
  "./metadata": {
282
272
  "types": "./dist/metadata.d.ts",
283
273
  "import": "./dist/metadata.js",