wiki-formant 0.30.0 → 0.33.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.
@@ -0,0 +1,78 @@
1
+ // headers.ts — the response headers every origin sends, and the CSP they share.
2
+ //
3
+ // Four `next.config.ts` files carried the same six headers and four
4
+ // hand-written policies, on the stated grounds that the config could not import
5
+ // this package. It could not because no subpath had a `default` condition, and
6
+ // Next resolves a config's imports the way `require` does; with one, Node 24
7
+ // loads the ESM module there unchanged. So the headers live here now, and the
8
+ // pairing the sanitiser's comment asked readers to keep by hand — `frame-src`
9
+ // against `DEFAULT_IFRAME_HOSTS` — is one list read twice. While it was kept by
10
+ // hand, one origin's policy dropped the bare `youtube.com` and
11
+ // `youtube-nocookie.com` its sanitiser kept, so those embeds survived cleaning
12
+ // and then rendered blank.
13
+ //
14
+ // Zero imports, so a config pays for nothing it does not use.
15
+ /**
16
+ * The embed hosts the editor's iframe, YouTube, tweet and map nodes produce:
17
+ * the sanitiser's default `iframeHosts` and the default `frame-src`, so an
18
+ * embed that clears one clears the other.
19
+ */
20
+ export const DEFAULT_IFRAME_HOSTS = [
21
+ 'www.youtube.com', 'youtube.com',
22
+ 'www.youtube-nocookie.com', 'youtube-nocookie.com',
23
+ 'platform.twitter.com',
24
+ 'www.google.com', 'maps.google.com',
25
+ 'embed.apple.com', 'maps.apple.com',
26
+ ];
27
+ /** `frame-src` sources for a host list — the one a sanitiser was given. */
28
+ export const frameSources = (hosts = DEFAULT_IFRAME_HOSTS) => hosts.length ? hosts.map(h => `https://${h}`) : ["'none'"];
29
+ /**
30
+ * The policy all four origins shared line for line. Everything a site adds is
31
+ * a source list it hands `contentSecurityPolicy`, which replaces the default
32
+ * for that directive rather than appending, so a reader sees the whole list.
33
+ */
34
+ export const BASE_CSP = {
35
+ 'default-src': ["'self'"],
36
+ 'base-uri': ["'self'"],
37
+ 'object-src': ["'none'"],
38
+ 'frame-ancestors': ["'none'"],
39
+ 'form-action': ["'self'"],
40
+ // Next's hydration scripts are inline. A nonce removes this, and a nonce
41
+ // makes every page it covers render per request — caper takes it on its
42
+ // dynamic routes only.
43
+ 'script-src': ["'self'", "'unsafe-inline'"],
44
+ 'style-src': ["'self'", "'unsafe-inline'"],
45
+ 'font-src': ["'self'", 'data:'],
46
+ 'img-src': ["'self'", 'data:'],
47
+ 'connect-src': ["'self'"],
48
+ 'frame-src': frameSources(),
49
+ };
50
+ /**
51
+ * The policy string: `BASE_CSP` with each directive in `directives` replacing
52
+ * its default. An empty list drops the directive.
53
+ */
54
+ export function contentSecurityPolicy(directives = {}) {
55
+ return Object.entries({ ...BASE_CSP, ...directives })
56
+ .filter(([, sources]) => sources?.length)
57
+ .map(([name, sources]) => `${name} ${sources.join(' ')}`)
58
+ .join('; ');
59
+ }
60
+ /**
61
+ * The six headers every route sends, plus the policy when one is given.
62
+ *
63
+ * Pass the policy only where it is enforced — production. Next's dev runtime
64
+ * is inline and `eval`, so every origin here exempts dev, and the switch is the
65
+ * caller's because only the caller knows what dev means for it. An origin that
66
+ * sets its policy per request (a nonce) passes none.
67
+ */
68
+ export function securityHeaders(csp) {
69
+ return [
70
+ ...(csp ? [{ key: 'Content-Security-Policy', value: csp }] : []),
71
+ { key: 'X-Frame-Options', value: 'DENY' },
72
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
73
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
74
+ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
75
+ { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
76
+ { key: 'X-DNS-Prefetch-Control', value: 'on' },
77
+ ];
78
+ }
package/dist/http.d.ts CHANGED
@@ -148,3 +148,41 @@ export declare function corpusValidatorsFrom(seed: ReadonlyArray<string | number
148
148
  * in the corpus changed.
149
149
  */
150
150
  export declare function corpusRoute(validators: () => Promise<CorpusValidators> | CorpusValidators, build: () => Promise<string> | string, headers?: (etag: string, lastModified: string) => Record<string, string>): (request: Request) => Promise<Response>;
151
+ /** JSON with a status or a full init, as a web-standard `Response`. */
152
+ export declare function json(data: unknown, init?: number | ResponseInit): Response;
153
+ /**
154
+ * The error answers every route handler gives, as `{ error, ...extra }`.
155
+ *
156
+ * One repo had these, one had a second convention with a third copy inside a
157
+ * single route, and two answered with whatever `Response.json` call was nearest
158
+ * — so the same mistake came back as `Unauthorized`, `Not authorized.` or a
159
+ * bare status depending on which origin an agent asked. `extra` carries what a
160
+ * surface adds to the body (a machine-readable `code`, a hint) without a fourth
161
+ * shape.
162
+ */
163
+ export declare const errors: {
164
+ readonly badRequest: (message: string, extra?: Record<string, unknown>) => Response;
165
+ readonly unauthorized: (message?: string, extra?: Record<string, unknown>) => Response;
166
+ readonly forbidden: (message?: string, extra?: Record<string, unknown>) => Response;
167
+ readonly notFound: (message?: string, extra?: Record<string, unknown>) => Response;
168
+ /** States its own wait: a 429 without `Retry-After` is retried immediately, extending the block. */
169
+ readonly tooManyRequests: (retryAfterSec: number, message?: string, extra?: Record<string, unknown>) => Response;
170
+ readonly internal: (message?: string, extra?: Record<string, unknown>) => Response;
171
+ };
172
+ /**
173
+ * A caller-fixable failure thrown from deep inside a handler, answered with
174
+ * its own status and message by `handleRoute`. Anything else thrown is a bug,
175
+ * answered with a generic 500 so its message never reaches the caller.
176
+ */
177
+ export declare class HttpError extends Error {
178
+ status: number;
179
+ extra?: Record<string, unknown> | undefined;
180
+ constructor(status: number, message: string, extra?: Record<string, unknown> | undefined);
181
+ }
182
+ /**
183
+ * Run a handler; a thrown `HttpError` becomes its own answer, anything else a
184
+ * logged 500 that names nothing internal. One handler here echoed every
185
+ * exception's message to the caller, which is how a database error's text
186
+ * becomes public.
187
+ */
188
+ export declare function handleRoute(fn: () => Promise<Response>, errorMsg?: string): Promise<Response>;
package/dist/http.js CHANGED
@@ -1,4 +1,5 @@
1
- // http.ts — conditional-GET plumbing for text corpus endpoints.
1
+ // http.ts — conditional-GET plumbing for text corpus endpoints, and the error
2
+ // answers every route handler gives (at the foot).
2
3
  //
3
4
  // The three llms depths (llms.txt / llms-index.txt / llms-full.txt) are the
4
5
  // most-recrawled URLs a wiki serves and the most expensive to render. With a
@@ -225,3 +226,58 @@ export function corpusRoute(validators, build, headers = textHeaders) {
225
226
  return notModified(request, etag, lastModified, sent) ?? new Response(await build(), { headers: sent });
226
227
  };
227
228
  }
229
+ // ---- route errors -----------------------------------------------------------
230
+ /** JSON with a status or a full init, as a web-standard `Response`. */
231
+ export function json(data, init) {
232
+ return Response.json(data, typeof init === 'number' ? { status: init } : init);
233
+ }
234
+ /**
235
+ * The error answers every route handler gives, as `{ error, ...extra }`.
236
+ *
237
+ * One repo had these, one had a second convention with a third copy inside a
238
+ * single route, and two answered with whatever `Response.json` call was nearest
239
+ * — so the same mistake came back as `Unauthorized`, `Not authorized.` or a
240
+ * bare status depending on which origin an agent asked. `extra` carries what a
241
+ * surface adds to the body (a machine-readable `code`, a hint) without a fourth
242
+ * shape.
243
+ */
244
+ export const errors = {
245
+ badRequest: (message, extra) => json({ error: message, ...extra }, 400),
246
+ unauthorized: (message = 'Unauthorized', extra) => json({ error: message, ...extra }, 401),
247
+ forbidden: (message = 'Forbidden', extra) => json({ error: message, ...extra }, 403),
248
+ notFound: (message = 'Not found', extra) => json({ error: message, ...extra }, 404),
249
+ /** States its own wait: a 429 without `Retry-After` is retried immediately, extending the block. */
250
+ tooManyRequests: (retryAfterSec, message = 'Rate limit exceeded', extra) => json({ error: message, ...extra }, { status: 429, headers: { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSec))) } }),
251
+ internal: (message = 'Internal server error', extra) => json({ error: message, ...extra }, 500),
252
+ };
253
+ /**
254
+ * A caller-fixable failure thrown from deep inside a handler, answered with
255
+ * its own status and message by `handleRoute`. Anything else thrown is a bug,
256
+ * answered with a generic 500 so its message never reaches the caller.
257
+ */
258
+ export class HttpError extends Error {
259
+ status;
260
+ extra;
261
+ constructor(status, message, extra) {
262
+ super(message);
263
+ this.status = status;
264
+ this.extra = extra;
265
+ }
266
+ }
267
+ /**
268
+ * Run a handler; a thrown `HttpError` becomes its own answer, anything else a
269
+ * logged 500 that names nothing internal. One handler here echoed every
270
+ * exception's message to the caller, which is how a database error's text
271
+ * becomes public.
272
+ */
273
+ export async function handleRoute(fn, errorMsg = 'Internal server error') {
274
+ try {
275
+ return await fn();
276
+ }
277
+ catch (error) {
278
+ if (error instanceof HttpError)
279
+ return json({ error: error.message, ...error.extra }, error.status);
280
+ console.error(errorMsg, error);
281
+ return errors.internal(errorMsg);
282
+ }
283
+ }
@@ -175,3 +175,85 @@ export declare function fittingTitle(variants: readonly string[], opts?: {
175
175
  suffix?: string;
176
176
  limit?: number;
177
177
  }): string;
