wiki-formant 0.53.0 → 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
@@ -412,7 +412,7 @@ Every agent group also allows the agent surface — `/api/mcp`, the three `llms`
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({
@@ -797,16 +811,17 @@ its readers asked for. The queries that return zero rows are the valuable ones
797
811
 
798
812
  `wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
799
813
  nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
800
- returns the whole of it. `gate.route` is the sign-in route,
801
- `gate.signedIn(cookieValue)` is the check a page makes, `gate.open()` is true
802
- 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
803
817
  models in `prisma/passkey.prisma`, and every token is kept only as its hash.
804
818
 
805
- There is no sign-up. While no passkey is saved, the page saves the first one
806
- from whoever opens it, so the owner should do that as soon as the door ships.
807
- Every passkey after that is saved from a single-use link that the
808
- `passkey-invite` bin prints from a machine holding `DATABASE_URL`. A passkey is
809
- 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.
810
825
 
811
826
  ```ts
812
827
  // src/lib/admin.ts
@@ -814,6 +829,7 @@ export const gate = createPasskeyGate({ sql, rpName: 'Caper' });
814
829
 
815
830
  // src/app/api/passkey/route.ts
816
831
  export const POST = gate.route;
832
+ export const DELETE = gate.signOut;
817
833
 
818
834
  // src/app/stats/page.tsx
819
835
  const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
@@ -824,7 +840,7 @@ return (
824
840
  );
825
841
  ```
826
842
 
827
- `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.
828
844
 
829
845
  `PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
830
846
  own subpath, because it imports `@simplewebauthn/browser`.
@@ -986,7 +1002,7 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
986
1002
  npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
987
1003
  ```
988
1004
 
989
- `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:
990
1006
 
991
1007
  ```sh
992
1008
  npx passkey-invite https://caper.network/stats
@@ -1011,7 +1027,7 @@ npx prisma-models analytics --check --in prisma/schema.prisma --tenant # miow
1011
1027
  npx fts-ddl pages
1012
1028
  ```
1013
1029
 
1014
- `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`:
1015
1031
 
1016
1032
  ```sh
1017
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'}`,
@@ -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
  }
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
+ }
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];
@@ -76,18 +76,14 @@ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
76
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.53.0",
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
  }