wiki-formant 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -1
- package/bin/passkey-invite.mjs +43 -0
- package/dist/base.css +54 -0
- package/dist/passkey-button.d.ts +9 -0
- package/dist/passkey-button.js +36 -0
- package/dist/passkey.d.ts +27 -0
- package/dist/passkey.js +130 -0
- package/dist/react-server.d.ts +20 -0
- package/dist/react-server.js +55 -0
- package/package.json +22 -3
package/README.md
CHANGED
|
@@ -632,9 +632,44 @@ leaves a wiki able to say what every crawler asked for and nothing about what
|
|
|
632
632
|
its readers asked for. The queries that return zero rows are the valuable ones
|
|
633
633
|
– they name a gap in the corpus in the reader's own words.
|
|
634
634
|
|
|
635
|
+
## Passkeys and the stats page
|
|
636
|
+
|
|
637
|
+
`wiki-formant/passkey` is the door to a site's own admin pages: a passkey and
|
|
638
|
+
nothing else, no account, password or email. `createPasskeyGate({ sql, rpName })`
|
|
639
|
+
returns the whole of it. `gate.route` is the sign-in route,
|
|
640
|
+
`gate.signedIn(cookieValue)` is the check a page makes, and `gate.cookie` is the
|
|
641
|
+
cookie's name. The two tables (`Passkey`, `PasskeyToken`) are stated as Prisma
|
|
642
|
+
models at the top of `src/passkey.ts`, and every token is kept only as its hash.
|
|
643
|
+
`@simplewebauthn/server` is an optional peer.
|
|
644
|
+
|
|
645
|
+
There is no sign-up. The first passkey is saved from a single-use link that the
|
|
646
|
+
`passkey-invite` bin prints from a machine holding `DATABASE_URL`, so the only
|
|
647
|
+
way in is through someone who can already reach the database. A passkey is bound
|
|
648
|
+
to the host it was saved on, so localhost needs its own.
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
// src/lib/admin.ts
|
|
652
|
+
export const gate = createPasskeyGate({ sql, rpName: 'Caper' });
|
|
653
|
+
|
|
654
|
+
// src/app/api/passkey/route.ts
|
|
655
|
+
export const POST = gate.route;
|
|
656
|
+
|
|
657
|
+
// src/app/stats/page.tsx
|
|
658
|
+
if (!(await gate.signedIn((await cookies()).get(gate.cookie)?.value)))
|
|
659
|
+
return <PasskeyButton endpoint="/api/passkey" invite={invite} />;
|
|
660
|
+
const days = statsDays((await searchParams).days); // 1, 7, 30 or 90, else 30
|
|
661
|
+
return <Stats digest={await digest(sql, { days })} days={days} />;
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
`PasskeyButton` (`wiki-formant/passkey-button`) is the browser half and has its
|
|
665
|
+
own subpath, because it imports the optional peer `@simplewebauthn/browser`.
|
|
666
|
+
`Stats` (`wiki-formant/react-server`) renders a `digest` as one page: the
|
|
667
|
+
headline figures against the window before, visitors per day, every non-empty
|
|
668
|
+
ranked list, and `?days=` links between windows. It ships no JavaScript.
|
|
669
|
+
|
|
635
670
|
## Base stylesheet
|
|
636
671
|
|
|
637
|
-
`wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a stored table's scroll box,
|
|
672
|
+
`wiki-formant/base.css` is the layout the package's markup does not work without, and nothing else: columns that stack until there is room, stored tab panels that show one at a time, a copy button pinned to its block's corner and visible on focus and on touch, a stored table's scroll box, the `aria-sort` arrow as a mask over `currentColor`, and the stats page's grid and bars. No colour and no scale, so a design system's own rules override it at equal specificity.
|
|
638
673
|
|
|
639
674
|
```css
|
|
640
675
|
@import "wiki-formant/base.css" layer(components);
|
|
@@ -680,6 +715,12 @@ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq
|
|
|
680
715
|
npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
|
|
681
716
|
```
|
|
682
717
|
|
|
718
|
+
`passkey-invite` prints a single-use link that saves a passkey for the page it names, good for a day (`--days n` for longer). Run it the same way:
|
|
719
|
+
|
|
720
|
+
```sh
|
|
721
|
+
npx passkey-invite https://caper.network/stats
|
|
722
|
+
```
|
|
723
|
+
|
|
683
724
|
## Licence
|
|
684
725
|
|
|
685
726
|
MIT.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/passkey-invite.mjs – print a single-use link that saves a passkey for a
|
|
3
|
+
// site's admin pages (`wiki-formant/passkey`). The first passkey can only come
|
|
4
|
+
// from here, so the only way in is through someone who holds the database URL.
|
|
5
|
+
//
|
|
6
|
+
// Run from the app's root, where `pg` is installed and `.env` holds
|
|
7
|
+
// DATABASE_URL (read from the environment first). The link is good for a day.
|
|
8
|
+
//
|
|
9
|
+
// npx passkey-invite https://caper.network/stats
|
|
10
|
+
// npx passkey-invite http://localhost:3000/stats --days 7
|
|
11
|
+
import pg from 'pg';
|
|
12
|
+
import { createPasskeyGate } from '../dist/passkey.js';
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const page = args.find(a => /^https?:\/\//.test(a));
|
|
16
|
+
const days = Number(args[args.indexOf('--days') + 1] ?? 1);
|
|
17
|
+
|
|
18
|
+
if (!page) {
|
|
19
|
+
console.error('usage: passkey-invite <page url> [--days n]');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
if (!process.env.DATABASE_URL) {
|
|
23
|
+
try {
|
|
24
|
+
process.loadEnvFile('.env');
|
|
25
|
+
} catch {
|
|
26
|
+
// reported below
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!process.env.DATABASE_URL) {
|
|
30
|
+
console.error('no DATABASE_URL in the environment or ./.env');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
35
|
+
try {
|
|
36
|
+
await client.connect();
|
|
37
|
+
const sql = (query, ...values) => client.query(query, values).then(r => r.rows);
|
|
38
|
+
const url = new URL(page);
|
|
39
|
+
url.searchParams.set('invite', await createPasskeyGate({ sql, rpName: '' }).invite(args.includes('--days') ? days : 1));
|
|
40
|
+
console.log(url.href);
|
|
41
|
+
} finally {
|
|
42
|
+
await client.end().catch(() => {});
|
|
43
|
+
}
|
package/dist/base.css
CHANGED
|
@@ -116,3 +116,57 @@ pre:hover > .code-copy-btn,
|
|
|
116
116
|
list-style: none;
|
|
117
117
|
}
|
|
118
118
|
.revision-diff { margin: 0.25em 0 0; }
|
|
119
|
+
|
|
120
|
+
/* ---- Stats ----------------------------------------------------------------
|
|
121
|
+
`--share` is a row's value over the largest in its list, 0 to 1. Bars take
|
|
122
|
+
`currentColor` so they show before a stylesheet colours them. */
|
|
123
|
+
.stats { display: grid; gap: 2rem; }
|
|
124
|
+
.stats-ranges { display: flex; flex-wrap: wrap; gap: 1rem; }
|
|
125
|
+
.stats-ranges a[aria-current] { text-decoration: underline; text-underline-offset: 0.3em; }
|
|
126
|
+
.stats-figures {
|
|
127
|
+
display: grid;
|
|
128
|
+
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
|
129
|
+
gap: 1rem;
|
|
130
|
+
margin: 0;
|
|
131
|
+
}
|
|
132
|
+
.stats-figures dd { margin: 0; font-variant-numeric: tabular-nums; }
|
|
133
|
+
.stats-chart {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: flex-end;
|
|
136
|
+
gap: 2px;
|
|
137
|
+
height: 8rem;
|
|
138
|
+
margin: 0;
|
|
139
|
+
padding: 0;
|
|
140
|
+
list-style: none;
|
|
141
|
+
}
|
|
142
|
+
.stats-chart li {
|
|
143
|
+
flex: 1;
|
|
144
|
+
height: calc(var(--share) * 100%);
|
|
145
|
+
min-height: 1px;
|
|
146
|
+
background: currentColor;
|
|
147
|
+
opacity: 0.4;
|
|
148
|
+
}
|
|
149
|
+
.stats-lists {
|
|
150
|
+
display: grid;
|
|
151
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
|
|
152
|
+
gap: 2rem;
|
|
153
|
+
}
|
|
154
|
+
.stats-list ol { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }
|
|
155
|
+
.stats-list li {
|
|
156
|
+
position: relative;
|
|
157
|
+
display: flex;
|
|
158
|
+
justify-content: space-between;
|
|
159
|
+
gap: 1rem;
|
|
160
|
+
padding: 0.25em 0.5em;
|
|
161
|
+
}
|
|
162
|
+
.stats-list li::before {
|
|
163
|
+
content: '';
|
|
164
|
+
position: absolute;
|
|
165
|
+
inset: 0 auto 0 0;
|
|
166
|
+
width: calc(var(--share) * 100%);
|
|
167
|
+
background: currentColor;
|
|
168
|
+
opacity: 0.08;
|
|
169
|
+
pointer-events: none;
|
|
170
|
+
}
|
|
171
|
+
.stats-list li > span:first-child { min-width: 0; overflow-wrap: anywhere; }
|
|
172
|
+
.stats-count { font-variant-numeric: tabular-nums; }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface PasskeyButtonProps {
|
|
2
|
+
/** The route that serves `gate.route`. */
|
|
3
|
+
endpoint: string;
|
|
4
|
+
/** The invite token from the link, when there is one. */
|
|
5
|
+
invite?: string | undefined;
|
|
6
|
+
className?: string;
|
|
7
|
+
errorClassName?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function PasskeyButton({ endpoint, invite, className, errorClassName }: PasskeyButtonProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// passkey-button.tsx – the browser half of `wiki-formant/passkey`.
|
|
4
|
+
//
|
|
5
|
+
// Its own subpath because it imports `@simplewebauthn/browser`, an optional
|
|
6
|
+
// peer that only an app with a passkey door installs. With an invite it saves a
|
|
7
|
+
// new passkey; without one it signs in with a saved one. Either way, success
|
|
8
|
+
// reloads the page without the invite, and the server sees the new cookie.
|
|
9
|
+
import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
async function post(endpoint, body) {
|
|
12
|
+
const res = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
13
|
+
if (!res.ok)
|
|
14
|
+
throw new Error((await res.json().catch(() => null))?.error ?? 'Sign-in failed.');
|
|
15
|
+
return res.status === 204 ? null : res.json();
|
|
16
|
+
}
|
|
17
|
+
export function PasskeyButton({ endpoint, invite, className, errorClassName }) {
|
|
18
|
+
const [busy, setBusy] = useState(false);
|
|
19
|
+
const [error, setError] = useState(null);
|
|
20
|
+
async function go() {
|
|
21
|
+
setBusy(true);
|
|
22
|
+
setError(null);
|
|
23
|
+
try {
|
|
24
|
+
const optionsJSON = await post(endpoint, { invite });
|
|
25
|
+
const response = invite ? await startRegistration({ optionsJSON }) : await startAuthentication({ optionsJSON });
|
|
26
|
+
await post(endpoint, { invite, response });
|
|
27
|
+
location.replace(location.pathname + location.search.replace(/([?&])invite=[^&]*&?/, '$1').replace(/[?&]$/, ''));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
// A prompt the person closed throws NotAllowedError, which needs no message.
|
|
31
|
+
setError(e instanceof Error && e.name !== 'NotAllowedError' ? e.message : null);
|
|
32
|
+
setBusy(false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: className, onClick: go, disabled: busy, children: invite ? 'Save a passkey' : 'Sign in with a passkey' }), error && (_jsx("p", { role: "alert", className: errorClassName, children: error }))] }));
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Sql } from './analytics.js';
|
|
2
|
+
export type { Sql };
|
|
3
|
+
export interface PasskeyGateOptions {
|
|
4
|
+
sql: Sql;
|
|
5
|
+
/** The name the browser shows when it offers to save the passkey. */
|
|
6
|
+
rpName: string;
|
|
7
|
+
/** Default `passkey_session`. */
|
|
8
|
+
cookie?: string;
|
|
9
|
+
/** How long a sign-in lasts. Default 90 days. */
|
|
10
|
+
sessionDays?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface PasskeyGate {
|
|
13
|
+
/** The session cookie's name, for the app to read it with. */
|
|
14
|
+
cookie: string;
|
|
15
|
+
/** Whether a session cookie's value is a live session. */
|
|
16
|
+
signedIn(session: string | undefined): Promise<boolean>;
|
|
17
|
+
/** A single-use invite token, good for `days`. The bin turns it into a link. */
|
|
18
|
+
invite(days?: number): Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* The whole sign-in route: `export const POST = gate.route`. The body is
|
|
21
|
+
* `{ invite?, response? }`: without `response` it answers with the options
|
|
22
|
+
* for the browser's prompt, with one it verifies it and sets the cookie. An
|
|
23
|
+
* invite turns the prompt into saving a new passkey.
|
|
24
|
+
*/
|
|
25
|
+
route(request: Request): Promise<Response>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createPasskeyGate({ sql, rpName, cookie, sessionDays }: PasskeyGateOptions): PasskeyGate;
|
package/dist/passkey.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// passkey.ts – the door to a site's own admin pages: a passkey, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// No account, no password, no email. Whoever holds a passkey saved for the
|
|
4
|
+
// site is in. The first one is saved from an invite link that the
|
|
5
|
+
// `passkey-invite` bin prints from a machine holding the database URL, and any
|
|
6
|
+
// passkey after that comes from another invite. So the only way in is through
|
|
7
|
+
// someone who can already reach the database.
|
|
8
|
+
//
|
|
9
|
+
// A passkey is bound to the host it was saved on. One saved on localhost does
|
|
10
|
+
// not open the deployed site, and one saved on the site does not open localhost.
|
|
11
|
+
//
|
|
12
|
+
// Like analytics, the package owns the SQL and the app owns the connection. The
|
|
13
|
+
// app declares the two tables in its own schema:
|
|
14
|
+
//
|
|
15
|
+
// model Passkey {
|
|
16
|
+
// id String @id // the credential id, base64url
|
|
17
|
+
// publicKey String // base64url
|
|
18
|
+
// counter Int
|
|
19
|
+
// transports String[]
|
|
20
|
+
// createdAt DateTime @default(now()) @db.Timestamptz(3)
|
|
21
|
+
// }
|
|
22
|
+
//
|
|
23
|
+
// model PasskeyToken {
|
|
24
|
+
// hash String @id // sha-256 of the token, base64url
|
|
25
|
+
// kind String // session | invite | challenge
|
|
26
|
+
// expiresAt DateTime @db.Timestamptz(3)
|
|
27
|
+
// }
|
|
28
|
+
//
|
|
29
|
+
// Every token is stored as its hash, so a read of the table opens nothing. A
|
|
30
|
+
// challenge is spent the moment a response quotes it, and an invite the moment
|
|
31
|
+
// a passkey is saved with it.
|
|
32
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
|
33
|
+
import { clientKey, rateLimit, rateLimitedResponse } from './rate-limit.js';
|
|
34
|
+
const MINUTE = 60_000;
|
|
35
|
+
const DAY = 86_400_000;
|
|
36
|
+
const CHALLENGE_MS = 5 * MINUTE;
|
|
37
|
+
const b64url = (bytes) => Buffer.from(bytes).toString('base64url');
|
|
38
|
+
const token = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
39
|
+
const hash = async (value) => b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))));
|
|
40
|
+
/** Store a token's hash; the token is returned and kept nowhere else. */
|
|
41
|
+
async function issue(sql, kind, ms, value = token()) {
|
|
42
|
+
await sql(`DELETE FROM "PasskeyToken" WHERE "expiresAt" < now()`);
|
|
43
|
+
await sql(`INSERT INTO "PasskeyToken" (hash, kind, "expiresAt") VALUES ($1, $2, now() + make_interval(secs => $3::int))`, await hash(value), kind, Math.round(ms / 1000));
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
/** True once for a live token of this kind; it is gone either way. */
|
|
47
|
+
async function spend(sql, kind, value) {
|
|
48
|
+
const rows = (await sql(`DELETE FROM "PasskeyToken" WHERE hash = $1 AND kind = $2 RETURNING "expiresAt" > now() AS live`, await hash(value), kind));
|
|
49
|
+
return rows[0]?.live === true;
|
|
50
|
+
}
|
|
51
|
+
async function live(sql, kind, value) {
|
|
52
|
+
const rows = (await sql(`SELECT 1 FROM "PasskeyToken" WHERE hash = $1 AND kind = $2 AND "expiresAt" > now()`, await hash(value), kind));
|
|
53
|
+
return rows.length > 0;
|
|
54
|
+
}
|
|
55
|
+
/** Any request that fails is told only that it failed. */
|
|
56
|
+
const refuse = (status, error) => Response.json({ error }, { status });
|
|
57
|
+
export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', sessionDays = 90 }) {
|
|
58
|
+
async function session(secure) {
|
|
59
|
+
const value = await issue(sql, 'session', sessionDays * DAY);
|
|
60
|
+
const attrs = [`${cookie}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${sessionDays * 86_400}`];
|
|
61
|
+
if (secure)
|
|
62
|
+
attrs.push('Secure');
|
|
63
|
+
return new Response(null, { status: 204, headers: { 'set-cookie': attrs.join('; ') } });
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
cookie,
|
|
67
|
+
signedIn: async (value) => !!value && live(sql, 'session', value),
|
|
68
|
+
invite: (days = 1) => issue(sql, 'invite', days * DAY),
|
|
69
|
+
async route(request) {
|
|
70
|
+
const limited = rateLimit(clientKey('passkey', request.headers), { capacity: 10, refillPerSec: 0.2 });
|
|
71
|
+
if (!limited.ok)
|
|
72
|
+
return rateLimitedResponse(limited.retryAfterSec);
|
|
73
|
+
// The browser writes the page's origin into what it signs, and binds the
|
|
74
|
+
// passkey to the host, so the request's own origin is the one to expect.
|
|
75
|
+
const { origin, hostname: rpID, protocol } = new URL(request.url);
|
|
76
|
+
const body = (await request.json().catch(() => null));
|
|
77
|
+
const invite = typeof body?.invite === 'string' ? body.invite : undefined;
|
|
78
|
+
const response = body?.response;
|
|
79
|
+
const expectedChallenge = (challenge) => spend(sql, 'challenge', challenge);
|
|
80
|
+
if (!response) {
|
|
81
|
+
if (invite && !(await live(sql, 'invite', invite)))
|
|
82
|
+
return refuse(403, 'This invite has expired.');
|
|
83
|
+
const options = invite
|
|
84
|
+
? await generateRegistrationOptions({
|
|
85
|
+
rpName,
|
|
86
|
+
rpID,
|
|
87
|
+
userName: rpName,
|
|
88
|
+
attestationType: 'none',
|
|
89
|
+
// Discoverable, so signing in later asks for nothing: the device offers it.
|
|
90
|
+
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
|
|
91
|
+
})
|
|
92
|
+
: await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
|
|
93
|
+
await issue(sql, 'challenge', CHALLENGE_MS, options.challenge);
|
|
94
|
+
return Response.json(options);
|
|
95
|
+
}
|
|
96
|
+
if (invite) {
|
|
97
|
+
const result = await verifyRegistrationResponse({
|
|
98
|
+
response,
|
|
99
|
+
expectedChallenge,
|
|
100
|
+
expectedOrigin: origin,
|
|
101
|
+
expectedRPID: rpID,
|
|
102
|
+
requireUserVerification: false,
|
|
103
|
+
}).catch(() => null);
|
|
104
|
+
if (!result?.verified)
|
|
105
|
+
return refuse(400, 'That passkey could not be saved.');
|
|
106
|
+
// Spent after the check, so a failed prompt leaves the invite to try again.
|
|
107
|
+
if (!(await spend(sql, 'invite', invite)))
|
|
108
|
+
return refuse(403, 'This invite has expired.');
|
|
109
|
+
const { credential } = result.registrationInfo;
|
|
110
|
+
await sql(`INSERT INTO "Passkey" (id, "publicKey", counter, transports) VALUES ($1, $2, $3, $4::text[])`, credential.id, b64url(credential.publicKey), credential.counter, credential.transports ?? []);
|
|
111
|
+
return session(protocol === 'https:');
|
|
112
|
+
}
|
|
113
|
+
const [passkey] = (await sql(`SELECT id, "publicKey", counter, transports FROM "Passkey" WHERE id = $1`, String(response.id)));
|
|
114
|
+
if (!passkey)
|
|
115
|
+
return refuse(403, 'This passkey is not one this site knows.');
|
|
116
|
+
const result = await verifyAuthenticationResponse({
|
|
117
|
+
response,
|
|
118
|
+
expectedChallenge,
|
|
119
|
+
expectedOrigin: origin,
|
|
120
|
+
expectedRPID: rpID,
|
|
121
|
+
credential: { ...passkey, publicKey: new Uint8Array(Buffer.from(passkey.publicKey, 'base64url')) },
|
|
122
|
+
requireUserVerification: false,
|
|
123
|
+
}).catch(() => null);
|
|
124
|
+
if (!result?.verified)
|
|
125
|
+
return refuse(403, 'Sign-in failed.');
|
|
126
|
+
await sql(`UPDATE "Passkey" SET counter = $2 WHERE id = $1`, passkey.id, result.authenticationInfo.newCounter);
|
|
127
|
+
return session(protocol === 'https:');
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/dist/react-server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from 'react';
|
|
2
2
|
import type { Control, FacetControlGroup, MetadataRow } from './taxonomy.js';
|
|
3
3
|
import type { HistoryChange } from './history.js';
|
|
4
|
+
import type { Digest } from './analytics.js';
|
|
4
5
|
/**
|
|
5
6
|
* The shape a router's link component has to satisfy here.
|
|
6
7
|
*
|
|
@@ -267,3 +268,22 @@ export interface RevisionChangesProps {
|
|
|
267
268
|
* are the importing stylesheet's.
|
|
268
269
|
*/
|
|
269
270
|
export declare function RevisionChanges({ changes, label }: RevisionChangesProps): import("react").JSX.Element | null;
|
|
271
|
+
export interface StatsProps {
|
|
272
|
+
/** One window's figures, from `digest` in `wiki-formant/analytics`. */
|
|
273
|
+
digest: Digest;
|
|
274
|
+
/** The window the digest covers, in days. */
|
|
275
|
+
days: number;
|
|
276
|
+
/** The windows offered as `?days=` links on the same page. */
|
|
277
|
+
ranges?: readonly number[];
|
|
278
|
+
}
|
|
279
|
+
/** The windows the stats page offers, in days. */
|
|
280
|
+
export declare const STATS_RANGES: readonly [1, 7, 30, 90];
|
|
281
|
+
/** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
|
|
282
|
+
export declare const statsDays: (param: unknown, ranges?: readonly number[], fallback?: number) => number;
|
|
283
|
+
/**
|
|
284
|
+
* A site's own analytics on one page: the headline figures against the window
|
|
285
|
+
* before, visitors per day, and every ranked list the digest carries. Lists
|
|
286
|
+
* with nothing in them are left out. Bars are `currentColor` until the
|
|
287
|
+
* importing stylesheet gives them a colour.
|
|
288
|
+
*/
|
|
289
|
+
export declare function Stats({ digest: d, days, ranges }: StatsProps): import("react").JSX.Element;
|
package/dist/react-server.js
CHANGED
|
@@ -225,3 +225,58 @@ export function RevisionChanges({ changes, label = type => type }) {
|
|
|
225
225
|
return null;
|
|
226
226
|
return (_jsx("ul", { className: "revision-changes", children: changes.map((c, i) => (_jsxs("li", { className: "revision-change", "data-action": c.action, children: [_jsx("span", { className: "revision-change-action", children: ACTION_LABEL[c.action] }), ` – ${label(c.type)} at ${blockLocation(c.path)}`, c.leafDiff && (_jsx("p", { className: "revision-diff", children: c.leafDiff.map(([op, text], j) => op === -1 ? _jsx("del", { children: text }, j) : op === 1 ? _jsx("ins", { children: text }, j) : _jsx("span", { children: text }, j)) }))] }, i))) }));
|
|
227
227
|
}
|
|
228
|
+
/** The windows the stats page offers, in days. */
|
|
229
|
+
export const STATS_RANGES = [1, 7, 30, 90];
|
|
230
|
+
/** A `?days=` value as one of `ranges`, else `fallback`: the page's only input. */
|
|
231
|
+
export const statsDays = (param, ranges = STATS_RANGES, fallback = 30) => ranges.find(n => String(n) === param) ?? fallback;
|
|
232
|
+
const count = (n) => n.toLocaleString('en');
|
|
233
|
+
const regions = new Intl.DisplayNames(['en'], { type: 'region' });
|
|
234
|
+
const country = (code) => {
|
|
235
|
+
try {
|
|
236
|
+
return regions.of(code) ?? code;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return code;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const duration = (s) => (s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`);
|
|
243
|
+
/** Unsigned at zero, so no change never reads as growth. */
|
|
244
|
+
const change = (now, before) => {
|
|
245
|
+
if (!before)
|
|
246
|
+
return null;
|
|
247
|
+
const pct = Math.round((100 * (now - before)) / before);
|
|
248
|
+
return pct > 0 ? `+${pct}%` : `${pct}%`;
|
|
249
|
+
};
|
|
250
|
+
function StatsList({ title, rows, label = v => v }) {
|
|
251
|
+
if (!rows.length)
|
|
252
|
+
return null;
|
|
253
|
+
const max = Math.max(...rows.map(([, n]) => n));
|
|
254
|
+
return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* A site's own analytics on one page: the headline figures against the window
|
|
258
|
+
* before, visitors per day, and every ranked list the digest carries. Lists
|
|
259
|
+
* with nothing in them are left out. Bars are `currentColor` until the
|
|
260
|
+
* importing stylesheet gives them a colour.
|
|
261
|
+
*/
|
|
262
|
+
export function Stats({ digest: d, days, ranges = STATS_RANGES }) {
|
|
263
|
+
const byDay = new Map(d.by_day.map(r => [r.date, r.visitors]));
|
|
264
|
+
// A rolling window of n days touches n + 1 UTC dates, the first of them in part.
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
const series = Array.from({ length: days + 1 }, (_, i) => {
|
|
267
|
+
const date = new Date(now - (days - i) * 86_400_000).toISOString().slice(0, 10);
|
|
268
|
+
return [date, byDay.get(date) ?? 0];
|
|
269
|
+
});
|
|
270
|
+
const peak = Math.max(1, ...series.map(([, n]) => n));
|
|
271
|
+
const figures = [
|
|
272
|
+
['Visitors', count(d.visitors), change(d.visitors, d.previous_visitors)],
|
|
273
|
+
['Page views', count(d.pageviews), null],
|
|
274
|
+
['Bounce rate', d.bounce_rate == null ? '–' : `${d.bounce_rate}%`, null],
|
|
275
|
+
['Visit length', d.visit_duration == null ? '–' : duration(d.visit_duration), null],
|
|
276
|
+
['With agents', count(d.visitors_incl_agents), null],
|
|
277
|
+
];
|
|
278
|
+
if (d.follow_clicks)
|
|
279
|
+
figures.push(['Clicks to X', count(d.follow_clicks.events), null]);
|
|
280
|
+
const rows = (list, k, m) => list.map(r => [r[k], r[m]]);
|
|
281
|
+
return (_jsxs("div", { className: "stats", children: [_jsx("nav", { className: "stats-ranges", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: `?days=${n}`, "aria-current": n === days ? 'page' : undefined, children: n === 1 ? '24 hours' : `${n} days` }, n))) }), _jsx("dl", { className: "stats-figures", children: figures.map(([label, value, delta]) => (_jsxs("div", { children: [_jsx("dt", { children: label }), _jsxs("dd", { children: [value, delta && _jsx("small", { children: ` ${delta}` })] })] }, label))) }), _jsx("ol", { className: "stats-chart", "aria-label": "Visitors per day", children: series.map(([date, n]) => (_jsx("li", { title: `${date}: ${count(n)}`, style: { '--share': n / peak } }, date))) }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors') }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', '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') })] })] }));
|
|
282
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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, and conditional-GET plumbing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -62,6 +62,14 @@
|
|
|
62
62
|
"types": "./dist/analytics.d.ts",
|
|
63
63
|
"import": "./dist/analytics.js"
|
|
64
64
|
},
|
|
65
|
+
"./passkey": {
|
|
66
|
+
"types": "./dist/passkey.d.ts",
|
|
67
|
+
"import": "./dist/passkey.js"
|
|
68
|
+
},
|
|
69
|
+
"./passkey-button": {
|
|
70
|
+
"types": "./dist/passkey-button.d.ts",
|
|
71
|
+
"import": "./dist/passkey-button.js"
|
|
72
|
+
},
|
|
65
73
|
"./blocks": {
|
|
66
74
|
"types": "./dist/blocks.d.ts",
|
|
67
75
|
"import": "./dist/blocks.js"
|
|
@@ -175,7 +183,8 @@
|
|
|
175
183
|
"bin": {
|
|
176
184
|
"check-classes": "bin/check-classes.mjs",
|
|
177
185
|
"check-pages": "bin/check-pages.mjs",
|
|
178
|
-
"analytics-digest": "bin/analytics-digest.mjs"
|
|
186
|
+
"analytics-digest": "bin/analytics-digest.mjs",
|
|
187
|
+
"passkey-invite": "bin/passkey-invite.mjs"
|
|
179
188
|
},
|
|
180
189
|
"files": [
|
|
181
190
|
"dist",
|
|
@@ -190,6 +199,8 @@
|
|
|
190
199
|
"prepare": "git config core.hooksPath .githooks || true"
|
|
191
200
|
},
|
|
192
201
|
"devDependencies": {
|
|
202
|
+
"@simplewebauthn/browser": "^14.0.0",
|
|
203
|
+
"@simplewebauthn/server": "^14.0.3",
|
|
193
204
|
"@tiptap/core": "^2.10.0",
|
|
194
205
|
"@tiptap/extension-code-block": "^2.10.0",
|
|
195
206
|
"@tiptap/extension-image": "^2.27.3",
|
|
@@ -227,9 +238,11 @@
|
|
|
227
238
|
"@tiptap/react": ">=2.10",
|
|
228
239
|
"@tiptap/starter-kit": ">=2.10",
|
|
229
240
|
"fast-diff": ">=1.3",
|
|
241
|
+
"pg": ">=8",
|
|
230
242
|
"react": ">=18",
|
|
231
243
|
"sanitize-html": ">=2",
|
|
232
|
-
"
|
|
244
|
+
"@simplewebauthn/server": ">=13",
|
|
245
|
+
"@simplewebauthn/browser": ">=13"
|
|
233
246
|
},
|
|
234
247
|
"peerDependenciesMeta": {
|
|
235
248
|
"react": {
|
|
@@ -282,6 +295,12 @@
|
|
|
282
295
|
},
|
|
283
296
|
"pg": {
|
|
284
297
|
"optional": true
|
|
298
|
+
},
|
|
299
|
+
"@simplewebauthn/server": {
|
|
300
|
+
"optional": true
|
|
301
|
+
},
|
|
302
|
+
"@simplewebauthn/browser": {
|
|
303
|
+
"optional": true
|
|
285
304
|
}
|
|
286
305
|
}
|
|
287
306
|
}
|