178
+ /**
179
+ * `pageMetadata` bound to a site's name, locale and handle, the three fields
180
+ * Next drops from every page that sets its own card. Two wikis wrapped it to
181
+ * restate them; the third passed them by hand at six call sites and forgot the
182
+ * locale at all of them.
183
+ */
184
+ export declare function sitePageMetadata(site: {
185
+ siteName: string;
186
+ locale?: string;
187
+ handle?: string;
188
+ }): (o: Omit<PageMetadataOptions, "siteName" | "locale" | "handle">) => {
189
+ openGraph: {
190
+ publishedTime?: string;
191
+ modifiedTime?: string;
192
+ section?: string;
193
+ tags?: string[];
194
+ images?: {
195
+ alt: string;
196
+ width: number;
197
+ height: number;
198
+ url: string;
199
+ }[] | undefined;
200
+ locale?: string | undefined;
201
+ siteName?: string | undefined;
202
+ url?: string | undefined;
203
+ description?: string | undefined;
204
+ type: "article" | "website";
205
+ title: string;
206
+ };
207
+ twitter: {
208
+ images?: string[] | undefined;
209
+ site?: string | undefined;
210
+ creator?: string | undefined;
211
+ description?: string | undefined;
212
+ card: "summary_large_image";
213
+ title: string;
214
+ };
215
+ alternates?: {
216
+ types?: {
217
+ 'text/markdown': string;
218
+ } | undefined;
219
+ canonical: string;
220
+ } | undefined;
221
+ };
222
+ /** The `@id` references a page's own node points at for `publisher` and `isPartOf`. */
223
+ export declare function siteRefs(url: string): {
224
+ organization: LdNode;
225
+ website: LdNode;
226
+ };
227
+ export interface SiteGraphOptions {
228
+ name: string;
229
+ /** The canonical origin, no trailing slash needed. */
230
+ url: string;
231
+ description?: string;
232
+ /** Absolute URL of the logo. */
233
+ logo?: string;
234
+ /** Public profiles. */
235
+ sameAs?: readonly string[];
236
+ /** Site search, with `{search_term_string}` where the query goes. */
237
+ searchUrl?: string;
238
+ /** The machine surface, as a `WebAPI` the Organization provides. */
239
+ api?: {
240
+ name?: string;
241
+ description: string;
242
+ url: string;
243
+ documentation?: string;
244
+ };
245
+ /** What only this site states about itself — `knowsAbout`, `publishingPrinciples`. */
246
+ organizationExtra?: LdNode;
247
+ websiteExtra?: LdNode;
248
+ }
249
+ /**
250
+ * The Organization, WebSite and (optionally) WebAPI nodes as one `@graph`,
251
+ * rendered once in the root layout.
252
+ *
253
+ * Each node carries an `@id`, and `siteRefs` hands out the same ids for every
254
+ * page's `publisher` and `isPartOf`. The three wikis built these three ways —
255
+ * separate nodes, a graph, inline copies — and the inline copies were where
256
+ * they disagreed: one site wrote its publisher four times and one of the four
257
+ * had a different URL.
258
+ */
259
+ export declare function siteGraphLd(o: SiteGraphOptions): LdNode;
package/dist/metadata.js CHANGED
@@ -167,3 +167,77 @@ export function fittingTitle(variants, opts = {}) {
167
167
  return '';
168
168
  return usable.find(v => v.length + suffix.length <= limit) ?? usable[usable.length - 1];
169
169
  }
