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 +30 -14
- package/bin/maintenance-log.mjs +13 -2
- package/dist/block-views.d.ts +5 -2
- package/dist/block-views.js +6 -3
- package/dist/indexnow.js +3 -1
- package/dist/maintenance.d.ts +7 -0
- package/dist/maintenance.js +21 -5
- package/dist/passkey-button.d.ts +6 -3
- package/dist/passkey-button.js +13 -6
- package/dist/passkey.d.ts +4 -4
- package/dist/passkey.js +38 -27
- package/dist/rola.js +6 -0
- package/dist/sitemap.d.ts +31 -0
- package/dist/sitemap.js +50 -0
- package/dist/stats.d.ts +6 -6
- package/dist/stats.js +9 -13
- package/dist/tiptap.d.ts +7 -0
- package/dist/tiptap.js +20 -2
- package/dist/upload.js +5 -1
- package/package.json +6 -1
- package/prisma/passkey.prisma +5 -1
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
|
|
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
|
|
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,
|
|
802
|
-
|
|
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.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
|
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
|
package/bin/maintenance-log.mjs
CHANGED
|
@@ -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. `
|
|
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'}`,
|
package/dist/block-views.d.ts
CHANGED
|
@@ -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;
|
package/dist/block-views.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
package/dist/maintenance.d.ts
CHANGED
|
@@ -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. */
|
package/dist/maintenance.js
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/dist/passkey-button.d.ts
CHANGED
|
@@ -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,
|
|
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;
|
package/dist/passkey-button.js
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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,
|
|
19
|
-
const saving = !!invite
|
|
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
|
|
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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
18
|
-
// a passkey is saved with it.
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
|
|
48
|
-
const
|
|
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 &&
|
|
58
|
-
|
|
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
|
|
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
|
|
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,
|
|
98
|
+
await issue(sql, ceremony, CHALLENGE_MS, options.challenge);
|
|
85
99
|
return Response.json(options);
|
|
86
100
|
}
|
|
87
|
-
if (invite
|
|
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 (
|
|
112
|
+
if (!(await spend(sql, 'invite', invite)))
|
|
99
113
|
return refuse(403, 'This invite has expired.');
|
|
100
114
|
const { credential } = result.registrationInfo;
|
|
101
|
-
|
|
102
|
-
|
|
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[];
|
package/dist/sitemap.js
ADDED
|
@@ -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`
|
|
52
|
-
* what the passkey prompt will do and the button
|
|
53
|
-
* it
|
|
54
|
-
*
|
|
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<
|
|
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`
|
|
80
|
-
* what the passkey prompt will do and the button
|
|
81
|
-
* it
|
|
82
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/prisma/passkey.prisma
CHANGED
|
@@ -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 |
|
|
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
|
}
|