@rapidmx/web-client 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -0,0 +1,13 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { FolderType, listFolders } from "@rapidmx/react-shared/mail/mailApi.js";
6
+
7
+ /** The uid of a mailbox's well-known folder of `type` (e.g. its Contacts or Tasks folder), for creating an
8
+ * item in a mailbox other than the one an app currently has loaded. Resolves `undefined` if the mailbox has
9
+ * no such folder; rejects if the folder list can't be fetched. */
10
+ export async function findWellKnownFolderUid(mailboxUid: string, type: FolderType): Promise<string | undefined> {
11
+ const folders = await listFolders(mailboxUid);
12
+ return folders.find((folder) => folder.type === type)?.uid;
13
+ }
@@ -0,0 +1,39 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ /** Page size the list pages (Contacts, Tasks) request per call - restapi caps `limit` at 500. */
7
+ export const LIST_PAGE_SIZE = 500;
8
+
9
+ /** Safety cap on pages fetched - stops a server that ignores `page` (and keeps returning a full first
10
+ * page) from looping forever. 40 pages x 500 = 20,000 items. */
11
+ export const MAX_LIST_PAGES = 40;
12
+
13
+ export interface ListAllPagesResult<T> {
14
+ items: T[];
15
+ /** `true` when fetching stopped at `maxPages` with the last page still full - there may be more items
16
+ * the caller isn't showing, so it should say so rather than presenting `items` as the whole list. */
17
+ truncated: boolean;
18
+ }
19
+
20
+ /**
21
+ * Fetches every page of a zero-based `limit`/`page` list endpoint, stopping at the first short page
22
+ * (fewer than `pageSize` items) - so a folder with more items than one page holds is shown in full
23
+ * rather than silently cut off at the first page. Stops at `maxPages` regardless, reporting `truncated`.
24
+ */
25
+ export async function listAllPages<T>(
26
+ fetchPage: (page: number) => Promise<T[]>,
27
+ pageSize: number = LIST_PAGE_SIZE,
28
+ maxPages: number = MAX_LIST_PAGES,
29
+ ): Promise<ListAllPagesResult<T>> {
30
+ const items: T[] = [];
31
+ for (let page = 0; page < maxPages; page++) {
32
+ const batch = await fetchPage(page);
33
+ items.push(...batch);
34
+ if (batch.length < pageSize) {
35
+ return { items, truncated: false };
36
+ }
37
+ }
38
+ return { items, truncated: true };
39
+ }
@@ -0,0 +1,98 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Mounts the Tier 2 local index's lifecycle: starts the background build for the active mailbox once it's
7
+ * unlocked, and destroys an index on the same events that destroy that mailbox's unlocked keys (spec §11
8
+ * "MUST be destroyed on the same events that destroy private keys: explicit logout, session revocation,
9
+ * and the configurable idle timeout").
10
+ *
11
+ * **Why polling, not hooking the existing destroy call sites directly**: `destroyUnlockedKeys()`
12
+ * (`react-shared/src/crypto/keySession.ts`) has no observer/callback hook. Watching `getUnlockedKeys()`
13
+ * for a present-to-absent transition catches *every* destroy path uniformly - idle timeout, the manual
14
+ * button, or anything else that calls it - without needing to know which one fired.
15
+ *
16
+ * Every mailbox this component has seen unlocked is tracked for the life of the mount - not just whichever
17
+ * is active right now - so switching mailboxes (or a folders-list refresh re-running the build effect)
18
+ * can never lose a pending unlocked-to-locked transition for a mailbox whose index was already built.
19
+ *
20
+ * **Explicit logout** navigates away immediately, which may not leave time for a poll tick - `AppShell.tsx`
21
+ * handles that case itself via `destroyAllLocalIndexes()`.
22
+ */
23
+ import { useEffect, useRef } from "react";
24
+ import { getUnlockedKeys } from "@rapidmx/react-shared/crypto/keySession.js";
25
+ import type { PublicKey } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
26
+ import type { Folder } from "@rapidmx/react-shared/mail/mailApi.js";
27
+ import { buildLocalIndex, cancelLocalIndexBuild } from "./localIndexBuilder.js";
28
+ import { destroyLocalIndex, pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
29
+
30
+ export const POLL_INTERVAL_MS = 5_000;
31
+
32
+ export interface LocalIndexLifecycleProps {
33
+ mailboxUid?: string;
34
+ mailboxKeys?: PublicKey[];
35
+ folders: Folder[];
36
+ /** Every mailbox the signed-in user can currently access. Once known, indexes on this device for any
37
+ * other mailbox (e.g. left behind by a different user who never signed out) are removed. */
38
+ accessibleMailboxUids?: string[];
39
+ }
40
+
41
+ /** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
42
+ export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }: LocalIndexLifecycleProps) {
43
+ // mailboxUid -> whether it was unlocked at the last check. An entry exists for every mailbox whose
44
+ // build this component started; only ever changed by a real observed transition, never reset by an
45
+ // effect re-run.
46
+ const trackedRef = useRef(new Map<string, boolean>());
47
+ // The latest props, for the long-lived polling interval below.
48
+ const latestRef = useRef({ mailboxUid, folders });
49
+ latestRef.current = { mailboxUid, folders };
50
+
51
+ function startBuildIfUnlocked(targetMailboxUid: string, targetFolders: Folder[]) {
52
+ const unlocked = getUnlockedKeys(targetMailboxUid);
53
+ if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
54
+ return;
55
+ }
56
+ trackedRef.current.set(targetMailboxUid, true);
57
+ // Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
58
+ // the rest of the app depends on.
59
+ buildLocalIndex(targetMailboxUid, unlocked, targetFolders).catch(() => undefined);
60
+ }
61
+
62
+ useEffect(() => {
63
+ if (mailboxUid) {
64
+ startBuildIfUnlocked(mailboxUid, folders);
65
+ }
66
+ }, [mailboxUid, folders]);
67
+
68
+ useEffect(() => {
69
+ const interval = setInterval(() => {
70
+ for (const [trackedUid, wasUnlocked] of trackedRef.current) {
71
+ if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
72
+ // A present-to-absent transition: something just destroyed this mailbox's keys.
73
+ trackedRef.current.set(trackedUid, false);
74
+ // Stop the pass still running with the now-destroyed keys; the destroy itself also makes
75
+ // the Worker reject anything that pass still sends.
76
+ void cancelLocalIndexBuild(trackedUid);
77
+ void destroyLocalIndex(trackedUid);
78
+ }
79
+ }
80
+ // The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
81
+ const { mailboxUid: activeUid, folders: activeFolders } = latestRef.current;
82
+ if (activeUid) {
83
+ startBuildIfUnlocked(activeUid, activeFolders);
84
+ }
85
+ }, POLL_INTERVAL_MS);
86
+ return () => clearInterval(interval);
87
+ }, []);
88
+
89
+ // A stable string key so a re-render with an equal-but-new array doesn't re-scan OPFS.
90
+ const accessibleKey = accessibleMailboxUids && accessibleMailboxUids.length > 0 ? JSON.stringify([...accessibleMailboxUids].sort()) : "";
91
+ useEffect(() => {
92
+ if (accessibleKey) {
93
+ void pruneInaccessibleLocalIndexes(JSON.parse(accessibleKey) as string[]);
94
+ }
95
+ }, [accessibleKey]);
96
+
97
+ return null;
98
+ }
@@ -0,0 +1,83 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The actual AES-256-GCM block cipher `localIndexVFS.ts`'s `EncryptingVFS` uses to encrypt/decrypt one
7
+ * physical storage block - deliberately split out from that file so it can be exercised with real
8
+ * WebCrypto in a plain unit test, with no OPFS/Worker/wa-sqlite involved at all (none of those are
9
+ * available under Vitest's jsdom environment, matching the same "node" test environment convention
10
+ * `react-shared`'s other crypto modules already use for exactly this reason).
11
+ *
12
+ * See `localIndexVFS.ts`'s own doc comment for the full design rationale (page layout, why a fresh
13
+ * random nonce per write, why AAD binds to filename+block index). This module is pure mechanism; that
14
+ * one is where the design is explained.
15
+ */
16
+
17
+ /** The logical SQLite page size `EncryptingVFS` is designed for - MUST match the `PRAGMA page_size` the
18
+ * database was created with (`localIndexWorker.ts` sets this before the first write). */
19
+ export const LOGICAL_BLOCK_SIZE = 4096;
20
+ const NONCE_LENGTH = 12;
21
+ const TAG_LENGTH = 16;
22
+ /** One physical block on disk: `nonce || ciphertext || tag`. */
23
+ export const PHYSICAL_BLOCK_SIZE = LOGICAL_BLOCK_SIZE + NONCE_LENGTH + TAG_LENGTH;
24
+
25
+ /** Thrown when a stored block fails AES-GCM authentication - a wrong/rotated key, on-disk corruption, or
26
+ * a block moved to the wrong position (see `localIndexVFS.ts`'s doc comment on why AAD binds position).
27
+ * The worker's caller treats this identically to a schema-version mismatch: discard the whole index and
28
+ * rebuild (spec §11 "Invalidation"). */
29
+ export class PageCorruptedError extends Error {
30
+ /** The underlying `DOMException`/error `crypto.subtle.decrypt()` threw, or an inner-VFS read failure
31
+ * - kept as a plain field rather than the ES2022 `Error` constructor's `cause` option, since
32
+ * web-client's `tsconfig.json` targets ES2020. */
33
+ readonly cause: unknown;
34
+
35
+ constructor(filename: string, blockIndex: number, cause: unknown) {
36
+ super(`Local search index block ${blockIndex} of '${filename}' failed to decrypt.`);
37
+ this.cause = cause;
38
+ }
39
+ }
40
+
41
+ export async function importAesGcmKey(rawKey: Uint8Array): Promise<CryptoKey> {
42
+ return crypto.subtle.importKey("raw", rawKey as BufferSource, "AES-GCM", false, ["encrypt", "decrypt"]);
43
+ }
44
+
45
+ function buildBlockAad(filename: string, blockIndex: number): Uint8Array {
46
+ return new TextEncoder().encode(`rapidmx-local-index-block:${filename}:${blockIndex}`);
47
+ }
48
+
49
+ /** Encrypts one logical block (`plaintext`, exactly `LOGICAL_BLOCK_SIZE` bytes) into its physical,
50
+ * on-disk representation (`PHYSICAL_BLOCK_SIZE` bytes: a fresh random nonce followed by
51
+ * ciphertext+tag). */
52
+ export async function encryptBlock(key: CryptoKey, filename: string, blockIndex: number, plaintext: Uint8Array): Promise<Uint8Array> {
53
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
54
+ const ciphertextAndTag = new Uint8Array(
55
+ await crypto.subtle.encrypt(
56
+ { name: "AES-GCM", iv: nonce as BufferSource, additionalData: buildBlockAad(filename, blockIndex) as BufferSource },
57
+ key,
58
+ plaintext as BufferSource,
59
+ ),
60
+ );
61
+ const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
62
+ physical.set(nonce, 0);
63
+ physical.set(ciphertextAndTag, NONCE_LENGTH);
64
+ return physical;
65
+ }
66
+
67
+ /** Inverse of `encryptBlock()`. Throws `PageCorruptedError` (never a raw `DOMException`) on auth
68
+ * failure - a wrong key, tampered ciphertext, or a block swapped into the wrong position (caught by the
69
+ * position-bound AAD not matching). */
70
+ export async function decryptBlock(key: CryptoKey, filename: string, blockIndex: number, physical: Uint8Array): Promise<Uint8Array> {
71
+ const nonce = physical.subarray(0, NONCE_LENGTH);
72
+ const ciphertextAndTag = physical.subarray(NONCE_LENGTH, PHYSICAL_BLOCK_SIZE);
73
+ try {
74
+ const plaintext = await crypto.subtle.decrypt(
75
+ { name: "AES-GCM", iv: nonce as BufferSource, additionalData: buildBlockAad(filename, blockIndex) as BufferSource },
76
+ key,
77
+ ciphertextAndTag as BufferSource,
78
+ );
79
+ return new Uint8Array(plaintext);
80
+ } catch (err) {
81
+ throw new PageCorruptedError(filename, blockIndex, err);
82
+ }
83
+ }