wiki-formant 0.28.0 → 0.30.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/dist/db.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** The options `new pg.Pool()` takes, for the fields set here. */
2
+ export interface PgPoolConfig {
3
+ connectionString: string | undefined;
4
+ max: number;
5
+ idleTimeoutMillis: number;
6
+ connectionTimeoutMillis: number;
7
+ }
8
+ /**
9
+ * Pool options sized by which Supabase pooler the URL names, because the
10
+ * ceiling belongs to the pooler and not to the app. Port 6543 is the
11
+ * TRANSACTION pooler, which exists to hold many short-lived clients; anything
12
+ * else is treated as the SESSION pooler on 5432, capped at 15 clients per
13
+ * project and shared with a build's workers, so three each is what fits.
14
+ *
15
+ * `pg` reports an exhausted pool as "timeout exceeded when trying to connect",
16
+ * which reads as a network fault and is not one: connecting takes well under a
17
+ * second, while an acquire queued behind busy clients waits the full
18
+ * `connectionTimeoutMillis`. `max: 1` was serial, and three everywhere rendered
19
+ * a page that fans out more than three queries in thirty seconds.
20
+ */
21
+ export declare function pgPoolConfig(url: string | undefined): PgPoolConfig;
package/dist/db.js ADDED
@@ -0,0 +1,27 @@
1
+ // db.ts — the pool settings every app here hands to `pg`.
2
+ //
3
+ // Four Prisma client modules carried the same block, comment and all. It is a
4
+ // value, not a client: the app still owns `new pg.Pool(...)`, the adapter and
5
+ // the globalThis cache, so this imports nothing.
6
+ /**
7
+ * Pool options sized by which Supabase pooler the URL names, because the
8
+ * ceiling belongs to the pooler and not to the app. Port 6543 is the
9
+ * TRANSACTION pooler, which exists to hold many short-lived clients; anything
10
+ * else is treated as the SESSION pooler on 5432, capped at 15 clients per
11
+ * project and shared with a build's workers, so three each is what fits.
12
+ *
13
+ * `pg` reports an exhausted pool as "timeout exceeded when trying to connect",
14
+ * which reads as a network fault and is not one: connecting takes well under a
15
+ * second, while an acquire queued behind busy clients waits the full
16
+ * `connectionTimeoutMillis`. `max: 1` was serial, and three everywhere rendered
17
+ * a page that fans out more than three queries in thirty seconds.
18
+ */
19
+ export function pgPoolConfig(url) {
20
+ return {
21
+ connectionString: url,
22
+ max: (url ?? '').includes(':6543') ? 10 : 3,
23
+ idleTimeoutMillis: 20_000,
24
+ // The acquire timeout, despite the message it fails with.
25
+ connectionTimeoutMillis: 30_000,
26
+ };
27
+ }
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
- export const FEED_HEADERS = {
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
  };
