wiki-formant 0.52.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -392,7 +392,7 @@ different lists.
392
392
  ```ts
393
393
  import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';
394
394
 
395
- trackAiBot(request, event, () => import('@/lib/track')); // in proxy.ts: label, or null
395
+ trackAiBot(request, event, () => import('@/lib/track')); // in proxy.ts: whether it counted
396
396
  const rules = crawlerRules({ allow: '/', disallow, aiAllow });
397
397
  ```
398
398
 
@@ -408,11 +408,11 @@ Every agent group also allows the agent surface — `/api/mcp`, the three `llms`
408
408
 
409
409
  `crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one for each search engine. Search engines get the HTML site and none of its twins — `/*.md$` always, plus `searchDisallow` — because the `*` group keeps the machine surface open to agents, and for Google a twin is a page it already has. Two origins wrote that group by hand and the third never did, so Google crawled its twins. The `<Link>` prefetch payload (`?_rsc=`) is refused in every group.
410
410
 
411
- `trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
411
+ `trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`: a crawler on the roster wherever it goes, and anything but a search engine that asks for what only agents read (a `.md` twin or `Accept: text/markdown`, the `llms` exports, `/openapi.json`, `/.well-known/`), since most agents are on no roster. Its user agent is kept, so `digest` can name it. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
412
412
 
413
413
  ## IndexNow
414
414
 
415
- Bing, Yandex, Seznam, Naver and Yep take a POST of changed URLs instead of waiting to recrawl. The `indexnow` bin reads an origin's live sitemap and posts the rows whose `lastModified` falls inside the window, so the editor, the MCP write tools and the sweep scripts that write straight to the database are all covered with no hook in any of them. It runs where the sweeps run, never as a cron on the host: a submission costs the origin one cached sitemap fetch and no function time.
415
+ Bing, Yandex, Seznam, Naver and Yep take a POST of changed URLs instead of waiting to recrawl. The `indexnow` bin reads an origin's live sitemap and posts the rows whose `lastModified` falls inside the window, so the editor, the MCP write tools and the sweep scripts that write straight to the database are all covered with no hook in any of them. It runs where the sweeps run, never as a cron on the host: a submission costs the origin one sitemap render and no scheduled function. The sitemap must render per request. A cached one froze at the last deploy and listed none of the pages edited since, so this bin submitted nothing.
416
416
 
417
417
  The key is public by design, since an engine checks it by fetching `/<key>.txt`. The file in the app's `public/` is the only copy, and the bin finds it there, so run it from the app's root. It checks that the live origin serves the key before posting anything.
418
418
 
@@ -535,6 +535,20 @@ export const GET = async (request: Request) => feedResponse(request, channel, aw
535
535
  had not — the recrawl the conditional-GET helpers exist to prevent. An empty feed
536
536
  carries no build date rather than a fictional one.
537
537
 
538
+ ## Sitemaps
539
+
540
+ `treeSitemap(pages, { sections, sectionRow, pageRow })` returns the rows a wiki's tree adds to its sitemap: a row per section, dated by the newest page at or beneath it, then a row per page, dated by its own `updatedAt`. All three wikis listed their sections, and each dated them differently. One used the newest page beneath, one did the same from a derived tree, and one used no date at all. A section with no page beneath it carries no date rather than an invented one.
541
+
542
+ ```ts
543
+ ...treeSitemap(pages, {
544
+ sections: tagPrefixes(pages.map(p => p.tagPath)),
545
+ sectionRow: path => ({ url: `${SITE_URL}/wiki/${path}`, changeFrequency: 'weekly', priority: 0.6 }),
546
+ pageRow: p => ({ url: pageUrl(p), changeFrequency: 'weekly', priority: 0.7 }),
547
+ }),
548
+ ```
549
+
550
+ Every URL appears once. An empty-slug page is its section's own article, so it dates the section and gets no row of its own. A section whose URL a page already holds is served as that page, so the section row is dropped. `tagPrefixes` lists every path at or above the given ones, for a wiki whose sections are wherever its pages sit rather than a configured list. Which sections exist, the URLs, and each row's frequency and priority stay with the caller.
551
+
538
552
  ## Licence declarations
539
553
 
540
554
 
@@ -657,11 +671,11 @@ export const POST = uploadHandler({
657
671
  });
658
672
  ```
659
673
 
660
- The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget, keyed on `authorize`'s id and address. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers. `normalizeImage(bytes, { maxPixels, encode })` is the same sniff, cap and re-encode without the route, for images that arrive another way – a server action, or a picture copied in from another site; it resolves to `{ buffer, format, contentType }` and throws a message fit to show the uploader.
674
+ The bytes say what the file is, never the name or type, and SVG is refused. Every image is re-encoded, upright, so nothing from the source's metadata is kept, a phone's GPS position included; the bytes as sent are never stored. `encode` is the re-encode, the sniffed format by default; a wiki with an image standard resizes and converts there. Each uploader has a budget keyed on `authorize`'s id and address, and each address one of its own, so an id minted fresh per burst (a self-signed wallet) refills nothing. `maxBytes`, `maxPixels` and `rateLimit` take other limits, and `store` replaces Vercel Blob. `sharp` and `@vercel/blob` are optional peers. `normalizeImage(bytes, { maxPixels, encode })` is the same sniff, cap and re-encode without the route, for images that arrive another way – a server action, or a picture copied in from another site; it resolves to `{ buffer, format, contentType }` and throws a message fit to show the uploader.
661
675
 
662
676
  ## Editor nodes
663
677
 
664
- `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`.
678
+ `wiki-formant/tiptap` carries the custom nodes both wiki editors had written twice: `Iframe`, `YouTube` (the stock extension plus the paste rule it does not ship with), `TwitterEmbed`, `createMapEmbed`, `createCodeBlock` and `createTabs`. `Iframe` and `createMapEmbed` draw a frame only for an https src on `DEFAULT_IFRAME_HOSTS` (`embedSrc`), since an editor draws stored HTML where its author is signed in, and a `javascript:` src runs there under any CSP that allows inline script.
665
679
 
666
680
  ```ts
667
681
  const CodeBlock = createCodeBlock({
@@ -778,10 +792,13 @@ and may be a batch; `trackSearch` normalises a search box's free text so
778
792
  the same question aggregates as one row, and drops an empty one. Pass the request's headers and the
779
793
  event joins whoever sent it, person or agent.
780
794
 
781
- `digest(sql, { days })` reads a site back as one JSON object: visitors, page
782
- views, bounce rate, visit length, sources (a link's `utm_source`, else the
783
- referring host), top and entry pages, countries, devices, visitors by day, MCP
784
- tool calls, and the searches that found nothing. A visit is a run of one
795
+ `digest(sql, { days })` reads a site back as one JSON object: visitors, agents,
796
+ page views, bounce rate, visit length, sources (a link's `utm_source`, else the
797
+ referring host), top and entry pages, countries, devices, visitors and agents by
798
+ day, the agents by name, MCP tool calls, and the searches that found nothing.
799
+ An agent is counted as a visitor is, one address and user agent a day, from its
800
+ `AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
801
+ among the visitors. `ownAgents` leaves the site's own out. A visit is a run of one
785
802
  visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
786
803
  key to every call and a `site` resolver to `collect`.
787
804
 
@@ -794,16 +811,17 @@ its readers asked for. The queries that return zero rows are the valuable ones
794
811
 
795
812
  `wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
796
813
  nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
797
- returns the whole of it. `gate.route` is the sign-in route,
798
- `gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
799
- while no passkey is saved, and `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
814
+ returns the whole of it. `gate.route` is the sign-in route, `gate.signOut` the
815
+ sign-out route, `gate.signedIn(cookieValue)` is the check a page makes, and
816
+ `gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
800
817
  models in `prisma/passkey.prisma`, and every token is kept only as its hash.
801
818
 
802
- There is no sign-up. While no passkey is saved, the page saves the first one
803
- from whoever opens it, so the owner should do that as soon as the door ships.
804
- Every passkey after that is saved from a single-use link that the
805
- `passkey-invite` bin prints from a machine holding `DATABASE_URL`. A passkey is
806
- bound to the host it was saved on, so localhost needs its own.
819
+ There is no sign-up. Every passkey, the first among them, is saved from a
820
+ single-use link that the `passkey-invite` bin prints from a machine holding
821
+ `DATABASE_URL`, so a fresh deployment cannot be claimed by whoever reaches it
822
+ first. A passkey is bound to the host it was saved on, so localhost needs its
823
+ own. A session names the passkey that signed in: deleting a `Passkey` row signs
824
+ out every session it opened.
807
825
 
808
826
  ```ts
809
827
  // src/lib/admin.ts
@@ -811,6 +829,7 @@ export const gate = createPasskeyGate({ sql, rpName: 'Caper' });
811
829
 
812
830
  // src/app/api/passkey/route.ts
813
831
  export const POST = gate.route;
832
+ export const DELETE = gate.signOut;
814
833
 
815
834
  // src/app/stats/page.tsx
816
835
  const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
@@ -821,7 +840,7 @@ return (
821
840
  );
822
841
  ```
823
842
 
824
- `StatsGate` (`wiki-formant/stats`) renders `children` for a signed-in reader and, for anyone else, the line saying what the prompt will do and the `PasskeyButton` that does it; `classNames` places the text, the button's box, the button and its error.
843
+ `StatsGate` (`wiki-formant/stats`) renders `children` and a sign-out button (`PasskeySignOut`) for a signed-in reader and, for anyone else, the line saying what the prompt will do and the `PasskeyButton` that does it; `classNames` places the text, the button's box, the button and its error.
825
844
 
826
845
  `PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
827
846
  own subpath, because it imports `@simplewebauthn/browser`.
@@ -831,8 +850,8 @@ headline figures, each with its change from the window before
831
850
  site's stylesheet to colour), one of them per day, every non-empty
832
851
  ranked list, and `?days=` links between windows, all time among them, pinned in
833
852
  the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
834
- figure is a button that charts it: visitors, page views, bounce rate, visit
835
- length, visitors with agents and clicks to X. `digest`'s `by_day` carries every
853
+ figure is a button that charts it: visitors, agents, page views, bounce rate,
854
+ visit length and clicks to X. `digest`'s `by_day` carries every
836
855
  one of those per UTC day, a visit on the day it began, so the days add up to the
837
856
  totals. A day with no visits has no bounce rate or visit length and is left out
838
857
  of those charts rather than drawn as zero. Everything but the figures and the
@@ -983,7 +1002,7 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
983
1002
  npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
984
1003
  ```
985
1004
 
986
- `passkey-invite` prints a single-use link that saves another passkey for the page it names, good for a day (`--days n` for longer). No routine calls it: the site's owner runs it by hand, from an app's checkout, when a second device or a second person needs into the stats page. Run it the same way:
1005
+ `passkey-invite` prints a single-use link that saves a passkey for the page it names, good for a day (`--days n` for longer). No routine calls it: the site's owner runs it by hand, from an app's checkout, for the first passkey and whenever another device or person needs into the stats page. Run it the same way:
987
1006
 
988
1007
  ```sh
989
1008
  npx passkey-invite https://caper.network/stats
@@ -1008,7 +1027,7 @@ npx prisma-models analytics --check --in prisma/schema.prisma --tenant # miow
1008
1027
  npx fts-ddl pages
1009
1028
  ```
1010
1029
 
1011
- `mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page; a revision is written at most once a day. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
1030
+ `mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
1012
1031
 
1013
1032
  ```sh
1014
1033
  npx mark-verified contents/tech/radix-engine scrypto # one page
@@ -26,13 +26,15 @@
26
26
  // `write` takes the whole new state: read, change, write back. A revision is
27
27
  // written at most once a day, and on every `compact`; other writes edit the
28
28
  // page in place, because a full copy per run is what made radix-wiki's log
29
- // over half its revisions table. `backlog-add` is how another routine files an
29
+ // over half its revisions table. `write` refuses a state whose totalRuns is
30
+ // below the stored one: that state was read before another run wrote.
31
+ // `backlog-add` is how another routine files an
30
32
  // item: `write` replaces the whole state, so an append through it could lose a
31
33
  // concurrent run's, and the row lock serialises the two. A revision's version
32
34
  // is the page's `version` column bumped a minor where the table has one, else
33
35
  // the latest revision's.
34
36
  import { randomUUID } from 'node:crypto';
35
- import { addBacklog, compact, ident, render, scopeSql } from '../dist/maintenance.js';
37
+ import { PINNED, addBacklog, compact, ident, render, scopeSql, staleWrite } from '../dist/maintenance.js';
36
38
  import { bump } from '../dist/versioning.js';
37
39
  import { arg, connect, invalidate } from './_cli.mjs';
38
40
 
@@ -105,6 +107,8 @@ try {
105
107
  const text = payload === undefined || payload === '-' ? await readStdin() : payload;
106
108
  if (!text.trim()) throw new Error('write needs the state as JSON, as an argument or on stdin with `write -`');
107
109
  input = JSON.parse(text);
110
+ const stale = staleWrite(page.metadata?.state ?? {}, input);
111
+ if (stale) throw new Error(stale);
108
112
  }
109
113
  const state = compact(input);
110
114
  const content = render(state, routine);
@@ -121,6 +125,13 @@ try {
121
125
  const [before, after] = [(input[key] ?? []).length, state[key].length];
122
126
  if (before !== after) console.log(` ${key}: ${before} → ${after}`);
123
127
  }
128
+ const kept = new Set(state.backlog.map(b => b.item));
129
+ for (const b of input.backlog ?? []) {
130
+ const item = typeof b === 'string' ? b : b.item;
131
+ if (PINNED.test(item ?? '') && (typeof b === 'string' || (b.status ?? 'open') === 'open') && !kept.has(item)) {
132
+ console.log(` flag dropped over the cap: ${item}`);
133
+ }
134
+ }
124
135
  console.log(
125
136
  ` ${dry ? '[dry] ' : ''}state ${JSON.stringify(input).length} → ${JSON.stringify(state).length} B, ` +
126
137
  `content ${JSON.stringify(content).length} B${revise ? `, revision v${version}` : ', no revision today'}`,
@@ -67,8 +67,9 @@ export interface DigestOptions {
67
67
  siblings?: string[];
68
68
  gaps?: Gap[];
69
69
  /**
70
- * MCP callers left out of `agent_tool_calls`, matched inside the user agent.
71
- * The default is the conformance suites (`<repo>-mcp-test`) and the studio's own tools.
70
+ * The site's own agents, left out of `agents`, `top_agents` and
71
+ * `agent_tool_calls`, matched inside the user agent. The default is the
72
+ * conformance suites (`<repo>-mcp-test`) and the studio's own tools.
72
73
  */
