@rapidmx/web-client 0.3.1 → 0.5.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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -0,0 +1,98 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The Tier 2 local index's on-disk naming and bulk-removal helpers, shared by the Worker
7
+ * (`localIndexWorker.ts`) and the main thread (`localIndexRpcClient.ts`). Deliberately free of any
8
+ * wa-sqlite import so the main thread can use it without pulling the WASM build into its own bundle.
9
+ *
10
+ * Every mailbox's index lives in its own top-level OPFS directory named `rapidmx-localsearch-<mailboxUid>`
11
+ * (`AccessHandlePoolVFS` creates it from the VFS name). Enumerating that prefix is how sign-out destroys
12
+ * *every* index on this device - including ones for mailboxes this page load never opened, which the
13
+ * RPC client's own session-scoped bookkeeping can't know about - and how a sign-in prunes indexes left
14
+ * behind for mailboxes the current user can no longer access.
15
+ */
16
+
17
+ export const LOCAL_INDEX_POOL_PREFIX = "rapidmx-localsearch-";
18
+
19
+ /** The OPFS directory name (and `EncryptingVFS` name) a mailbox's index lives under. */
20
+ export function poolNameFor(mailboxUid: string): string {
21
+ return `${LOCAL_INDEX_POOL_PREFIX}${mailboxUid}`;
22
+ }
23
+
24
+ /** Inverse of `poolNameFor()`; `undefined` for any OPFS entry that isn't a local index directory. */
25
+ export function mailboxUidFromPoolName(name: string): string | undefined {
26
+ return name.startsWith(LOCAL_INDEX_POOL_PREFIX) && name.length > LOCAL_INDEX_POOL_PREFIX.length
27
+ ? name.slice(LOCAL_INDEX_POOL_PREFIX.length)
28
+ : undefined;
29
+ }
30
+
31
+ /** `navigator.storage.getDirectory()`, or `undefined` where OPFS isn't available at all (older browsers,
32
+ * jsdom/node) - there is nothing on disk to remove in that case. */
33
+ async function getOpfsRoot(): Promise<FileSystemDirectoryHandle | undefined> {
34
+ const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
35
+ if (!storage?.getDirectory) {
36
+ return undefined;
37
+ }
38
+ return storage.getDirectory();
39
+ }
40
+
41
+ function isNotFound(err: unknown): boolean {
42
+ return (err as { name?: string } | undefined)?.name === "NotFoundError";
43
+ }
44
+
45
+ /** Removes one mailbox's index directory. Resolves quietly when it doesn't exist; rejects for any other
46
+ * failure (most commonly `NoModificationAllowedError` - another tab still holds its access handles). */
47
+ export async function removeLocalIndexDirectory(mailboxUid: string): Promise<void> {
48
+ const root = await getOpfsRoot();
49
+ if (!root) {
50
+ return;
51
+ }
52
+ try {
53
+ await root.removeEntry(poolNameFor(mailboxUid), { recursive: true });
54
+ } catch (err) {
55
+ if (!isNotFound(err)) {
56
+ throw err;
57
+ }
58
+ }
59
+ }
60
+
61
+ export interface LocalIndexRemovalResult {
62
+ removed: string[];
63
+ /** Mailbox uids whose directory could not be removed (e.g. still open in another tab). */
64
+ failed: string[];
65
+ }
66
+
67
+ /** Every mailbox uid that has a local index directory on this origin. */
68
+ export async function listLocalIndexMailboxUids(): Promise<string[]> {
69
+ const root = await getOpfsRoot();
70
+ if (!root) {
71
+ return [];
72
+ }
73
+ const mailboxUids: string[] = [];
74
+ // `keys()` is part of the OPFS spec and every browser that has `getDirectory()` at all, but isn't in
75
+ // this TypeScript version's DOM lib yet.
76
+ for await (const name of (root as unknown as { keys(): AsyncIterable<string> }).keys()) {
77
+ const mailboxUid = mailboxUidFromPoolName(name);
78
+ if (mailboxUid) {
79
+ mailboxUids.push(mailboxUid);
80
+ }
81
+ }
82
+ return mailboxUids;
83
+ }
84
+
85
+ /** Removes every local index directory on this origin except those whose mailbox uid is in `keep`. */
86
+ export async function removeLocalIndexDirectories(keep: ReadonlySet<string> = new Set()): Promise<LocalIndexRemovalResult> {
87
+ const result: LocalIndexRemovalResult = { removed: [], failed: [] };
88
+ const mailboxUids = (await listLocalIndexMailboxUids()).filter((mailboxUid) => !keep.has(mailboxUid));
89
+ for (const mailboxUid of mailboxUids) {
90
+ try {
91
+ await removeLocalIndexDirectory(mailboxUid);
92
+ result.removed.push(mailboxUid);
93
+ } catch {
94
+ result.failed.push(mailboxUid);
95
+ }
96
+ }
97
+ return result;
98
+ }
@@ -0,0 +1,266 @@
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
+ /** Set once any block has failed to decrypt/authenticate (or the inner VFS failed a read/write outright)
190
+ * - `localIndexWorker.ts` checks this after a failed SQLite call to tell real corruption (discard and
191
+ * rebuild, spec §11 "Invalidation") apart from an ordinary error such as a malformed FTS5 query. */
192
+ corruptionDetected = false;
193
+
194
+ /**
195
+ * `jRead`/`jWrite` MUST NOT throw: with the Asyncify build, an exception escaping an async VFS method
196
+ * never reaches the awaiting `sqlite3.*` call - it surfaces only as an unhandled rejection and the
197
+ * SQLite call hangs forever (confirmed by direct reproduction in node against the real wa-sqlite build).
198
+ * A thrown `PageCorruptedError` therefore used to wedge the whole connection instead of triggering a
199
+ * rebuild. Errors are converted to an I/O error return code here, which SQLite does propagate as a
200
+ * normal `SQLiteError`, and recorded on `corruptionDetected`.
201
+ */
202
+ async jRead(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
203
+ try {
204
+ return await this.#jReadBlocks(pFile, pData, iOffset);
205
+ } catch {
206
+ this.corruptionDetected = true;
207
+ return VFS.SQLITE_IOERR_READ;
208
+ }
209
+ }
210
+
211
+ async jWrite(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
212
+ try {
213
+ return await this.#jWriteBlocks(pFile, pData, iOffset);
214
+ } catch {
215
+ this.corruptionDetected = true;
216
+ return VFS.SQLITE_IOERR_WRITE;
217
+ }
218
+ }
219
+
220
+ async #jReadBlocks(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
221
+ const filename = this.#filenamesByFileId.get(pFile) ?? `(unknown:${pFile})`;
222
+ const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
223
+ const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
224
+ let anyAbsent = false;
225
+ for (let block = startBlock; block <= endBlock; block++) {
226
+ const { plaintext, absent } = await this.#readBlock(pFile, filename, block);
227
+ const blockStart = block * LOGICAL_BLOCK_SIZE;
228
+ const copyStart = Math.max(iOffset, blockStart);
229
+ const copyEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
230
+ pData.set(plaintext.subarray(copyStart - blockStart, copyEnd - blockStart), copyStart - iOffset);
231
+ // SQLITE_IOERR_SHORT_READ is how SQLite distinguishes "nothing here yet" from "here is real
232
+ // (possibly zero-filled) data," e.g. when probing whether a file exists at all - see
233
+ // #readBlock's own doc comment on why this is tracked explicitly, not inferred from content.
234
+ if (absent) {
235
+ anyAbsent = true;
236
+ }
237
+ }
238
+ return anyAbsent ? VFS.SQLITE_IOERR_SHORT_READ : VFS.SQLITE_OK;
239
+ }
240
+
241
+ async #jWriteBlocks(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
242
+ const filename = this.#filenamesByFileId.get(pFile) ?? `(unknown:${pFile})`;
243
+ const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
244
+ const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
245
+ for (let block = startBlock; block <= endBlock; block++) {
246
+ const blockStart = block * LOGICAL_BLOCK_SIZE;
247
+ const writeStart = Math.max(iOffset, blockStart);
248
+ const writeEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
249
+ // Whole-block write (the common case once SQLite's page size is fixed): no need to read the
250
+ // old block first. Anything narrower (a sub-page write, or this block only partially
251
+ // overlaps the requested range) needs the existing content as a base - a real
252
+ // read-modify-write - since the physical block is re-encrypted as a single AEAD unit.
253
+ const plaintext =
254
+ writeStart === blockStart && writeEnd === blockStart + LOGICAL_BLOCK_SIZE
255
+ ? pData.subarray(writeStart - iOffset, writeEnd - iOffset)
256
+ : await this.#readBlock(pFile, filename, block).then(({ plaintext: existing }) => {
257
+ const merged = existing.slice();
258
+ merged.set(pData.subarray(writeStart - iOffset, writeEnd - iOffset), writeStart - blockStart);
259
+ return merged;
260
+ });
261
+ const rc = await this.#writeBlock(pFile, filename, block, plaintext);
262
+ if (rc !== VFS.SQLITE_OK) return rc;
263
+ }
264
+ return VFS.SQLITE_OK;
265
+ }
266
+ }