170
+ // ---- the site's own identity ----------------------------------------------------
171
+ /**
172
+ * `pageMetadata` bound to a site's name, locale and handle, the three fields
173
+ * Next drops from every page that sets its own card. Two wikis wrapped it to
174
+ * restate them; the third passed them by hand at six call sites and forgot the
175
+ * locale at all of them.
176
+ */
177
+ export function sitePageMetadata(site) {
178
+ const bound = { locale: 'en_US', ...site };
179
+ return (o) => pageMetadata({ ...o, ...bound });
180
+ }
181
+ /** The `@id` references a page's own node points at for `publisher` and `isPartOf`. */
182
+ export function siteRefs(url) {
183
+ const base = url.replace(/\/$/, '');
184
+ return { organization: { '@id': `${base}/#organization` }, website: { '@id': `${base}/#website` } };
185
+ }
186
+ /**
187
+ * The Organization, WebSite and (optionally) WebAPI nodes as one `@graph`,
188
+ * rendered once in the root layout.
189
+ *
190
+ * Each node carries an `@id`, and `siteRefs` hands out the same ids for every
191
+ * page's `publisher` and `isPartOf`. The three wikis built these three ways —
192
+ * separate nodes, a graph, inline copies — and the inline copies were where
193
+ * they disagreed: one site wrote its publisher four times and one of the four
194
+ * had a different URL.
195
+ */
196
+ export function siteGraphLd(o) {
197
+ const url = o.url.replace(/\/$/, '');
198
+ const { organization, website } = siteRefs(url);
199
+ return {
200
+ '@context': 'https://schema.org',
201
+ '@graph': [
202
+ {
203
+ '@type': 'Organization',
204
+ ...organization,
205
+ name: o.name,
206
+ url,
207
+ ...(o.logo ? { logo: { '@type': 'ImageObject', url: o.logo } } : {}),
208
+ ...(o.description ? { description: o.description } : {}),
209
+ ...(o.sameAs?.length ? { sameAs: [...o.sameAs] } : {}),
210
+ ...o.organizationExtra,
211
+ },
212
+ {
213
+ '@type': 'WebSite',
214
+ ...website,
215
+ name: o.name,
216
+ url,
217
+ ...(o.description ? { description: o.description } : {}),
218
+ publisher: organization,
219
+ ...(o.searchUrl
220
+ ? {
221
+ potentialAction: {
222
+ '@type': 'SearchAction',
223
+ target: { '@type': 'EntryPoint', urlTemplate: o.searchUrl },
224
+ 'query-input': 'required name=search_term_string',
225
+ },
226
+ }
227
+ : {}),
228
+ ...o.websiteExtra,
229
+ },
230
+ ...(o.api
231
+ ? [{
232
+ '@type': 'WebAPI',
233
+ '@id': `${url}/#api`,
234
+ name: o.api.name ?? `${o.name} API`,
235
+ description: o.api.description,
236
+ url: o.api.url,
237
+ ...(o.api.documentation ? { documentation: o.api.documentation } : {}),
238
+ provider: organization,
239
+ }]
240
+ : []),
241
+ ],
242
+ };
243
+ }
@@ -3,7 +3,9 @@ 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;
6
8
  className?: string;