73
74
  ownAgents?: string[];
74
75
  /**
@@ -89,6 +90,7 @@ export interface DigestDay {
89
90
  bounces: number;
90
91
  /** Seconds from each visit's first view to its last, summed. */
91
92
  seconds: number;
93
+ agents: number;
92
94
  visitors_incl_agents: number;
93
95
  /** Clicks on links to the site's X handle; zero without a handle. */
94
96
  follow_clicks: number;
@@ -100,6 +102,13 @@ export interface Digest {
100
102
  bounce_rate: number | null;
101
103
  /** Mean seconds from a visit's first view to its last; null with no visits. */
102
104
  visit_duration: number | null;
105
+ /**
106
+ * Agents, counted as visitors are: an AI crawler, an MCP client, or anything
107
+ * else reading the machine surface (`trackAiBot`), one per address and user
108
+ * agent a day, the site's own left out. They run no page script, so they are
109
+ * never among `visitors`.
110
+ */
111
+ agents: number;
103
112
  /** Visitors plus every agent that sent an event, such as an MCP call. */
104
113
  visitors_incl_agents: number;
105
114
  /**
@@ -108,7 +117,7 @@ export interface Digest {
108
117
  * the archive cannot stand in for it: event figures always, all of them
109
118
  * when filtered.
110
119
  */
111
- previous: Record<'visitors' | 'pageviews' | 'bounce_rate' | 'visit_duration' | 'visitors_incl_agents' | 'follow_clicks', number | null>;
120
+ previous: Record<'visitors' | 'pageviews' | 'bounce_rate' | 'visit_duration' | 'agents' | 'visitors_incl_agents' | 'follow_clicks', number | null>;
112
121
  /** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
113
122
  counted_from: string | null;
114
123
  top_sources: Ranked<'source', 'visitors'>;
@@ -125,6 +134,8 @@ export interface Digest {
125
134
  events: number;
126
135
  visitors: number;
127
136
  } | null;
137
+ /** Each agent by name: the roster's label, else what its user agent calls itself. */
138
+ top_agents: Ranked<'agent', 'visitors'>;
128
139
  agent_tool_calls: Ranked<'tool', 'events'>;
129
140
  gaps: Ranked<'query', 'events'>;
130
141
  }
