@rapidmx/web-client 0.5.0 → 0.6.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 (164) hide show
  1. package/README.md +88 -0
  2. package/apps/shared/components/admin/layout/AdminShell.tsx +25 -8
  3. package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
  4. package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
  5. package/apps/shared/components/layout/AppShell.tsx +36 -18
  6. package/apps/shared/components/mail/layout/MailShell.tsx +420 -418
  7. package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -9
  8. package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
  9. package/apps/shared/plugins/pluginNav.ts +73 -0
  10. package/apps/shared/search/localIndexRpcClient.ts +3 -1
  11. package/dist/apps/admin/_404.d.ts +2 -0
  12. package/dist/apps/admin/_500.d.ts +2 -0
  13. package/dist/apps/admin/_layout.d.ts +8 -0
  14. package/dist/apps/admin/audit-log/index.d.ts +11 -0
  15. package/dist/apps/admin/branding/index.d.ts +3 -0
  16. package/dist/apps/admin/data-requests/index.d.ts +3 -0
  17. package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
  18. package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
  19. package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
  20. package/dist/apps/admin/domains/[uid].d.ts +7 -0
  21. package/dist/apps/admin/domains/index.d.ts +3 -0
  22. package/dist/apps/admin/domains/new/index.d.ts +3 -0
  23. package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
  24. package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
  25. package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
  26. package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
  27. package/dist/apps/admin/index.d.ts +3 -0
  28. package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
  29. package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
  30. package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
  31. package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
  32. package/dist/apps/admin/plugins/index.d.ts +3 -0
  33. package/dist/apps/admin/quarantine/index.d.ts +6 -0
  34. package/dist/apps/admin/retention-policy/index.d.ts +3 -0
  35. package/dist/apps/admin/setup/index.d.ts +3 -0
  36. package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
  37. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
  38. package/dist/apps/admin/transport-rules/index.d.ts +3 -0
  39. package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
  40. package/dist/apps/escrow/_layout.d.ts +10 -0
  41. package/dist/apps/escrow/audit-log/index.d.ts +9 -0
  42. package/dist/apps/escrow/index.d.ts +3 -0
  43. package/dist/apps/escrow/matters/[uid].d.ts +7 -0
  44. package/dist/apps/escrow/matters/new/index.d.ts +3 -0
  45. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
  46. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
  47. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
  48. package/dist/apps/shared/components/admin/layout/AdminShell.js +12 -5
  49. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
  50. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
  51. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
  52. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
  53. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +9 -0
  54. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
  55. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +12 -0
  56. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
  57. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
  58. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +10 -0
  59. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +4 -0
  60. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +9 -0
  61. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
  62. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
  63. package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
  64. package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
  65. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
  67. package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
  68. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
  69. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
  70. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
  71. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
  72. package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
  73. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
  75. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
  76. package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
  77. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
  78. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
  79. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
  80. package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
  81. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
  82. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  83. package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
  84. package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
  85. package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
  86. package/dist/apps/shared/components/layout/AppShell.js +15 -13
  87. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
  88. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
  89. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
  90. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
  91. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
  92. package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
  93. package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -0
  94. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +23 -0
  95. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +93 -0
  96. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +58 -0
  97. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
  98. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
  99. package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
  100. package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
  101. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +34 -0
  102. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
  103. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
  104. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +57 -0
  105. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -2
  106. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
  107. package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
  108. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
  109. package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
  110. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
  111. package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -9
  112. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
  113. package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
  114. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
  115. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  116. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
  117. package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
  118. package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
  119. package/dist/apps/shared/plugins/pluginNav.js +33 -0
  120. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
  121. package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
  122. package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
  123. package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
  124. package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
  125. package/dist/apps/shared/search/localIndexRpcClient.js +3 -1
  126. package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
  127. package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
  128. package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
  129. package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
  130. package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
  131. package/dist/apps/shared/search/searchTier2.d.ts +30 -0
  132. package/dist/apps/www/_404.d.ts +2 -0
  133. package/dist/apps/www/_500.d.ts +2 -0
  134. package/dist/apps/www/_layout.d.ts +14 -0
  135. package/dist/apps/www/calendar/index.d.ts +3 -0
  136. package/dist/apps/www/contacts/[uid].d.ts +13 -0
  137. package/dist/apps/www/contacts/index.d.ts +3 -0
  138. package/dist/apps/www/index.d.ts +3 -0
  139. package/dist/apps/www/messages/[uid].d.ts +11 -0
  140. package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
  141. package/dist/apps/www/settings/encryption/index.d.ts +7 -0
  142. package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
  143. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
  144. package/dist/apps/www/settings/filters/index.d.ts +4 -0
  145. package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
  146. package/dist/apps/www/settings/focused-inbox/index.d.ts +4 -0
  147. package/dist/apps/www/settings/labels/index.d.ts +4 -0
  148. package/dist/apps/www/settings/privacy/index.d.ts +4 -0
  149. package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
  150. package/dist/apps/www/settings/sharing/index.d.ts +4 -0
  151. package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
  152. package/dist/apps/www/settings/signatures/index.d.ts +4 -0
  153. package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
  154. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
  155. package/dist/apps/www/tasks/index.d.ts +3 -0
  156. package/package.json +3 -3
  157. package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
  158. package/apps/www/settings/booking-types/[uid].tsx +0 -306
  159. package/apps/www/settings/booking-types/index.tsx +0 -98
  160. package/apps/www/settings/booking-types/new/index.tsx +0 -212
  161. package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
  162. package/dist/apps/www/settings/booking-types/[uid].js +0 -134
  163. package/dist/apps/www/settings/booking-types/index.js +0 -29
  164. package/dist/apps/www/settings/booking-types/new/index.js +0 -86