7
9
  errorClassName?: string;
8
10
  }
9
- export declare function PasskeyButton({ endpoint, invite, className, errorClassName }: PasskeyButtonProps): import("react").JSX.Element;
11
+ export declare function PasskeyButton({ endpoint, invite, create, className, errorClassName }: PasskeyButtonProps): import("react").JSX.Element;
@@ -3,9 +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 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.
6
+ // peer that only an app with a passkey door installs. With an invite, or with
7
+ // `create` while the site has no passkey (`gate.open()`), it saves a new
8
+ // passkey; otherwise it signs in with a saved one. Either way, success reloads
9
+ // the page without the invite, and the server sees the new cookie.
9
10
  import { startAuthentication, startRegistration } from '@simplewebauthn/browser';
10
11
  import { useState } from 'react';
11
12
  async function post(endpoint, body) {
@@ -14,7 +15,8 @@ async function post(endpoint, body) {
14
15
  throw new Error((await res.json().catch(() => null))?.error ?? 'Sign-in failed.');
15
16
  return res.status === 204 ? null : res.json();
16
17
  }
17
- export function PasskeyButton({ endpoint, invite, className, errorClassName }) {
18
+ export function PasskeyButton({ endpoint, invite, create, className, errorClassName }) {
19
+ const saving = !!invite || !!create;
18
20
  const [busy, setBusy] = useState(false);
19
21
  const [error, setError] = useState(null);
20
22
  async function go() {
@@ -22,7 +24,7 @@ export function PasskeyButton({ endpoint, invite, className, errorClassName }) {
22
24
  setError(null);
23
25
  try {
24
26
  const optionsJSON = await post(endpoint, { invite });
25
- const response = invite ? await startRegistration({ optionsJSON }) : await startAuthentication({ optionsJSON });
27
+ const response = saving ? await startRegistration({ optionsJSON }) : await startAuthentication({ optionsJSON });
26
28
  await post(endpoint, { invite, response });
27
29
  location.replace(location.pathname + location.search.replace(/([?&])invite=[^&]*&?/, '$1').replace(/[?&]$/, ''));
28
30
  }
@@ -32,5 +34,5 @@ export function PasskeyButton({ endpoint, invite, className, errorClassName }) {
32
34
  setBusy(false);
33
35
  }
34
36
  }
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 }))] }));
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 }))] }));
36
38
  }
