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.
- package/README.md +108 -14
- package/bin/fts-ddl.mjs +79 -0
- package/dist/analytics.d.ts +2 -2
- package/dist/analytics.js +2 -2
- package/dist/base.css +56 -3
- package/dist/conformance.d.ts +15 -0
- package/dist/conformance.js +21 -2
- package/dist/crawlers.d.ts +36 -2
- package/dist/crawlers.js +37 -8
- package/dist/headers.d.ts +35 -0
- package/dist/headers.js +78 -0
- package/dist/http.d.ts +38 -0
- package/dist/http.js +57 -1
- package/dist/metadata.d.ts +82 -0
- package/dist/metadata.js +74 -0
- package/dist/passkey-button.d.ts +3 -1
- package/dist/passkey-button.js +8 -6
- package/dist/passkey.d.ts +3 -1
- package/dist/passkey.js +16 -9
- package/dist/react.d.ts +25 -0
- package/dist/react.js +46 -0
- package/dist/sanitize.d.ts +0 -6
- package/dist/sanitize.js +1 -12
- package/package.json +91 -44
- package/tsconfig.base.json +25 -0
package/dist/headers.js
ADDED
|
@@ -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
|
+
}
|
package/dist/metadata.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/passkey-button.d.ts
CHANGED
|
@@ -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;
|
package/dist/passkey-button.js
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 =
|
|
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:
|
|
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
|
|
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.
|
|
5
|
-
//
|
|
6
|
-
// passkey after that
|
|
7
|
-
//
|
|
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)
|
|
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.
|
package/dist/sanitize.d.ts
CHANGED
|
@@ -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 = [
|