@@ -0,0 +1,33 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** A same-origin absolute path: starts with `/`, but not `//` or `/\` (both of which browsers treat as
6
+ * protocol-relative URLs to another host). */
7
+ export function isSafePluginHref(href) {
8
+ return /^\/(?![/\\])/.test(href);
9
+ }
10
+ /**
11
+ * Appends a plugin's nav items after the core ones. Skips any item that isn't well-formed, whose `href`
12
+ * isn't a same-origin path (see `isSafePluginHref`), or whose id is already taken - by a core item or
13
+ * `reservedIds` (core ids always win) or by an earlier plugin item.
14
+ */
15
+ export function mergePluginNavItems(coreItems, pluginItems, toItem, reservedIds = []) {
16
+ if (!pluginItems?.length) {
17
+ return coreItems;
18
+ }
19
+ const taken = new Set([...coreItems.map((item) => item.id), ...reservedIds]);
20
+ const merged = [...coreItems];
21
+ for (const item of pluginItems) {
22
+ if (typeof item?.id !== "string" ||
23
+ typeof item.label !== "string" ||
24
+ typeof item.href !== "string" ||
25
+ !isSafePluginHref(item.href) ||
26
+ taken.has(item.id)) {
27
+ continue;
28
+ }
29
+ taken.add(item.id);
30
+ merged.push(toItem(item));
31
+ }
32
+ return merged;
33
+ }
@@ -0,0 +1,13 @@
1
+ import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
2
+ import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
3
+ export declare const POLL_INTERVAL_MS = 5000;
4
+ export interface LocalIndexLifecycleProps {
5
+ mailboxUid?: string;
6
+ mailboxKeys?: PublicKey[];
7
+ folders: Folder[];
8
+ /** Every mailbox the signed-in user can currently access. Once known, indexes on this device for any
9
+ * other mailbox (e.g. left behind by a different user who never signed out) are removed. */
10
+ accessibleMailboxUids?: string[];
11
+ }
12
+ /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
13
+ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }: LocalIndexLifecycleProps): null;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The actual AES-256-GCM block cipher `localIndexVFS.ts`'s `EncryptingVFS` uses to encrypt/decrypt one
3
+ * physical storage block - deliberately split out from that file so it can be exercised with real
4
+ * WebCrypto in a plain unit test, with no OPFS/Worker/wa-sqlite involved at all (none of those are
5
+ * available under Vitest's jsdom environment, matching the same "node" test environment convention
6
+ * `react-shared`'s other crypto modules already use for exactly this reason).
7
+ *
8
+ * See `localIndexVFS.ts`'s own doc comment for the full design rationale (page layout, why a fresh
9
+ * random nonce per write, why AAD binds to filename+block index). This module is pure mechanism; that
10
+ * one is where the design is explained.
11
+ */
12
+ /** The logical SQLite page size `EncryptingVFS` is designed for - MUST match the `PRAGMA page_size` the
13
+ * database was created with (`localIndexWorker.ts` sets this before the first write). */
14
+ export declare const LOGICAL_BLOCK_SIZE = 4096;
15
+ /** One physical block on disk: `nonce || ciphertext || tag`. */
16
+ export declare const PHYSICAL_BLOCK_SIZE: number;
17
+ /** Thrown when a stored block fails AES-GCM authentication - a wrong/rotated key, on-disk corruption, or
18
+ * a block moved to the wrong position (see `localIndexVFS.ts`'s doc comment on why AAD binds position).
19
+ * The worker's caller treats this identically to a schema-version mismatch: discard the whole index and
20
+ * rebuild (spec §11 "Invalidation"). */
21
+ export declare class PageCorruptedError extends Error {
22
+ /** The underlying `DOMException`/error `crypto.subtle.decrypt()` threw, or an inner-VFS read failure
23
+ * - kept as a plain field rather than the ES2022 `Error` constructor's `cause` option, since
24
+ * web-client's `tsconfig.json` targets ES2020. */
25
+ readonly cause: unknown;
26
+ constructor(filename: string, blockIndex: number, cause: unknown);
27
+ }
28
+ export declare function importAesGcmKey(rawKey: Uint8Array): Promise<CryptoKey>;
29
+ /** Encrypts one logical block (`plaintext`, exactly `LOGICAL_BLOCK_SIZE` bytes) into its physical,
30
+ * on-disk representation (`PHYSICAL_BLOCK_SIZE` bytes: a fresh random nonce followed by
31
+ * ciphertext+tag). */
32
+ export declare function encryptBlock(key: CryptoKey, filename: string, blockIndex: number, plaintext: Uint8Array): Promise<Uint8Array>;
33
+ /** Inverse of `encryptBlock()`. Throws `PageCorruptedError` (never a raw `DOMException`) on auth
34
+ * failure - a wrong key, tampered ciphertext, or a block swapped into the wrong position (caught by the
35
+ * position-bound AAD not matching). */
36
+ export declare function decryptBlock(key: CryptoKey, filename: string, blockIndex: number, physical: Uint8Array): Promise<Uint8Array>;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The Tier 2 local index's background builder (`specs/search.md` §11 "Initial build... incrementally,
3
+ * newest-first"). Runs on the main thread - the actual storage I/O and encryption happen in
4
+ * `localIndexWorker.ts` (off the UI thread, per that file's own doc comment); this module's own work is
5
+ * orchestration (deciding what to fetch) plus already-async fetch/decrypt calls, not CPU-heavy work that
6
+ * would itself need to move off-thread.
7
+ *
8
+ * **Known scoping simplification**: walks the mailbox's real mail folders one at a time (a fixed,
9
+ * reasonable priority order - Inbox and Sent first), each already newest-first via `listMessages()`'s own
10
+ * default sort, rather than a true interleaved k-way merge producing one single globally-newest-first
11
+ * stream across folders. A message in, say, Sent slightly older than the *oldest-processed-so-far*
12
+ * message in Inbox can therefore be indexed slightly out of true global date order relative to it. Given
13
+ * the byte-budget eviction below is itself date-based (oldest `date_for_sort` first, enforced by
14
+ * `localIndexWorker.ts`'s own `applyEviction()` after every insert - see that file), a small amount of
15
+ * cross-folder interleaving imprecision here does not affect *what* ultimately survives the window, only
16
+ * the exact order entities are inserted (and therefore briefly evicted-and-reinserted) in.
17
+ *
18
+ * Only encrypted messages (`subject === "[...]"`) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
19
+ * own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
20
+ * fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
21
+ * content that didn't need it.
22
+ *
23
+ * **Verification seals.** A message this pass decrypts that has no seal for the vault's current `masterKeyGeneration` is
24
+ * evaluated with `evaluateMessageSecurityWithSeal()`, against the sender's pinned signing keys from the reader's
25
+ * contacts (`pinnedSigners.ts`, the message pane's own source, looked up once per sender per pass), so a verified
26
+ * message gets its seal without being opened (see `apps/shared/components/mail/verificationSeals.ts`). Pins only change
27
+ * the security state, never the recovered subject or body this index stores. Seal writes are best effort and bounded:
28
+ * at most `SEAL_WRITE_CONCURRENCY` in flight, at most `MAX_SEAL_WRITES_PER_PASS` per pass (past it, messages are
29
+ * evaluated without seals), failures ignored, and nothing new starts once the pass is aborted or its keys are destroyed.
30
+ * A seal carries only a hash, fingerprint, state and time, never plaintext. With no readable vault generation the pass
31
+ * seals nothing. Signed-only (unencrypted) messages aren't sealed here: this pass never fetches their raw MIME (see
32
+ * above), and fetching it only to seal would cost a download per message; they are sealed when opened.
33
+ */
34
+ import { type Folder } from "@rapidmx/react-shared/mail/mailApi.js";
35
+ import type { UnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
36
+ /** §11's Window Sizing table's time-floor column - explicitly unvalidated starting-point default per the
37
+ * spec's own §16 "Measurement Task" ("cannot be supplied by design work... MUST NOT be treated as
38
+ * validated"). Used as-is rather than invented/adjusted here. Not user-adjustable today (unlike the byte
39
+ * budget - see `localIndexSizePreference.ts`) - nothing in this codebase has asked for that yet. */
40
+ export declare const WEB_TIME_FLOOR_MONTHS = 12;
41
+ /** Either bound as `0` means "no limit": `applyEviction()` (`localIndexWorker.ts`) already treats a
42
+ * falsy byte budget as unconfigured/unenforced, and `buildLocalIndex()` below mirrors that same
43
+ * convention for `timeFloorMonths` so a single `0` means the same thing in both dimensions. */
44
+ export interface LocalIndexWindowConfig {
45
+ timeFloorMonths: number;
46
+ byteBudgetBytes: number;
47
+ }
48
+ /** How many times a pass walks a folder whose total count changed during the walk. `listMessages()` only
49
+ * pages by offset, so a message removed from an already-walked page shifts a later one onto it unseen;
50
+ * only a folder whose count held steady across a whole walk is trusted for completeness and pruning. */
51
+ export declare const MAX_WALK_ATTEMPTS = 3;
52
+ /** Subtracted from the pass's start time before it's recorded as the end of guaranteed coverage - a
53
+ * message's server-assigned `receivedDate` can be ahead of this device's clock. */
54
+ export declare const CLOCK_SKEW_MARGIN_MS: number;
55
+ /** How many raw-MIME fetch+decrypt calls run at once - a page of 100 encrypted messages used to fire all
56
+ * 100 requests simultaneously. */
57
+ export declare const FETCH_CONCURRENCY = 6;
58
+ /** How many verification seal writes run at once during a pass. */
59
+ export declare const SEAL_WRITE_CONCURRENCY = 2;
60
+ /** The most verification seals one pass writes. */
61
+ export declare const MAX_SEAL_WRITES_PER_PASS = 200;
62
+ /** Runs one full incremental build pass for `mailboxUid`: sets the window, walks mail folders newest-first
63
+ * (per this module's own doc comment on the folder-order simplification), decrypts and indexes only
64
+ * `"[...]"`-subject messages until each folder's own coverage passes the time floor, then clears the
65
+ * `building` flag. Messages already indexed at the same version are skipped, fetches run at most
66
+ * `FETCH_CONCURRENCY` at a time, and messages older than the eviction watermark (`WindowState.evictedBefore`)
67
+ * are neither fetched nor walked past. A failure partway through leaves whatever was indexed so far in place
68
+ * and records the pass as incomplete (spec §11's "incomplete-index UX... MUST indicate that coverage is
69
+ * partial" - see `Coverage.complete`). Rows the server no longer lists (deleted or moved elsewhere by any
70
+ * client) are pruned only within folders whose count held steady for a whole walk (see `MAX_WALK_ATTEMPTS`).
71
+ *
72
+ * Only one pass runs per mailbox (a newer call cancels the older), and every Worker call carries this pass's
73
+ * generation, so a pass outlived by a destroy can't write anything (see `cancelLocalIndexBuild()`).
74
+ *
75
+ * `windowConfig` defaults to this device's own configured byte budget (`getLocalIndexByteBudget()` -
76
+ * 500 MB in a browser tab, 1 GB in Electron, or whatever the user has since set in Settings > Encryption)
77
+ * alongside the fixed time floor above. Evaluated fresh on every call with no explicit override, so a
78
+ * preference change in Settings takes effect starting with this mailbox's next build pass (its next
79
+ * unlock), without requiring a reload.
80
+ */
81
+ export declare function buildLocalIndex(mailboxUid: string, unlocked: UnlockedKeys, folders: Folder[], windowConfig?: LocalIndexWindowConfig): Promise<void>;
82
+ /** Stops a mailbox's running build pass, if any (e.g. its keys were just destroyed), resolving once it has
83
+ * wound down. The Worker independently rejects the pass's later calls once the index is destroyed (see
84
+ * `GenerationParams`); this just stops the fetching and decrypting too. Never rejects. */
85
+ export declare function cancelLocalIndexBuild(mailboxUid: string): Promise<void>;
@@ -0,0 +1,4 @@
1
+ /** Derives this mailbox's local-index page-encryption key from its already-unlocked master key. Every
2
+ * call with the same `(masterKey, mailboxUid)` pair returns the identical key - callers never need to
3
+ * persist it themselves. */
4
+ export declare function deriveLocalIndexKey(masterKey: Uint8Array, mailboxUid: string): Promise<Uint8Array>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Main-thread Promise-based RPC wrapper around `localIndexWorker.ts`. One Worker per tab (spawned
3
+ * lazily, on first use, not at module load - a page that never touches search shouldn't pay for booting
4
+ * the WASM module at all), shared across every mailbox `init()`-ed this session - the Worker itself keeps
5
+ * a per-mailbox connection map (see that file's own doc comment).
6
+ *
7
+ * `postMessage` has no native request/response pairing, so every call generates its own numeric `id` and
8
+ * this module correlates the eventual matching response via a pending-request map.
9
+ *
10
+ * **Generations**: `nextLocalIndexGeneration()` hands out one monotonic counter shared by build passes and
11
+ * destroys, so the Worker can reject a build call issued before the index was destroyed (see
12
+ * `GenerationParams`).
13
+ *
14
+ * **Sign-out reaches every tab**: `destroyAllLocalIndexes()` broadcasts on a `BroadcastChannel`; any other
15
+ * tab running a Worker closes its connections and refuses further `init`s.
16
+ *
17
+ * **Failed deletions are retried**: a destroy that couldn't remove a directory (another tab held it, the page
18
+ * navigated away first) is recorded in `localStorage` and retried before this tab's first `init`.
19
+ */
20
+ import type { ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
21
+ import type { Coverage, IndexEntitiesResult, InitParams, LocalSearchPage, WindowState } from "./localIndexWorker.js";
22
+ import type { LocalIndexEntity } from "./localIndexSchema.js";
23
+ /** A fresh generation - see this module's doc comment. */
24
+ export declare function nextLocalIndexGeneration(): number;
25
+ export declare const SIGN_OUT_CHANNEL = "rapidmx-localsearch";
26
+ export declare const PENDING_DELETIONS_KEY = "rapidmx-localsearch-pending-deletions";
27
+ /** Retries every deletion an earlier page load couldn't finish. Runs once per page load (memoized), before
28
+ * this tab's first `init` - so it never races an index this tab itself opens. Never rejects. */
29
+ export declare function retryPendingLocalIndexDeletions(): Promise<void>;
30
+ export declare function initLocalIndex(params: InitParams): Promise<void>;
31
+ export declare function indexLocalEntities(mailboxUid: string, entities: LocalIndexEntity[], generation?: number): Promise<IndexEntitiesResult>;
32
+ /** Drops one message from this session's local index (a delete). A no-op for a mailbox not indexed in
33
+ * this tab - a later build pass prunes it instead. Never rejects: best-effort housekeeping. */
34
+ export declare function removeLocalEntity(mailboxUid: string, entityUid: string): Promise<void>;
35
+ /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
36
+ * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. */
37
+ export declare function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string): Promise<void>;
38
+ export declare function getIndexedVersions(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>>;
39
+ export declare function pruneLocalEntities(mailboxUid: string, keepEntityUids: string[], since: string | undefined, options?: {
40
+ folderUids?: string[];
41
+ generation?: number;
42
+ }): Promise<number>;
43
+ export declare function searchLocal(mailboxUid: string, parsed: ParsedSearchQuery, limit: number, offset?: number): Promise<LocalSearchPage>;
44
+ export declare function getLocalCoverage(mailboxUid: string): Promise<Coverage>;
45
+ export declare function setLocalIndexWindow(mailboxUid: string, timeFloorMonths: number, byteBudgetBytes: number, generation?: number): Promise<WindowState>;
46
+ export declare function setLocalIndexBuilding(mailboxUid: string, building: boolean, completion?: {
47
+ complete: boolean;
48
+ coveredFrom?: string;
49
+ coveredUntil?: string;
50
+ generation?: number;
51
+ }): Promise<void>;
52
+ /** Destroys one mailbox's local index (both the SQLite connection and its on-disk OPFS storage) - spec
53
+ * §11 "MUST be destroyed on the same events that destroy private keys." Never rejects (it's called from
54
+ * lifecycle hooks where a failure mustn't block key destruction) but resolves `false` - and logs why -
55
+ * when the index could not be removed, e.g. because another tab still has it open; the deletion is then
56
+ * retried on the next page load. */
57
+ export declare function destroyLocalIndex(mailboxUid: string): Promise<boolean>;
58
+ /** How long sign-out waits for local index destruction before navigating anyway. */
59
+ export declare const DESTROY_ALL_TIMEOUT_MS = 3000;
60
+ /**
61
+ * Destroys **every** local index on this device - not just the mailboxes this page load opened (sign-out
62
+ * from Calendar, say, never opened any), by enumerating OPFS for the index directory prefix - and tells every
63
+ * other tab to close its own connections. Closes this tab's own open connections through the Worker first
64
+ * (only if one was ever spawned - there's nothing to close otherwise, and booting one just to delete files
65
+ * would be waste), then removes the directories. Anything left behind is retried on the next load. Resolves
66
+ * `true` when everything was removed within `timeoutMs`; never rejects.
67
+ */
68
+ export declare function destroyAllLocalIndexes(timeoutMs?: number): Promise<boolean>;
69
+ /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
70
+ * user who signed in on this device without signing out. Never rejects. */
71
+ export declare function pruneInaccessibleLocalIndexes(accessibleMailboxUids: Iterable<string>): Promise<void>;
@@ -18,7 +18,9 @@ export const SIGN_OUT_CHANNEL = "rapidmx-localsearch";
18
18
  let channel;
19
19
  function getWorker() {
20
20
  if (!worker) {
21
- worker = new Worker(new URL("./localIndexWorker.ts", import.meta.url), { type: "module" });
21
+ // Named by its compiled `.js` file, like every other relative import: tsc copies the literal into
22
+ // `dist` unchanged, where only the `.js` file exists, and Vite maps it back to the `.ts` source.
23
+ worker = new Worker(new URL("./localIndexWorker.js", import.meta.url), { type: "module" });
22
24
  worker.addEventListener("message", (event) => {
23
25
  const entry = pending.get(event.data.id);
24
26
  if (!entry) {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The Tier 2 local index's SQLite schema (`specs/search.md` §13). One database per mailbox (see
3
+ * `localIndexWorker.ts`), holding both the FTS5 index and the metadata cache in the same store - the
4
+ * spec's own rationale for choosing SQLite over an in-memory JS index in the first place ("the index
5
+ * database also holds the decrypted metadata cache... so there is one local store rather than two").
6
+ *
7
+ * Bumping `SCHEMA_VERSION` is how a schema change invalidates every existing local index (§11
8
+ * "Invalidation... on schema version change") - `localIndexWorker.ts` compares it against `meta`'s
9
+ * stored value on open and discards+rebuilds on a mismatch, the same path a corruption/GCM-auth-failure
10
+ * takes.
11
+ */
12
+ /** Bump whenever `CREATE_SCHEMA_SQL` changes in a way existing on-disk databases can't be reconciled
13
+ * with in place. A mismatch deletes and recreates the whole database file (not just its rows), so
14
+ * creation-time-only settings like `auto_vacuum` also apply to upgraded indexes.
15
+ *
16
+ * 2 - added `entities.entity_version` (incremental rebuild skip) and `auto_vacuum=INCREMENTAL`. */
17
+ export declare const SCHEMA_VERSION = 2;
18
+ /**
19
+ * `entities` is the real row store (metadata + the plaintext content fields), `entities_fts` is an FTS5
20
+ * *external content* table over it (`content='entities'`) - the standard SQLite pattern for keeping one
21
+ * copy of the text instead of duplicating it into the FTS5 shadow tables, kept in sync via the three
22
+ * triggers below (SQLite's own documented pattern for external-content FTS5 tables; there is no
23
+ * "ON CONFLICT UPDATE re-index" primitive, so update is modeled as delete-then-reinsert into the FTS
24
+ * index specifically, not into `entities` itself).
25
+ *
26
+ * Column order in `entities_fts` (`subject, participants, body, attachment_text`) is load-bearing: every
27
+ * `bm25(entities_fts, 3.0, 2.0, 1.0, 1.0)` call elsewhere in this module family assumes that exact
28
+ * positional order, matching `searchScoring.ts`'s `SEARCH_FIELD_WEIGHTS` (`subject: 3, participants: 2,
29
+ * body: 1, attachmentText: 1`) so Tier 2's local ranking agrees with the Tier 1/Tier 3 re-scoring the
30
+ * spec requires for one consistent ordering across tiers (§7).
31
+ */
32
+ export declare const CREATE_SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS meta (\n key TEXT PRIMARY KEY,\n value TEXT\n);\n\nCREATE TABLE IF NOT EXISTS entities (\n rowid INTEGER PRIMARY KEY,\n entity_type TEXT NOT NULL,\n entity_uid TEXT NOT NULL UNIQUE,\n mailbox_uid TEXT NOT NULL,\n folder_uid TEXT,\n date_for_sort TEXT NOT NULL,\n participants TEXT,\n flags TEXT,\n has_attachments INTEGER NOT NULL DEFAULT 0,\n subject TEXT,\n body TEXT,\n attachment_text TEXT,\n byte_size INTEGER NOT NULL DEFAULT 0,\n entity_version TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_entities_date ON entities(date_for_sort);\nCREATE INDEX IF NOT EXISTS idx_entities_mailbox ON entities(mailbox_uid);\n\nCREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(\n subject, participants, body, attachment_text,\n content='entities', content_rowid='rowid', tokenize='unicode61'\n);\n\nCREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN\n INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)\n VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN\n INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)\n VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);\nEND;\n\nCREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN\n INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)\n VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);\n INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)\n VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);\nEND;\n";
33
+ /** The exact `bm25()` weight arguments every ranked query against `entities_fts` MUST pass, in column
34
+ * order - see this module's own doc comment on why the order is load-bearing. Centralized here so a
35
+ * future column reorder can't silently desync a query building its own literal weight list. */
36
+ export declare const BM25_WEIGHTS_SQL = "3.0, 2.0, 1.0, 1.0";
37
+ /** One message's decrypted content, ready to index - `localIndexBuilder.ts`'s own output shape, built
38
+ * from `Message` + the recovered `MessageSecurityResult` fields the same way `searchTier3.ts` already
39
+ * derives them for its own per-candidate matching. */
40
+ export interface LocalIndexEntity {
41
+ entityType: "message";
42
+ entityUid: string;
43
+ mailboxUid: string;
44
+ folderUid?: string;
45
+ /** ISO 8601 - compares correctly as plain text since every value here is UTC. */
46
+ dateForSort: string;
47
+ participants: string;
48
+ /** Comma-delimited, leading/trailing commas included (`,read,flagged,`) - simplest possible substring
49
+ * match (`flags LIKE '%,read,%'`) without needing SQLite's JSON1 extension compiled in. */
50
+ flags: string;
51
+ hasAttachments: boolean;
52
+ subject?: string;
53
+ body?: string;
54
+ attachmentText?: string;
55
+ /** Rough on-disk cost of this entity's own content, in bytes - what `localIndexBuilder.ts`'s
56
+ * byte-budget accounting (spec §11) sums against the configured budget. */
57
+ byteSize: number;
58
+ /** Opaque change marker for the source entity (the builder uses the message's `version` plus its
59
+ * folder) - lets a rebuild skip re-fetching/decrypting anything already indexed unchanged. */
60
+ entityVersion?: string;
61
+ }
62
+ /** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
63
+ * so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
64
+ * `entities_au` trigger keeps `entities_fts` in sync automatically. */
65
+ export declare const UPSERT_ENTITY_SQL = "\nINSERT INTO entities (entity_type, entity_uid, mailbox_uid, folder_uid, date_for_sort, participants, flags, has_attachments, subject, body, attachment_text, byte_size, entity_version)\nVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\nON CONFLICT(entity_uid) DO UPDATE SET\n folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,\n flags = excluded.flags, has_attachments = excluded.has_attachments, subject = excluded.subject,\n body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size,\n entity_version = excluded.entity_version\n";
66
+ /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
67
+ * out of sync with each other. */
68
+ export declare function entityBindValues(entity: LocalIndexEntity): (string | number)[];
69
+ /** A parsed query's structured (non-free-text) fields - the subset of `ParsedSearchQuery`
70
+ * (`react-shared`'s `queryGrammar.ts`) this module's predicate builder reads. Typed locally rather than
71
+ * importing `ParsedSearchQuery` itself so this Worker-bundled module has no dependency on `@rapidmx/
72
+ * react-shared` beyond what it actually uses - `localIndexBuilder.ts`/`searchTier2.ts` (main-thread side)
73
+ * pass the real `ParsedSearchQuery` in, which structurally satisfies this. */
74
+ export interface LocalSearchPredicateFields {
75
+ from?: string;
76
+ to?: string;
77
+ cc?: string;
78
+ subject?: string;
79
+ hasAttachment?: boolean;
80
+ before?: Date;
81
+ after?: Date;
82
+ folderUid?: string;
83
+ flags?: string[];
84
+ }
85
+ /**
86
+ * Builds the SQL `WHERE` predicate (and its bind params) for every *structured* operator this local
87
+ * index can actually evaluate. **Known simplification**: unlike Tier 1's server-side `SearchDocument`
88
+ * (which splits `from`/`to`/`cc` into distinct fields - confirmed already implemented server-side), this
89
+ * local schema keeps only the combined `participants` field (see `CREATE_SCHEMA_SQL`'s own doc comment) -
90
+ * `from:`/`to:`/`cc:` are therefore evaluated here as a substring match against that combined field
91
+ * rather than a precise per-role match. Reasonable for a bounded, best-effort recent-window cache
92
+ * (Tier 1 already serves the precise version for anything it indexes), but a real gap if Tier 2 is later
93
+ * extended to distinguish them - flagged here rather than left silently approximate.
94
+ */
95
+ export declare function buildSearchPredicates(parsed: LocalSearchPredicateFields, mailboxUid: string): {
96
+ where: string;
97
+ params: (string | number)[];
98
+ };
99
+ /**
100
+ * Builds the FTS5 `MATCH` expression for a parsed query's free-text and `subject:` portions, or
101
+ * `undefined` when there's nothing to match on text at all (a pure operator/structured-filter query -
102
+ * `buildSearchPredicates()`'s `WHERE` clause alone already narrows that case correctly, no `MATCH`
103
+ * needed). `parsed.text` is passed through close to verbatim (quoted phrases, `OR`, `-` negation - FTS5's
104
+ * own query syntax supports the same shape `queryGrammar.ts`'s own doc comment says every provider's
105
+ * free-text engine is expected to), wrapped only enough to combine it with a `subject:`-scoped clause
106
+ * when both are present.
107
+ */
108
+ export declare function buildMatchExpression(parsed: {
109
+ text: string;
110
+ subject?: string;
111
+ }): string | undefined;
@@ -0,0 +1,27 @@
1
+ /** §11's Window Sizing table, Web row. */
2
+ export declare const WEB_DEFAULT_BYTE_BUDGET_BYTES: number;
3
+ /** §11's Window Sizing table, Native desktop row - still a real, user-adjustable ceiling (not
4
+ * `UNBOUNDED`), just a roomier default given Electron's storage is disk-limited rather than
5
+ * browser-quota-limited. */
6
+ export declare const ELECTRON_DEFAULT_BYTE_BUDGET_BYTES: number;
7
+ export interface LocalIndexSizeOption {
8
+ bytes: number;
9
+ label: string;
10
+ }
11
+ /** Selectable presets for the Settings UI. `0` means "unlimited" - `applyEviction()`
12
+ * (`localIndexWorker.ts`) already treats a falsy byte budget as unconfigured/unenforced. */
13
+ export declare const LOCAL_INDEX_SIZE_OPTIONS: LocalIndexSizeOption[];
14
+ /** This device's default byte budget before any explicit preference is saved - `500 MB` in a browser
15
+ * tab, `1 GB` in the Electron shell. */
16
+ export declare function getDefaultLocalIndexByteBudget(): number;
17
+ /**
18
+ * Reads this device's configured local-index byte budget. Falls back to
19
+ * `getDefaultLocalIndexByteBudget()` for a never-configured device, a corrupted/non-numeric stored
20
+ * value, or a `localStorage` access that throws (private-browsing/storage-blocked contexts) - never
21
+ * throws itself, matching `getIdleTimeoutMinutes()`'s identical fallback posture.
22
+ */
23
+ export declare function getLocalIndexByteBudget(): number;
24
+ /** Persists this device's local-index byte-budget preference. A `localStorage` write failure is
25
+ * swallowed, not thrown - the setting just doesn't survive a reload in that case, same fallback-to-default
26
+ * behavior `getLocalIndexByteBudget()` already has for a storage-blocked context. */
27
+ export declare function setLocalIndexByteBudget(bytes: number): void;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The Tier 2 local index's on-disk naming and bulk-removal helpers, shared by the Worker
3
+ * (`localIndexWorker.ts`) and the main thread (`localIndexRpcClient.ts`). Deliberately free of any
4
+ * wa-sqlite import so the main thread can use it without pulling the WASM build into its own bundle.
5
+ *
6
+ * Every mailbox's index lives in its own top-level OPFS directory named `rapidmx-localsearch-<mailboxUid>`
7
+ * (`AccessHandlePoolVFS` creates it from the VFS name). Enumerating that prefix is how sign-out destroys
8
+ * *every* index on this device - including ones for mailboxes this page load never opened, which the
9
+ * RPC client's own session-scoped bookkeeping can't know about - and how a sign-in prunes indexes left
10
+ * behind for mailboxes the current user can no longer access.
11
+ */
12
+ export declare const LOCAL_INDEX_POOL_PREFIX = "rapidmx-localsearch-";
13
+ /** The OPFS directory name (and `EncryptingVFS` name) a mailbox's index lives under. */
14
+ export declare function poolNameFor(mailboxUid: string): string;
15
+ /** Inverse of `poolNameFor()`; `undefined` for any OPFS entry that isn't a local index directory. */
16
+ export declare function mailboxUidFromPoolName(name: string): string | undefined;
17
+ /** Removes one mailbox's index directory. Resolves quietly when it doesn't exist; rejects for any other
18
+ * failure (most commonly `NoModificationAllowedError` - another tab still holds its access handles). */
19
+ export declare function removeLocalIndexDirectory(mailboxUid: string): Promise<void>;
20
+ export interface LocalIndexRemovalResult {
21
+ removed: string[];
22
+ /** Mailbox uids whose directory could not be removed (e.g. still open in another tab). */
23
+ failed: string[];
24
+ }
25
+ /** Every mailbox uid that has a local index directory on this origin. */
26
+ export declare function listLocalIndexMailboxUids(): Promise<string[]>;
27
+ /** Removes every local index directory on this origin except those whose mailbox uid is in `keep`. */
28
+ export declare function removeLocalIndexDirectories(keep?: ReadonlySet<string>): Promise<LocalIndexRemovalResult>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `EncryptingVFS` - the Tier 2 local index's at-rest encryption layer (`specs/search.md` §11
3
+ * "Persistence and protection", §13 "Encryption at Rest": "no official SQLCipher WASM build exists...
4
+ * encryption at rest requires a custom VFS that encrypts pages before they reach OPFS").
5
+ *
6
+ * Wraps (composes, does not subclass - `AccessHandlePoolVFS`'s own file-table state is private `#`
7
+ * fields) an `AccessHandlePoolVFS` instance and delegates every `FacadeVFS` method straight through
8
+ * except `jRead`/`jWrite`, which it intercepts to decrypt/encrypt pages.
9
+ *
10
+ * ## Page layout
11
+ *
12
+ * SQLite is opened with a fixed 4096-byte page size (`PRAGMA page_size=4096` - set by
13
+ * `localIndexWorker.ts` before the first write, since SQLite fixes a database's page size on creation).
14
+ * Each *logical* 4096-byte page is stored *physically* as `nonce(12) || AES-256-GCM(ciphertext(4096) ||
15
+ * tag(16))` = 4124 bytes, at a remapped offset (`physicalOffset(page) = page * 4124`). This block size
16
+ * is this class's own fixed constant, independent of whatever byte range a given `jRead`/`jWrite` call
17
+ * actually requests - reads/writes are handled generically over whichever physical blocks the requested
18
+ * logical range overlaps (including a read-modify-write for a sub-block write), not by assuming SQLite
19
+ * only ever issues page-aligned, page-sized I/O. That assumption holds for the *main database file* once
20
+ * its page size is fixed, but handling the general case costs little and removes the need to rely on it.
21
+ *
22
+ * **A fresh random nonce is generated on every write, never reused or derived from a counter** - the
23
+ * simplest way to make an AES-GCM (key, nonce) pair never repeat across different plaintexts, which is
24
+ * the one hard requirement GCM has. The nonce travels with its block, so decryption never needs any
25
+ * external state (a lost/corrupted counter, a persisted salt) to reconstruct it.
26
+ *
27
+ * **AAD binds each block to its logical filename and page index** (not just its own ciphertext) - GCM
28
+ * authenticates a block's own content but not its *position*; without this, an attacker able to write
29
+ * directly into this origin's OPFS storage (outside this codebase's own threat model per spec §4, which
30
+ * excludes a compromised client, but cheap to close off anyway) could silently swap two blocks and each
31
+ * would still decrypt "successfully" on its own, corrupting data instead of failing loudly. Binding to
32
+ * position turns a swap into a caught decryption failure - see `PageCorruptedError` below - the same
33
+ * "invalidate and rebuild" path a schema-version mismatch already takes (spec §11 "Invalidation").
34
+ *
35
+ * **No WAL, no rollback journal.** `localIndexWorker.ts` opens the database with `journal_mode=OFF`. A
36
+ * page cipher for the main database file is one encryption surface; WAL frames (their own header +
37
+ * checksum format) and rollback-journal records (their own, different record format) would each be a
38
+ * *second* one. This index has no durability requirement to justify that cost - spec §11 already
39
+ * requires discarding and rebuilding it on corruption, schema change, key rotation, or platform storage
40
+ * eviction, and eviction "MUST NOT block search" - so an interrupted write in the worst case is just
41
+ * caught by the same GCM-auth-failure -> rebuild path as any other corruption, never partial/torn state
42
+ * silently trusted.
43
+ *
44
+ * Every `jRead`/`jWrite` here is `async` (declared `async` specifically so `FacadeVFS.hasAsyncMethod()`
45
+ * detects it via `instanceof AsyncFunction` and awaits it) because `crypto.subtle.encrypt`/`decrypt` has
46
+ * no synchronous form in a browser - this is *why* `localIndexWorker.ts` boots the Asyncify SQLite build
47
+ * (`dist/wa-sqlite-async.mjs`), not the plain synchronous one `AccessHandlePoolVFS`'s own doc comment
48
+ * says it's designed for: that claim is about `AccessHandlePoolVFS`'s *own* methods (real synchronous
49
+ * OPFS access-handle calls), which stay synchronous and work fine wrapped underneath an async outer VFS
50
+ * on an Asyncify build - the build choice is driven by this class's needs, not the inner VFS's.
51
+ */
52
+ import { FacadeVFS } from "@journeyapps/wa-sqlite/src/FacadeVFS.js";
53
+ export { PageCorruptedError } from "./localIndexBlockCipher.js";
54
+ export declare class EncryptingVFS extends FacadeVFS {
55
+ #private;
56
+ private constructor();
57
+ /** `rawKey` MUST be exactly 32 bytes (AES-256) - see `localIndexKey.ts`'s `deriveLocalIndexKey()`,
58
+ * the only intended source of this value. */
59
+ static create(name: string, module: unknown, rawKey: Uint8Array): Promise<EncryptingVFS>;
60
+ /**
61
+ * Releases every pooled OPFS sync access handle `AccessHandlePoolVFS` opened and holds open for its
62
+ * entire lifetime (not per SQLite-file-open/close - `jOpen`/`jClose` above only associate/disassociate
63
+ * a SQLite fileId with an already-open handle, they never open or close the handles themselves). MUST
64
+ * be called before creating another VFS instance against the same OPFS pool `name` - confirmed by
65
+ * direct reproduction: skipping this and calling `create()` again with the same `name` throws
66
+ * "Access Handles cannot be created if there is another open Access Handle," since the previous
67
+ * instance's handles are still live. `localIndexWorker.ts` calls this alongside `sqlite3.close(db)`
68
+ * (which closes the SQLite *connection*, a separate, shorter-lived thing from the VFS itself) whenever
69
+ * it tears down a mailbox's connection - on `destroy()` and in `selfTest()`'s own close/reopen check.
70
+ */
71
+ close(): void | Promise<void>;
72
+ jOpen(filename: string | null, pFile: number, flags: number, pOutFlags: DataView): number | Promise<number>;
73
+ jClose(pFile: number): number | Promise<number>;
74
+ jDelete(filename: string, syncDir: number): number | Promise<number>;
75
+ jAccess(filename: string, flags: number, pResOut: DataView): number | Promise<number>;
76
+ jFullPathname(filename: string, zOut: Uint8Array): number | Promise<number>;
77
+ jSync(pFile: number, flags: number): number | Promise<number>;
78
+ jSectorSize(pFile: number): number;
79
+ jDeviceCharacteristics(pFile: number): number;
80
+ /** Logical file size = physical size scaled back down to the logical block size - the inner VFS's
81
+ * own `jFileSize` reports the *physical* (post-remap) byte count, which is always an exact multiple
82
+ * of `PHYSICAL_BLOCK_SIZE` since every write here always fills whole physical blocks. */
83
+ jFileSize(pFile: number, pSize64: DataView): Promise<number>;
84
+ /** `iSize` is a logical byte size - truncates to the smallest whole number of *physical* blocks that
85
+ * still covers it, so a partially-truncated trailing block is never left half-written. */
86
+ jTruncate(pFile: number, iSize: number): Promise<number>;
87
+ /** Set once any block has failed to decrypt/authenticate (or the inner VFS failed a read/write outright)
88
+ * - `localIndexWorker.ts` checks this after a failed SQLite call to tell real corruption (discard and
89
+ * rebuild, spec §11 "Invalidation") apart from an ordinary error such as a malformed FTS5 query. */
90
+ corruptionDetected: boolean;
91
+ /**
92
+ * `jRead`/`jWrite` MUST NOT throw: with the Asyncify build, an exception escaping an async VFS method
93
+ * never reaches the awaiting `sqlite3.*` call - it surfaces only as an unhandled rejection and the
94
+ * SQLite call hangs forever (confirmed by direct reproduction in node against the real wa-sqlite build).
95
+ * A thrown `PageCorruptedError` therefore used to wedge the whole connection instead of triggering a
96
+ * rebuild. Errors are converted to an I/O error return code here, which SQLite does propagate as a
97
+ * normal `SQLiteError`, and recorded on `corruptionDetected`.
98
+ */
99
+ jRead(pFile: number, pData: Uint8Array, iOffset: number): Promise<number>;
100
+ jWrite(pFile: number, pData: Uint8Array, iOffset: number): Promise<number>;
101
+ }