package/dist/passkey.d.ts CHANGED
@@ -14,13 +14,15 @@ export interface PasskeyGate {
14
14
  cookie: string;
15
15
  /** Whether a session cookie's value is a live session. */
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>;
17
19
  /** A single-use invite token, good for `days`. The bin turns it into a link. */
18
20
  invite(days?: number): Promise<string>;
19
21
  /**
20
22
  * The whole sign-in route: `export const POST = gate.route`. The body is
21
23
  * `{ invite?, response? }`: without `response` it answers with the options
22
24
  * 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.
25
+ * invite, or a site with no passkey yet, turns the prompt into saving one.
24
26
  */
25
27
  route(request: Request): Promise<Response>;
26
28
  }
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. 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.
4
+ // site is in. While none is saved, the page saves the first one without asking
5
+ // for anything, so the owner opens it from the deployed site the way they would
6
+ // any other. Every passkey after that needs an invite link, which the
7
+ // `passkey-invite` bin prints from a machine holding the database URL.
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.
@@ -28,7 +28,9 @@
28
28
  //
29
29
  // Every token is stored as its hash, so a read of the table opens nothing. A
30
30
  // challenge is spent the moment a response quotes it, and an invite the moment
31
- // a passkey is saved with it.
31
+ // a passkey is saved with it. The first passkey without an invite is checked
32
+ // against an empty table in the insert itself, so two people racing to it
33
+ // cannot both win.
32
34
  import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
