wiki-formant 0.27.1 → 0.29.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 +106 -31
- package/bin/analytics-digest.mjs +57 -0
- package/dist/analytics.d.ts +166 -26
- package/dist/analytics.js +267 -47
- package/dist/blocks.d.ts +139 -0
- package/dist/blocks.js +68 -0
- package/dist/db.d.ts +21 -0
- package/dist/db.js +27 -0
- package/dist/dom.d.ts +15 -0
- package/dist/dom.js +57 -0
- package/dist/feed.d.ts +0 -1
- package/dist/feed.js +1 -1
- package/dist/freshness.d.ts +6 -1
- package/dist/freshness.js +7 -3
- package/dist/http.d.ts +10 -0
- package/dist/http.js +13 -0
- package/dist/link-check.d.ts +0 -24
- package/dist/link-check.js +3 -3
- package/dist/mcp.d.ts +6 -5
- package/dist/mcp.js +4 -3
- package/dist/rate-limit.d.ts +28 -2
- package/dist/rate-limit.js +55 -3
- package/dist/react.d.ts +7 -0
- package/dist/react.js +9 -1
- package/dist/validation.d.ts +29 -0
- package/dist/validation.js +51 -0
- package/package.json +12 -25
- package/dist/index.d.ts +0 -22
- package/dist/index.js +0 -27
- package/dist/rola.d.ts +0 -127
- package/dist/rola.js +0 -150
- package/dist/x402.d.ts +0 -95
- package/dist/x402.js +0 -212
package/dist/dom.js
CHANGED
|
@@ -322,3 +322,60 @@ export function scrollTables(html) {
|
|
|
322
322
|
return hadBox ? tag : SCROLL_BOX + tag;
|
|
323
323
|
});
|
|
324
324
|
}
|
|
325
|
+
// ---- analytics beacon -------------------------------------------------------
|
|
326
|
+
let beaconEndpoint = '/api/view';
|
|
327
|
+
let beaconStarted = false;
|
|
328
|
+
const sendBeacon = (body) => navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
|
|
329
|
+
/**
|
|
330
|
+
* Report each page view to the site's own `endpoint`, whose route hands it to
|
|
331
|
+
* `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
|
|
332
|
+
* runs and no blocklist names the request.
|
|
333
|
+
*
|
|
334
|
+
* A client-side router moves between pages with `history.pushState`, which no
|
|
335
|
+
* load event marks, so both history writes are wrapped and a view is sent
|
|
336
|
+
* whenever the path changes; a change to the query or hash alone is not a page.
|
|
337
|
+
* A click on a link to another host is sent as an "Outbound Link: Click" event
|
|
338
|
+
* carrying its `url`. Starts once per page load however often it is called, so
|
|
339
|
+
* React's doubled effects in development send one view, not two.
|
|
340
|
+
*/
|
|
341
|
+
export function startBeacon(endpoint = '/api/view') {
|
|
342
|
+
if (beaconStarted || typeof window === 'undefined')
|
|
343
|
+
return;
|
|
344
|
+
beaconStarted = true;
|
|
345
|
+
beaconEndpoint = endpoint;
|
|
346
|
+
let last = '';
|
|
347
|
+
let first = true;
|
|
348
|
+
const view = () => {
|
|
349
|
+
if (location.pathname === last)
|
|
350
|
+
return;
|
|
351
|
+
last = location.pathname;
|
|
352
|
+
// document.referrer belongs to the page load, so only its first view carries it.
|
|
353
|
+
sendBeacon({
|
|
354
|
+
path: last,
|
|
355
|
+
referrer: first ? document.referrer : undefined,
|
|
356
|
+
utm: new URLSearchParams(location.search).get('utm_source') ?? undefined,
|
|
357
|
+
});
|
|
358
|
+
first = false;
|
|
359
|
+
};
|
|
360
|
+
for (const method of ['pushState', 'replaceState']) {
|
|
361
|
+
const write = history[method];
|
|
362
|
+
history[method] = function (...args) {
|
|
363
|
+
write.apply(this, args);
|
|
364
|
+
view();
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
addEventListener('popstate', view);
|
|
368
|
+
document.addEventListener('click', e => {
|
|
369
|
+
const a = e.target?.closest?.('a[href]');
|
|
370
|
+
if (a instanceof HTMLAnchorElement && /^https?:$/.test(a.protocol) && a.host !== location.host) {
|
|
371
|
+
track('Outbound Link: Click', { url: a.href });
|
|
372
|
+
}
|
|
373
|
+
}, { capture: true });
|
|
374
|
+
view();
|
|
375
|
+
}
|
|
376
|
+
/** Record a named event from the browser, on the endpoint `startBeacon` was given. */
|
|
377
|
+
export function track(name, props) {
|
|
378
|
+
if (typeof window === 'undefined')
|
|
379
|
+
return;
|
|
380
|
+
sendBeacon({ name, path: location.pathname, props });
|
|
381
|
+
}
|
package/dist/feed.d.ts
CHANGED
|
@@ -53,7 +53,6 @@ export interface FeedChannel {
|
|
|
53
53
|
* exist to prevent. An empty feed has no build date rather than a fictional one.
|
|
54
54
|
*/
|
|
55
55
|
export declare function renderFeed(channel: FeedChannel, items: readonly FeedItem[]): string;
|
|
56
|
-
export declare const FEED_HEADERS: Record<string, string>;
|
|
57
56
|
/**
|
|
58
57
|
* The feed as a response, with the validators a poller revalidates against.
|
|
59
58
|
*
|
package/dist/feed.js
CHANGED
|
@@ -89,7 +89,7 @@ export function renderFeed(channel, items) {
|
|
|
89
89
|
].join('\n');
|
|
90
90
|
}
|
|
91
91
|
const lastBuild = (channel, items) => channel.lastBuild ?? items.reduce((max, item) => (!max || item.date > max ? item.date : max), null);
|
|
92
|
-
|
|
92
|
+
const FEED_HEADERS = {
|
|
93
93
|
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
94
94
|
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
|
95
95
|
};
|
package/dist/freshness.d.ts
CHANGED
|
@@ -39,8 +39,13 @@ type When = Date | string | number;
|
|
|
39
39
|
* timestamp formatted in the server's zone, or the browser's, lands on the
|
|
40
40
|
* previous day for half the world, and the server and the hydrating client
|
|
41
41
|
* disagree about which day it is.
|
|
42
|
+
*
|
|
43
|
+
* `locale` defaults to `en-US`; `{ locale: 'en-GB', month: 'long' }` reads
|
|
44
|
+
* `19 September 2026`.
|
|
42
45
|
*/
|
|
43
|
-
export declare function formatDay(date: When, options?: Intl.DateTimeFormatOptions
|
|
46
|
+
export declare function formatDay(date: When, options?: Intl.DateTimeFormatOptions & {
|
|
47
|
+
locale?: string;
|
|
48
|
+
}): string;
|
|
44
49
|
/**
|
|
45
50
|
* - `compact`: `now`, `2m`, `3h`, `5d`, `2mo`, `1y` — for dense rows.
|
|
46
51
|
* - `short`: `just now`, `2m ago` … `1y ago`.
|
package/dist/freshness.js
CHANGED
|
@@ -60,14 +60,18 @@ const toMs = (d) => (typeof d === 'number' ? d : d instanceof Date ? d.getTime()
|
|
|
60
60
|
* timestamp formatted in the server's zone, or the browser's, lands on the
|
|
61
61
|
* previous day for half the world, and the server and the hydrating client
|
|
62
62
|
* disagree about which day it is.
|
|
63
|
+
*
|
|
64
|
+
* `locale` defaults to `en-US`; `{ locale: 'en-GB', month: 'long' }` reads
|
|
65
|
+
* `19 September 2026`.
|
|
63
66
|
*/
|
|
64
|
-
export function formatDay(date, options) {
|
|
65
|
-
|
|
67
|
+
export function formatDay(date, options = {}) {
|
|
68
|
+
const { locale = 'en-US', ...format } = options;
|
|
69
|
+
return new Date(toMs(date)).toLocaleDateString(locale, {
|
|
66
70
|
year: 'numeric',
|
|
67
71
|
month: 'short',
|
|
68
72
|
day: 'numeric',
|
|
69
73
|
timeZone: 'UTC',
|
|
70
|
-
...
|
|
74
|
+
...format,
|
|
71
75
|
});
|
|
72
76
|
}
|
|
73
77
|
const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'} ago`;
|
package/dist/http.d.ts
CHANGED
|
@@ -123,6 +123,16 @@ export interface CorpusValidators {
|
|
|
123
123
|
etag: string;
|
|
124
124
|
lastModified: string;
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The validators for an aggregate: the ETag seeded from `seed` (the depth or
|
|
128
|
+
* scope, then the row count) with the newest stamp appended, and that stamp as
|
|
129
|
+
* Last-Modified. A corpus with no rows, or stamps all null, dates from the
|
|
130
|
+
* epoch, so an empty corpus still revalidates rather than throwing.
|
|
131
|
+
*
|
|
132
|
+
* Every wiki here wrote this after its `aggregate` call: newest of the `_max`
|
|
133
|
+
* columns, `?? new Date(0)`, then the same two lines.
|
|
134
|
+
*/
|
|
135
|
+
export declare function corpusValidatorsFrom(seed: ReadonlyArray<string | number | null | undefined>, stamps: ReadonlyArray<Date | null | undefined>): CorpusValidators;
|
|
126
136
|
/**
|
|
127
137
|
* A GET handler serving `build()` under corpus validators — or a 304 instead.
|
|
128
138
|
*
|
package/dist/http.js
CHANGED
|
@@ -191,6 +191,19 @@ export function pageLine(opts) {
|
|
|
191
191
|
const stamp = opts.updated ? isoDate(opts.updated) : '';
|
|
192
192
|
return `- [${opts.title}](${opts.url})${excerpt}${stamp ? ` _(updated ${stamp})_` : ''}`;
|
|
193
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* The validators for an aggregate: the ETag seeded from `seed` (the depth or
|
|
196
|
+
* scope, then the row count) with the newest stamp appended, and that stamp as
|
|
197
|
+
* Last-Modified. A corpus with no rows, or stamps all null, dates from the
|
|
198
|
+
* epoch, so an empty corpus still revalidates rather than throwing.
|
|
199
|
+
*
|
|
200
|
+
* Every wiki here wrote this after its `aggregate` call: newest of the `_max`
|
|
201
|
+
* columns, `?? new Date(0)`, then the same two lines.
|
|
202
|
+
*/
|
|
203
|
+
export function corpusValidatorsFrom(seed, stamps) {
|
|
204
|
+
const newest = stamps.reduce((max, d) => (d instanceof Date && d.getTime() > max.getTime() ? d : max), new Date(0));
|
|
205
|
+
return { etag: corpusEtag([...seed, newest]), lastModified: newest.toUTCString() };
|
|
206
|
+
}
|
|
194
207
|
/**
|
|
195
208
|
* A GET handler serving `build()` under corpus validators — or a 304 instead.
|
|
196
209
|
*
|
package/dist/link-check.d.ts
CHANGED
|
@@ -42,30 +42,6 @@ export declare function probeUrlFor(url: string): string;
|
|
|
42
42
|
* exactly as a hand re-probe does it.
|
|
43
43
|
*/
|
|
44
44
|
export declare function perHost<T>(url: string, fn: () => Promise<T>): Promise<T>;
|
|
45
|
-
/**
|
|
46
|
-
* Probe one URL, honestly.
|
|
47
|
-
*
|
|
48
|
-
* A timeout is not a death and neither is a dropped connection: slow replays —
|
|
49
|
-
* web.archive.org above all, which a sweep leans on to rescue dying citations —
|
|
50
|
-
* exceed the ordinary budget and land as status 0. So a status-0 result with a
|
|
51
|
-
* transient code earns one more attempt on the longer budget before it is
|
|
52
|
-
* reported. A 404 does not: it is an answer.
|
|
53
|
-
*/
|
|
54
|
-
export declare function probeUrl(url: string, opts?: ProbeOptions): Promise<Probe>;
|
|
55
|
-
export declare const YOUTUBE_EMBED: RegExp;
|
|
56
|
-
/**
|
|
57
|
-
* A YouTube `/embed/<id>` URL answers 200 for private, deleted and
|
|
58
|
-
* playback-restricted videos alike, so HEADing it can never spot a dead hero
|
|
59
|
-
* video. oEmbed can:
|
|
60
|
-
*
|
|
61
|
-
* 200 → public and embeddable | 404 → deleted | 401/403 → private or embedding off
|
|
62
|
-
*/
|
|
63
|
-
export declare function probeYouTube(videoId: string, opts?: ProbeOptions): Promise<{
|
|
64
|
-
status: number;
|
|
65
|
-
ok: boolean;
|
|
66
|
-
reason?: string;
|
|
67
|
-
error?: string;
|
|
68
|
-
}>;
|
|
69
45
|
/** An external anchor, with a video treated as a video. */
|
|
70
46
|
export declare function probeExternal(url: string, opts?: ProbeOptions): Promise<Probe & {
|
|
71
47
|
videoId?: string;
|
package/dist/link-check.js
CHANGED
|
@@ -134,7 +134,7 @@ async function attempt(url, budgetMs) {
|
|
|
134
134
|
* transient code earns one more attempt on the longer budget before it is
|
|
135
135
|
* reported. A 404 does not: it is an answer.
|
|
136
136
|
*/
|
|
137
|
-
|
|
137
|
+
async function probeUrl(url, opts = {}) {
|
|
138
138
|
const { timeoutMs, slowTimeoutMs } = { ...DEFAULTS, ...opts };
|
|
139
139
|
const target = probeUrlFor(url);
|
|
140
140
|
const first = await perHost(target, () => attempt(target, timeoutMs));
|
|
@@ -145,7 +145,7 @@ export async function probeUrl(url, opts = {}) {
|
|
|
145
145
|
return { ...second, url };
|
|
146
146
|
}
|
|
147
147
|
// ---- YouTube ----------------------------------------------------------------
|
|
148
|
-
|
|
148
|
+
const YOUTUBE_EMBED = /^https?:\/\/(?:www\.)?(?:youtube-nocookie\.com|youtube\.com)\/embed\/([\w-]+)/;
|
|
149
149
|
/**
|
|
150
150
|
* The same video cited as a LINK rather than an iframe. `youtu.be/<id>` 303s and
|
|
151
151
|
* `youtube.com/watch?v=<id>` 200s for deleted and private videos alike, so an
|
|
@@ -160,7 +160,7 @@ const YOUTUBE_WATCH = /^https?:\/\/(?:(?:www\.)?youtube\.com\/(?:watch\?(?:[^#]*
|
|
|
160
160
|
*
|
|
161
161
|
* 200 → public and embeddable | 404 → deleted | 401/403 → private or embedding off
|
|
162
162
|
*/
|
|
163
|
-
|
|
163
|
+
async function probeYouTube(videoId, opts = {}) {
|
|
164
164
|
const { timeoutMs } = { ...DEFAULTS, ...opts };
|
|
165
165
|
const oembed = `https://www.youtube.com/oembed?url=https%3A//www.youtube.com/watch%3Fv%3D${videoId}&format=json`;
|
|
166
166
|
try {
|
package/dist/mcp.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare const MCP_MODERN_VERSIONS: readonly ["2026-07-28"];
|
|
|
10
10
|
* endpoint speak both, and a legacy client meeting a dual-era server works;
|
|
11
11
|
* going modern-only would fail the handshake of every client in the field.
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
declare const MCP_LEGACY_VERSIONS: readonly ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
14
14
|
/**
|
|
15
15
|
* Every version this transport speaks, newest first. It is the `supported` list
|
|
16
16
|
* a `-32022` names and the `supportedVersions` of `server/discover`, so a modern
|
|
@@ -239,8 +239,8 @@ export declare const withAdjustments: <T extends object>(args: {
|
|
|
239
239
|
}, result: T) => T | (T & {
|
|
240
240
|
adjustments: readonly ArgAdjustment[];
|
|
241
241
|
});
|
|
242
|
-
/** One entry in a JSON-RPC envelope. Exported
|
|
243
|
-
*
|
|
242
|
+
/** One entry in a JSON-RPC envelope. Exported for an `McpServerConfig.gate`,
|
|
243
|
+
* which rewrites the envelope before `handleMcp` sees it (acuiq2's x402 gate). */
|
|
244
244
|
export type RpcRequest = {
|
|
245
245
|
jsonrpc: '2.0';
|
|
246
246
|
id: string | number | null;
|
|
@@ -250,8 +250,9 @@ export type RpcRequest = {
|
|
|
250
250
|
/** How many entries one JSON-RPC batch may carry. Quoted by the conformance suite. */
|
|
251
251
|
export declare const DEFAULT_MAX_BATCH = 20;
|
|
252
252
|
/**
|
|
253
|
-
* A tool result envelope. Exported
|
|
254
|
-
* it withholds a
|
|
253
|
+
* A tool result envelope. Exported for an envelope gate, which builds the same
|
|
254
|
+
* shape when it withholds a call: acuiq2's x402 gate had grown two more copies
|
|
255
|
+
* of it doing so.
|
|
255
256
|
*/
|
|
256
257
|
export declare const toolText: (id: RpcRequest["id"], text: string, isError?: boolean, extra?: Record<string, unknown>) => {
|
|
257
258
|
jsonrpc: "2.0";
|
package/dist/mcp.js
CHANGED
|
@@ -25,7 +25,7 @@ export const MCP_MODERN_VERSIONS = ['2026-07-28'];
|
|
|
25
25
|
* endpoint speak both, and a legacy client meeting a dual-era server works;
|
|
26
26
|
* going modern-only would fail the handshake of every client in the field.
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
const MCP_LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
29
29
|
/**
|
|
30
30
|
* Every version this transport speaks, newest first. It is the `supported` list
|
|
31
31
|
* a `-32022` names and the `supportedVersions` of `server/discover`, so a modern
|
|
@@ -157,8 +157,9 @@ const rpcError = (id, code, message, data) => ({
|
|
|
157
157
|
error: { code, message, ...(data ? { data } : {}) },
|
|
158
158
|
});
|
|
159
159
|
/**
|
|
160
|
-
* A tool result envelope. Exported
|
|
161
|
-
* it withholds a
|
|
160
|
+
* A tool result envelope. Exported for an envelope gate, which builds the same
|
|
161
|
+
* shape when it withholds a call: acuiq2's x402 gate had grown two more copies
|
|
162
|
+
* of it doing so.
|
|
162
163
|
*/
|
|
163
164
|
export const toolText = (id, text, isError = false, extra = {}) => ({
|
|
164
165
|
jsonrpc: '2.0',
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -3,19 +3,38 @@ export interface RateLimitOptions {
|
|
|
3
3
|
capacity: number;
|
|
4
4
|
/** Tokens added per second: the sustained rate. */
|
|
5
5
|
refillPerSec: number;
|
|
6
|
+
/**
|
|
7
|
+
* Running dry blocks `blockKey` outright for this long, rather than letting
|
|
8
|
+
* the bucket refill a token at a time. For a caller already established as
|
|
9
|
+
* abusive, the trickle the refill allows is the wrong answer.
|
|
10
|
+
*/
|
|
11
|
+
blockMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* What a block shuts out. Default the bucket's own key; pass the bare
|
|
14
|
+
* address (`rest:1.2.3.4`) to lock a caller out of every endpoint that names
|
|
15
|
+
* the same `blockKey` once it blows one endpoint's budget.
|
|
16
|
+
*/
|
|
17
|
+
blockKey?: string;
|
|
6
18
|
}
|
|
7
19
|
export type RateLimitResult = {
|
|
8
20
|
ok: true;
|
|
9
21
|
remaining: number;
|
|
10
|
-
}
|
|
22
|
+
}
|
|
23
|
+
/** `blocked` is true when an earlier block refused the call, rather than this one's bucket. */
|
|
24
|
+
| {
|
|
11
25
|
ok: false;
|
|
12
26
|
retryAfterSec: number;
|
|
27
|
+
blocked?: boolean;
|
|
13
28
|
};
|
|
14
29
|
/**
|
|
15
30
|
* Spend one token against `key`.
|
|
16
31
|
*
|
|
17
32
|
* Keys are namespaced by the caller (`"mcp:1.2.3.4"`, `"write:page:17"`), so
|
|
18
33
|
* one map backs every limiter in a process without them colliding.
|
|
34
|
+
*
|
|
35
|
+
* With `blockMs`, a block on `blockKey` is checked before any token is spent,
|
|
36
|
+
* and running the bucket dry starts one; the refusal then states the block's
|
|
37
|
+
* length as its wait.
|
|
19
38
|
*/
|
|
20
39
|
export declare function rateLimit(key: string, opts: RateLimitOptions): RateLimitResult;
|
|
21
40
|
/** Anything with a header getter: a `Request`'s headers, or Next's `headers()`. */
|
|
@@ -23,7 +42,14 @@ export interface HeaderReader {
|
|
|
23
42
|
get(name: string): string | null | undefined;
|
|
24
43
|
}
|
|
25
44
|
/**
|
|
26
|
-
* The caller's address, from the
|
|
45
|
+
* The caller's address, from the header the edge sets.
|
|
46
|
+
*
|
|
47
|
+
* `x-real-ip` first: Vercel sets it to the connecting client and overwrites any
|
|
48
|
+
* inbound copy, so a client cannot choose it. The leftmost `x-forwarded-for`
|
|
49
|
+
* entry is only as trustworthy as every proxy in front of the app — one that
|
|
50
|
+
* appends rather than replaces hands the client the first slot, and with it a
|
|
51
|
+
* fresh bucket and a fresh visitor per request. It stays as the fallback for a
|
|
52
|
+
* host that sets no `x-real-ip`.
|
|
27
53
|
*
|
|
28
54
|
* Falls back to a single shared bucket rather than to per-caller buckets, so an
|
|
29
55
|
* unidentifiable caller is still limited — collectively, but limited. Keying on
|
package/dist/rate-limit.js
CHANGED
|
@@ -14,14 +14,53 @@
|
|
|
14
14
|
const buckets = new Map();
|
|
15
15
|
/** Bounds memory under high-cardinality keying — one bucket per attacker IP. */
|
|
16
16
|
const MAX_KEYS = 10_000;
|
|
17
|
+
/**
|
|
18
|
+
* Blocks, held as an expiry timestamp rather than a setTimeout: a pending timer
|
|
19
|
+
* keeps a serverless instance's event loop alive for no reason.
|
|
20
|
+
*/
|
|
21
|
+
const blocks = new Map();
|
|
22
|
+
/** Seconds left on a block, or 0. An expired block is dropped on the way past. */
|
|
23
|
+
function blockedFor(key, now) {
|
|
24
|
+
const until = blocks.get(key);
|
|
25
|
+
if (until === undefined)
|
|
26
|
+
return 0;
|
|
27
|
+
if (now < until)
|
|
28
|
+
return Math.ceil((until - now) / 1000);
|
|
29
|
+
blocks.delete(key);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
function block(key, until, now) {
|
|
33
|
+
if (blocks.size >= MAX_KEYS) {
|
|
34
|
+
for (const [k, expiry] of blocks)
|
|
35
|
+
if (expiry <= now)
|
|
36
|
+
blocks.delete(k);
|
|
37
|
+
// Still full of live blocks: the oldest goes, as with buckets.
|
|
38
|
+
if (blocks.size >= MAX_KEYS) {
|
|
39
|
+
const oldest = blocks.keys().next().value;
|
|
40
|
+
if (oldest !== undefined)
|
|
41
|
+
blocks.delete(oldest);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
blocks.set(key, until);
|
|
45
|
+
}
|
|
17
46
|
/**
|
|
18
47
|
* Spend one token against `key`.
|
|
19
48
|
*
|
|
20
49
|
* Keys are namespaced by the caller (`"mcp:1.2.3.4"`, `"write:page:17"`), so
|
|
21
50
|
* one map backs every limiter in a process without them colliding.
|
|
51
|
+
*
|
|
52
|
+
* With `blockMs`, a block on `blockKey` is checked before any token is spent,
|
|
53
|
+
* and running the bucket dry starts one; the refusal then states the block's
|
|
54
|
+
* length as its wait.
|
|
22
55
|
*/
|
|
23
56
|
export function rateLimit(key, opts) {
|
|
24
57
|
const now = Date.now();
|
|
58
|
+
const blockKey = opts.blockKey ?? key;
|
|
59
|
+
if (opts.blockMs) {
|
|
60
|
+
const blockedSec = blockedFor(blockKey, now);
|
|
61
|
+
if (blockedSec)
|
|
62
|
+
return { ok: false, retryAfterSec: blockedSec, blocked: true };
|
|
63
|
+
}
|
|
25
64
|
const existing = buckets.get(key);
|
|
26
65
|
if (!existing && buckets.size >= MAX_KEYS) {
|
|
27
66
|
// Map iteration is insertion-ordered, so this evicts the oldest key.
|
|
@@ -35,13 +74,24 @@ export function rateLimit(key, opts) {
|
|
|
35
74
|
bucket.updatedAt = now;
|
|
36
75
|
buckets.set(key, bucket);
|
|
37
76
|
if (bucket.tokens < 1) {
|
|
77
|
+
if (opts.blockMs) {
|
|
78
|
+
block(blockKey, now + opts.blockMs, now);
|
|
79
|
+
return { ok: false, retryAfterSec: Math.ceil(opts.blockMs / 1000) };
|
|
80
|
+
}
|
|
38
81
|
return { ok: false, retryAfterSec: Math.ceil((1 - bucket.tokens) / opts.refillPerSec) };
|
|
39
82
|
}
|
|
40
83
|
bucket.tokens -= 1;
|
|
41
84
|
return { ok: true, remaining: Math.floor(bucket.tokens) };
|
|
42
85
|
}
|
|
43
86
|
/**
|
|
44
|
-
* The caller's address, from the
|
|
87
|
+
* The caller's address, from the header the edge sets.
|
|
88
|
+
*
|
|
89
|
+
* `x-real-ip` first: Vercel sets it to the connecting client and overwrites any
|
|
90
|
+
* inbound copy, so a client cannot choose it. The leftmost `x-forwarded-for`
|
|
91
|
+
* entry is only as trustworthy as every proxy in front of the app — one that
|
|
92
|
+
* appends rather than replaces hands the client the first slot, and with it a
|
|
93
|
+
* fresh bucket and a fresh visitor per request. It stays as the fallback for a
|
|
94
|
+
* host that sets no `x-real-ip`.
|
|
45
95
|
*
|
|
46
96
|
* Falls back to a single shared bucket rather than to per-caller buckets, so an
|
|
47
97
|
* unidentifiable caller is still limited — collectively, but limited. Keying on
|
|
@@ -50,8 +100,9 @@ export function rateLimit(key, opts) {
|
|
|
50
100
|
* meant to catch.
|
|
51
101
|
*/
|
|
52
102
|
export function clientIp(headers) {
|
|
53
|
-
|
|
54
|
-
|
|
103
|
+
return (headers.get('x-real-ip')?.trim() ||
|
|
104
|
+
headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
105
|
+
'anon');
|
|
55
106
|
}
|
|
56
107
|
/** `prefix:ip` — the key `rateLimit` expects for a per-IP gate. */
|
|
57
108
|
export function clientKey(prefix, headers) {
|
|
@@ -109,6 +160,7 @@ export function withRateLimit(res, result, opts) {
|
|
|
109
160
|
/** Test seam: the bucket map is module state and outlives a single test. */
|
|
110
161
|
export function resetRateLimits() {
|
|
111
162
|
buckets.clear();
|
|
163
|
+
blocks.clear();
|
|
112
164
|
}
|
|
113
165
|
// ---- the MCP budget ---------------------------------------------------------
|
|
114
166
|
/**
|
package/dist/react.d.ts
CHANGED
|
@@ -444,3 +444,10 @@ export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
|
|
|
444
444
|
export declare function useArticlePasses(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
|
|
445
445
|
/** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
|
|
446
446
|
export declare function useTweetEmbeds(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
|
|
447
|
+
/**
|
|
448
|
+
* Counts the page views of the app it is mounted in: place it once, in the root
|
|
449
|
+
* layout. `endpoint` is the route that hands the beacon to `collect`.
|
|
450
|
+
*/
|
|
451
|
+
export declare function Beacon({ endpoint }: {
|
|
452
|
+
endpoint?: string;
|
|
453
|
+
}): null;
|
package/dist/react.js
CHANGED
|
@@ -27,7 +27,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
27
27
|
// state on <html> before first paint. The hook keeps that attribute in
|
|
28
28
|
// sync afterwards, so CSS has one source of truth either side of hydration.
|
|
29
29
|
import { railBreakpointMismatch, readRailFloating, resolveSidebarOpen, SIDEBAR_ATTRIBUTE, } from './sidebar.js';
|
|
30
|
-
import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables } from './dom.js';
|
|
30
|
+
import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables, startBeacon } from './dom.js';
|
|
31
31
|
import { comboboxAria } from './combobox.js';
|
|
32
32
|
import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
|
|
33
33
|
const readStored = (key) => {
|
|
@@ -820,3 +820,11 @@ export function useTweetEmbeds(ref, deps) {
|
|
|
820
820
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
821
821
|
}, deps);
|
|
822
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Counts the page views of the app it is mounted in: place it once, in the root
|
|
825
|
+
* layout. `endpoint` is the route that hands the beacon to `collect`.
|
|
826
|
+
*/
|
|
827
|
+
export function Beacon({ endpoint = '/api/view' }) {
|
|
828
|
+
useEffect(() => startBeacon(endpoint), [endpoint]);
|
|
829
|
+
return null;
|
|
830
|
+
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -19,6 +19,35 @@ export declare function validateLinkGroups(groups: unknown): boolean;
|
|
|
19
19
|
export declare function validateCodeTabs(tabs: unknown): boolean;
|
|
20
20
|
/** `[{ value, label, suffix? }]` — a stats block's cards. */
|
|
21
21
|
export declare function validateStatItems(items: unknown): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The most rows an author-supplied count may ask for. Every `limit` and every
|
|
24
|
+
* `pageIds` list ends up in a database `take:` or `in:`, so the bound belongs
|
|
25
|
+
* between the editor and the query: a fractional `take` is a validation error
|
|
26
|
+
* against an Int column and an unbounded one is a table scan on every render.
|
|
27
|
+
*/
|
|
28
|
+
export declare const MAX_BLOCK_ROWS = 100;
|
|
29
|
+
/** Clamp an author-supplied row count to a positive integer within the cap. The read-side twin of the check below. */
|
|
30
|
+
export declare function blockLimit(value: unknown, fallback: number, max?: number): number;
|
|
31
|
+
export interface CoreAtomicValidatorOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Whether one `pageIds` entry is an id this wiki can look up. The format is
|
|
34
|
+
* each wiki's own — a cuid, an Int read back with `Number()`, a
|
|
35
|
+
* `section/slug` — so the default accepts any string.
|
|
36
|
+
*/
|
|
37
|
+
pageId?: (id: string) => boolean;
|
|
38
|
+
/** Default `MAX_BLOCK_ROWS`. */
|
|
39
|
+
maxRows?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The leaf checks for the core types, as one function a repo's `validateAtomic`
|
|
43
|
+
* falls through to: `default: return core(b)`. False for any type outside the
|
|
44
|
+
* core, so the repo's own cases still decide its native types.
|
|
45
|
+
*
|
|
46
|
+
* `recentPages.limit` must be a whole number within the cap and `pageIds` no
|
|
47
|
+
* longer than it — caper's rule, which the other two lacked. A scope field
|
|
48
|
+
* (`tagPath`, `section`) is a string when present.
|
|
49
|
+
*/
|
|
50
|
+
export declare function coreAtomicValidator(opts?: CoreAtomicValidatorOptions): (block: Record<string, unknown>) => boolean;
|
|
22
51
|
export interface BlockValidatorOptions {
|
|
23
52
|
/** True for a type this wiki knows at all. */
|
|
24
53
|
isKnownType: (type: string) => boolean;
|
package/dist/validation.js
CHANGED
|
@@ -72,6 +72,57 @@ export function validateStatItems(items) {
|
|
|
72
72
|
(typeof s.value === 'string' || typeof s.value === 'number') &&
|
|
73
73
|
(s.suffix === undefined || s.suffix === null || typeof s.suffix === 'string')));
|
|
74
74
|
}
|
|
75
|
+
// ---- the core leaf set ------------------------------------------------------
|
|
76
|
+
/**
|
|
77
|
+
* The most rows an author-supplied count may ask for. Every `limit` and every
|
|
78
|
+
* `pageIds` list ends up in a database `take:` or `in:`, so the bound belongs
|
|
79
|
+
* between the editor and the query: a fractional `take` is a validation error
|
|
80
|
+
* against an Int column and an unbounded one is a table scan on every render.
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_BLOCK_ROWS = 100;
|
|
83
|
+
/** Clamp an author-supplied row count to a positive integer within the cap. The read-side twin of the check below. */
|
|
84
|
+
export function blockLimit(value, fallback, max = MAX_BLOCK_ROWS) {
|
|
85
|
+
const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
86
|
+
return Math.min(Math.max(n, 1), max);
|
|
87
|
+
}
|
|
88
|
+
const okLimit = (value, max) => Number.isSafeInteger(value) && value > 0 && value <= max;
|
|
89
|
+
const optionalString = (v) => v === undefined || typeof v === 'string';
|
|
90
|
+
/**
|
|
91
|
+
* The leaf checks for the core types, as one function a repo's `validateAtomic`
|
|
92
|
+
* falls through to: `default: return core(b)`. False for any type outside the
|
|
93
|
+
* core, so the repo's own cases still decide its native types.
|
|
94
|
+
*
|
|
95
|
+
* `recentPages.limit` must be a whole number within the cap and `pageIds` no
|
|
96
|
+
* longer than it — caper's rule, which the other two lacked. A scope field
|
|
97
|
+
* (`tagPath`, `section`) is a string when present.
|
|
98
|
+
*/
|
|
99
|
+
export function coreAtomicValidator(opts = {}) {
|
|
100
|
+
const { pageId = () => true, maxRows = MAX_BLOCK_ROWS } = opts;
|
|
101
|
+
return b => {
|
|
102
|
+
switch (b.type) {
|
|
103
|
+
case 'content':
|
|
104
|
+
return typeof b.text === 'string';
|
|
105
|
+
case 'recentPages':
|
|
106
|
+
return okLimit(b.limit, maxRows) && optionalString(b.tagPath) && optionalString(b.section);
|
|
107
|
+
case 'pageList':
|
|
108
|
+
return (Array.isArray(b.pageIds) &&
|
|
109
|
+
b.pageIds.length <= maxRows &&
|
|
110
|
+
b.pageIds.every(id => typeof id === 'string' && pageId(id)));
|
|
111
|
+
case 'codeTabs':
|
|
112
|
+
return validateCodeTabs(b.tabs);
|
|
113
|
+
case 'stats':
|
|
114
|
+
return validateStatItems(b.items);
|
|
115
|
+
case 'banner':
|
|
116
|
+
return typeof b.variant === 'string';
|
|
117
|
+
case 'references':
|
|
118
|
+
return validateReferenceItems(b.items);
|
|
119
|
+
case 'linkGrid':
|
|
120
|
+
return validateLinkGroups(b.groups);
|
|
121
|
+
default:
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
75
126
|
/**
|
|
76
127
|
* The block-tree walk, with one wiki's leaf switch plugged into it. The caller
|
|
77
128
|
* keeps its own type parameter, so this stays free of any repo's types while
|