package/dist/analytics.js CHANGED
@@ -160,6 +160,11 @@ const VISIT_TEST = {
160
160
  country: v => `bool_or(country = ${v})`,
161
161
  device: v => `bool_or(device = ${v})`,
162
162
  };
163
+ /**
164
+ * An agent's name from its event: the roster's label, else what its user agent
165
+ * calls itself, `compatible; X` or the first product token.
166
+ */
167
+ const AGENT_NAME = `coalesce(props->>'bot', substring(props->>'ua' from 'compatible; ?([^/;) ]+)'), nullif(split_part(split_part(props->>'ua', '/', 1), ' ', 1), ''), '(unknown)')`;
163
168
  /**
164
169
  * Everything a report needs about one site's last `days` days, in one query.
165
170
  * Visitor figures count distinct visitors, so a person who comes back on
@@ -181,6 +186,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
181
186
  const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
182
187
  const tests = STATS_FILTERS.flatMap(k => (filter[k] ? [VISIT_TEST[k]($(filter[k]))] : []));
183
188
  const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
189
+ /** The site's own agents, as a test on an event's user agent. */
190
+ const ours = `coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])`;
184
191
  const gapSelects = gaps.map(g => {
185
192
  const prop = $(g.prop);
186
193
  return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
@@ -210,6 +217,9 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
210
217
  ), e${s} AS (
211
218
  SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
212
219
  ${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
220
+ ), ag${s} AS (
221
+ SELECT visitor, at, ${AGENT_NAME} AS agent FROM e${s}
222
+ WHERE name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
213
223
  ), a${s} AS (
214
224
  SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
215
225
  ), sessions${s} AS (
@@ -226,6 +236,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
226
236
  'pageviews', (SELECT count(*) FROM v${s}) + (SELECT pageviews FROM archived${s}),
227
237
  'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions${s}),
228
238
  'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions${s}),
239
+ 'agents', (SELECT count(DISTINCT visitor) FROM ag${s}),
229
240
  'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM e${s}) u) + (SELECT visitors FROM archived${s})`;
230
241
  const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
231
242
  SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
@@ -243,17 +254,18 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
243
254
  'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
244
255
  'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
245
256
  'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
246
- sum(seconds) AS seconds, sum(agents) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
247
- SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0 FROM v GROUP BY 1
248
- UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0 FROM visits GROUP BY 1
249
- UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM e) u GROUP BY 1
250
- ${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
251
- UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, visitors, 0 FROM a
252
- ) t(date, visitors, pageviews, visits, bounces, seconds, agents, follows) GROUP BY 1`, undefined, '1')},
257
+ sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
258
+ SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
259
+ UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
260
+ UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
261
+ UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM e) u GROUP BY 1
262
+ ${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
263
+ UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, 0, visitors, 0 FROM a
264
+ ) t(date, visitors, pageviews, visits, bounces, seconds, agents, everyone, follows) GROUP BY 1`, undefined, '1')},
253
265
  'follow_clicks', ${follows ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e WHERE ${follows})` : 'NULL'},
266
+ 'top_agents', ${list('SELECT agent, count(DISTINCT visitor) AS visitors FROM ag GROUP BY 1', 10)},
254
267
  'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
255
- WHERE name = 'MCP Call' AND props->>'method' = 'tools/call'
256
- AND NOT coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])
268
+ WHERE name = 'MCP Call' AND props->>'method' = 'tools/call' AND NOT ${ours}
257
269
  GROUP BY 1`, 10)},
258
270
  'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