33
35
  import { clientKey, rateLimit, rateLimitedResponse } from './rate-limit.js';
34
36
  const MINUTE = 60_000;
@@ -62,9 +64,11 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
62
64
  attrs.push('Secure');
63
65
  return new Response(null, { status: 204, headers: { 'set-cookie': attrs.join('; ') } });
64
66
  }
67
+ const open = async () => (await sql(`SELECT 1 FROM "Passkey" LIMIT 1`)).length === 0;
65
68
  return {
66
69
  cookie,
67
70
  signedIn: async (value) => !!value && live(sql, 'session', value),
71
+ open,
68
72
  invite: (days = 1) => issue(sql, 'invite', days * DAY),
69
73
  async route(request) {
70
74
  const limited = rateLimit(clientKey('passkey', request.headers), { capacity: 10, refillPerSec: 0.2 });
@@ -80,7 +84,7 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
80
84
  if (!response) {
81
85
  if (invite && !(await live(sql, 'invite', invite)))
82
86
  return refuse(403, 'This invite has expired.');
83
- const options = invite
87
+ const options = invite || (await open())
84
88
  ? await generateRegistrationOptions({
85
89
  rpName,
86
90
  rpID,
@@ -93,7 +97,7 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
93
97
  await issue(sql, 'challenge', CHALLENGE_MS, options.challenge);
94
98
  return Response.json(options);
95
99
  }
96
- if (invite) {
100
+ if (invite || (await open())) {
97
101
  const result = await verifyRegistrationResponse({
98
102
  response,
99
103
  expectedChallenge,
@@ -104,10 +108,13 @@ export function createPasskeyGate({ sql, rpName, cookie = 'passkey_session', ses
104
108
  if (!result?.verified)
105
109
  return refuse(400, 'That passkey could not be saved.');
106
110
  // Spent after the check, so a failed prompt leaves the invite to try again.
107
- if (!(await spend(sql, 'invite', invite)))
111
+ if (invite && !(await spend(sql, 'invite', invite)))
108
112
  return refuse(403, 'This invite has expired.');
109
113
  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 ?? []);
114
+ const saved = (await sql(`INSERT INTO "Passkey" (id, "publicKey", counter, transports)
115
+ SELECT $1, $2, $3, $4::text[] WHERE $5::boolean OR NOT EXISTS (SELECT 1 FROM "Passkey") RETURNING id`, credential.id, b64url(credential.publicKey), credential.counter, credential.transports ?? [], !!invite));
116
+ if (!saved.length)
117
+ return refuse(403, 'This site already has a passkey. Adding another takes an invite.');
111
118
  return session(protocol === 'https:');
112
119
  }
113
120
  const [passkey] = (await sql(`SELECT id, "publicKey", counter, transports FROM "Passkey" WHERE id = $1`, String(response.id)));
package/dist/react.d.ts CHANGED
@@ -349,6 +349,31 @@ export declare function useCopy<K extends string = string>(revertAfter?: number)
349
349
  copied: K | null;
350
350
  copy: (text: string, key?: K) => Promise<boolean>;
351
351
  };
352
+ export interface SnapRow<T extends HTMLElement> {
353
+ /** The row: give it `overflow-x: auto` and `scroll-snap-type: x mandatory`, and its children `scroll-snap-align: start`. */
354
+ ref: RefObject<T | null>;
355
+ /** The row's `onScroll`, which keeps `at` current. */
356
+ onScroll: () => void;
357
+ /** The item in view, or the last once the row is scrolled to its end: for a counter or dots. */
358
+ at: number;
359
+ /** Most of a view along, as an arrow or an arrow key moves it, wrapping round from either end. */
360
+ step: (dir: 1 | -1) => void;
361
+ /** Item `i` to the start of the row, as a dot moves it; `smooth: false` to open on it. */
362
+ go: (i: number, smooth?: boolean) => void;
363
+ }
364
+ /**
365
+ * A carousel as a row scrolled sideways and snapping into place. A finger
366
+ * swipes it with no script at all, which a carousel that swaps one item for
367
+ * the next in state cannot do: AcuiQ's protocol card moved only by its
368
+ * buttons. This is the rest, with no markup and no class names, since what the
369
+ * row holds differs from one use to the next.
370
+ *
371
+ * `step` scrolls by most of a view and lets the snap settle it, so a row of
372
+ * items narrower than itself pages a screenful at a time and a row of full-width
373
+ * ones moves one. It wraps at either end, because an arrow that does nothing
374
+ * there reads as broken. Motion honours `prefers-reduced-motion`.
375
+ */
376
+ export declare function useSnapRow<T extends HTMLElement = HTMLDivElement>(): SnapRow<T>;
352
377
  /**
353
378
  * The one thing hooks cannot express, so it is worth owning once.
354
379
  *
package/dist/react.js CHANGED
@@ -698,6 +698,52 @@ export function useCopy(revertAfter = 2000) {
698
698
  }, [revertAfter]);
699
699
  return { copied, copy };
700
700
  }
701
+ /**
702
+ * A carousel as a row scrolled sideways and snapping into place. A finger
703
+ * swipes it with no script at all, which a carousel that swaps one item for
704
+ * the next in state cannot do: AcuiQ's protocol card moved only by its
705
+ * buttons. This is the rest, with no markup and no class names, since what the
706
+ * row holds differs from one use to the next.
707
+ *
708
+ * `step` scrolls by most of a view and lets the snap settle it, so a row of
709
+ * items narrower than itself pages a screenful at a time and a row of full-width
710
+ * ones moves one. It wraps at either end, because an arrow that does nothing
711
+ * there reads as broken. Motion honours `prefers-reduced-motion`.
712
+ */
713
+ export function useSnapRow() {
714
+ const ref = useRef(null);
715
+ const [at, setAt] = useState(0);
716
+ const onScroll = useCallback(() => {
717
+ const row = ref.current;
718
+ if (!row)
719
+ return;
720
+ const items = [...row.children];
721
+ const left = row.getBoundingClientRect().left;
722
+ const offsets = items.map(el => Math.abs(el.getBoundingClientRect().left - left));
723
+ setAt(row.scrollLeft >= snapEnd(row) - 1 ? items.length - 1 : offsets.indexOf(Math.min(...offsets)));
724
+ }, []);
725
+ const go = useCallback((i, smooth = true) => {
726
+ const row = ref.current;
727
+ const n = row?.children.length ?? 0;
728
+ const el = row?.children[((i % n) + n) % n];
729
+ if (row && el)
730
+ snapTo(row, row.scrollLeft + el.getBoundingClientRect().left - row.getBoundingClientRect().left, smooth);
731
+ }, []);
732
+ const step = useCallback((dir) => {
733
+ const row = ref.current;
734
+ if (!row)
735
+ return;
736
+ if (dir > 0 && row.scrollLeft >= snapEnd(row) - 1)
737
+ snapTo(row, 0);
738
+ else if (dir < 0 && row.scrollLeft <= 1)
739
+ snapTo(row, snapEnd(row));
740
+ else
741
+ snapTo(row, row.scrollLeft + dir * row.clientWidth * 0.8);
742
+ }, []);
743
+ return { ref, onScroll, at, step, go };
744
+ }
745
+ const snapEnd = (row) => row.scrollWidth - row.clientWidth;
746
+ const snapTo = (row, left, smooth = true) => row.scrollTo({ left, behavior: smooth && !matchMedia('(prefers-reduced-motion: reduce)').matches ? 'smooth' : 'auto' });
701
747
  // ---- error boundary ---------------------------------------------------------
702
748
  /**
703
749
  * The one thing hooks cannot express, so it is worth owning once.
@@ -1,9 +1,3 @@
1
- /**
2
- * The embed hosts the editor's iframe, YouTube, tweet and map nodes produce.
3
- * This is half of a pair: the CSP `frame-src` in each `next.config.ts` must
4
- * allow the same hosts, or an iframe survives sanitising and is then blocked.
5
- */
6
- export declare const DEFAULT_IFRAME_HOSTS: readonly string[];
7
1
  export interface HtmlSanitizerOptions {
8
2
  /** Hosts an `<iframe src>` may point at. Defaults to `DEFAULT_IFRAME_HOSTS`. */
9
3
  iframeHosts?: readonly string[];
package/dist/sanitize.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // server-side, on the render path, so it covers rows written before it existed
14
14
  // and no sanitiser ships to the client. `sanitize-html` is an optional peer.
15
15
  import sanitizeHtml from 'sanitize-html';
16
+ import { DEFAULT_IFRAME_HOSTS } from './headers.js';
16
17
  // Inline SVG, presentational elements only. The infographics pipeline embeds
17
18
  // diagrams as inline SVG in two of the wikis, so a prose-only list would erase
18
19
  // them. `foreignObject` (arbitrary HTML), `use`/`image` (external refs),
@@ -33,18 +34,6 @@ const SVG_ATTRS = [
33
34
  'dominant-baseline', 'offset', 'stop-color', 'stop-opacity', 'gradientUnits',
34
35
  'gradientTransform', 'clip-path', 'mask', 'role', 'aria-label', 'style',
35
36
  ];
36
- /**
37
- * The embed hosts the editor's iframe, YouTube, tweet and map nodes produce.
38
- * This is half of a pair: the CSP `frame-src` in each `next.config.ts` must
39
- * allow the same hosts, or an iframe survives sanitising and is then blocked.
40
- */
41
- export const DEFAULT_IFRAME_HOSTS = [
42
- 'www.youtube.com', 'youtube.com',
43
- 'www.youtube-nocookie.com', 'youtube-nocookie.com',
44
- 'platform.twitter.com',
45
- 'www.google.com', 'maps.google.com',
46
- 'embed.apple.com', 'maps.apple.com',
47
- ];
48
37
  // Layout and paint only. `position`, `z-index` and friends are left out so a
49
38
  // page cannot lay a fake signing prompt over the site's chrome.
50
39
  const STYLE_PROPS = [