@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.
- package/README.md +88 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +25 -8
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
- package/apps/shared/components/layout/AppShell.tsx +36 -18
- package/apps/shared/components/mail/layout/MailShell.tsx +420 -418
- package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -9
- package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
- package/apps/shared/plugins/pluginNav.ts +73 -0
- package/apps/shared/search/localIndexRpcClient.ts +3 -1
- package/dist/apps/admin/_404.d.ts +2 -0
- package/dist/apps/admin/_500.d.ts +2 -0
- package/dist/apps/admin/_layout.d.ts +8 -0
- package/dist/apps/admin/audit-log/index.d.ts +11 -0
- package/dist/apps/admin/branding/index.d.ts +3 -0
- package/dist/apps/admin/data-requests/index.d.ts +3 -0
- package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
- package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
- package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
- package/dist/apps/admin/domains/[uid].d.ts +7 -0
- package/dist/apps/admin/domains/index.d.ts +3 -0
- package/dist/apps/admin/domains/new/index.d.ts +3 -0
- package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
- package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
- package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
- package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
- package/dist/apps/admin/index.d.ts +3 -0
- package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
- package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
- package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
- package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
- package/dist/apps/admin/plugins/index.d.ts +3 -0
- package/dist/apps/admin/quarantine/index.d.ts +6 -0
- package/dist/apps/admin/retention-policy/index.d.ts +3 -0
- package/dist/apps/admin/setup/index.d.ts +3 -0
- package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
- package/dist/apps/admin/transport-rules/index.d.ts +3 -0
- package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
- package/dist/apps/escrow/_layout.d.ts +10 -0
- package/dist/apps/escrow/audit-log/index.d.ts +9 -0
- package/dist/apps/escrow/index.d.ts +3 -0
- package/dist/apps/escrow/matters/[uid].d.ts +7 -0
- package/dist/apps/escrow/matters/new/index.d.ts +3 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +12 -5
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +12 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +10 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +4 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +9 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
- package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
- package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
- package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +2 -2
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
- package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
- package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
- package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
- package/dist/apps/shared/components/layout/AppShell.js +15 -13
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
- package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +23 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +93 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +58 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +34 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +57 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -2
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
- package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
- package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
- package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -9
- package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
- package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
- package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
- package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
- package/dist/apps/shared/plugins/pluginNav.js +33 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
- package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
- package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
- package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +3 -1
- package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
- package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
- package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
- package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
- package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
- package/dist/apps/shared/search/searchTier2.d.ts +30 -0
- package/dist/apps/www/_404.d.ts +2 -0
- package/dist/apps/www/_500.d.ts +2 -0
- package/dist/apps/www/_layout.d.ts +14 -0
- package/dist/apps/www/calendar/index.d.ts +3 -0
- package/dist/apps/www/contacts/[uid].d.ts +13 -0
- package/dist/apps/www/contacts/index.d.ts +3 -0
- package/dist/apps/www/index.d.ts +3 -0
- package/dist/apps/www/messages/[uid].d.ts +11 -0
- package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
- package/dist/apps/www/settings/encryption/index.d.ts +7 -0
- package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
- package/dist/apps/www/settings/filters/index.d.ts +4 -0
- package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
- package/dist/apps/www/settings/focused-inbox/index.d.ts +4 -0
- package/dist/apps/www/settings/labels/index.d.ts +4 -0
- package/dist/apps/www/settings/privacy/index.d.ts +4 -0
- package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
- package/dist/apps/www/settings/sharing/index.d.ts +4 -0
- package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
- package/dist/apps/www/settings/signatures/index.d.ts +4 -0
- package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
- package/dist/apps/www/tasks/index.d.ts +3 -0
- package/package.json +3 -3
- package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
- package/apps/www/settings/booking-types/[uid].tsx +0 -306
- package/apps/www/settings/booking-types/index.tsx +0 -98
- package/apps/www/settings/booking-types/new/index.tsx +0 -212
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
- package/dist/apps/www/settings/booking-types/[uid].js +0 -134
- package/dist/apps/www/settings/booking-types/index.js +0 -29
- 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
|
-
|
|
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
|
+
}
|