259
271
  ) AS digest`, ...values));
@@ -267,7 +279,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
267
279
  for (const k of Object.keys(p))
268
280
  p[k] = null;
269
281
  else
270
- p.visitors_incl_agents = p.follow_clicks = null;
282
+ p.agents = p.visitors_incl_agents = p.follow_clicks = null;
271
283
  }
272
284
  return d;
273
285
  }
@@ -112,11 +112,14 @@ export declare function StatsView({ items, columns, }: {
112
112
  * The fallback `message` arrives as a prop because it is the wiki's editorial
113
113
  * voice — "You can help RADIX Wiki by expanding it" has a name in it. `icon`
114
114
  * is optional: a component library that hardcoded an icon set would make its
115
- * consumers install that icon set.
115
+ * consumers install that icon set. `policy` is the wiki's page for the
116
+ * standard the notice enforces; given, the label links to it, so a reader who
117
+ * meets "Needs citations" can find out what a citation has to be.
116
118
  */
117
- export declare function BannerView({ variant, text, message, icon, }: {
119
+ export declare function BannerView({ variant, text, message, icon, policy, }: {
118
120
  variant: string;
119
121
  text?: string | null;
120
122
  message: string;
121
123
  icon?: ReactNode;
124
+ policy?: string;
122
125
  }): import("react").JSX.Element;
@@ -123,9 +123,12 @@ export function StatsView({ items, columns = 4, }) {
123
123
  * The fallback `message` arrives as a prop because it is the wiki's editorial
124
124
  * voice — "You can help RADIX Wiki by expanding it" has a name in it. `icon`
125
125
  * is optional: a component library that hardcoded an icon set would make its
126
- * consumers install that icon set.
126
+ * consumers install that icon set. `policy` is the wiki's page for the
127
+ * standard the notice enforces; given, the label links to it, so a reader who
128
+ * meets "Needs citations" can find out what a citation has to be.
127
129
  */
128
- export function BannerView({ variant, text, message, icon, }) {
130
+ export function BannerView({ variant, text, message, icon, policy, }) {
129
131
  const known = bannerVariant(variant);
130
- return (_jsxs("div", { className: cx('editorial-banner', `editorial-banner-${known}`), role: "note", children: [icon, _jsxs("p", { className: "editorial-banner-body", children: [_jsxs("strong", { children: [BANNER_LABELS[known], "."] }), " ", text?.trim() || message] })] }));
132
+ const label = BANNER_LABELS[known];
133
+ return (_jsxs("div", { className: cx('editorial-banner', `editorial-banner-${known}`), role: "note", children: [icon, _jsxs("p", { className: "editorial-banner-body", children: [_jsxs("strong", { children: [policy ? _jsx("a", { href: policy, children: label }) : label, "."] }), " ", text?.trim() || message] })] }));
131
134
  }
@@ -46,16 +46,20 @@ export declare function crawlerRules(opts: {
46
46
  searchDisallow?: string | string[];
47
47
  }): RobotsGroup[];
48
48
  /**
49
- * Count an AI crawler's visit from a proxy, without holding up its response.
49
+ * Count an agent's visit from a proxy, without holding up its response: a
50
+ * crawler on the roster wherever it goes, and anything but a search engine
51
+ * that asks for what only agents read, since most agents are on no roster.
52
+ * The user agent is kept beside the label, so `digest` can name an agent the
53
+ * roster does not know and leave out the site's own.
50
54
  *
51
55
  * `load` is a dynamic import of the module that records it, because that
52
56
  * module pulls in a database client, and a static import would load it for
53
57
  * every request the proxy sees, bots or not. Three proxies wrote these lines.
54
- * Returns the bot's label, or null.
58
+ * Returns whether the request was counted.
55
59
  */
56
60
  export declare function trackAiBot(request: {
57
61
  url: string;
58
62
  headers: HeaderReader;
59
63
  }, event: {
60
64
  waitUntil(promise: Promise<unknown>): void;
61
- }, load: () => Promise<Pick<Tracker, 'trackEvent'>>): string | null;
65
+ }, load: () => Promise<Pick<Tracker, 'trackEvent'>>): boolean;
package/dist/crawlers.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // crawlers.ts — one roster of AI crawler tokens, for both surfaces that need it.
2
2
  //
3
3
  // The proxy counts an "AI Bot Visit" by user-agent substring, and `robots.ts`
4
- // gives each agent its own group. Both read this one roster.
4
+ // gives each agent its own group. Both read this one roster. The proxy also
5
+ // counts any agent reading the machine surface, roster or not.
5
6
  //
6
7
  // One difference between the two uses is deliberate. `Applebot-Extended`
7
8
  // never fetches a page: it is a robots.txt-only token that Applebot consults
@@ -11,6 +12,7 @@
11
12
  // Every other token appears in both, because a crawler obeys only its
12
13
  // most-specific matching group: an agent with no group of its own falls through
13
14
  // to `*` and is granted whatever that grants.
15
+ import { wantsMarkdown } from './http.js';
14
16
  /**
15
17
  * Order is significant: `detectAiBot` returns the first token the user agent
16
18
  * contains, so a token that is a substring of another must come after it.
@@ -113,16 +115,30 @@ export function crawlerRules(opts) {
113
115
  ];
114
116
  }
115
117
  /**
116
- * Count an AI crawler's visit from a proxy, without holding up its response.
118
+ * What only an agent asks for: a markdown twin, an `llms` export, the OpenAPI
119
+ * document, a well-known descriptor. MCP is left out, since "MCP Call" counts it.
120
+ */
121
+ const AGENT_ONLY = /\.md$|^\/llms[\w-]*\.txt$|^\/openapi\.json$|^\/\.well-known\//;
122
+ /**
123
+ * Count an agent's visit from a proxy, without holding up its response: a
124
+ * crawler on the roster wherever it goes, and anything but a search engine
125
+ * that asks for what only agents read, since most agents are on no roster.
126
+ * The user agent is kept beside the label, so `digest` can name an agent the
127
+ * roster does not know and leave out the site's own.
117
128
  *
118
129
  * `load` is a dynamic import of the module that records it, because that
119
130
  * module pulls in a database client, and a static import would load it for
120
131
  * every request the proxy sees, bots or not. Three proxies wrote these lines.
121
- * Returns the bot's label, or null.
132
+ * Returns whether the request was counted.
122
133
  */
123
134
  export function trackAiBot(request, event, load) {
124
- const bot = detectAiBot(request.headers.get('user-agent'));
125
- if (bot)
126
- event.waitUntil(load().then(m => m.trackEvent('AI Bot Visit', request.url, { bot }, request.headers)));
127
- return bot;
135
+ const ua = request.headers.get('user-agent') ?? '';
136
+ const bot = detectAiBot(ua);
137
+ const agent = bot !== null ||
138
+ (!!ua && !SEARCH_ENGINES.some(s => ua.includes(s)) && (AGENT_ONLY.test(new URL(request.url).pathname) || wantsMarkdown(request)));
139
+ if (agent) {
140
+ const props = { ...(bot ? { bot } : {}), ua: ua.slice(0, 80) };
141
+ event.waitUntil(load().then(m => m.trackEvent('AI Bot Visit', request.url, props, request.headers)));
142
+ }
143
+ return agent;
128
144
  }
package/dist/http.d.ts CHANGED
@@ -74,7 +74,7 @@ export declare function markdownHeaders(lastModified?: string | null, opts?: {
74
74
  export declare function wantsMarkdown(request: {
75
75
  url: string;
76
76
  headers: {
77
- get(name: string): string | null;
77
+ get(name: string): string | null | undefined;
78
78
  };
79
79
  }): boolean;
80
80
  /** The header every response from a content-negotiated URL carries. */
package/dist/indexnow.js CHANGED
@@ -4,7 +4,9 @@
4
4
  // The `indexnow` bin reads an origin's live sitemap, keeps the rows whose
5
5
  // `lastModified` falls inside the window, and posts them. It runs on the machine
6
6
  // that runs the sweeps, not as a cron on the host: a submission costs the origin
7
- // one cached sitemap fetch and no function time. The sitemap is the one place
7
+ // one sitemap render and no scheduled function. The sitemap renders per request,
8
+ // because one Vercel served from cache froze at the last deploy and listed none
9
+ // of the pages edited since. The sitemap is the one place
8
10
  // every writer shows up — the editor, the MCP write tools, and the sweep scripts
9
11
  // that write to the database without passing through the app — so reading it
10
12
  // covers all of them with no hook in any.
@@ -40,6 +40,13 @@ export interface LogState {
40
40
  }
41
41
  /** Drops what is closed or a diary entry, and caps what is append-only. Newest first. */
42
42
  export declare function compact(state: LogState): LogState;
43
+ /**
44
+ * Why a `write` of `next` over `stored` would lose runs, or null. A run that
45
+ * writes back a state it read before another run wrote (a copy left in /tmp, a
46
+ * stale variable) carries a lower `totalRuns`, and replacing the whole state
47
+ * with it silently erases what the later run recorded.
48
+ */
49
+ export declare function staleWrite(stored: LogState, next: LogState): string | null;
43
50
  /** New backlog items, deduplicated against what is there, dated today unless they carry a date. */
44
51
  export declare function addBacklog(state: LogState, items: readonly BacklogEntry[], today: string): BacklogEntry[];
45
52
  /** The page's content blocks, re-rendered from state on every write. `routine` names who keeps it. */
@@ -14,17 +14,21 @@
14
14
  // its revisions table. Two kinds of entry are pinned against the caps because
15
15
  // a run cannot recreate them: a backlog item flagged for a human, and a
16
16
  // watermark that banks a verdict on a whole class of site (a bot wall, a 403),
17
- // which is what lets an audit spend no probes on its flag list.
17
+ // which is what lets an audit spend no probes on its flag list. Flags have a
18
+ // cap of their own, the page's backlog window: pinned without one, they grew to
19
+ // 48 on radix-wiki's log and held most of the backlog's sixty slots, and a flag
20
+ // that no longer fits on the page is one no human reads.
18
21
  //
19
22
  // No subpath exports this: the bins and the tests import it from dist.
20
23
  import { escXml } from './feed.js';
21
- /** Held in state. */
22
- const MAX = { backlog: 60, runs: 30, feedback: 40, sources: 150 };
23
24
  /** Shown on the page. What a reader pays is words, and the entries run long. */
24
25
  const SHOWN = { backlog: 15, runs: 5, feedback: 8, sources: 15 };
26
+ /** Held in state. */
27
+ const MAX = { backlog: 60, flags: SHOWN.backlog, runs: 30, feedback: 40, sources: 150 };
25
28
  /** "run 214 (ecosystem): link audit CLEAN…" is a diary entry, not a work item. */
26
29
  const JOURNAL = /^run\s+\d+\b/i;
27
- const PINNED = /FLAG FOR A HUMAN/i;
30
+ /** @internal The bin names the flags a write drops. */
31
+ export const PINNED = /FLAG FOR A HUMAN/i;
28
32
  const BANKED = /\bbanked\b|bot[- ]?(wall|block)|\b(403|429|999)\b|cloudflare/i;
29
33
  const itemOf = (b) => (typeof b === 'string' ? b : b.item);
30
34
  const recency = (a, b) => String(b[1]?.lastChecked ?? '').localeCompare(String(a[1]?.lastChecked ?? ''));
@@ -35,7 +39,7 @@ export function compact(state) {
35
39
  .map((b, i) => ({ status: 'open', ...(typeof b === 'string' ? { date: '', item: b } : b), i }))
36
40
  .filter(b => b.status === 'open' && b.item && (PINNED.test(b.item) || !JOURNAL.test(b.item)))
37
41
  .sort((a, b) => String(b.date ?? '').localeCompare(String(a.date ?? '')) || b.i - a.i);
38
- const backlog = [...open.filter(b => PINNED.test(b.item)), ...open.filter(b => !PINNED.test(b.item))]
42
+ const backlog = [...open.filter(b => PINNED.test(b.item)).slice(0, MAX.flags), ...open.filter(b => !PINNED.test(b.item))]
39
43
  .slice(0, MAX.backlog)
40
44
  .map(({ i: _i, ...b }) => b);
41
45
  // Most source keys are one-off verdicts on a single site. Sorting by
@@ -51,6 +55,18 @@ export function compact(state) {
51
55
  runHistory: (state.runHistory ?? []).slice(-MAX.runs),
52
56
  };
53
57
  }
58
+ /**
59
+ * Why a `write` of `next` over `stored` would lose runs, or null. A run that
60
+ * writes back a state it read before another run wrote (a copy left in /tmp, a
61
+ * stale variable) carries a lower `totalRuns`, and replacing the whole state
62
+ * with it silently erases what the later run recorded.
63
+ */
64
+ export function staleWrite(stored, next) {
65
+ const [had, has] = [stored.totalRuns ?? 0, next.totalRuns ?? 0];
66
+ return has < had
67
+ ? `refusing a state with totalRuns ${has} over the stored ${had}: it predates a run that has already written. Read the log again and reapply this run's changes.`
68
+ : null;
69
+ }
54
70
  /** New backlog items, deduplicated against what is there, dated today unless they carry a date. */
