wiki-formant 0.52.1 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -22
- package/bin/maintenance-log.mjs +13 -2
- package/dist/analytics.d.ts +14 -3
- package/dist/analytics.js +22 -10
- package/dist/block-views.d.ts +5 -2
- package/dist/block-views.js +6 -3
- package/dist/crawlers.d.ts +7 -3
- package/dist/crawlers.js +23 -7
- package/dist/http.d.ts +1 -1
- 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-figures.js +2 -7
- package/dist/stats.d.ts +6 -6
- package/dist/stats.js +10 -14
- 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
|
@@ -392,7 +392,7 @@ different lists.
|
|
|
392
392
|
```ts
|
|
393
393
|
import { trackAiBot, crawlerRules } from 'wiki-formant/crawlers';
|
|
394
394
|
|
|
395
|
-
trackAiBot(request, event, () => import('@/lib/track')); // in proxy.ts:
|
|
395
|
+
trackAiBot(request, event, () => import('@/lib/track')); // in proxy.ts: whether it counted
|
|
396
396
|
const rules = crawlerRules({ allow: '/', disallow, aiAllow });
|
|
397
397
|
```
|
|
398
398
|
|
|
@@ -408,11 +408,11 @@ Every agent group also allows the agent surface — `/api/mcp`, the three `llms`
|
|
|
408
408
|
|
|
409
409
|
`crawlerRules` returns every group robots.txt needs: the wildcard, one per AI crawler, and one for each search engine. Search engines get the HTML site and none of its twins — `/*.md$` always, plus `searchDisallow` — because the `*` group keeps the machine surface open to agents, and for Google a twin is a page it already has. Two origins wrote that group by hand and the third never did, so Google crawled its twins. The `<Link>` prefetch payload (`?_rsc=`) is refused in every group.
|
|
410
410
|
|
|
411
|
-
`trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
|
|
411
|
+
`trackAiBot(request, event, load)` counts an `AI Bot Visit` inside `event.waitUntil`: a crawler on the roster wherever it goes, and anything but a search engine that asks for what only agents read (a `.md` twin or `Accept: text/markdown`, the `llms` exports, `/openapi.json`, `/.well-known/`), since most agents are on no roster. Its user agent is kept, so `digest` can name it. `load` is a dynamic import because the tracker pulls in a database client, which a static import would load for every request the proxy sees.
|
|
412
412
|
|
|
413
413
|
## IndexNow
|
|
414
414
|
|
|
415
|
-
Bing, Yandex, Seznam, Naver and Yep take a POST of changed URLs instead of waiting to recrawl. The `indexnow` bin reads an origin's live sitemap and posts the rows whose `lastModified` falls inside the window, so the editor, the MCP write tools and the sweep scripts that write straight to the database are all covered with no hook in any of them. It runs where the sweeps run, never as a cron on the host: a submission costs the origin one
|
|
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({
|
|
@@ -778,10 +792,13 @@ and may be a batch; `trackSearch` normalises a search box's free text so
|
|
|
778
792
|
the same question aggregates as one row, and drops an empty one. Pass the request's headers and the
|
|
779
793
|
event joins whoever sent it, person or agent.
|
|
780
794
|
|
|
781
|
-
`digest(sql, { days })` reads a site back as one JSON object: visitors,
|
|
782
|
-
views, bounce rate, visit length, sources (a link's `utm_source`, else the
|
|
783
|
-
referring host), top and entry pages, countries, devices, visitors
|
|
784
|
-
tool calls, and the searches that found nothing.
|
|
795
|
+
`digest(sql, { days })` reads a site back as one JSON object: visitors, agents,
|
|
796
|
+
page views, bounce rate, visit length, sources (a link's `utm_source`, else the
|
|
797
|
+
referring host), top and entry pages, countries, devices, visitors and agents by
|
|
798
|
+
day, the agents by name, MCP tool calls, and the searches that found nothing.
|
|
799
|
+
An agent is counted as a visitor is, one address and user agent a day, from its
|
|
800
|
+
`AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
|
|
801
|
+
among the visitors. `ownAgents` leaves the site's own out. A visit is a run of one
|
|
785
802
|
visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
|
|
786
803
|
key to every call and a `site` resolver to `collect`.
|
|
787
804
|
|
|
@@ -794,16 +811,17 @@ its readers asked for. The queries that return zero rows are the valuable ones
|
|
|
794
811
|
|
|
795
812
|
`wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
|
|
796
813
|
nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
|
|
797
|
-
returns the whole of it. `gate.route` is the sign-in route,
|
|
798
|
-
`gate.signedIn(cookieValue)` is the check a page makes,
|
|
799
|
-
|
|
814
|
+
returns the whole of it. `gate.route` is the sign-in route, `gate.signOut` the
|
|
815
|
+
sign-out route, `gate.signedIn(cookieValue)` is the check a page makes, and
|
|
816
|
+
`gate.cookie` is the cookie's name. The two tables (`Passkey`, `PasskeyToken`) ship as Prisma
|
|
800
817
|
models in `prisma/passkey.prisma`, and every token is kept only as its hash.
|
|
801
818
|
|
|
802
|
-
There is no sign-up.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
819
|
+
There is no sign-up. Every passkey, the first among them, is saved from a
|
|
820
|
+
single-use link that the `passkey-invite` bin prints from a machine holding
|
|
821
|
+
`DATABASE_URL`, so a fresh deployment cannot be claimed by whoever reaches it
|
|
822
|
+
first. A passkey is bound to the host it was saved on, so localhost needs its
|
|
823
|
+
own. A session names the passkey that signed in: deleting a `Passkey` row signs
|
|
824
|
+
out every session it opened.
|
|
807
825
|
|
|
808
826
|
```ts
|
|
809
827
|
// src/lib/admin.ts
|
|
@@ -811,6 +829,7 @@ export const gate = createPasskeyGate({ sql, rpName: 'Caper' });
|
|
|
811
829
|
|
|
812
830
|
// src/app/api/passkey/route.ts
|
|
813
831
|
export const POST = gate.route;
|
|
832
|
+
export const DELETE = gate.signOut;
|
|
814
833
|
|
|
815
834
|
// src/app/stats/page.tsx
|
|
816
835
|
const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30) and the filters
|
|
@@ -821,7 +840,7 @@ return (
|
|
|
821
840
|
);
|
|
822
841
|
```
|
|
823
842
|
|
|
824
|
-
`StatsGate` (`wiki-formant/stats`) renders `children` for a signed-in reader and, for anyone else, the line saying what the prompt will do and the `PasskeyButton` that does it; `classNames` places the text, the button's box, the button and its error.
|
|
843
|
+
`StatsGate` (`wiki-formant/stats`) renders `children` and a sign-out button (`PasskeySignOut`) for a signed-in reader and, for anyone else, the line saying what the prompt will do and the `PasskeyButton` that does it; `classNames` places the text, the button's box, the button and its error.
|
|
825
844
|
|
|
826
845
|
`PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
|
|
827
846
|
own subpath, because it imports `@simplewebauthn/browser`.
|
|
@@ -831,8 +850,8 @@ headline figures, each with its change from the window before
|
|
|
831
850
|
site's stylesheet to colour), one of them per day, every non-empty
|
|
832
851
|
ranked list, and `?days=` links between windows, all time among them, pinned in
|
|
833
852
|
the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
|
|
834
|
-
figure is a button that charts it: visitors, page views, bounce rate,
|
|
835
|
-
length
|
|
853
|
+
figure is a button that charts it: visitors, agents, page views, bounce rate,
|
|
854
|
+
visit length and clicks to X. `digest`'s `by_day` carries every
|
|
836
855
|
one of those per UTC day, a visit on the day it began, so the days add up to the
|
|
837
856
|
totals. A day with no visits has no bounce rate or visit length and is left out
|
|
838
857
|
of those charts rather than drawn as zero. Everything but the figures and the
|
|
@@ -983,7 +1002,7 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
|
|
|
983
1002
|
npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
|
|
984
1003
|
```
|
|
985
1004
|
|
|
986
|
-
`passkey-invite` prints a single-use link that saves
|
|
1005
|
+
`passkey-invite` prints a single-use link that saves a passkey for the page it names, good for a day (`--days n` for longer). No routine calls it: the site's owner runs it by hand, from an app's checkout, for the first passkey and whenever another device or person needs into the stats page. Run it the same way:
|
|
987
1006
|
|
|
988
1007
|
```sh
|
|
989
1008
|
npx passkey-invite https://caper.network/stats
|
|
@@ -1008,7 +1027,7 @@ npx prisma-models analytics --check --in prisma/schema.prisma --tenant # miow
|
|
|
1008
1027
|
npx fts-ddl pages
|
|
1009
1028
|
```
|
|
1010
1029
|
|
|
1011
|
-
`mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page; a revision is written at most once a day. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
|
|
1030
|
+
`mark-verified` stamps `last_verified_at` on pages a sweep checked and found current, so `freshnessBanner` stops calling them outdated, and `maintenance-log` is the state I/O of a wiki-sweep routine whose memory lives in one page's `metadata.state`: `read`, `write` (the whole state, or `-` for stdin), `compact` and `backlog-add`, which appends under a row lock so another routine's item cannot be lost to a concurrent `write`. Every write compacts and re-renders the page, holding at most as many FLAG FOR A HUMAN items as the page shows; a revision is written at most once a day. `write` refuses a state whose `totalRuns` is below the stored one, which is a state read before another run wrote. Two wikis carried both scripts against their own tables, so the tables are arguments. Run them from the app's root, like `passkey-invite`:
|
|
1012
1031
|
|
|
1013
1032
|
```sh
|
|
1014
1033
|
npx mark-verified contents/tech/radix-engine scrypto # one page
|
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/analytics.d.ts
CHANGED
|
@@ -67,8 +67,9 @@ export interface DigestOptions {
|
|
|
67
67
|
siblings?: string[];
|
|
68
68
|
gaps?: Gap[];
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
70
|
+
* The site's own agents, left out of `agents`, `top_agents` and
|
|
71
|
+
* `agent_tool_calls`, matched inside the user agent. The default is the
|
|
72
|
+
* conformance suites (`<repo>-mcp-test`) and the studio's own tools.
|
|
72
73
|
*/
|
|
73
74
|
ownAgents?: string[];
|
|
74
75
|
/**
|
|
@@ -89,6 +90,7 @@ export interface DigestDay {
|
|
|
89
90
|
bounces: number;
|
|
90
91
|
/** Seconds from each visit's first view to its last, summed. */
|
|
91
92
|
seconds: number;
|
|
93
|
+
agents: number;
|
|
92
94
|
visitors_incl_agents: number;
|
|
93
95
|
/** Clicks on links to the site's X handle; zero without a handle. */
|
|
94
96
|
follow_clicks: number;
|
|
@@ -100,6 +102,13 @@ export interface Digest {
|
|
|
100
102
|
bounce_rate: number | null;
|
|
101
103
|
/** Mean seconds from a visit's first view to its last; null with no visits. */
|
|
102
104
|
visit_duration: number | null;
|
|
105
|
+
/**
|
|
106
|
+
* Agents, counted as visitors are: an AI crawler, an MCP client, or anything
|
|
107
|
+
* else reading the machine surface (`trackAiBot`), one per address and user
|
|
108
|
+
* agent a day, the site's own left out. They run no page script, so they are
|
|
109
|
+
* never among `visitors`.
|
|
110
|
+
*/
|
|
111
|
+
agents: number;
|
|
103
112
|
/** Visitors plus every agent that sent an event, such as an MCP call. */
|
|
104
113
|
visitors_incl_agents: number;
|
|
105
114
|
/**
|
|
@@ -108,7 +117,7 @@ export interface Digest {
|
|
|
108
117
|
* the archive cannot stand in for it: event figures always, all of them
|
|
109
118
|
* when filtered.
|
|
110
119
|
*/
|
|
111
|
-
previous: Record<'visitors' | 'pageviews' | 'bounce_rate' | 'visit_duration' | 'visitors_incl_agents' | 'follow_clicks', number | null>;
|
|
120
|
+
previous: Record<'visitors' | 'pageviews' | 'bounce_rate' | 'visit_duration' | 'agents' | 'visitors_incl_agents' | 'follow_clicks', number | null>;
|
|
112
121
|
/** The first UTC day this site counted itself, before which only `ViewDay` speaks; null with no views. */
|
|
113
122
|
counted_from: string | null;
|
|
114
123
|
top_sources: Ranked<'source', 'visitors'>;
|
|
@@ -125,6 +134,8 @@ export interface Digest {
|
|
|
125
134
|
events: number;
|
|
126
135
|
visitors: number;
|
|
127
136
|
} | null;
|
|
137
|
+
/** Each agent by name: the roster's label, else what its user agent calls itself. */
|
|
138
|
+
top_agents: Ranked<'agent', 'visitors'>;
|
|
128
139
|
agent_tool_calls: Ranked<'tool', 'events'>;
|
|
129
140
|
gaps: Ranked<'query', 'events'>;
|
|
130
141
|
}
|
package/dist/analytics.js
CHANGED
|
@@ -160,6 +160,11 @@ const VISIT_TEST = {
|
|
|
160
160
|
country: v => `bool_or(country = ${v})`,
|
|
161
161
|
device: v => `bool_or(device = ${v})`,
|
|
162
162
|
};
|
|
163
|
+
/**
|
|
164
|
+
* An agent's name from its event: the roster's label, else what its user agent
|
|
165
|
+
* calls itself, `compatible; X` or the first product token.
|
|
166
|
+
*/
|
|
167
|
+
const AGENT_NAME = `coalesce(props->>'bot', substring(props->>'ua' from 'compatible; ?([^/;) ]+)'), nullif(split_part(split_part(props->>'ua', '/', 1), ' ', 1), ''), '(unknown)')`;
|
|
163
168
|
/**
|
|
164
169
|
* Everything a report needs about one site's last `days` days, in one query.
|
|
165
170
|
* Visitor figures count distinct visitors, so a person who comes back on
|
|
@@ -181,6 +186,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
181
186
|
const follows = handle && `name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[])`;
|
|
182
187
|
const tests = STATS_FILTERS.flatMap(k => (filter[k] ? [VISIT_TEST[k]($(filter[k]))] : []));
|
|
183
188
|
const day = (at) => `to_char(${at} AT TIME ZONE 'UTC', 'YYYY-MM-DD')`;
|
|
189
|
+
/** The site's own agents, as a test on an event's user agent. */
|
|
190
|
+
const ours = `coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])`;
|
|
184
191
|
const gapSelects = gaps.map(g => {
|
|
185
192
|
const prop = $(g.prop);
|
|
186
193
|
return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
|
|
@@ -210,6 +217,9 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
210
217
|
), e${s} AS (
|
|
211
218
|
SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
|
|
212
219
|
${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
|
|
220
|
+
), ag${s} AS (
|
|
221
|
+
SELECT visitor, at, ${AGENT_NAME} AS agent FROM e${s}
|
|
222
|
+
WHERE name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
|
|
213
223
|
), a${s} AS (
|
|
214
224
|
SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
|
|
215
225
|
), sessions${s} AS (
|
|
@@ -226,6 +236,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
226
236
|
'pageviews', (SELECT count(*) FROM v${s}) + (SELECT pageviews FROM archived${s}),
|
|
227
237
|
'bounce_rate', (SELECT round(100.0 * bounces / nullif(visits, 0)) FROM sessions${s}),
|
|
228
238
|
'visit_duration', (SELECT round(seconds / nullif(visits, 0)) FROM sessions${s}),
|
|
239
|
+
'agents', (SELECT count(DISTINCT visitor) FROM ag${s}),
|
|
229
240
|
'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v${s} UNION SELECT visitor FROM e${s}) u) + (SELECT visitors FROM archived${s})`;
|
|
230
241
|
const [row] = (await sql(`WITH ${chain('', 0)}, ${chain('_p', 1)}, src AS (
|
|
231
242
|
SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1
|
|
@@ -243,17 +254,18 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
243
254
|
'countries', ${list(merged('country', 'countries', 'SELECT country, count(DISTINCT visitor) FROM v WHERE country IS NOT NULL GROUP BY 1'), 10)},
|
|
244
255
|
'devices', ${list(merged('device', 'devices', 'SELECT device, count(DISTINCT visitor) FROM v GROUP BY 1'))},
|
|
245
256
|
'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
|
|
246
|
-
sum(seconds) AS seconds, sum(agents) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
|
|
247
|
-
SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0 FROM v GROUP BY 1
|
|
248
|
-
UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0 FROM visits GROUP BY 1
|
|
249
|
-
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0
|
|
250
|
-
|
|
251
|
-
UNION ALL SELECT
|
|
252
|
-
|
|
257
|
+
sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
|
|
258
|
+
SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
|
|
259
|
+
UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
|
|
260
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
|
|
261
|
+
UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM e) u GROUP BY 1
|
|
262
|
+
${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
|
|
263
|
+
UNION ALL SELECT to_char(day, 'YYYY-MM-DD'), visitors, pageviews, visits, bounces, seconds, 0, visitors, 0 FROM a
|
|
264
|
+
) t(date, visitors, pageviews, visits, bounces, seconds, agents, everyone, follows) GROUP BY 1`, undefined, '1')},
|
|
253
265
|
'follow_clicks', ${follows ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e WHERE ${follows})` : 'NULL'},
|
|
266
|
+
'top_agents', ${list('SELECT agent, count(DISTINCT visitor) AS visitors FROM ag GROUP BY 1', 10)},
|
|
254
267
|
'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
|
|
255
|
-
WHERE name = 'MCP Call' AND props->>'method' = 'tools/call'
|
|
256
|
-
AND NOT coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])
|
|
268
|
+
WHERE name = 'MCP Call' AND props->>'method' = 'tools/call' AND NOT ${ours}
|
|
257
269
|
GROUP BY 1`, 10)},
|
|
258
270
|
'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
|
|
259
271
|
) AS digest`, ...values));
|
|
@@ -267,7 +279,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
|
|
|
267
279
|
for (const k of Object.keys(p))
|
|
268
280
|
p[k] = null;
|
|
269
281
|
else
|
|
270
|
-
p.visitors_incl_agents = p.follow_clicks = null;
|
|
282
|
+
p.agents = p.visitors_incl_agents = p.follow_clicks = null;
|
|
271
283
|
}
|
|
272
284
|
return d;
|
|
273
285
|
}
|
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/crawlers.d.ts
CHANGED
|
@@ -46,16 +46,20 @@ export declare function crawlerRules(opts: {
|
|
|
46
46
|
searchDisallow?: string | string[];
|
|
47
47
|
}): RobotsGroup[];
|
|
48
48
|
/**
|
|
49
|
-
* Count an
|
|
49
|
+
* Count an agent's visit from a proxy, without holding up its response: a
|
|
50
|
+
* crawler on the roster wherever it goes, and anything but a search engine
|
|
51
|
+
* that asks for what only agents read, since most agents are on no roster.
|
|
52
|
+
* The user agent is kept beside the label, so `digest` can name an agent the
|
|
53
|
+
* roster does not know and leave out the site's own.
|
|
50
54
|
*
|
|
51
55
|
* `load` is a dynamic import of the module that records it, because that
|
|
52
56
|
* module pulls in a database client, and a static import would load it for
|
|
53
57
|
* every request the proxy sees, bots or not. Three proxies wrote these lines.
|
|
54
|
-
* Returns the
|
|
58
|
+
* Returns whether the request was counted.
|
|
55
59
|
*/
|
|
56
60
|
export declare function trackAiBot(request: {
|
|
57
61
|
url: string;
|
|
58
62
|
headers: HeaderReader;
|
|
59
63
|
}, event: {
|
|
60
64
|
waitUntil(promise: Promise<unknown>): void;
|
|
61
|
-
}, load: () => Promise<Pick<Tracker, 'trackEvent'>>):
|
|
65
|
+
}, load: () => Promise<Pick<Tracker, 'trackEvent'>>): boolean;
|
package/dist/crawlers.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// crawlers.ts — one roster of AI crawler tokens, for both surfaces that need it.
|
|
2
2
|
//
|
|
3
3
|
// The proxy counts an "AI Bot Visit" by user-agent substring, and `robots.ts`
|
|
4
|
-
// gives each agent its own group. Both read this one roster.
|
|
4
|
+
// gives each agent its own group. Both read this one roster. The proxy also
|
|
5
|
+
// counts any agent reading the machine surface, roster or not.
|
|
5
6
|
//
|
|
6
7
|
// One difference between the two uses is deliberate. `Applebot-Extended`
|
|
7
8
|
// never fetches a page: it is a robots.txt-only token that Applebot consults
|
|
@@ -11,6 +12,7 @@
|
|
|
11
12
|
// Every other token appears in both, because a crawler obeys only its
|
|
12
13
|
// most-specific matching group: an agent with no group of its own falls through
|
|
13
14
|
// to `*` and is granted whatever that grants.
|
|
15
|
+
import { wantsMarkdown } from './http.js';
|
|
14
16
|
/**
|
|
15
17
|
* Order is significant: `detectAiBot` returns the first token the user agent
|
|
16
18
|
* contains, so a token that is a substring of another must come after it.
|
|
@@ -113,16 +115,30 @@ export function crawlerRules(opts) {
|
|
|
113
115
|
];
|
|
114
116
|
}
|
|
115
117
|
/**
|
|
116
|
-
*
|
|
118
|
+
* What only an agent asks for: a markdown twin, an `llms` export, the OpenAPI
|
|
119
|
+
* document, a well-known descriptor. MCP is left out, since "MCP Call" counts it.
|
|
120
|
+
*/
|
|
121
|
+
const AGENT_ONLY = /\.md$|^\/llms[\w-]*\.txt$|^\/openapi\.json$|^\/\.well-known\//;
|
|
122
|
+
/**
|
|
123
|
+
* Count an agent's visit from a proxy, without holding up its response: a
|
|
124
|
+
* crawler on the roster wherever it goes, and anything but a search engine
|
|
125
|
+
* that asks for what only agents read, since most agents are on no roster.
|
|
126
|
+
* The user agent is kept beside the label, so `digest` can name an agent the
|
|
127
|
+
* roster does not know and leave out the site's own.
|
|
117
128
|
*
|
|
118
129
|
* `load` is a dynamic import of the module that records it, because that
|
|
119
130
|
* module pulls in a database client, and a static import would load it for
|
|
120
131
|
* every request the proxy sees, bots or not. Three proxies wrote these lines.
|
|
121
|
-
* Returns the
|
|
132
|
+
* Returns whether the request was counted.
|
|
122
133
|
*/
|
|
123
134
|
export function trackAiBot(request, event, load) {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
const ua = request.headers.get('user-agent') ?? '';
|
|
136
|
+
const bot = detectAiBot(ua);
|
|
137
|
+
const agent = bot !== null ||
|
|
138
|
+
(!!ua && !SEARCH_ENGINES.some(s => ua.includes(s)) && (AGENT_ONLY.test(new URL(request.url).pathname) || wantsMarkdown(request)));
|
|
139
|
+
if (agent) {
|
|
140
|
+
const props = { ...(bot ? { bot } : {}), ua: ua.slice(0, 80) };
|
|
141
|
+
event.waitUntil(load().then(m => m.trackEvent('AI Bot Visit', request.url, props, request.headers)));
|
|
142
|
+
}
|
|
143
|
+
return agent;
|
|
128
144
|
}
|
package/dist/http.d.ts
CHANGED
|
@@ -74,7 +74,7 @@ export declare function markdownHeaders(lastModified?: string | null, opts?: {
|
|
|
74
74
|
export declare function wantsMarkdown(request: {
|
|
75
75
|
url: string;
|
|
76
76
|
headers: {
|
|
77
|
-
get(name: string): string | null;
|
|
77
|
+
get(name: string): string | null | undefined;
|
|
78
78
|
};
|
|
79
79
|
}): boolean;
|
|
80
80
|
/** The header every response from a content-negotiated URL carries. */
|
package/dist/indexnow.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// The `indexnow` bin reads an origin's live sitemap, keeps the rows whose
|
|
5
5
|
// `lastModified` falls inside the window, and posts them. It runs on the machine
|
|
6
6
|
// that runs the sweeps, not as a cron on the host: a submission costs the origin
|
|
7
|
-
// one
|
|
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-figures.js
CHANGED
|
@@ -24,6 +24,7 @@ function change(now, before, { points = false, lowerIsBetter = false } = {}) {
|
|
|
24
24
|
}
|
|
25
25
|
const figuresOf = ({ previous: p, ...d }) => [
|
|
26
26
|
{ label: 'Visitors', value: count(d.visitors), delta: change(d.visitors, p.visitors), day: r => r.visitors },
|
|
27
|
+
{ label: 'Agents', value: count(d.agents), delta: change(d.agents, p.agents), day: r => r.agents },
|
|
27
28
|
{ label: 'Page views', value: count(d.pageviews), delta: change(d.pageviews, p.pageviews), day: r => r.pageviews },
|
|
28
29
|
{
|
|
29
30
|
label: 'Bounce rate',
|
|
@@ -39,12 +40,6 @@ const figuresOf = ({ previous: p, ...d }) => [
|
|
|
39
40
|
day: r => (r.visits ? r.seconds / r.visits : null),
|
|
40
41
|
format: duration,
|
|
41
42
|
},
|
|
42
|
-
{
|
|
43
|
-
label: 'With agents',
|
|
44
|
-
value: count(d.visitors_incl_agents),
|
|
45
|
-
delta: change(d.visitors_incl_agents, p.visitors_incl_agents),
|
|
46
|
-
day: r => r.visitors_incl_agents,
|
|
47
|
-
},
|
|
48
43
|
...(d.follow_clicks
|
|
49
44
|
? [
|
|
50
45
|
{
|
|
@@ -57,7 +52,7 @@ const figuresOf = ({ previous: p, ...d }) => [
|
|
|
57
52
|
: []),
|
|
58
53
|
];
|
|
59
54
|
const DAY = 86_400;
|
|
60
|
-
const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, visitors_incl_agents: 0, follow_clicks: 0 };
|
|
55
|
+
const EMPTY = { date: '', visitors: 0, pageviews: 0, visits: 0, bounces: 0, seconds: 0, agents: 0, visitors_incl_agents: 0, follow_clicks: 0 };
|
|
61
56
|
/**
|
|
62
57
|
* The figures as `.stats-figures button[aria-pressed]`, each with its change
|
|
63
58
|
* as `.stats-delta[data-better]` for a stylesheet to colour, and the pressed one per
|
package/dist/stats.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface StatsGateProps {
|
|
|
36
36
|
invite?: string | undefined;
|
|
37
37
|
/** The site's host, named in the prompt. */
|
|
38
38
|
host: string;
|
|
39
|
-
/** The route that serves `gate.route
|
|
39
|
+
/** The route that serves `gate.route` as POST and `gate.signOut` as DELETE. */
|
|
40
40
|
endpoint?: string;
|
|
41
41
|
/** What a signed-in reader sees, rendered only for one. */
|
|
42
42
|
children: () => ReactNode | Promise<ReactNode>;
|
|
@@ -48,9 +48,9 @@ export interface StatsGateProps {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* A stats page's door: `children`
|
|
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];
|
|
@@ -73,21 +73,17 @@ export function Stats({ digest: d, days, filter = {}, ranges = STATS_RANGES }) {
|
|
|
73
73
|
// Each window is another digest, so a link rather than a button, pinned
|
|
74
74
|
// with the chart's own controls.
|
|
75
75
|
const windows = (_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: href(n, filter), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }));
|
|
76
|
-
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: href(days, { ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, windows: windows }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => href(days, { ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
76
|
+
return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: href(days, { ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, windows: windows }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => href(days, { ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
|
-
* A stats page's door: `children`
|
|
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
|
}
|