@@ -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): string;
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
- return new Date(toMs(date)).toLocaleDateString('en-US', {
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
- ...options,
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
  *
@@ -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;
@@ -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
- export async function probeUrl(url, opts = {}) {
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
- export const YOUTUBE_EMBED = /^https?:\/\/(?:www\.)?(?:youtube-nocookie\.com|youtube\.com)\/embed\/([\w-]+)/;
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
- export async function probeYouTube(videoId, opts = {}) {
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
- export declare const MCP_LEGACY_VERSIONS: readonly ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
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 because `wiki-formant/x402`
243
- * gates the envelope before `handleMcp` ever sees it. */
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 because `x402.ts` builds the same shape when
254
- * it withholds a paid call, and had grown two more copies of it doing so.
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
- export const MCP_LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
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 because `x402.ts` builds the same shape when
161
- * it withholds a paid call, and had grown two more copies of it doing so.
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',
@@ -0,0 +1,9 @@
1
+ export interface PasskeyButtonProps {
2
+ /** The route that serves `gate.route`. */
3
+ endpoint: string;
4
+ /** The invite token from the link, when there is one. */
5
+ invite?: string | undefined;
6
+ className?: string;
7
+ errorClassName?: string;
8
+ }
9
+ export declare function PasskeyButton({ endpoint, invite, className, errorClassName }: PasskeyButtonProps): import("react").JSX.Element;
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // passkey-button.tsx – the browser half of `wiki-formant/passkey`.
4
+ //
5
+ // Its own subpath because it imports `@simplewebauthn/browser`, an optional
6
+ // peer that only an app with a passkey door installs. With an invite it saves a
7
+ // new passkey; without one it signs in with a saved one. Either way, success
8
+ // reloads the page without the invite, and the server sees the new cookie.
9
+ import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
10
+ import { useState } from 'react';
11
+ async function post(endpoint, body) {
12
+ const res = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
13
+ if (!res.ok)
14
+ throw new Error((await res.json().catch(() => null))?.error ?? 'Sign-in failed.');
15
+ return res.status === 204 ? null : res.json();
16
+ }
17
+ export function PasskeyButton({ endpoint, invite, className, errorClassName }) {
18
+ const [busy, setBusy] = useState(false);
19
+ const [error, setError] = useState(null);
20
+ async function go() {
21
+ setBusy(true);
22
+ setError(null);
23
+ try {
24
+ const optionsJSON = await post(endpoint, { invite });
25
+ const response = invite ? await startRegistration({ optionsJSON }) : await startAuthentication({ optionsJSON });
26
+ await post(endpoint, { invite, response });
27
+ location.replace(location.pathname + location.search.replace(/([?&])invite=[^&]*&?/, '$1').replace(/[?&]$/, ''));
28
+ }
29
+ catch (e) {
30
+ // A prompt the person closed throws NotAllowedError, which needs no message.
31
+ setError(e instanceof Error && e.name !== 'NotAllowedError' ? e.message : null);
32
+ setBusy(false);
33
+ }
34
+ }
35
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: className, onClick: go, disabled: busy, children: invite ? 'Save a passkey' : 'Sign in with a passkey' }), error && (_jsx("p", { role: "alert", className: errorClassName, children: error }))] }));
36
+ }
@@ -0,0 +1,27 @@
1
+ import type { Sql } from './analytics.js';
2
+ export type { Sql };
3
+ export interface PasskeyGateOptions {
4
+ sql: Sql;
5
+ /** The name the browser shows when it offers to save the passkey. */
6
+ rpName: string;
7
+ /** Default `passkey_session`. */
8
+ cookie?: string;
9
+ /** How long a sign-in lasts. Default 90 days. */
10
+ sessionDays?: number;
11
+ }
12
+ export interface PasskeyGate {
13
+ /** The session cookie's name, for the app to read it with. */
14
+ cookie: string;
15
+ /** Whether a session cookie's value is a live session. */
16
+ signedIn(session: string | undefined): Promise<boolean>;
17
+ /** A single-use invite token, good for `days`. The bin turns it into a link. */
18
+ invite(days?: number): Promise<string>;
19
+ /**
20
+ * The whole sign-in route: `export const POST = gate.route`. The body is
21
+ * `{ invite?, response? }`: without `response` it answers with the options
22
+ * for the browser's prompt, with one it verifies it and sets the cookie. An
23
+ * invite turns the prompt into saving a new passkey.
24
+ */
25
+ route(request: Request): Promise<Response>;
26
+ }
27
+ export declare function createPasskeyGate({ sql, rpName, cookie, sessionDays }: PasskeyGateOptions): PasskeyGate;
@@ -0,0 +1,130 @@
1
+ // passkey.ts – the door to a site's own admin pages: a passkey, and nothing else.
2
+ //
3
+ // No account, no password, no email. Whoever holds a passkey saved for the
4
+ // site is in. The first one is saved from an invite link that the
5
+ // `passkey-invite` bin prints from a machine holding the database URL, and any
6
+ // passkey after that comes from another invite. So the only way in is through
7
+ // someone who can already reach the database.
8
+ //
9
+ // A passkey is bound to the host it was saved on. One saved on localhost does
10
+ // not open the deployed site, and one saved on the site does not open localhost.
11
+ //
12
+ // Like analytics, the package owns the SQL and the app owns the connection. The
13
+ // app declares the two tables in its own schema:
14
+ //
15
+ // model Passkey {
16
+ // id String @id // the credential id, base64url
17
+ // publicKey String // base64url
18
+ // counter Int
19
+ // transports String[]
20
+ // createdAt DateTime @default(now()) @db.Timestamptz(3)
21
+ // }
22
+ //
23
+ // model PasskeyToken {
24
+ // hash String @id // sha-256 of the token, base64url
25
+ // kind String // session | invite | challenge
26
+ // expiresAt DateTime @db.Timestamptz(3)
27
+ // }
28
+ //
29
+ // Every token is stored as its hash, so a read of the table opens nothing. A
30
+ // challenge is spent the moment a response quotes it, and an invite the moment
31
+ // a passkey is saved with it.
32
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
33
+ import { clientKey, rateLimit, rateLimitedResponse } from './rate-limit.js';
34
+ const MINUTE = 60_000;
35
+ const DAY = 86_400_000;
36
+ const CHALLENGE_MS = 5 * MINUTE;
37
+ const b64url = (bytes) => Buffer.from(bytes).toString('base64url');
38
+ const token = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
39
+ const hash = async (value) => b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))));
40
+ /** Store a token's hash; the token is returned and kept nowhere else. */
41
+ async function issue(sql, kind, ms, value = token()) {
42
+ await sql(`DELETE FROM "PasskeyToken" WHERE "expiresAt" < now()`);
43
+ await sql(`INSERT INTO "PasskeyToken" (hash, kind, "expiresAt") VALUES ($1, $2, now() + make_interval(secs => $3::int))`, await hash(value), kind, Math.round(ms / 1000));
44
+ return value;
45
+ }
46
+ /** True once for a live token of this kind; it is gone either way. */
47
+ async function spend(sql, kind, value) {
48
+ const rows = (await sql(`DELETE FROM "PasskeyToken" WHERE hash = $1 AND kind = $2 RETURNING "expiresAt" > now() AS live`, await hash(value), kind));
49
+ return rows[0]?.live === true;
50
+ }
51
+ async function live(sql, kind, value) {
52
+ const rows = (await sql(`SELECT 1 FROM "PasskeyToken" WHERE hash = $1 AND kind = $2 AND "expiresAt" > now()`, await hash(value), kind));
53
+ return rows.length > 0;
54
+ }
55
+ /** Any request that fails is told only that it failed. */
56
+ const refuse = (status, error) => Response.json({ error }, { status });
57
+ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', sessionDays = 90 }) {
58
+ async function session(secure) {
59
+ const value = await issue(sql, 'session', sessionDays * DAY);
60
+ const attrs = [`${cookie}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${sessionDays * 86_400}`];
61
+ if (secure)
62
+ attrs.push('Secure');
63
+ return new Response(null, { status: 204, headers: { 'set-cookie': attrs.join('; ') } });
64
+ }
65
+ return {
66
+ cookie,
67
+ signedIn: async (value) => !!value && live(sql, 'session', value),
68
+ invite: (days = 1) => issue(sql, 'invite', days * DAY),
69
+ async route(request) {
70
+ const limited = rateLimit(clientKey('passkey', request.headers), { capacity: 10, refillPerSec: 0.2 });
71
+ if (!limited.ok)
72
+ return rateLimitedResponse(limited.retryAfterSec);
73
+ // The browser writes the page's origin into what it signs, and binds the
74
+ // passkey to the host, so the request's own origin is the one to expect.
75
+ const { origin, hostname: rpID, protocol } = new URL(request.url);
76
+ const body = (await request.json().catch(() => null));
77
+ const invite = typeof body?.invite === 'string' ? body.invite : undefined;
78
+ const response = body?.response;
79
+ const expectedChallenge = (challenge) => spend(sql, 'challenge', challenge);
80
+ if (!response) {
81
+ if (invite && !(await live(sql, 'invite', invite)))
82
+ return refuse(403, 'This invite has expired.');
83
+ const options = invite
84
+ ? await generateRegistrationOptions({
85
+ rpName,
86
+ rpID,
87
+ userName: rpName,
88
+ attestationType: 'none',
89
+ // Discoverable, so signing in later asks for nothing: the device offers it.
90
+ authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
91
+ })
92
+ : await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
93
+ await issue(sql, 'challenge', CHALLENGE_MS, options.challenge);
94
+ return Response.json(options);
95
+ }
96
+ if (invite) {
97
+ const result = await verifyRegistrationResponse({
98
+ response,
99
+ expectedChallenge,
100
+ expectedOrigin: origin,
101
+ expectedRPID: rpID,
102
+ requireUserVerification: false,
103
+ }).catch(() => null);
104
+ if (!result?.verified)
105
+ return refuse(400, 'That passkey could not be saved.');
106
+ // Spent after the check, so a failed prompt leaves the invite to try again.
107
+ if (!(await spend(sql, 'invite', invite)))
108
+ return refuse(403, 'This invite has expired.');
109
+ const { credential } = result.registrationInfo;
110
+ await sql(`INSERT INTO "Passkey" (id, "publicKey", counter, transports) VALUES ($1, $2, $3, $4::text[])`, credential.id, b64url(credential.publicKey), credential.counter, credential.transports ?? []);
111
+ return session(protocol === 'https:');
112
+ }
113
+ const [passkey] = (await sql(`SELECT id, "publicKey", counter, transports FROM "Passkey" WHERE id = $1`, String(response.id)));
114
+ if (!passkey)
115
+ return refuse(403, 'This passkey is not one this site knows.');
116
+ const result = await verifyAuthenticationResponse({
117
+ response,
118
+ expectedChallenge,
119
+ expectedOrigin: origin,
120
+ expectedRPID: rpID,
121
+ credential: { ...passkey, publicKey: new Uint8Array(Buffer.from(passkey.publicKey, 'base64url')) },
122
+ requireUserVerification: false,
123
+ }).catch(() => null);
124
+ if (!result?.verified)
125
+ return refuse(403, 'Sign-in failed.');
126
+ await sql(`UPDATE "Passkey" SET counter = $2 WHERE id = $1`, passkey.id, result.authenticationInfo.newCounter);
127
+ return session(protocol === 'https:');
128
+ },
129
+ };
130
+ }
@@ -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 proxy header the edge sets.
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
@@ -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 proxy header the edge sets.
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
- const forwarded = headers.get('x-forwarded-for');
54
- return forwarded?.split(',')[0]?.trim() || headers.get('x-real-ip')?.trim() || 'anon';
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
  /**
@@ -1,6 +1,7 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
2
  import type { Control, FacetControlGroup, MetadataRow } from './taxonomy.js';
3
3
  import type { HistoryChange } from './history.js';
4
+ import type { Digest } from './analytics.js';
4
5
  /**
5
6
  * The shape a router's link component has to satisfy here.
6
7
  *
@@ -267,3 +268,22 @@ export interface RevisionChangesProps {
267
268
  * are the importing stylesheet's.
268
269
  */
269
270
  export declare function RevisionChanges({ changes, label }: RevisionChangesProps): import("react").JSX.Element | null;
271
+ export interface StatsProps {
272
+ /** One window's figures, from `digest` in `wiki-formant/analytics`. */
273
+ digest: Digest;
274
+ /** The window the digest covers, in days. */
275
+ days: number;
276
+ /** The windows offered as `?days=` links on the same page. */
277
+ ranges?: readonly number[];
278
+ }
279
+ /** The windows the stats page offers, in days. */
280
+ export declare const STATS_RANGES: readonly [1, 7, 30, 90];
281
+ /** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
282
+ export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
283
+ /**
284
+ * A site's own analytics on one page: the headline figures against the window
285
+ * before, visitors per day, and every ranked list the digest carries. Lists
286
+ * with nothing in them are left out. Bars are `currentColor` until the
287
+ * importing stylesheet gives them a colour.
288
+ */
289
+ export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;