seemore 1.10.9 → 1.11.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/dist/index.d.ts CHANGED
@@ -106,14 +106,32 @@ declare const configSchema: z.ZodObject<{
106
106
  "export-html": "export-html";
107
107
  }>>>;
108
108
  exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
109
+ auth: z.ZodPreprocess<z.ZodOptional<z.ZodObject<{
110
+ id: z.ZodOptional<z.ZodString>;
111
+ remember: z.ZodOptional<z.ZodString>;
112
+ }, z.core.$strict>>, unknown>;
109
113
  }, z.core.$strip>;
110
114
  /** What a user writes in `seemore.config.ts`. */
111
- type SeemoreConfig = Omit<z.input<typeof configSchema>, 'features' | 'theme' | 'search'> & {
115
+ type SeemoreConfig = Omit<z.input<typeof configSchema>, 'features' | 'theme' | 'search' | 'auth'> & {
112
116
  /** An array of {@link FeatureFlag} also works, but the map is the documented form. */
113
117
  features?: FeatureMap | FeatureFlag[];
114
118
  theme?: Theme;
115
119
  search?: z.input<typeof searchSchema>;
120
+ /**
121
+ * Password protection: `seemore build` encrypts the site, and visitors unlock it with the
122
+ * password. The password itself comes from `SEEMORE_PASSWORD` at build time, never from here.
123
+ */
124
+ auth?: boolean | AuthOptions;
116
125
  };
126
+ interface AuthOptions {
127
+ /**
128
+ * A stable name the key is derived from; defaults to `title`. Set it if you expect to
129
+ * rename the site, so visitors stay unlocked. Changing it logs everyone out.
130
+ */
131
+ id?: string;
132
+ /** How long a visitor stays unlocked after their last visit: `'12h'` or `'7d'`. Default `'1d'`. */
133
+ remember?: `${number}h` | `${number}d`;
134
+ }
117
135
  type SearchConfig = {
118
136
  provider: 'static';
119
137
  } | {
@@ -152,6 +170,11 @@ interface ResolvedSeemoreConfig {
152
170
  search: SearchConfig;
153
171
  pageActions: ActionId[];
154
172
  exclude: string[];
173
+ /** Present when the site is password-protected. Nothing here is ever sent to the browser. */
174
+ auth?: {
175
+ id: string;
176
+ remember: number;
177
+ };
155
178
  /** Directory the config was resolved from — relative paths in it hang off this. */
156
179
  root: string;
157
180
  /** Absolute path of the config file, when there is one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seemore",
3
- "version": "1.10.9",
3
+ "version": "1.11.0",
4
4
  "description": "Let AI write the Markdown. Let seemore show it better — zero config documentation framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,9 +4,22 @@ import { RouterProvider, createBrowserRouter } from 'react-router';
4
4
  import { config } from 'virtual:seemore/config';
5
5
  import { decodePath, stripBase, toBasename } from '../shared/base.js';
6
6
  import { createRouteObjects } from './router.js';
7
+ import { onVitePreloadError } from './lib/chunkReload.js';
7
8
  import { preloadPage } from './lib/pages.js';
8
9
  import './styles/globals.css';
9
10
 
11
+ // Vite dispatches this when a chunk preload fails and rethrows when it is not cancelled.
12
+ // One reload picks up the new chunk names after a deploy; the guard keeps a genuinely
13
+ // broken chunk from looping.
14
+ window.addEventListener('vite:preloadError', (event) => {
15
+ event.preventDefault();
16
+ try {
17
+ onVitePreloadError(sessionStorage, Date.now(), () => window.location.reload());
18
+ } catch {
19
+ // Storage unavailable (private mode): a reload without a guard could loop.
20
+ }
21
+ });
22
+
10
23
  const container = document.getElementById('root');
11
24
  if (container === null) throw new Error('seemore: #root is missing from the page shell.');
12
25
 
@@ -1,9 +1,10 @@
1
1
  import { Link, useLocation } from 'react-router';
2
- import { Moon, PanelLeft, Search, Sun } from 'lucide-react';
2
+ import { LogOut, Moon, PanelLeft, Search, Sun } from 'lucide-react';
3
3
  import { useSearchContext } from 'fumadocs-ui/contexts/search';
4
4
  import { SidebarTrigger } from 'fumadocs-ui/components/sidebar/base';
5
5
  import { useTheme } from 'fumadocs-ui/provider/base';
6
6
  import { config } from 'virtual:seemore/config';
7
+ import { lockSite } from '../lib/auth.js';
7
8
  import { useSidebarCollapse } from './Sidebar.js';
8
9
 
9
10
  export function Header() {
@@ -59,6 +60,13 @@ export function Header() {
59
60
  </button>
60
61
  ) : undefined}
61
62
 
63
+ {/* Read directly, not through a helper, so an unprotected bundle folds this away. */}
64
+ {import.meta.env.SEEMORE_AUTH ? (
65
+ <button type="button" className="seemore-lock" aria-label="Lock site" title="Lock site" onClick={() => void lockSite()}>
66
+ <LogOut aria-hidden="true" />
67
+ </button>
68
+ ) : undefined}
69
+
62
70
  <button
63
71
  type="button"
64
72
  className="seemore-theme-toggle"
@@ -0,0 +1,31 @@
1
+ import { config } from 'virtual:seemore/config';
2
+ import { parseManifest } from '../../shared/auth/crypto.js';
3
+ import { LOCK_MESSAGE, MANIFEST_FILE, recordId } from '../../shared/auth/files.js';
4
+ import { indexedDbStore } from '../../shared/auth/store.js';
5
+
6
+ /**
7
+ * The Lock button: forget the key now rather than when `remember` runs out. The stored key is
8
+ * deleted, the worker drops the content key it holds in memory, and the reload lands on the
9
+ * lock shell.
10
+ */
11
+ export async function lockSite(): Promise<void> {
12
+ try {
13
+ const response = await fetch(config.base + MANIFEST_FILE, { cache: 'no-store' });
14
+ const { salt } = parseManifest(await response.json()).kdf;
15
+ await indexedDbStore().delete(recordId(new URL(config.base, window.location.origin).href, salt));
16
+ } catch {
17
+ // The worker forgets it below as well.
18
+ }
19
+
20
+ const worker = navigator.serviceWorker?.controller;
21
+ if (worker) {
22
+ await new Promise<void>((resolve) => {
23
+ const channel = new MessageChannel();
24
+ channel.port1.onmessage = () => resolve();
25
+ worker.postMessage({ type: LOCK_MESSAGE }, [channel.port2]);
26
+ setTimeout(resolve, 3000);
27
+ });
28
+ }
29
+
30
+ window.location.reload();
31
+ }
@@ -0,0 +1,23 @@
1
+ /** sessionStorage key holding the time of the last chunk-failure reload. */
2
+ export const CHUNK_RELOAD_KEY = 'seemore:chunk-reload';
3
+
4
+ /** How long one reload blocks the next: a second failure inside the window renders the error instead of looping. */
5
+ export const CHUNK_RELOAD_WINDOW_MS = 5_000;
6
+
7
+ /**
8
+ * One reload for a chunk that vanished under the open tab — a deploy renamed the hashed
9
+ * files, and the fresh HTML names the new ones. The timestamp guard allows one reload per
10
+ * window: a reload that lands on the same failure falls through to the error boundary
11
+ * instead of looping, and once the window passes, a later deploy recovers normally.
12
+ */
13
+ export function onVitePreloadError(storage: Storage, now: number, reload: () => void): void {
14
+ const last = storage.getItem(CHUNK_RELOAD_KEY);
15
+ if (last !== null && now - Number(last) < CHUNK_RELOAD_WINDOW_MS) return;
16
+ try {
17
+ storage.setItem(CHUNK_RELOAD_KEY, String(now));
18
+ } catch {
19
+ // Storage blocked means no guard, and an unguarded reload could loop forever.
20
+ return;
21
+ }
22
+ reload();
23
+ }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState, type ComponentProps } from 'react';
2
2
  import { FileText } from 'lucide-react';
3
+ import { isRemoteHref } from '../../shared/base.js';
3
4
 
4
5
  /**
5
6
  * Sibling PDFs render in the browser's own viewer.
@@ -23,6 +24,7 @@ import { FileText } from 'lucide-react';
23
24
  */
24
25
  export function Pdf({ src, title, ...props }: ComponentProps<'embed'> & { src: string }) {
25
26
  const [unsupported, setUnsupported] = useState(false);
27
+ const file = useDecryptedUrl(src);
26
28
 
27
29
  useEffect(() => {
28
30
  const noPdfViewerApi = 'pdfViewerEnabled' in navigator && !navigator.pdfViewerEnabled;
@@ -34,8 +36,9 @@ export function Pdf({ src, title, ...props }: ComponentProps<'embed'> & { src: s
34
36
 
35
37
  return (
36
38
  <span className={unsupported ? 'seemore-pdf seemore-pdf-unsupported' : 'seemore-pdf'}>
37
- <embed src={src} type="application/pdf" title={title} {...props} />
38
- <a className="seemore-pdf-fallback" href={src} download>
39
+ {file === undefined ? undefined : <embed src={file} type="application/pdf" title={title} {...props} />}
40
+ {/* A blob URL has no file name of its own, so a protected build names the download. */}
41
+ <a className="seemore-pdf-fallback" href={file ?? src} download={file?.startsWith('blob:') ? src.split('/').pop() : true}>
39
42
  <FileText className="seemore-pdf-fallback-icon" aria-hidden="true" />
40
43
  <span className="seemore-pdf-fallback-title">Download {title ?? 'PDF'}</span>
41
44
  </a>
@@ -43,4 +46,37 @@ export function Pdf({ src, title, ...props }: ComponentProps<'embed'> & { src: s
43
46
  );
44
47
  }
45
48
 
49
+ /**
50
+ * The URL to hand `<embed>`. On a password-protected build the file on the host is ciphertext,
51
+ * and browsers load `<embed>` without going through the service worker that decrypts — so the
52
+ * PDF is fetched first, which does go through it, and embedded as a blob. The raw address is
53
+ * never embedded: WebKit caches that ciphertext response and serves it to later fetches too.
54
+ */
55
+ function useDecryptedUrl(src: string): string | undefined {
56
+ // A remote PDF is not on this host, so it was never encrypted — and a cross-origin fetch of
57
+ // it would usually be refused anyway.
58
+ const encrypted = import.meta.env.SEEMORE_AUTH && !isRemoteHref(src);
59
+ const [url, setUrl] = useState<string | undefined>(encrypted ? undefined : src);
60
+
61
+ useEffect(() => {
62
+ if (!encrypted) return;
63
+ let revoked = false;
64
+ let blobUrl: string | undefined;
65
+ void fetch(src)
66
+ .then((response) => (response.ok ? response.blob() : Promise.reject(new Error(String(response.status)))))
67
+ .then((blob) => {
68
+ if (revoked) return;
69
+ blobUrl = URL.createObjectURL(blob.type === 'application/pdf' ? blob : new Blob([blob], { type: 'application/pdf' }));
70
+ setUrl(blobUrl);
71
+ })
72
+ .catch(() => undefined);
73
+ return () => {
74
+ revoked = true;
75
+ if (blobUrl !== undefined) URL.revokeObjectURL(blobUrl);
76
+ };
77
+ }, [src, encrypted]);
78
+
79
+ return url;
80
+ }
81
+
46
82
  export default Pdf;
@@ -62,7 +62,7 @@
62
62
  }
63
63
 
64
64
  .seemore-search-trigger {
65
- @apply ms-auto inline-flex shrink-0 items-center gap-2 rounded-lg border border-fd-border p-2 text-sm text-fd-muted-foreground sm:px-3 sm:py-1.5;
65
+ @apply ms-auto inline-flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-fd-border p-2 text-sm text-fd-muted-foreground sm:px-3 sm:py-1.5;
66
66
  }
67
67
 
68
68
  /* Icon only where there is no room for the label. */
@@ -75,7 +75,8 @@
75
75
  @apply rounded border border-fd-border px-1 text-xs;
76
76
  }
77
77
 
78
- .seemore-theme-toggle {
78
+ .seemore-theme-toggle,
79
+ .seemore-lock {
79
80
  @apply shrink-0;
80
81
  }
81
82
 
@@ -103,8 +104,9 @@
103
104
  @apply h-4 w-4 animate-spin rounded-full border-2 border-fd-muted-foreground/30 border-t-fd-muted-foreground;
104
105
  }
105
106
 
106
- .seemore-theme-toggle {
107
- @apply rounded-lg border border-fd-border p-2;
107
+ .seemore-theme-toggle,
108
+ .seemore-lock {
109
+ @apply cursor-pointer rounded-lg border border-fd-border p-2;
108
110
  }
109
111
 
110
112
  .seemore-icon-dark {
@@ -15,3 +15,8 @@ declare module 'virtual:seemore/routes' {
15
15
  declare module 'virtual:seemore/config' {
16
16
  export const config: import('../shared/types.js').ClientConfig;
17
17
  }
18
+
19
+ interface ImportMetaEnv {
20
+ /** Defined by the client build: whether the site is password-protected (`auth`). */
21
+ readonly SEEMORE_AUTH?: boolean;
22
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Password protection's cryptography.
3
+ *
4
+ * All of it is WebCrypto — `globalThis.crypto.subtle` exists in the browser, in a service
5
+ * worker and in Node ≥ 20 — so the build that encrypts and the browser that decrypts share
6
+ * this one implementation.
7
+ *
8
+ * Envelope encryption: the password derives a key-encryption key (KEK), which wraps a random
9
+ * content key made fresh by every build. Visitors keep the KEK, so a deploy with the same
10
+ * password never logs them out, and old ciphertext can never be mixed with new.
11
+ */
12
+
13
+ export const KDF_ITERATIONS = 600_000;
14
+
15
+ /** `SMP1`: the first four bytes of every encrypted file. */
16
+ export const MAGIC = new Uint8Array([0x53, 0x4d, 0x50, 0x31]);
17
+
18
+ const IV_BYTES = 12;
19
+ const HEADER_BYTES = MAGIC.length + IV_BYTES;
20
+ const SALT_PREFIX = 'seemore-auth-v1\0';
21
+
22
+ /** `auth.json`: public, and holds nothing a guess can be checked against but the wrapped key. */
23
+ export interface AuthManifest {
24
+ v: 1;
25
+ kdf: { name: 'PBKDF2'; hash: 'SHA-256'; iterations: number; salt: string };
26
+ /** The content key, wrapped with the KEK (AES-KW), base64. */
27
+ key: string;
28
+ /** Seconds a visitor stays unlocked after their last visit; 0 means only while the tab is open. */
29
+ remember: number;
30
+ }
31
+
32
+ const encoder = new TextEncoder();
33
+
34
+ function subtle(): SubtleCrypto {
35
+ return globalThis.crypto.subtle;
36
+ }
37
+
38
+ /** A password typed on macOS and on Windows must derive the same key. */
39
+ export function normalisePassword(password: string): string {
40
+ return password.normalize('NFC');
41
+ }
42
+
43
+ /**
44
+ * Derived from the site's stable name, not random: a random salt per build would log every
45
+ * visitor out on every deploy. Prefixed, so the digest is never a plain hash of the title.
46
+ */
47
+ export async function deriveSalt(id: string): Promise<Uint8Array<ArrayBuffer>> {
48
+ return new Uint8Array(await subtle().digest('SHA-256', encoder.encode(SALT_PREFIX + id)));
49
+ }
50
+
51
+ /** Non-extractable: page code can use the stored key, never read its bytes. */
52
+ export async function deriveKek(
53
+ password: string,
54
+ salt: Uint8Array<ArrayBuffer>,
55
+ iterations: number = KDF_ITERATIONS,
56
+ ): Promise<CryptoKey> {
57
+ const material = await subtle().importKey('raw', encoder.encode(normalisePassword(password)), 'PBKDF2', false, [
58
+ 'deriveKey',
59
+ ]);
60
+ return await subtle().deriveKey(
61
+ { name: 'PBKDF2', hash: 'SHA-256', salt, iterations },
62
+ material,
63
+ { name: 'AES-KW', length: 256 },
64
+ false,
65
+ ['wrapKey', 'unwrapKey'],
66
+ );
67
+ }
68
+
69
+ export async function generateContentKey(): Promise<CryptoKey> {
70
+ return await subtle().generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
71
+ }
72
+
73
+ export async function wrapContentKey(contentKey: CryptoKey, kek: CryptoKey): Promise<Uint8Array<ArrayBuffer>> {
74
+ return new Uint8Array(await subtle().wrapKey('raw', contentKey, kek, 'AES-KW'));
75
+ }
76
+
77
+ /**
78
+ * A wrong password fails here: AES-KW carries its own integrity check. That failure is the
79
+ * password check — there is no separate verifier to attack.
80
+ */
81
+ export async function unwrapContentKey(wrapped: Uint8Array<ArrayBuffer>, kek: CryptoKey): Promise<CryptoKey> {
82
+ return await subtle().unwrapKey('raw', wrapped, kek, 'AES-KW', 'AES-GCM', false, ['decrypt']);
83
+ }
84
+
85
+ /**
86
+ * `SMP1 || iv || ciphertext+tag`. The file's published path is the associated data, so a
87
+ * ciphertext moved to another path fails authentication.
88
+ */
89
+ export async function encryptFile(
90
+ key: CryptoKey,
91
+ path: string,
92
+ plaintext: Uint8Array<ArrayBuffer>,
93
+ ): Promise<Uint8Array<ArrayBuffer>> {
94
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
95
+ const sealed = await subtle().encrypt(
96
+ { name: 'AES-GCM', iv, additionalData: encoder.encode(path), tagLength: 128 },
97
+ key,
98
+ plaintext,
99
+ );
100
+ const out = new Uint8Array(HEADER_BYTES + sealed.byteLength);
101
+ out.set(MAGIC, 0);
102
+ out.set(iv, MAGIC.length);
103
+ out.set(new Uint8Array(sealed), HEADER_BYTES);
104
+ return out;
105
+ }
106
+
107
+ export function isEncrypted(bytes: Uint8Array): boolean {
108
+ return bytes.length > HEADER_BYTES && MAGIC.every((byte, index) => bytes[index] === byte);
109
+ }
110
+
111
+ export async function decryptFile(
112
+ key: CryptoKey,
113
+ path: string,
114
+ bytes: Uint8Array<ArrayBuffer>,
115
+ ): Promise<Uint8Array<ArrayBuffer>> {
116
+ if (!isEncrypted(bytes)) throw new Error(`${path} is not an encrypted file.`);
117
+ const iv = bytes.slice(MAGIC.length, HEADER_BYTES);
118
+ const plain = await subtle().decrypt(
119
+ { name: 'AES-GCM', iv, additionalData: encoder.encode(path), tagLength: 128 },
120
+ key,
121
+ bytes.subarray(HEADER_BYTES),
122
+ );
123
+ return new Uint8Array(plain);
124
+ }
125
+
126
+ export interface CreatedManifest {
127
+ manifest: AuthManifest;
128
+ contentKey: CryptoKey;
129
+ }
130
+
131
+ /** A fresh content key, wrapped for this password and site name. */
132
+ export async function createManifest(options: {
133
+ password: string;
134
+ id: string;
135
+ remember: number;
136
+ iterations?: number;
137
+ }): Promise<CreatedManifest> {
138
+ const iterations = options.iterations ?? KDF_ITERATIONS;
139
+ const salt = await deriveSalt(options.id);
140
+ const kek = await deriveKek(options.password, salt, iterations);
141
+ const contentKey = await generateContentKey();
142
+
143
+ return {
144
+ contentKey,
145
+ manifest: {
146
+ v: 1,
147
+ kdf: { name: 'PBKDF2', hash: 'SHA-256', iterations, salt: encodeBase64(salt) },
148
+ key: encodeBase64(await wrapContentKey(contentKey, kek)),
149
+ remember: options.remember,
150
+ },
151
+ };
152
+ }
153
+
154
+ export function parseManifest(value: unknown): AuthManifest {
155
+ const manifest = value as Partial<AuthManifest> | null;
156
+ if (
157
+ manifest === null ||
158
+ typeof manifest !== 'object' ||
159
+ manifest.v !== 1 ||
160
+ typeof manifest.key !== 'string' ||
161
+ typeof manifest.remember !== 'number' ||
162
+ !(manifest.remember > 0) ||
163
+ typeof manifest.kdf?.salt !== 'string' ||
164
+ typeof manifest.kdf.iterations !== 'number'
165
+ ) {
166
+ throw new Error('auth.json is not a manifest this version of seemore can read.');
167
+ }
168
+ return manifest as AuthManifest;
169
+ }
170
+
171
+ export async function deriveManifestKek(password: string, manifest: AuthManifest): Promise<CryptoKey> {
172
+ return await deriveKek(password, decodeBase64(manifest.kdf.salt), manifest.kdf.iterations);
173
+ }
174
+
175
+ export async function unlockManifest(manifest: AuthManifest, kek: CryptoKey): Promise<CryptoKey> {
176
+ return await unwrapContentKey(decodeBase64(manifest.key), kek);
177
+ }
178
+
179
+ export function encodeBase64(bytes: Uint8Array): string {
180
+ let binary = '';
181
+ for (const byte of bytes) binary += String.fromCharCode(byte);
182
+ return btoa(binary);
183
+ }
184
+
185
+ export function decodeBase64(text: string): Uint8Array<ArrayBuffer> {
186
+ const binary = atob(text);
187
+ const bytes = new Uint8Array(binary.length);
188
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
189
+ return bytes;
190
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Names shared by the build, the lock shell, the service worker and the app.
3
+ */
4
+
5
+ /**
6
+ * Files a protected build writes unencrypted, relative to the base. Everything else in the
7
+ * output must be encrypted or the build fails; the configured favicon is added at build time.
8
+ */
9
+ export const PUBLIC_FILES = [
10
+ 'index.html',
11
+ '404.html',
12
+ '200.html',
13
+ '_redirects',
14
+ '.nojekyll',
15
+ '_headers',
16
+ 'sw.js',
17
+ 'auth.json',
18
+ ] as const;
19
+
20
+ export const MANIFEST_FILE = 'auth.json';
21
+ export const WORKER_FILE = 'sw.js';
22
+ /** The app's HTML template: encrypted, and served by the worker for every navigation. */
23
+ export const APP_FILE = 'app.html';
24
+
25
+ /** Posted to the service worker by the Lock button, to forget the key. */
26
+ export const LOCK_MESSAGE = 'seemore-auth:lock';
27
+
28
+ /** The id of the lock shell's config element; the worker recognises the lock shell by it. */
29
+ export const SHELL_CONFIG_ID = 'seemore-auth-config';
30
+
31
+ /**
32
+ * The stored key's record id: the site's scope and salt, so two protected sites on one origin
33
+ * never read or delete each other's key.
34
+ */
35
+ export function recordId(scope: string, salt: string): string {
36
+ return `${scope} ${salt}`;
37
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The lock screen's script, bundled and inlined into the lock shell. It depends on nothing
3
+ * in the app bundle, which is encrypted.
4
+ *
5
+ * Register the worker, derive the KEK from the typed password, prove it by unwrapping the
6
+ * manifest's key, store the KEK, and reload — the worker then serves the app.
7
+ */
8
+ import { deriveManifestKek, parseManifest, unlockManifest, type AuthManifest } from './crypto.js';
9
+ import { MANIFEST_FILE, SHELL_CONFIG_ID, WORKER_FILE, recordId } from './files.js';
10
+ import { indexedDbStore } from './store.js';
11
+
12
+ interface ShellConfig {
13
+ base: string;
14
+ }
15
+
16
+ /** When the last hard-reload recovery ran; guards it from looping. */
17
+ const RESUME_KEY = 'seemore-auth:resume';
18
+
19
+ const element = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
20
+
21
+ const config = JSON.parse(element(SHELL_CONFIG_ID).textContent ?? '{}') as ShellConfig;
22
+ const scope = new URL(config.base, location.origin).href;
23
+ const form = element<HTMLFormElement>('seemore-auth-form');
24
+ const input = element<HTMLInputElement>('seemore-auth-password');
25
+ const button = element<HTMLButtonElement>('seemore-auth-submit');
26
+ const status = element<HTMLParagraphElement>('seemore-auth-status');
27
+ const unsupported = element<HTMLParagraphElement>('seemore-auth-unsupported');
28
+ const buttonLabel = button.textContent ?? 'Unlock';
29
+
30
+ form.addEventListener('submit', (event) => {
31
+ event.preventDefault();
32
+ void submit();
33
+ });
34
+
35
+ void start();
36
+
37
+ async function start(): Promise<void> {
38
+ // Not a secure context, service workers disabled, or a private window that refuses them.
39
+ if (!window.isSecureContext || !('serviceWorker' in navigator)) return showUnsupported();
40
+ try {
41
+ await navigator.serviceWorker.register(config.base + WORKER_FILE, { scope: config.base });
42
+ // A browser can accept the registration and still never keep it (service workers
43
+ // blocked by policy): without one, unlocking could only hang.
44
+ if ((await navigator.serviceWorker.getRegistration(config.base)) === undefined) return showUnsupported();
45
+ } catch {
46
+ return showUnsupported();
47
+ }
48
+
49
+ showForm();
50
+ await resumeAfterHardReload();
51
+ }
52
+
53
+ function showForm(): void {
54
+ unsupported.hidden = true;
55
+ form.hidden = false;
56
+ input.focus();
57
+ }
58
+
59
+ function showUnsupported(): void {
60
+ form.hidden = true;
61
+ unsupported.hidden = false;
62
+ }
63
+
64
+ async function submit(): Promise<void> {
65
+ button.disabled = true;
66
+ button.textContent = 'Unlocking…';
67
+ status.textContent = '';
68
+
69
+ let manifest: AuthManifest;
70
+ let kek: CryptoKey;
71
+ try {
72
+ manifest = await fetchManifest();
73
+ kek = await deriveManifestKek(input.value, manifest);
74
+ } catch {
75
+ return fail("Couldn't load this site. Check your connection and try again.");
76
+ }
77
+
78
+ try {
79
+ await unlockManifest(manifest, kek);
80
+ } catch {
81
+ return fail('Password is incorrect', true);
82
+ }
83
+
84
+ try {
85
+ await storeKey(manifest, kek);
86
+ await controlledByWorker();
87
+ } catch {
88
+ return fail("This browser blocked site storage, so the key can't be saved. Allow site data or try another browser.");
89
+ }
90
+
91
+ location.reload();
92
+ }
93
+
94
+ function fail(message: string, shake = false): void {
95
+ button.disabled = false;
96
+ button.textContent = buttonLabel;
97
+ status.textContent = message;
98
+ if (shake) {
99
+ form.classList.remove('lock-shake');
100
+ void form.offsetWidth;
101
+ form.classList.add('lock-shake');
102
+ }
103
+ input.focus();
104
+ input.select();
105
+ }
106
+
107
+ async function fetchManifest(): Promise<AuthManifest> {
108
+ const response = await fetch(config.base + MANIFEST_FILE, { cache: 'no-store' });
109
+ if (!response.ok) throw new Error(`${MANIFEST_FILE} answered ${response.status}.`);
110
+ return parseManifest(await response.json());
111
+ }
112
+
113
+ async function storeKey(manifest: AuthManifest, kek: CryptoKey): Promise<void> {
114
+ await indexedDbStore().put(recordId(scope, manifest.kdf.salt), { kek, lastSeen: Date.now() });
115
+ }
116
+
117
+ /**
118
+ * `clients.claim()` hands this document to the worker as it activates. If the worker was
119
+ * already active and this document still is not controlled, a plain reload is what hands it
120
+ * over, so waiting is capped.
121
+ */
122
+ async function controlledByWorker(): Promise<void> {
123
+ const ready = await Promise.race([navigator.serviceWorker.ready.then(() => true), delay(10_000).then(() => false)]);
124
+ if (!ready) throw new Error('The service worker never became active.');
125
+ if (navigator.serviceWorker.controller !== null) return;
126
+ await new Promise<void>((resolve) => {
127
+ navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
128
+ setTimeout(resolve, 3000);
129
+ });
130
+ }
131
+
132
+ /**
133
+ * A shift-reload bypasses the worker, so a visitor who is still unlocked lands on the lock
134
+ * shell. One ordinary reload hands the navigation back to the worker, which decides.
135
+ */
136
+ async function resumeAfterHardReload(): Promise<void> {
137
+ if (navigator.serviceWorker.controller !== null) return;
138
+ try {
139
+ const manifest = await fetchManifest();
140
+ if ((await indexedDbStore().get(recordId(scope, manifest.kdf.salt))) === undefined) return;
141
+ if (Date.now() - Number(sessionStorage.getItem(RESUME_KEY)) < 10_000) return;
142
+ sessionStorage.setItem(RESUME_KEY, String(Date.now()));
143
+ await navigator.serviceWorker.ready;
144
+ location.reload();
145
+ } catch {
146
+ // Nothing to resume: the form is already showing.
147
+ }
148
+ }
149
+
150
+ function delay(ms: number): Promise<void> {
151
+ return new Promise((resolve) => setTimeout(resolve, ms));
152
+ }