55
71
  export function addBacklog(state, items, today) {
56
72
  const seen = new Set((state.backlog ?? []).map(itemOf));
@@ -3,9 +3,12 @@ export interface PasskeyButtonProps {
3
3
  endpoint: string;
4
4
  /** The invite token from the link, when there is one. */
5
5
  invite?: string | undefined;
6
- /** Save a passkey without an invite: pass `await gate.open()`. */
7
- create?: boolean;
8
6
  className?: string;
9
7
  errorClassName?: string;
10
8
  }
11
- export declare function PasskeyButton({ endpoint, invite, create, className, errorClassName }: PasskeyButtonProps): import("react").JSX.Element;
9
+ export declare function PasskeyButton({ endpoint, invite, className, errorClassName }: PasskeyButtonProps): import("react").JSX.Element;
10
+ /** Ends the session at `endpoint` (`gate.signOut` as DELETE) and reloads the page. */
11
+ export declare function PasskeySignOut({ endpoint, className }: {
12
+ endpoint: string;
13
+ className?: string;
14
+ }): import("react").JSX.Element;
@@ -3,10 +3,10 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  // passkey-button.tsx – the browser half of `wiki-formant/passkey`.
4
4
  //
5
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, or with
7
- // `create` while the site has no passkey (`gate.open()`), it saves a new
8
- // passkey; otherwise it signs in with a saved one. Either way, success reloads
9
- // the page without the invite, and the server sees the new cookie.
6
+ // peer that only an app with a passkey door installs. With an invite it saves a
7
+ // new passkey; otherwise 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
+ // `PasskeySignOut` calls the route's DELETE (`gate.signOut`) and reloads.
10
10
  import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
11
11
  import { useState } from 'react';
12
12
  async function post(endpoint, body) {
@@ -15,8 +15,8 @@ async function post(endpoint, body) {
15
15
  throw new Error((await res.json().catch(() => null))?.error ?? 'Sign-in failed.');
16
16
  return res.status === 204 ? null : res.json();
17
17
  }
18
- export function PasskeyButton({ endpoint, invite, create, className, errorClassName }) {
19
- const saving = !!invite || !!create;
18
+ export function PasskeyButton({ endpoint, invite, className, errorClassName }) {
19
+ const saving = !!invite;
20
20
  const [busy, setBusy] = useState(false);
21
21
  const [error, setError] = useState(null);
22
22
  async function go() {
@@ -36,3 +36,10 @@ export function PasskeyButton({ endpoint, invite, create, className, errorClassN
36
36
  }
37
37
  return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: className, onClick: go, disabled: busy, children: saving ? 'Save a passkey' : 'Sign in with a passkey' }), error && (_jsx("p", { role: "alert", className: errorClassName, children: error }))] }));
38
38
  }
39
+ /** Ends the session at `endpoint` (`gate.signOut` as DELETE) and reloads the page. */
40
+ export function PasskeySignOut({ endpoint, className }) {
41
+ return (_jsx("button", { type: "button", className: className, onClick: async () => {
42
+ await fetch(endpoint, { method: 'DELETE' }).catch(() => null);
43
+ location.reload();
44
+ }, children: "Sign out" }));
45
+ }
package/dist/passkey.d.ts CHANGED
@@ -12,18 +12,18 @@ export interface PasskeyGateOptions {
12
12
  export interface PasskeyGate {
13
13
  /** The session cookie's name, for the app to read it with. */
14
14
  cookie: string;
15
- /** Whether a session cookie's value is a live session. */
15
+ /** Whether a session cookie's value is a live session whose passkey is still saved. */
16
16
  signedIn(session: string | undefined): Promise<boolean>;
17
- /** True while no passkey is saved, when the page offers to save the first one. */
18
- open(): Promise<boolean>;
19
17
  /** A single-use invite token, good for `days`. The bin turns it into a link. */
20
18
  invite(days?: number): Promise<string>;
21
19
  /**
22
20
  * The whole sign-in route: `export const POST = gate.route`. The body is
23
21
  * `{ invite?, response? }`: without `response` it answers with the options
24
22
  * for the browser's prompt, with one it verifies it and sets the cookie. An
25
- * invite, or a site with no passkey yet, turns the prompt into saving one.
23
+ * invite turns the prompt into saving one.
26
24
  */
27
25
  route(request: Request): Promise<Response>;
26
+ /** The sign-out route: `export const DELETE = gate.signOut`. Ends the session and clears the cookie. */
27
+ signOut(request: Request): Promise<Response>;
28
28
  }
29
29
  export declare function createPasskeyGate({ sql, rpName, cookie, sessionDays }: PasskeyGateOptions): PasskeyGate;
package/dist/passkey.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // passkey.ts – the door to a site's own admin pages: a passkey, and nothing else.
2
2
  //
3
3
  // No account, no password, no email. Whoever holds a passkey saved for the
4
- // site is in. While none is saved, the page saves the first one without asking
5
- // for anything, so the owner opens it from the deployed site the way they would
6
- // any other. Every passkey after that needs an invite link, which the
7
- // `passkey-invite` bin prints from a machine holding the database URL.
4
+ // site is in. Every passkey, the first among them, is saved from an invite
5
+ // link, which the `passkey-invite` bin prints from a machine holding the
6
+ // database URL. A fresh deployment once saved the first passkey from whoever
7
+ // opened the page, so a stranger who got there before the owner held the door.
8
8
  //
9
9
  // A passkey is bound to the host it was saved on. One saved on localhost does
10
10
  // not open the deployed site, and one saved on the site does not open localhost.
@@ -14,10 +14,10 @@
14
14
  // `prisma-models` bin copies into the app's schema folder.
15
15
  //
16
16
  // Every token is stored as its hash, so a read of the table opens nothing. A
17
- // challenge is spent the moment a response quotes it, and an invite the moment
18
- // a passkey is saved with it. The first passkey without an invite is checked
19
- // against an empty table in the insert itself, so two people racing to it
20
- // cannot both win.
17
+ // challenge is spent the moment a response quotes it, and only by the ceremony
18
+ // it was issued for; an invite the moment a passkey is saved with it. A session
19
+ // names the passkey that signed in, and lives only as long as that passkey's
20
+ // row: deleting a passkey signs out everyone who signed in with it.
21
21
  import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
22
22
  import { clientKey, rateLimit, rateLimitedResponse } from './rate-limit.js';
23
23
  const MINUTE = 60_000;
@@ -27,9 +27,9 @@ const b64url = (bytes) => Buffer.from(bytes).toString('base64url');
27
27
  const token = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
28
28
  const hash = async (value) => b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))));
