@rapidmx/web-client 0.3.0 → 0.4.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.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. package/package.json +3 -2
@@ -0,0 +1,227 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The Tier 2 local index's SQLite schema (`specs/search.md` §13). One database per mailbox (see
7
+ * `localIndexWorker.ts`), holding both the FTS5 index and the metadata cache in the same store - the
8
+ * spec's own rationale for choosing SQLite over an in-memory JS index in the first place ("the index
9
+ * database also holds the decrypted metadata cache... so there is one local store rather than two").
10
+ *
11
+ * Bumping `SCHEMA_VERSION` is how a schema change invalidates every existing local index (§11
12
+ * "Invalidation... on schema version change") - `localIndexWorker.ts` compares it against `meta`'s
13
+ * stored value on open and discards+rebuilds on a mismatch, the same path a corruption/GCM-auth-failure
14
+ * takes.
15
+ */
16
+
17
+ /** Bump whenever `CREATE_SCHEMA_SQL` changes in a way existing on-disk databases can't be reconciled
18
+ * with in place. */
19
+ export const SCHEMA_VERSION = 1;
20
+
21
+ /**
22
+ * `entities` is the real row store (metadata + the plaintext content fields), `entities_fts` is an FTS5
23
+ * *external content* table over it (`content='entities'`) - the standard SQLite pattern for keeping one
24
+ * copy of the text instead of duplicating it into the FTS5 shadow tables, kept in sync via the three
25
+ * triggers below (SQLite's own documented pattern for external-content FTS5 tables; there is no
26
+ * "ON CONFLICT UPDATE re-index" primitive, so update is modeled as delete-then-reinsert into the FTS
27
+ * index specifically, not into `entities` itself).
28
+ *
29
+ * Column order in `entities_fts` (`subject, participants, body, attachment_text`) is load-bearing: every
30
+ * `bm25(entities_fts, 3.0, 2.0, 1.0, 1.0)` call elsewhere in this module family assumes that exact
31
+ * positional order, matching `searchScoring.ts`'s `SEARCH_FIELD_WEIGHTS` (`subject: 3, participants: 2,
32
+ * body: 1, attachmentText: 1`) so Tier 2's local ranking agrees with the Tier 1/Tier 3 re-scoring the
33
+ * spec requires for one consistent ordering across tiers (§7).
34
+ */
35
+ export const CREATE_SCHEMA_SQL = `
36
+ CREATE TABLE IF NOT EXISTS meta (
37
+ key TEXT PRIMARY KEY,
38
+ value TEXT
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS entities (
42
+ rowid INTEGER PRIMARY KEY,
43
+ entity_type TEXT NOT NULL,
44
+ entity_uid TEXT NOT NULL UNIQUE,
45
+ mailbox_uid TEXT NOT NULL,
46
+ folder_uid TEXT,
47
+ date_for_sort TEXT NOT NULL,
48
+ participants TEXT,
49
+ flags TEXT,
50
+ has_attachments INTEGER NOT NULL DEFAULT 0,
51
+ subject TEXT,
52
+ body TEXT,
53
+ attachment_text TEXT,
54
+ byte_size INTEGER NOT NULL DEFAULT 0
55
+ );
56
+ CREATE INDEX IF NOT EXISTS idx_entities_date ON entities(date_for_sort);
57
+ CREATE INDEX IF NOT EXISTS idx_entities_mailbox ON entities(mailbox_uid);
58
+
59
+ CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
60
+ subject, participants, body, attachment_text,
61
+ content='entities', content_rowid='rowid', tokenize='unicode61'
62
+ );
63
+
64
+ CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
65
+ INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
66
+ VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
67
+ END;
68
+
69
+ CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
70
+ INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
71
+ VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
72
+ END;
73
+
74
+ CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
75
+ INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
76
+ VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
77
+ INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
78
+ VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
79
+ END;
80
+ `;
81
+
82
+ /** The exact `bm25()` weight arguments every ranked query against `entities_fts` MUST pass, in column
83
+ * order - see this module's own doc comment on why the order is load-bearing. Centralized here so a
84
+ * future column reorder can't silently desync a query building its own literal weight list. */
85
+ export const BM25_WEIGHTS_SQL = "3.0, 2.0, 1.0, 1.0";
86
+
87
+ /** One message's decrypted content, ready to index - `localIndexBuilder.ts`'s own output shape, built
88
+ * from `Message` + the recovered `MessageSecurityResult` fields the same way `searchTier3.ts` already
89
+ * derives them for its own per-candidate matching. */
90
+ export interface LocalIndexEntity {
91
+ entityType: "message";
92
+ entityUid: string;
93
+ mailboxUid: string;
94
+ folderUid?: string;
95
+ /** ISO 8601 - compares correctly as plain text since every value here is UTC. */
96
+ dateForSort: string;
97
+ participants: string;
98
+ /** Comma-delimited, leading/trailing commas included (`,read,flagged,`) - simplest possible substring
99
+ * match (`flags LIKE '%,read,%'`) without needing SQLite's JSON1 extension compiled in. */
100
+ flags: string;
101
+ hasAttachments: boolean;
102
+ subject?: string;
103
+ body?: string;
104
+ attachmentText?: string;
105
+ /** Rough on-disk cost of this entity's own content, in bytes - what `localIndexBuilder.ts`'s
106
+ * byte-budget accounting (spec §11) sums against the configured budget. */
107
+ byteSize: number;
108
+ }
109
+
110
+ /** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
111
+ * so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
112
+ * `entities_au` trigger keeps `entities_fts` in sync automatically. */
113
+ export const UPSERT_ENTITY_SQL = `
114
+ INSERT INTO entities (entity_type, entity_uid, mailbox_uid, folder_uid, date_for_sort, participants, flags, has_attachments, subject, body, attachment_text, byte_size)
115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
116
+ ON CONFLICT(entity_uid) DO UPDATE SET
117
+ folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,
118
+ flags = excluded.flags, has_attachments = excluded.has_attachments, subject = excluded.subject,
119
+ body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size
120
+ `;
121
+
122
+ /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
123
+ * out of sync with each other. */
124
+ export function entityBindValues(entity: LocalIndexEntity): (string | number)[] {
125
+ return [
126
+ entity.entityType,
127
+ entity.entityUid,
128
+ entity.mailboxUid,
129
+ entity.folderUid ?? null!,
130
+ entity.dateForSort,
131
+ entity.participants,
132
+ entity.flags,
133
+ entity.hasAttachments ? 1 : 0,
134
+ entity.subject ?? null!,
135
+ entity.body ?? null!,
136
+ entity.attachmentText ?? null!,
137
+ entity.byteSize,
138
+ ];
139
+ }
140
+
141
+ /** A parsed query's structured (non-free-text) fields - the subset of `ParsedSearchQuery`
142
+ * (`react-shared`'s `queryGrammar.ts`) this module's predicate builder reads. Typed locally rather than
143
+ * importing `ParsedSearchQuery` itself so this Worker-bundled module has no dependency on `@rapidmx/
144
+ * react-shared` beyond what it actually uses - `localIndexBuilder.ts`/`searchTier2.ts` (main-thread side)
145
+ * pass the real `ParsedSearchQuery` in, which structurally satisfies this. */
146
+ export interface LocalSearchPredicateFields {
147
+ from?: string;
148
+ to?: string;
149
+ cc?: string;
150
+ subject?: string;
151
+ hasAttachment?: boolean;
152
+ before?: Date;
153
+ after?: Date;
154
+ folderUid?: string;
155
+ flags?: string[];
156
+ }
157
+
158
+ /**
159
+ * Builds the SQL `WHERE` predicate (and its bind params) for every *structured* operator this local
160
+ * index can actually evaluate. **Known simplification**: unlike Tier 1's server-side `SearchDocument`
161
+ * (which splits `from`/`to`/`cc` into distinct fields - confirmed already implemented server-side), this
162
+ * local schema keeps only the combined `participants` field (see `CREATE_SCHEMA_SQL`'s own doc comment) -
163
+ * `from:`/`to:`/`cc:` are therefore evaluated here as a substring match against that combined field
164
+ * rather than a precise per-role match. Reasonable for a bounded, best-effort recent-window cache
165
+ * (Tier 1 already serves the precise version for anything it indexes), but a real gap if Tier 2 is later
166
+ * extended to distinguish them - flagged here rather than left silently approximate.
167
+ */
168
+ export function buildSearchPredicates(parsed: LocalSearchPredicateFields, mailboxUid: string): { where: string; params: (string | number)[] } {
169
+ const clauses: string[] = ["e.mailbox_uid = ?"];
170
+ const params: (string | number)[] = [mailboxUid];
171
+ if (parsed.folderUid) {
172
+ clauses.push("e.folder_uid = ?");
173
+ params.push(parsed.folderUid);
174
+ }
175
+ if (parsed.before) {
176
+ clauses.push("e.date_for_sort < ?");
177
+ params.push(parsed.before.toISOString());
178
+ }
179
+ if (parsed.after) {
180
+ clauses.push("e.date_for_sort > ?");
181
+ params.push(parsed.after.toISOString());
182
+ }
183
+ if (parsed.hasAttachment !== undefined) {
184
+ clauses.push("e.has_attachments = ?");
185
+ params.push(parsed.hasAttachment ? 1 : 0);
186
+ }
187
+ for (const flag of parsed.flags ?? []) {
188
+ clauses.push("e.flags LIKE ?");
189
+ params.push(`%,${flag},%`);
190
+ }
191
+ for (const participant of [parsed.from, parsed.to, parsed.cc]) {
192
+ if (participant) {
193
+ clauses.push("e.participants LIKE ?");
194
+ params.push(`%${participant}%`);
195
+ }
196
+ }
197
+ return { where: clauses.join(" AND "), params };
198
+ }
199
+
200
+ /** Escapes a free-text fragment for safe embedding inside an FTS5 `MATCH` phrase - FTS5's own quoting
201
+ * rule for a `"..."` phrase is doubling an embedded `"`, mirroring SQL string-literal escaping. */
202
+ function escapeFtsPhrase(value: string): string {
203
+ return value.replace(/"/g, '""');
204
+ }
205
+
206
+ /**
207
+ * Builds the FTS5 `MATCH` expression for a parsed query's free-text and `subject:` portions, or
208
+ * `undefined` when there's nothing to match on text at all (a pure operator/structured-filter query -
209
+ * `buildSearchPredicates()`'s `WHERE` clause alone already narrows that case correctly, no `MATCH`
210
+ * needed). `parsed.text` is passed through close to verbatim (quoted phrases, `OR`, `-` negation - FTS5's
211
+ * own query syntax supports the same shape `queryGrammar.ts`'s own doc comment says every provider's
212
+ * free-text engine is expected to), wrapped only enough to combine it with a `subject:`-scoped clause
213
+ * when both are present.
214
+ */
215
+ export function buildMatchExpression(parsed: { text: string; subject?: string }): string | undefined {
216
+ const parts: string[] = [];
217
+ if (parsed.subject) {
218
+ parts.push(`subject:"${escapeFtsPhrase(parsed.subject)}"`);
219
+ }
220
+ if (parsed.text.trim()) {
221
+ parts.push(parsed.subject ? `(${parsed.text})` : parsed.text);
222
+ }
223
+ if (parts.length === 0) {
224
+ return undefined;
225
+ }
226
+ return parts.join(" AND ");
227
+ }
@@ -0,0 +1,88 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The user-adjustable byte budget behind the Tier 2 local index's window (`localIndexBuilder.ts`'s
7
+ * `buildLocalIndex()`). Stored in `localStorage`, not synced to the server - a per-device preference
8
+ * about *this device's* own local OPFS storage, the same posture `idleTimeout.ts`
9
+ * (`@rapidmx/react-shared`) already takes for its own per-device setting, which this file mirrors.
10
+ *
11
+ * `specs/search.md` §10/§11 distinguish "Web" (quota-limited) from "Native desktop" (disk-limited) as
12
+ * different rows of the same Window Sizing table, not different storage engines - Electron's renderer is
13
+ * Chromium, so it runs this exact same OPFS/wa-sqlite/`EncryptingVFS` code, just with more disk headroom
14
+ * to spend. `isElectronRuntime()` reads `window.rapidmx` - the `contextBridge` global
15
+ * `electron-client/src/main/preload.ts` exposes only inside that renderer (see its own `global.d.ts`) -
16
+ * as a runtime duck-type signal, so this file (which `electron-client` consumes unmodified via its
17
+ * `link:../web-client` dependency - see `localIndexBuilder.ts`'s own doc comment on that) never needs an
18
+ * explicit "which platform am I" value threaded down from anywhere.
19
+ */
20
+ const STORAGE_KEY = "rapidmx:local-index-byte-budget";
21
+
22
+ const MB = 1024 * 1024;
23
+ const GB = 1024 * MB;
24
+
25
+ /** §11's Window Sizing table, Web row. */
26
+ export const WEB_DEFAULT_BYTE_BUDGET_BYTES = 500 * MB;
27
+ /** §11's Window Sizing table, Native desktop row - still a real, user-adjustable ceiling (not
28
+ * `UNBOUNDED`), just a roomier default given Electron's storage is disk-limited rather than
29
+ * browser-quota-limited. */
30
+ export const ELECTRON_DEFAULT_BYTE_BUDGET_BYTES = 1 * GB;
31
+
32
+ export interface LocalIndexSizeOption {
33
+ bytes: number;
34
+ label: string;
35
+ }
36
+
37
+ /** Selectable presets for the Settings UI. `0` means "unlimited" - `applyEviction()`
38
+ * (`localIndexWorker.ts`) already treats a falsy byte budget as unconfigured/unenforced. */
39
+ export const LOCAL_INDEX_SIZE_OPTIONS: LocalIndexSizeOption[] = [
40
+ { bytes: 100 * MB, label: "100 MB" },
41
+ { bytes: 250 * MB, label: "250 MB" },
42
+ { bytes: WEB_DEFAULT_BYTE_BUDGET_BYTES, label: "500 MB" },
43
+ { bytes: ELECTRON_DEFAULT_BYTE_BUDGET_BYTES, label: "1 GB" },
44
+ { bytes: 2 * GB, label: "2 GB" },
45
+ { bytes: 5 * GB, label: "5 GB" },
46
+ { bytes: 10 * GB, label: "10 GB" },
47
+ { bytes: 0, label: "Unlimited (disk space only)" },
48
+ ];
49
+
50
+ function isElectronRuntime(): boolean {
51
+ return typeof window !== "undefined" && "rapidmx" in window;
52
+ }
53
+
54
+ /** This device's default byte budget before any explicit preference is saved - `500 MB` in a browser
55
+ * tab, `1 GB` in the Electron shell. */
56
+ export function getDefaultLocalIndexByteBudget(): number {
57
+ return isElectronRuntime() ? ELECTRON_DEFAULT_BYTE_BUDGET_BYTES : WEB_DEFAULT_BYTE_BUDGET_BYTES;
58
+ }
59
+
60
+ /**
61
+ * Reads this device's configured local-index byte budget. Falls back to
62
+ * `getDefaultLocalIndexByteBudget()` for a never-configured device, a corrupted/non-numeric stored
63
+ * value, or a `localStorage` access that throws (private-browsing/storage-blocked contexts) - never
64
+ * throws itself, matching `getIdleTimeoutMinutes()`'s identical fallback posture.
65
+ */
66
+ export function getLocalIndexByteBudget(): number {
67
+ try {
68
+ const stored = localStorage.getItem(STORAGE_KEY);
69
+ if (stored === null) {
70
+ return getDefaultLocalIndexByteBudget();
71
+ }
72
+ const parsed = Number(stored);
73
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : getDefaultLocalIndexByteBudget();
74
+ } catch {
75
+ return getDefaultLocalIndexByteBudget();
76
+ }
77
+ }
78
+
79
+ /** Persists this device's local-index byte-budget preference. A `localStorage` write failure is
80
+ * swallowed, not thrown - the setting just doesn't survive a reload in that case, same fallback-to-default
81
+ * behavior `getLocalIndexByteBudget()` already has for a storage-blocked context. */
82
+ export function setLocalIndexByteBudget(bytes: number): void {
83
+ try {
84
+ localStorage.setItem(STORAGE_KEY, String(bytes));
85
+ } catch {
86
+ // Best-effort - see this function's own doc comment.
87
+ }
88
+ }
@@ -0,0 +1,235 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * `EncryptingVFS` - the Tier 2 local index's at-rest encryption layer (`specs/search.md` §11
7
+ * "Persistence and protection", §13 "Encryption at Rest": "no official SQLCipher WASM build exists...
8
+ * encryption at rest requires a custom VFS that encrypts pages before they reach OPFS").
9
+ *
10
+ * Wraps (composes, does not subclass - `AccessHandlePoolVFS`'s own file-table state is private `#`
11
+ * fields) an `AccessHandlePoolVFS` instance and delegates every `FacadeVFS` method straight through
12
+ * except `jRead`/`jWrite`, which it intercepts to decrypt/encrypt pages.
13
+ *
14
+ * ## Page layout
15
+ *
16
+ * SQLite is opened with a fixed 4096-byte page size (`PRAGMA page_size=4096` - set by
17
+ * `localIndexWorker.ts` before the first write, since SQLite fixes a database's page size on creation).
18
+ * Each *logical* 4096-byte page is stored *physically* as `nonce(12) || AES-256-GCM(ciphertext(4096) ||
19
+ * tag(16))` = 4124 bytes, at a remapped offset (`physicalOffset(page) = page * 4124`). This block size
20
+ * is this class's own fixed constant, independent of whatever byte range a given `jRead`/`jWrite` call
21
+ * actually requests - reads/writes are handled generically over whichever physical blocks the requested
22
+ * logical range overlaps (including a read-modify-write for a sub-block write), not by assuming SQLite
23
+ * only ever issues page-aligned, page-sized I/O. That assumption holds for the *main database file* once
24
+ * its page size is fixed, but handling the general case costs little and removes the need to rely on it.
25
+ *
26
+ * **A fresh random nonce is generated on every write, never reused or derived from a counter** - the
27
+ * simplest way to make an AES-GCM (key, nonce) pair never repeat across different plaintexts, which is
28
+ * the one hard requirement GCM has. The nonce travels with its block, so decryption never needs any
29
+ * external state (a lost/corrupted counter, a persisted salt) to reconstruct it.
30
+ *
31
+ * **AAD binds each block to its logical filename and page index** (not just its own ciphertext) - GCM
32
+ * authenticates a block's own content but not its *position*; without this, an attacker able to write
33
+ * directly into this origin's OPFS storage (outside this codebase's own threat model per spec §4, which
34
+ * excludes a compromised client, but cheap to close off anyway) could silently swap two blocks and each
35
+ * would still decrypt "successfully" on its own, corrupting data instead of failing loudly. Binding to
36
+ * position turns a swap into a caught decryption failure - see `PageCorruptedError` below - the same
37
+ * "invalidate and rebuild" path a schema-version mismatch already takes (spec §11 "Invalidation").
38
+ *
39
+ * **No WAL, no rollback journal.** `localIndexWorker.ts` opens the database with `journal_mode=OFF`. A
40
+ * page cipher for the main database file is one encryption surface; WAL frames (their own header +
41
+ * checksum format) and rollback-journal records (their own, different record format) would each be a
42
+ * *second* one. This index has no durability requirement to justify that cost - spec §11 already
43
+ * requires discarding and rebuilding it on corruption, schema change, key rotation, or platform storage
44
+ * eviction, and eviction "MUST NOT block search" - so an interrupted write in the worst case is just
45
+ * caught by the same GCM-auth-failure -> rebuild path as any other corruption, never partial/torn state
46
+ * silently trusted.
47
+ *
48
+ * Every `jRead`/`jWrite` here is `async` (declared `async` specifically so `FacadeVFS.hasAsyncMethod()`
49
+ * detects it via `instanceof AsyncFunction` and awaits it) because `crypto.subtle.encrypt`/`decrypt` has
50
+ * no synchronous form in a browser - this is *why* `localIndexWorker.ts` boots the Asyncify SQLite build
51
+ * (`dist/wa-sqlite-async.mjs`), not the plain synchronous one `AccessHandlePoolVFS`'s own doc comment
52
+ * says it's designed for: that claim is about `AccessHandlePoolVFS`'s *own* methods (real synchronous
53
+ * OPFS access-handle calls), which stay synchronous and work fine wrapped underneath an async outer VFS
54
+ * on an Asyncify build - the build choice is driven by this class's needs, not the inner VFS's.
55
+ */
56
+ import { FacadeVFS } from "@journeyapps/wa-sqlite/src/FacadeVFS.js";
57
+ import { AccessHandlePoolVFS } from "@journeyapps/wa-sqlite/src/examples/AccessHandlePoolVFS.js";
58
+ import * as VFS from "@journeyapps/wa-sqlite/src/VFS.js";
59
+ import {
60
+ LOGICAL_BLOCK_SIZE,
61
+ PHYSICAL_BLOCK_SIZE,
62
+ PageCorruptedError,
63
+ decryptBlock,
64
+ encryptBlock,
65
+ importAesGcmKey,
66
+ } from "./localIndexBlockCipher.js";
67
+
68
+ export { PageCorruptedError } from "./localIndexBlockCipher.js";
69
+
70
+ export class EncryptingVFS extends FacadeVFS {
71
+ #inner: AccessHandlePoolVFS;
72
+ #key: CryptoKey | undefined;
73
+ /** `jOpen`'s `filename` is stable across the life of a fileId; `fileId` itself is only valid for one
74
+ * open handle, never persisted - this map lets `jRead`/`jWrite` recover the stable filename an AAD
75
+ * needs to bind to, from the ephemeral fileId SQLite actually passes them. */
76
+ #filenamesByFileId = new Map<number, string>();
77
+
78
+ private constructor(name: string, module: unknown, inner: AccessHandlePoolVFS) {
79
+ super(name, module);
80
+ this.#inner = inner;
81
+ }
82
+
83
+ /** `rawKey` MUST be exactly 32 bytes (AES-256) - see `localIndexKey.ts`'s `deriveLocalIndexKey()`,
84
+ * the only intended source of this value. */
85
+ static async create(name: string, module: unknown, rawKey: Uint8Array): Promise<EncryptingVFS> {
86
+ const inner = await AccessHandlePoolVFS.create(name, module);
87
+ const vfs = new EncryptingVFS(name, module, inner);
88
+ vfs.#key = await importAesGcmKey(rawKey);
89
+ return vfs;
90
+ }
91
+
92
+ /**
93
+ * Releases every pooled OPFS sync access handle `AccessHandlePoolVFS` opened and holds open for its
94
+ * entire lifetime (not per SQLite-file-open/close - `jOpen`/`jClose` above only associate/disassociate
95
+ * a SQLite fileId with an already-open handle, they never open or close the handles themselves). MUST
96
+ * be called before creating another VFS instance against the same OPFS pool `name` - confirmed by
97
+ * direct reproduction: skipping this and calling `create()` again with the same `name` throws
98
+ * "Access Handles cannot be created if there is another open Access Handle," since the previous
99
+ * instance's handles are still live. `localIndexWorker.ts` calls this alongside `sqlite3.close(db)`
100
+ * (which closes the SQLite *connection*, a separate, shorter-lived thing from the VFS itself) whenever
101
+ * it tears down a mailbox's connection - on `destroy()` and in `selfTest()`'s own close/reopen check.
102
+ */
103
+ close(): void | Promise<void> {
104
+ return this.#inner.close();
105
+ }
106
+
107
+ // Every method below except jRead/jWrite is a pure passthrough to the inner (real storage) VFS -
108
+ // this class's only job is to sit in the read/write path.
109
+ jOpen(filename: string | null, pFile: number, flags: number, pOutFlags: DataView): number | Promise<number> {
110
+ const result = this.#inner.jOpen(filename, pFile, flags, pOutFlags);
111
+ this.#filenamesByFileId.set(pFile, filename ?? `(anon:${pFile})`);
112
+ return result;
113
+ }
114
+ jClose(pFile: number): number | Promise<number> {
115
+ this.#filenamesByFileId.delete(pFile);
116
+ return this.#inner.jClose(pFile);
117
+ }
118
+ jDelete(filename: string, syncDir: number): number | Promise<number> {
119
+ return this.#inner.jDelete(filename, syncDir);
120
+ }
121
+ jAccess(filename: string, flags: number, pResOut: DataView): number | Promise<number> {
122
+ return this.#inner.jAccess(filename, flags, pResOut);
123
+ }
124
+ jFullPathname(filename: string, zOut: Uint8Array): number | Promise<number> {
125
+ return this.#inner.jFullPathname(filename, zOut);
126
+ }
127
+ jSync(pFile: number, flags: number): number | Promise<number> {
128
+ return this.#inner.jSync(pFile, flags);
129
+ }
130
+ jSectorSize(pFile: number): number {
131
+ return LOGICAL_BLOCK_SIZE;
132
+ }
133
+ jDeviceCharacteristics(pFile: number): number {
134
+ return this.#inner.jDeviceCharacteristics(pFile);
135
+ }
136
+
137
+ /** Logical file size = physical size scaled back down to the logical block size - the inner VFS's
138
+ * own `jFileSize` reports the *physical* (post-remap) byte count, which is always an exact multiple
139
+ * of `PHYSICAL_BLOCK_SIZE` since every write here always fills whole physical blocks. */
140
+ async jFileSize(pFile: number, pSize64: DataView): Promise<number> {
141
+ const buf = new DataView(new ArrayBuffer(8));
142
+ const rc = await this.#inner.jFileSize(pFile, buf);
143
+ if (rc !== VFS.SQLITE_OK) return rc;
144
+ const physicalSize = Number(buf.getBigInt64(0, true));
145
+ const logicalSize = Math.floor(physicalSize / PHYSICAL_BLOCK_SIZE) * LOGICAL_BLOCK_SIZE;
146
+ pSize64.setBigInt64(0, BigInt(logicalSize), true);
147
+ return VFS.SQLITE_OK;
148
+ }
149
+
150
+ /** `iSize` is a logical byte size - truncates to the smallest whole number of *physical* blocks that
151
+ * still covers it, so a partially-truncated trailing block is never left half-written. */
152
+ async jTruncate(pFile: number, iSize: number): Promise<number> {
153
+ const blocks = Math.ceil(iSize / LOGICAL_BLOCK_SIZE);
154
+ return this.#inner.jTruncate(pFile, blocks * PHYSICAL_BLOCK_SIZE);
155
+ }
156
+
157
+ /**
158
+ * Reads one physical block and decrypts it. `absent: true` means nothing has ever been written at
159
+ * this block (the inner VFS's own read came back short) - `plaintext` is a zero-filled logical block
160
+ * in that case, matching what SQLite expects for a region it has never written, and it is
161
+ * deliberately never handed to `crypto.subtle.decrypt()` at all (there aren't enough physical bytes
162
+ * present to contain a real nonce+tag).
163
+ *
164
+ * `absent` is determined *only* from the inner VFS's own return code, never inferred from whether the
165
+ * decrypted plaintext happens to be all-zero - a real, fully-written SQLite page is routinely
166
+ * all-zero (an unused/freelist page, or a page beyond a freshly created database's real content), so
167
+ * that content shape means nothing about whether the block is actually present.
168
+ */
169
+ async #readBlock(pFile: number, filename: string, blockIndex: number): Promise<{ plaintext: Uint8Array; absent: boolean }> {
170
+ const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
171
+ const rc = await this.#inner.jRead(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
172
+ if (rc === VFS.SQLITE_IOERR_SHORT_READ) {
173
+ // #writeBlock() never writes fewer than PHYSICAL_BLOCK_SIZE bytes at a time, so a short read
174
+ // here only ever means "this block was never written," not "partially written."
175
+ return { plaintext: new Uint8Array(LOGICAL_BLOCK_SIZE), absent: true };
176
+ }
177
+ if (rc !== VFS.SQLITE_OK) {
178
+ throw new PageCorruptedError(filename, blockIndex, new Error(`inner VFS read failed: rc=${rc}`));
179
+ }
180
+ const plaintext = await decryptBlock(this.#key!, filename, blockIndex, physical);
181
+ return { plaintext, absent: false };
182
+ }
183
+
184
+ async #writeBlock(pFile: number, filename: string, blockIndex: number, plaintext: Uint8Array): Promise<number> {
185
+ const physical = await encryptBlock(this.#key!, filename, blockIndex, plaintext);
186
+ return this.#inner.jWrite(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
187
+ }
188
+
189
+ async jRead(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
190
+ const filename = this.#filenamesByFileId.get(pFile) ?? `(unknown:${pFile})`;
191
+ const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
192
+ const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
193
+ let anyAbsent = false;
194
+ for (let block = startBlock; block <= endBlock; block++) {
195
+ const { plaintext, absent } = await this.#readBlock(pFile, filename, block);
196
+ const blockStart = block * LOGICAL_BLOCK_SIZE;
197
+ const copyStart = Math.max(iOffset, blockStart);
198
+ const copyEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
199
+ pData.set(plaintext.subarray(copyStart - blockStart, copyEnd - blockStart), copyStart - iOffset);
200
+ // SQLITE_IOERR_SHORT_READ is how SQLite distinguishes "nothing here yet" from "here is real
201
+ // (possibly zero-filled) data," e.g. when probing whether a file exists at all - see
202
+ // #readBlock's own doc comment on why this is tracked explicitly, not inferred from content.
203
+ if (absent) {
204
+ anyAbsent = true;
205
+ }
206
+ }
207
+ return anyAbsent ? VFS.SQLITE_IOERR_SHORT_READ : VFS.SQLITE_OK;
208
+ }
209
+
210
+ async jWrite(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
211
+ const filename = this.#filenamesByFileId.get(pFile) ?? `(unknown:${pFile})`;
212
+ const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
213
+ const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
214
+ for (let block = startBlock; block <= endBlock; block++) {
215
+ const blockStart = block * LOGICAL_BLOCK_SIZE;
216
+ const writeStart = Math.max(iOffset, blockStart);
217
+ const writeEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
218
+ // Whole-block write (the common case once SQLite's page size is fixed): no need to read the
219
+ // old block first. Anything narrower (a sub-page write, or this block only partially
220
+ // overlaps the requested range) needs the existing content as a base - a real
221
+ // read-modify-write - since the physical block is re-encrypted as a single AEAD unit.
222
+ const plaintext =
223
+ writeStart === blockStart && writeEnd === blockStart + LOGICAL_BLOCK_SIZE
224
+ ? pData.subarray(writeStart - iOffset, writeEnd - iOffset)
225
+ : await this.#readBlock(pFile, filename, block).then(({ plaintext: existing }) => {
226
+ const merged = existing.slice();
227
+ merged.set(pData.subarray(writeStart - iOffset, writeEnd - iOffset), writeStart - blockStart);
228
+ return merged;
229
+ });
230
+ const rc = await this.#writeBlock(pFile, filename, block, plaintext);
231
+ if (rc !== VFS.SQLITE_OK) return rc;
232
+ }
233
+ return VFS.SQLITE_OK;
234
+ }
235
+ }