29
29
  /** Store a token's hash; the token is returned and kept nowhere else. */
30
- async function issue(sql, kind, ms, value = token()) {
30
+ async function issue(sql, kind, ms, value = token(), passkeyId = null) {
31
31
  await sql(`DELETE FROM "PasskeyToken" WHERE "expiresAt" < now()`);
32
- 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));
32
+ await sql(`INSERT INTO "PasskeyToken" (hash, kind, "expiresAt", "passkeyId") VALUES ($1, $2, now() + make_interval(secs => $3::int), $4)`, await hash(value), kind, Math.round(ms / 1000), passkeyId);
33
33
  return value;
34
34
  }
35
35
  /** True once for a live token of this kind; it is gone either way. */
@@ -44,19 +44,32 @@ async function live(sql, kind, value) {
44
44
  /** Any request that fails is told only that it failed. */
45
45
  const refuse = (status, error) => Response.json({ error }, { status });
46
46
  export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', sessionDays = 90 }) {
47
- async function session(secure) {
48
- const value = await issue(sql, 'session', sessionDays * DAY);
49
- const attrs = [`${cookie}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${sessionDays * 86_400}`];
47
+ const setCookie = (value, maxAge, secure) => {
48
+ const attrs = [`${cookie}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${maxAge}`];
50
49
  if (secure)
51
50
  attrs.push('Secure');
52
51
  return new Response(null, { status: 204, headers: { 'set-cookie': attrs.join('; ') } });
52
+ };
53
+ async function session(passkeyId, secure) {
54
+ const value = await issue(sql, 'session', sessionDays * DAY, undefined, passkeyId);
55
+ return setCookie(value, sessionDays * 86_400, secure);
53
56
  }
54
- const open = async () => (await sql(`SELECT 1 FROM "Passkey" LIMIT 1`)).length === 0;
55
57
  return {
56
58
  cookie,
57
- signedIn: async (value) => !!value && live(sql, 'session', value),
58
- open,
59
+ signedIn: async (value) => !!value &&
60
+ (await sql(`SELECT 1 FROM "PasskeyToken" t JOIN "Passkey" p ON p.id = t."passkeyId"
61
+ WHERE t.hash = $1 AND t.kind = 'session' AND t."expiresAt" > now()`, await hash(value))).length > 0,
59
62
  invite: (days = 1) => issue(sql, 'invite', days * DAY),
63
+ async signOut(request) {
64
+ const value = request.headers
65
+ .get('cookie')
66
+ ?.split(/;\s*/)
67
+ .find(c => c.startsWith(`${cookie}=`))
68
+ ?.slice(cookie.length + 1);
69
+ if (value)
70
+ await spend(sql, 'session', value);
71
+ return setCookie('', 0, new URL(request.url).protocol === 'https:');
72
+ },
60
73
  async route(request) {
61
74
  const limited = rateLimit(clientKey('passkey', request.headers), { capacity: 10, refillPerSec: 0.2 });
62
75
  if (!limited.ok)
@@ -67,11 +80,12 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
67
80
  const body = (await request.json().catch(() => null));
68
81
  const invite = typeof body?.invite === 'string' ? body.invite : undefined;
69
82
  const response = body?.response;
70
- const expectedChallenge = (challenge) => spend(sql, 'challenge', challenge);
83
+ const ceremony = invite ? 'registration' : 'authentication';
84
+ const expectedChallenge = (challenge) => spend(sql, ceremony, challenge);
71
85
  if (!response) {
72
86
  if (invite && !(await live(sql, 'invite', invite)))
73
87
  return refuse(403, 'This invite has expired.');
74
- const options = invite || (await open())
88
+ const options = invite
75
89
  ? await generateRegistrationOptions({
76
90
  rpName,
77
91
  rpID,
@@ -81,10 +95,10 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
81
95
  authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
82
96
  })
83
97
  : await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
84
- await issue(sql, 'challenge', CHALLENGE_MS, options.challenge);
98
+ await issue(sql, ceremony, CHALLENGE_MS, options.challenge);
85
99
  return Response.json(options);
86
100
  }
87
- if (invite || (await open())) {
101
+ if (invite) {
88
102
  const result = await verifyRegistrationResponse({
89
103
  response,
90
104
  expectedChallenge,
@@ -95,14 +109,11 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
95
109
  if (!result?.verified)
96
110
  return refuse(400, 'That passkey could not be saved.');
97
111
  // Spent after the check, so a failed prompt leaves the invite to try again.
98
- if (invite && !(await spend(sql, 'invite', invite)))
112
+ if (!(await spend(sql, 'invite', invite)))
99
113
  return refuse(403, 'This invite has expired.');
100
114
  const { credential } = result.registrationInfo;
101
- const saved = (await sql(`INSERT INTO "Passkey" (id, "publicKey", counter, transports)
102
- SELECT $1, $2, $3, $4::text[] WHERE $5::boolean OR NOT EXISTS (SELECT 1 FROM "Passkey") RETURNING id`, credential.id, b64url(credential.publicKey), credential.counter, credential.transports ?? [], !!invite));
103
- if (!saved.length)
104
- return refuse(403, 'This site already has a passkey. Adding another takes an invite.');
105
- return session(protocol === 'https:');
115
+ await sql(`INSERT INTO "Passkey" (id, "publicKey", counter, transports) VALUES ($1, $2, $3, $4::text[])`, credential.id, b64url(credential.publicKey), credential.counter, credential.transports ?? []);
116
+ return session(credential.id, protocol === 'https:');
106
117
  }
107
118
  const [passkey] = (await sql(`SELECT id, "publicKey", counter, transports FROM "Passkey" WHERE id = $1`, String(response.id)));
108
119
  if (!passkey)
@@ -118,7 +129,7 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
118
129
  if (!result?.verified)
119
130
  return refuse(403, 'Sign-in failed.');
120
131
  await sql(`UPDATE "Passkey" SET counter = $2 WHERE id = $1`, passkey.id, result.authenticationInfo.newCounter);
121
- return session(protocol === 'https:');
132
+ return session(passkey.id, protocol === 'https:');
122
133
  },
123
134
  };
124
135
  }
package/dist/rola.js CHANGED
@@ -30,6 +30,12 @@ export function createRola({ challenges, challengeTtlSec = 300, ...identity }) {
30
30
  },
31
31
  async verifySignedChallenge(signed) {
32
32
  try {
33
+ // Anything but a string is a filter, not a challenge: an absent one
34
+ // matches every row, so one bad request would spend every pending
35
+ // sign-in's challenge.
36
+ if (typeof signed?.challenge !== 'string' || typeof signed.address !== 'string') {
37
+ return { isValid: false, error: 'Invalid or expired challenge' };
38
+ }
33
39
  const { count } = await challenges.deleteMany({
34
40
  where: { challenge: signed.challenge, expiresAt: { gt: new Date() } },
35
41
  });
@@ -0,0 +1,31 @@
1
+ export type ChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
2
+ export interface SitemapRow {
3
+ url: string;
4
+ lastModified?: Date;
5
+ changeFrequency?: ChangeFrequency;
6
+ priority?: number;
7
+ }
8
+ /** The minimum a page must be to date its sections and earn a row. */
9
+ export interface TreePage {
10
+ tagPath: string;
11
+ slug: string;
12
+ updatedAt: Date;
13
+ }
14
+ export interface TreeSitemapOptions<P extends TreePage> {
15
+ /** Section tag paths to list, in order. A path with no page beneath it is listed undated. */
16
+ sections: Iterable<string>;
17
+ sectionRow: (tagPath: string) => Omit<SitemapRow, 'lastModified'>;
18
+ pageRow: (page: P) => Omit<SitemapRow, 'lastModified'>;
19
+ }
20
+ /**
21
+ * One row per section, dated by the newest page at or beneath it, then one row
22
+ * per page, dated by its own `updatedAt`.
23
+ *
24
+ * Every URL appears once. An empty-slug page is its section's own article (a
25
+ * category hub): it dates the section and gets no row of its own, since its URL
26
+ * is the section's. A section whose URL a page row already holds is served as
27
+ * that page, so the section's row is dropped.
28
+ */
29
+ export declare function treeSitemap<P extends TreePage>(pages: readonly P[], o: TreeSitemapOptions<P>): SitemapRow[];
30
+ /** Every tag path at or above the given ones, each once: `a/b` yields `a` and `a/b`. */
31
+ export declare function tagPrefixes(tagPaths: Iterable<string>): string[];
@@ -0,0 +1,50 @@
1
+ // sitemap.ts — the rows a wiki's tree contributes to its sitemap.
2
+ //
3
+ // All three wikis list their sections beside their pages, and each dated them
4
+ // its own way: one by the newest page beneath, one the same but derived from
5
+ // where the pages sit, and one not at all. A section's listing changes when a
6
+ // page under it does, so the newest page beneath is its honest date; a section
7
+ // with no page under it carries none rather than a fictional one.
8
+ //
9
+ // What stays with the caller is which sections exist, every URL, and the
10
+ // frequency and priority each row carries. The return is structurally Next's
11
+ // `MetadataRoute.Sitemap`, so a `sitemap.ts` spreads it as it is.
12
+ /**
13
+ * One row per section, dated by the newest page at or beneath it, then one row
14
+ * per page, dated by its own `updatedAt`.
15
+ *
16
+ * Every URL appears once. An empty-slug page is its section's own article (a
17
+ * category hub): it dates the section and gets no row of its own, since its URL
18
+ * is the section's. A section whose URL a page row already holds is served as
19
+ * that page, so the section's row is dropped.
20
+ */
21
+ export function treeSitemap(pages, o) {
22
+ const pageRows = pages
23
+ .filter(p => p.slug)
24
+ .map((p) => ({ ...o.pageRow(p), lastModified: p.updatedAt }));
25
+ const taken = new Set(pageRows.map(r => r.url));
26
+ const sectionRows = [];
27
+ for (const path of o.sections) {
28
+ const row = o.sectionRow(path);
29
+ if (taken.has(row.url))
30
+ continue;
31
+ let newest;
32
+ for (const p of pages) {
33
+ if ((p.tagPath === path || p.tagPath.startsWith(`${path}/`)) && (!newest || p.updatedAt > newest))
34
+ newest = p.updatedAt;
35
+ }
36
+ taken.add(row.url);
37
+ sectionRows.push({ ...row, lastModified: newest });
38
+ }
39
+ return [...sectionRows, ...pageRows];
40
+ }
41
+ /** Every tag path at or above the given ones, each once: `a/b` yields `a` and `a/b`. */
42
+ export function tagPrefixes(tagPaths) {
43
+ const out = new Set();
44
+ for (const tagPath of tagPaths) {
45
+ const segments = tagPath.split('/').filter(Boolean);
46
+ for (let depth = 1; depth <= segments.length; depth++)
47
+ out.add(segments.slice(0, depth).join('/'));
48
+ }
49
+ return [...out];
50
+ }
@@ -24,6 +24,7 @@ function change(now, before, { points = false, lowerIsBetter = false } = {}) {
24
24
  }
25
25
  const figuresOf = ({ previous: p, ...d }) => [
26
26
  { label: 'Visitors', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: r => r.visitors },
27
+ { label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: r => r.agents },
27
28
  { label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
28
29
  {
29
30
  label: 'Bounce rate',
@@ -39,12 +40,6 @@ const figuresOf = ({ previous: p, ...d }) => [
39
40
  day: r => (r.visits ? r.seconds / r.visits : null),
40
41
  format: duration,
41
42
  },
42
- {
43
- label: 'With agents',
44
- value: count(d.visitors_incl_agents),
45
- delta: change(d.visitors_incl_agents, p.visitors_incl_agents),
46
- day: r => r.visitors_incl_agents,
47
- },
48
43
  ...(d.follow_clicks
49
44
  ? [
50
45
  {
@@ -57,7 +52,7 @@ const figuresOf = ({ previous: p, ...d }) => [
57
52
  : []),
58
53
  ];
59
54
  const DAY = 86_400;
60
- const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, visitors_incl_agents: 0, follow_clicks: 0 };
55
+ const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, agents: 0, visitors_incl_agents: 0, follow_clicks: 0 };
61
56
  /**
62
57
  * The figures as `.stats-figures button[aria-pressed]`, each with its change
63
58
  * as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
package/dist/stats.d.ts CHANGED
@@ -36,7 +36,7 @@ export interface StatsGateProps {
36
36
  invite?: string | undefined;
37
37
  /** The site's host, named in the prompt. */
38
38
  host: string;
39
- /** The route that serves `gate.route`. */
39
+ /** The route that serves `gate.route` as POST and `gate.signOut` as DELETE. */
40
40
  endpoint?: string;
41
41
  /** What a signed-in reader sees, rendered only for one. */
42
42
  children: () => ReactNode | Promise<ReactNode>;
@@ -48,9 +48,9 @@ export interface StatsGateProps {
48
48
  };
49
49
  }
50
50
  /**
51
- * A stats page's door: `children` for a signed-in reader, else a line saying
52
- * what the passkey prompt will do and the button that does it. With an invite
53
- * it saves a passkey, as it does while the site has none; otherwise it signs
54
- * in. Three apps wrote this branch and its three sentences.
51
+ * A stats page's door: `children` and a sign-out button for a signed-in
52
+ * reader, else a line saying what the passkey prompt will do and the button
53
+ * that does it. With an invite it saves a passkey; otherwise it signs in.
54
+ * Three apps wrote this branch and its sentences.
55
55
  */
56
- export declare function StatsGate({ gate, session, invite, host, endpoint, children, classNames: c }: StatsGateProps): Promise<string | number | bigint | boolean | Iterable<ReactNode> | import("react").JSX.Element | null | undefined>;
56
+ export declare function StatsGate({ gate, session, invite, host, endpoint, children, classNames: c }: StatsGateProps): Promise<import("react").JSX.Element>;
package/dist/stats.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { STATS_FILTERS } from './analytics.js';
3
- import { PasskeyButton } from './passkey-button.js';
3
+ import { PasskeyButton, PasskeySignOut } from './passkey-button.js';
4
4
  import { StatsFigures } from './stats-figures.js';
5
5
  /** The windows the stats page offers, in days; `Infinity` is all time. */
6
6
  const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
@@ -73,21 +73,17 @@ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
73
73
  // Each window is another digest, so a link rather than a button, pinned
74
74
  // with the chart's own controls.
75
75
  const windows = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: href(n, filter), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }));
76
- return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: href(days, { ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, windows: windows }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => href(days, { ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
76
+ return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: href(days, { ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, windows: windows }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => href(days, { ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
77
77
  }
78
78
  /**
79
- * A stats page's door: `children` for a signed-in reader, else a line saying
80
- * what the passkey prompt will do and the button that does it. With an invite
81
- * it saves a passkey, as it does while the site has none; otherwise it signs
82
- * in. Three apps wrote this branch and its three sentences.
79
+ * A stats page's door: `children` and a sign-out button for a signed-in
80
+ * reader, else a line saying what the passkey prompt will do and the button
81
+ * that does it. With an invite it saves a passkey; otherwise it signs in.
82
+ * Three apps wrote this branch and its sentences.
83
83
  */
84
84
  export async function StatsGate({ gate, session, invite, host, endpoint = '/api/passkey', children, classNames: c = {} }) {
85
- if (!invite && (await gate.signedIn(session)))
86
- return children();
87
- const open = !invite && (await gate.open());
88
- return (_jsxs(_Fragment, { children: [_jsx("p", { className: c.text, children: invite
89
- ? `Save a passkey on this device to open ${host}'s stats.`
90
- : open
91
- ? `No passkey is saved for ${host} yet. Save the first one here; any after that needs an invite.`
92
- : 'Sign in with a passkey saved for this site.' }), _jsx("div", { className: c.actions, children: _jsx(PasskeyButton, { endpoint: endpoint, invite: invite, create: open, className: c.button, errorClassName: c.error }) })] }));
85
+ if (!invite && (await gate.signedIn(session))) {
86
+ return (_jsxs(_Fragment, { children: [await children(), _jsx("div", { className: c.actions, children: _jsx(PasskeySignOut, { endpoint: endpoint, className: c.button }) })] }));
87
+ }
88
+ return (_jsxs(_Fragment, { children: [_jsx("p", { className: c.text, children: invite ? `Save a passkey on this device to open ${host}'s stats.` : 'Sign in with a passkey saved for this site.' }), _jsx("div", { className: c.actions, children: _jsx(PasskeyButton, { endpoint: endpoint, invite: invite, className: c.button, errorClassName: c.error }) })] }));
93
89
  }
package/dist/tiptap.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { Extension, Node as TiptapNode } from '@tiptap/core';
3
3
  /** Local `cn`. Both wikis import one; the package will not depend on one. */
4
+ /**
5
+ * An embed's address, if it is https on a host the sanitiser and the CSP
6
+ * admit; otherwise null, and the node draws an empty frame. The editor draws
7
+ * stored HTML on the origin where its author is signed in, and a
8
+ * `javascript:` src runs there under any CSP with 'unsafe-inline' script.
9
+ */
10
+ export declare function embedSrc(src: unknown): string | null;
4
11
  export declare const Iframe: TiptapNode<any, any>;
5
12
  /** The stock extension plus the paste rule it does not ship with. */
6
13
  export declare const YouTube: TiptapNode<import("@tiptap/extension-youtube").YoutubeOptions, any>;
package/dist/tiptap.js CHANGED
@@ -17,6 +17,7 @@ import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap
17
17
  import TiptapYoutube from '@tiptap/extension-youtube';
18
18
  import TiptapCodeBlock from '@tiptap/extension-code-block';
19
19
  import { onTweetResize, tweetEmbedSrc } from './dom.js';
20
+ import { DEFAULT_IFRAME_HOSTS } from './headers.js';
20
21
  import { toMapEmbedUrl } from './maps.js';
21
22
  import { useClickOutside } from './react.js';
22
23
  import { cx } from './html.js';
@@ -24,6 +25,23 @@ import { slugifyHeading, uniqueHeadingId } from './headings.js';
24
25
  import { CODE_LANGS, DEFAULT_LANG } from './blocks.js';
25
26
  /** Local `cn`. Both wikis import one; the package will not depend on one. */
26
27
  // ---- iframe -----------------------------------------------------------------
28
+ /**
29
+ * An embed's address, if it is https on a host the sanitiser and the CSP
30
+ * admit; otherwise null, and the node draws an empty frame. The editor draws
31
+ * stored HTML on the origin where its author is signed in, and a
32
+ * `javascript:` src runs there under any CSP with 'unsafe-inline' script.
33
+ */
34
+ export function embedSrc(src) {
35
+ if (typeof src !== 'string')
36
+ return null;
37
+ try {
38
+ const u = new URL(src);
39
+ return u.protocol === 'https:' && DEFAULT_IFRAME_HOSTS.includes(u.hostname) ? u.href : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
27
45
  export const Iframe = TiptapNode.create({
28
46
  name: 'iframe',
29
47
  group: 'block',
@@ -38,7 +56,7 @@ export const Iframe = TiptapNode.create({
38
56
  return [
39
57
  'div',
40
58
  { 'data-iframe-embed': '', class: 'iframe-embed' },
41
- ['iframe', mergeAttributes(HTMLAttributes, { frameborder: '0', allowfullscreen: 'true' })],
59
+ ['iframe', mergeAttributes({ ...HTMLAttributes, src: embedSrc(HTMLAttributes.src) }, { frameborder: '0', allowfullscreen: 'true' })],
42
60
  ];
43
61
  },
44
62
  });
@@ -159,7 +177,7 @@ export function createMapEmbed({ resolveMapUrl }) {
159
177
  [
160
178
  'iframe',
161
179
  {
162
- src: node.attrs.src,
180
+ src: embedSrc(node.attrs.src),
163
181
  frameborder: '0',
164
182
  allowfullscreen: 'true',
165
183
  loading: 'lazy',
package/dist/upload.js CHANGED
@@ -84,7 +84,11 @@ export function uploadHandler({ authorize, maxBytes = 4 * 1024 * 1024, maxPixels
84
84
  const who = await authorize(request);
85
85
  if (who instanceof Response)
86
86
  return who;
87
- const limit = limitClient(`upload:${who}`, request.headers, rateLimit);
87
+ // The address gets a bucket of its own: an id keyed with it is free to mint
88
+ // where sign-in is a self-signed wallet proof, so a fresh one per burst would
89
+ // otherwise refill the budget.
90
+ const limit = [limitClient(`upload:${who}`, request.headers, rateLimit), limitClient('upload-ip', request.headers, rateLimit)]
91
+ .find(l => !l.ok) ?? { ok: true };
88
92
  if (!limit.ok) {
89
93
  return errors.tooManyRequests(limit.retryAfterSec, `Too many uploads. Try again in ${limit.retryAfterSec}s.`);
90
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.52.1",
3
+ "version": "0.56.0",
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",
@@ -52,6 +52,11 @@
52
52
  "import": "./dist/pagination.js",
53
53
  "default": "./dist/pagination.js"
54
54
  },
55
+ "./sitemap": {
56
+ "types": "./dist/sitemap.d.ts",
57
+ "import": "./dist/sitemap.js",
58
+ "default": "./dist/sitemap.js"
59
+ },
55
60
  "./versioning": {
56
61
  "types": "./dist/versioning.d.ts",
57
62
  "import": "./dist/versioning.js",
@@ -12,10 +12,14 @@ model Passkey {
12
12
  counter Int
13
13
  transports String[]
14
14
  createdAt DateTime @default(now()) @db.Timestamptz(3)
15
+ sessions PasskeyToken[]
15
16
  }
16
17
 
17
18
  model PasskeyToken {
18
19
  hash String @id // sha-256 of the token, base64url
19
- kind String // session | invite | challenge
20
+ kind String // session | invite | registration | authentication
20
21
  expiresAt DateTime @db.Timestamptz(3)
22
+ // The passkey a session signed in with: deleting the passkey ends its sessions.
23
+ passkeyId String?
24
+ passkey Passkey? @relation(fields: [passkeyId], references: [id], onDelete: Cascade)
21
25
  }