@rapidmx/web-client 0.36.0 → 0.37.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 (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -32,8 +32,10 @@ import {
32
32
  type ZeroAccessEscrowWrapInput,
33
33
  type ZeroAccessKey,
34
34
  } from "./keyvaultApi.js";
35
+ import { certificateHoldsKey } from "./keyPairMatch.js";
35
36
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
36
37
  import {
38
+ addZeroAccessKey,
37
39
  getUnlockedKeys,
38
40
  installSharedSession,
39
41
  personalSessions,
@@ -106,9 +108,13 @@ function keyOrder(status: Pick<SharedKeyStatus, "activeKeyFingerprint" | "zeroAc
106
108
  /**
107
109
  * Opens shared mailbox `mailboxUid` for its member, with no password: their member wraps, opened with the zero-access keys of a mailbox they
108
110
  * own and have unlocked, become this session's keys for it (`installSharedSession()`). See `SharedUnlockOutcome` for when it can't.
111
+ *
112
+ * `refresh`: a shared session already open is opened again when it lacks the mailbox's active key and the member now holds a wrap of it -
113
+ * after a rotation, new mail is sealed to a key the session doesn't have. Its `masterKey` secret is kept, so the local index stays readable.
109
114
  */
110
- export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient): Promise<SharedUnlockOutcome> {
111
- if (getUnlockedKeys(mailboxUid)) {
115
+ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient, refresh = false): Promise<SharedUnlockOutcome> {
116
+ const existing: UnlockedKeys | undefined = getUnlockedKeys(mailboxUid);
117
+ if (existing && !(refresh && existing.shared)) {
112
118
  return "unlocked";
113
119
  }
114
120
  const generation: string = sharedUnlockGeneration(mailboxUid);
@@ -117,14 +123,22 @@ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient
117
123
  status = await getSharedKeyStatus(mailboxUid, client);
118
124
  } catch (err) {
119
125
  if (err instanceof ApiRequestError && (err.status === 403 || err.status === 404)) {
120
- return "not_shared";
126
+ return existing ? "unlocked" : "not_shared";
121
127
  }
122
128
  throw err;
123
129
  }
130
+ const holdsActive = (keys: { fingerprint: string }[] | undefined): boolean => !!keys?.some((key) => key.fingerprint === status.activeKeyFingerprint);
131
+ if (existing && (!status.activeKeyFingerprint || holdsActive(existing.zeroAccessKeys))) {
132
+ return "unlocked";
133
+ }
124
134
  if (status.role !== "member") {
125
- return "not_shared";
135
+ return existing ? "unlocked" : "not_shared";
126
136
  }
127
137
  const wraps: MemberKeyWrap[] = await getMyMemberKeyWraps(mailboxUid, client);
138
+ // Nothing new to open yet: the active key's wrap is still on its way.
139
+ if (existing && !holdsActive(wraps.map((wrap) => ({ fingerprint: wrap.zeroAccessKeyFingerprint })))) {
140
+ return "unlocked";
141
+ }
128
142
  if (wraps.length === 0) {
129
143
  return "pending";
130
144
  }
@@ -141,18 +155,27 @@ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient
141
155
  }
142
156
  const pkcs8 = unwrapped.pkcs8;
143
157
  try {
158
+ // The certificate is the server's: one that isn't this key's must never be what this session seals to.
159
+ if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
160
+ continue;
161
+ }
144
162
  secret ??= await sessionSecret(pkcs8);
145
163
  const privateKey = await importPrivateKeyPkcs8(pkcs8, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"]);
146
164
  opened.push({ fingerprint: wrap.zeroAccessKeyFingerprint, certDer: fromBase64(certificate.publicKey), privateKey });
147
165
  openedWith.add(unwrapped.openedWith);
148
166
  } catch {
149
- // A wrap that doesn't hold a usable key is skipped; the others still open the mailbox.
167
+ // A wrap that doesn't hold a usable key (or one its certificate doesn't match) is skipped; the others still open the mailbox.
150
168
  } finally {
151
169
  pkcs8.fill(0);
152
170
  }
153
171
  }
154
172
  if (!secret || opened.length === 0) {
155
- return "own_keys_locked";
173
+ return existing ? "unlocked" : "own_keys_locked";
174
+ }
175
+ if (existing) {
176
+ // Opened again: the secret the local index key is derived from stays the one the session started with.
177
+ secret.fill(0);
178
+ secret = new Uint8Array(existing.masterKey);
156
179
  }
157
180
  const unlocked: UnlockedKeys = { masterKey: secret, zeroAccessKeys: opened, openedWith: [...openedWith] };
158
181
  installSharedSession(mailboxUid, unlocked, generation);
@@ -160,7 +183,8 @@ export async function unlockSharedMailbox(mailboxUid: string, client?: ApiClient
160
183
  }
161
184
 
162
185
  /** The PKCS#8 of every zero-access key of `mailboxUid` the caller can open, by fingerprint - from the vault with the owner's unlocked master
163
- * key, or from the caller's own member wraps. The caller zeroes them. */
186
+ * key, or from the caller's own member wraps. Only keys whose certificate in `status` (the server's) is really theirs: a key is wrapped
187
+ * for members and escrow under that certificate's fingerprint, so a mismatched one is left out. The caller zeroes them. */
164
188
  async function openPrivateKeys(
165
189
  mailboxUid: string,
166
190
  status: SharedKeyStatus,
@@ -168,6 +192,14 @@ async function openPrivateKeys(
168
192
  client?: ApiClient,
169
193
  ): Promise<Map<string, Uint8Array>> {
170
194
  const keys = new Map<string, Uint8Array>();
195
+ const keep = async (fingerprint: string, pkcs8: Uint8Array): Promise<void> => {
196
+ const certificate = status.zeroAccessKeys.find((key) => key.fingerprint === fingerprint);
197
+ if (certificate && (await certificateHoldsKey(fromBase64(certificate.publicKey), pkcs8))) {
198
+ keys.set(fingerprint, pkcs8);
199
+ } else {
200
+ pkcs8.fill(0);
201
+ }
202
+ };
171
203
  if (ownerUnlocked) {
172
204
  const vault = await getKeyVault(mailboxUid, client);
173
205
  const aad = buildAad(mailboxUid, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE);
@@ -175,7 +207,7 @@ async function openPrivateKeys(
175
207
  const wrapped = vault.wrappedKeys.find((w) => w.fingerprint === key.fingerprint && w.useType === "zero-access");
176
208
  if (wrapped) {
177
209
  try {
178
- keys.set(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
210
+ await keep(key.fingerprint, await openWithKey(ownerUnlocked.masterKey, wrapped, aad));
179
211
  } catch {
180
212
  // Sealed under a master key since replaced: not this device's to share.
181
213
  }
@@ -187,7 +219,7 @@ async function openPrivateKeys(
187
219
  for (const wrap of await getMyMemberKeyWraps(mailboxUid, client)) {
188
220
  const unwrapped = await openWrap(wrap, own);
189
221
  if (unwrapped) {
190
- keys.set(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
222
+ await keep(wrap.zeroAccessKeyFingerprint, unwrapped.pkcs8);
191
223
  }
192
224
  }
193
225
  return keys;
@@ -201,9 +233,22 @@ async function wrapFor(member: { userUid: string; certificate: ZeroAccessKey },
201
233
  };
202
234
  }
203
235
 
204
- /** The members with a certificate of their own - the ones a key can be wrapped for. */
236
+ /** The members with a certificate of their own - the ones a key can be wrapped for. A certificate listed under the fingerprint of one of
237
+ * this user's own opened zero-access keys must be that key's certificate (checked against its private key at unlock): one the server
238
+ * swapped is left out, so a wrap meant for this user never goes to a key that isn't theirs. */
205
239
  function wrappable(status: SharedKeyStatus): { userUid: string; certificate: ZeroAccessKey; wrapped: string[] }[] {
206
- return status.members.filter((member): member is typeof member & { certificate: ZeroAccessKey } => !!member.certificate);
240
+ const own = ownZeroAccessKeys();
241
+ return status.members.filter((member): member is typeof member & { certificate: ZeroAccessKey } => {
242
+ if (!member.certificate) {
243
+ return false;
244
+ }
245
+ const mine = own.find((key) => key.fingerprint === member.certificate!.fingerprint);
246
+ return !mine || sameBytes(mine.certDer, fromBase64(member.certificate.publicKey));
247
+ });
248
+ }
249
+
250
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
251
+ return a.length === b.length && a.every((byte, index) => byte === b[index]);
207
252
  }
208
253
 
209
254
  /** `pkcs8` wrapped to the certificate of the escrow scope `escrow` describes. */
@@ -232,10 +277,17 @@ export interface SharedMaintenance {
232
277
  rotated: boolean;
233
278
  /** Wraps added for waiting members. */
234
279
  wrapped: number;
280
+ /** Wraps of older keys the server refused for waiting members (tried again on the next pass). */
281
+ refused: number;
235
282
  /** Departed users whose wraps were removed. */
236
283
  removed: number;
237
284
  /** Keys wrapped for the mailbox's escrow scope. */
238
285
  escrowed: number;
286
+ /**
287
+ * The server's reason when it refused (400) the new key of a pending rotation - e.g. the vault couldn't hold the new key's member wraps
288
+ * next to those it holds - so the current key stays active and members keep reading; the rotation is tried again on the next pass.
289
+ */
290
+ rotationRefused?: string;
239
291
  }
240
292
 
241
293
  /**
@@ -245,11 +297,13 @@ export interface SharedMaintenance {
245
297
  * A mailbox without an owner and without a key gets one (unless the administrator disallows zero-access), wrapped for every member who has a
246
298
  * certificate. Once a user who held the active key has left (`rotationPending`), a new key replaces it - enrolled by the owner with their
247
299
  * master key (`provisionZeroAccessKey()`), or for a mailbox without an owner with the members' wraps - and then the departed users' wraps
248
- * are removed. Members still waiting (`pending`) get wraps of every key this client can open, and the escrow scope of a mailbox without an
249
- * owner gets copies of the keys it is missing (`escrowPending`).
300
+ * are removed. Members still waiting (`pending`) get wraps of every key this client can open, one write per key with the active key's
301
+ * first (a refused older key doesn't stop the rest), and the escrow scope of a mailbox without an owner gets copies of the keys it is
302
+ * missing (`escrowPending`). A rotation the server refuses (400) is reported in `rotationRefused`, not thrown: the current key stays active
303
+ * and the rest of the pass goes ahead.
250
304
  */
251
305
  export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress: string, client?: ApiClient): Promise<SharedMaintenance> {
252
- const result: SharedMaintenance = { provisioned: false, rotated: false, wrapped: 0, removed: 0, escrowed: 0 };
306
+ const result: SharedMaintenance = { provisioned: false, rotated: false, wrapped: 0, refused: 0, removed: 0, escrowed: 0 };
253
307
  let status: SharedKeyStatus = await getSharedKeyStatus(mailboxUid, client);
254
308
  if (!status.manager || (status.members.length === 0 && status.departed.length === 0)) {
255
309
  return result;
@@ -267,14 +321,25 @@ export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress:
267
321
  status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
268
322
  result.provisioned = true;
269
323
  } else if (status.rotationPending) {
270
- if (status.ownerless) {
271
- status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
272
- } else {
273
- const vault = await getKeyVault(mailboxUid, client);
274
- await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked!.masterKey, vault.masterKeyGeneration, client);
275
- status = await getSharedKeyStatus(mailboxUid, client);
324
+ try {
325
+ if (status.ownerless) {
326
+ status = await enrollNewSharedKey(mailboxUid, mailboxAddress, status, client);
327
+ } else {
328
+ const vault = await getKeyVault(mailboxUid, client);
329
+ const { key } = await provisionZeroAccessKey(mailboxUid, mailboxAddress, ownerUnlocked!.masterKey, vault.masterKeyGeneration, client);
330
+ // The owner's open session gets the new key as its active one, as `ensureZeroAccessKey()` does: new mail is sealed to it.
331
+ addZeroAccessKey(mailboxUid, ownerUnlocked!, key);
332
+ status = await getSharedKeyStatus(mailboxUid, client);
333
+ }
334
+ result.rotated = true;
335
+ } catch (err) {
336
+ // Refused before any key was issued (restapi: the vault can't hold the new key's member wraps next to those it holds): the
337
+ // current key stays active, so the rest of the pass - waiting members' wraps of it - still goes ahead. Anything else throws.
338
+ if (!(err instanceof ApiRequestError) || err.status !== 400) {
339
+ throw err;
340
+ }
341
+ result.rotationRefused = err.message;
276
342
  }
277
- result.rotated = true;
278
343
  }
279
344
  // The departed hold no wrap of the active key any more (or never did): their old wraps go.
280
345
  if (!status.rotationPending) {
@@ -289,17 +354,30 @@ export async function maintainSharedMailbox(mailboxUid: string, mailboxAddress:
289
354
  if (missing.length > 0 || escrowPending.length > 0) {
290
355
  const keys = await openPrivateKeys(mailboxUid, status, ownerUnlocked, client);
291
356
  try {
292
- const wraps: MemberKeyWrap[] = [];
293
- for (const member of missing) {
294
- for (const [fingerprint, pkcs8] of keys) {
357
+ // One write per key, the active key's first: it alone opens new mail, so a refused backfill of an older key (the server bounds
358
+ // each key's wraps and the vault's total) never keeps a member from it. A failed older key is left for the next pass.
359
+ const order = keyOrder(status);
360
+ for (const [fingerprint, pkcs8] of [...keys].sort(([a], [b]) => order(a) - order(b))) {
361
+ const wraps: MemberKeyWrap[] = [];
362
+ for (const member of missing) {
295
363
  if (!member.wrapped.includes(fingerprint)) {
296
364
  wraps.push({ ...(await wrapFor(member, pkcs8)), zeroAccessKeyFingerprint: fingerprint });
297
365
  }
298
366
  }
299
- }
300
- if (wraps.length > 0) {
301
- await putMemberKeyWraps(mailboxUid, wraps, client);
302
- result.wrapped = wraps.length;
367
+ if (wraps.length === 0) {
368
+ continue;
369
+ }
370
+ if (fingerprint === status.activeKeyFingerprint) {
371
+ await putMemberKeyWraps(mailboxUid, wraps, client);
372
+ result.wrapped += wraps.length;
373
+ } else {
374
+ try {
375
+ await putMemberKeyWraps(mailboxUid, wraps, client);
376
+ result.wrapped += wraps.length;
377
+ } catch {
378
+ result.refused += wraps.length;
379
+ }
380
+ }
303
381
  }
304
382
  const held: string[] = escrowPending.filter((fingerprint) => keys.has(fingerprint));
305
383
  if (held.length > 0) {
@@ -77,7 +77,8 @@ export function applyBaselineOuterHeaders(headers: ProtectedHeaders): ProtectedH
77
77
  return { ...headers, subject: "[...]" };
78
78
  }
79
79
 
80
- function base64Wrapped(bytes: Uint8Array): string {
80
+ /** @internal Shared with `pgpMessage.ts`. */
81
+ export function base64Wrapped(bytes: Uint8Array): string {
81
82
  const encoded = toBase64(bytes);
82
83
  const lines: string[] = [];
83
84
  for (let i = 0; i < encoded.length; i += BASE64_LINE_WIDTH) {
@@ -86,7 +87,8 @@ function base64Wrapped(bytes: Uint8Array): string {
86
87
  return lines.join(CRLF);
87
88
  }
88
89
 
89
- function generateBoundary(): string {
90
+ /** @internal Shared with `pgpMessage.ts`. */
91
+ export function generateBoundary(): string {
90
92
  const bytes = crypto.getRandomValues(new Uint8Array(16));
91
93
  return `----=_RapidMX_${Array.from(bytes)
92
94
  .map((b) => b.toString(16).padStart(2, "0"))
@@ -94,7 +96,7 @@ function generateBoundary(): string {
94
96
  }
95
97
 
96
98
  /** Strips CR/LF/NUL from a header value that has no further encoding (Date, Message-ID, Content-Type). */
97
- function flattenHeaderValue(value: string): string {
99
+ export function flattenHeaderValue(value: string): string {
98
100
  return value.replace(/[\r\n\0]+/g, " ");
99
101
  }
100
102
 
@@ -116,7 +118,8 @@ function headerLines(headers: ProtectedHeaders, prefix = ""): string[] {
116
118
  ];
117
119
  }
118
120
 
119
- function protectedHeaderLines(headers: ProtectedHeaders, hpOuter?: ProtectedHeaders): string {
121
+ /** @internal Shared with `pgpMessage.ts`: the protected header block of an inner entity (with `HP-Outer:` copies of `hpOuter`). */
122
+ export function protectedHeaderLines(headers: ProtectedHeaders, hpOuter?: ProtectedHeaders): string {
120
123
  const lines = headerLines(headers);
121
124
  if (hpOuter) {
122
125
  // RFC 9788 Section 2.2.1's `hp-outer` field: the literal string "HP-Outer:" followed by the
@@ -369,7 +372,7 @@ export type ComparableOuterHeaders = Partial<Pick<ProtectedHeaders, "from" | "to
369
372
  * (a real bug if reused for this - JSON/`Record` keys aren't multi-valued). Scans the raw header block
370
373
  * text directly instead. Returns `undefined` when no `HP-Outer:` lines are present at all, so a caller
371
374
  * can distinguish "nothing to compare" from "compared and everything matched". */
372
- function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined {
375
+ export function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeaders | undefined {
373
376
  const result: ComparableOuterHeaders = {};
374
377
  let found = false;
375
378
  for (const { name, value } of fields) {
@@ -386,7 +389,7 @@ function extractHpOuterHeaders(fields: MimeHeaderField[]): ComparableOuterHeader
386
389
  /** `true` when every field `hpOuter` actually carries matches `actualOuterHeaders`' value for that same
387
390
  * field - a field `hpOuter` doesn't carry (e.g. no `Cc` on this message) is not compared, since RFC
388
391
  * 9788 never claims coverage for it either way. */
389
- function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean {
392
+ export function outerHeadersMatch(hpOuter: ComparableOuterHeaders, actualOuterHeaders: ComparableOuterHeaders): boolean {
390
393
  return (Object.keys(hpOuter) as (keyof ComparableOuterHeaders)[]).every((field) => hpOuter[field] === actualOuterHeaders[field]);
391
394
  }
392
395
 
@@ -477,7 +480,8 @@ export function splitHeadersAndBody(entity: string): { headers: Record<string, s
477
480
  return { headers, contentType: headers["content-type"], body, rawHeaderBlock };
478
481
  }
479
482
 
480
- function headersToProtectedHeaders(headers: Record<string, string>): ProtectedHeaders {
483
+ /** @internal Shared with `pgpMessage.ts`. */
484
+ export function headersToProtectedHeaders(headers: Record<string, string>): ProtectedHeaders {
481
485
  return {
482
486
  from: headers["from"] ?? "",
483
487
  to: headers["to"] ?? "",
@@ -11,6 +11,7 @@
11
11
  import type { ApiClient } from "../util/api.js";
12
12
  import { fromBase64 } from "./encoding.js";
13
13
  import { type KeyVault, enrollKey, getKeyVault } from "./keyvaultApi.js";
14
+ import { certificateHoldsKey } from "./keyPairMatch.js";
14
15
  import { exportPrivateKeyPkcs8, generateKeyPairWithCsr, importPrivateKeyPkcs8 } from "./keys.js";
15
16
  import { addZeroAccessKey, getUnlockedKeys, type RetainedEncryptionKey, ZERO_ACCESS_PRIVATE_KEY_AAD_PURPOSE } from "./keySession.js";
16
17
  import { buildAad, sealWithKey } from "./masterKey.js";
@@ -56,6 +57,10 @@ export async function provisionZeroAccessKey(
56
57
  if (!enrolled || !certificate) {
57
58
  throw new Error("The server did not return the enrolled zero-access key.");
58
59
  }
60
+ // The certificate is the server's: this device seals to it (index segments, protected meetings' keys), so it must be the new key's.
61
+ if (!(await certificateHoldsKey(fromBase64(certificate.publicKey), raw))) {
62
+ throw new Error("The server returned a zero-access certificate that isn't the enrolled key's.");
63
+ }
59
64
  const privateKey = await importPrivateKeyPkcs8(raw, { name: "ECDH", namedCurve: "P-256" }, ["deriveBits"], false);
60
65
  return { vault, key: { fingerprint: enrolled.fingerprint, certDer: fromBase64(certificate.publicKey), privateKey } };
61
66
  } finally {
@@ -135,6 +135,12 @@ export interface Mailbox {
135
135
  /** "Enable zero-access encryption": unset means on. Only the mailbox's owner can change it - restapi drops anybody
136
136
  * else's value. Has no effect while the server's `EncryptionPolicy.allowZeroAccess` is off. */
137
137
  zeroAccessEnabled?: boolean;
138
+ /** Which data types zero-access encryption covers - see `ZeroAccessTypes`. Owner only. */
139
+ zeroAccessTypes?: ZeroAccessTypes;
140
+ /** The ActiveSync and Outlook clients that sync this mailbox, as the server last saw them. Server-written. */
141
+ syncClients?: SyncClients;
142
+ /** When the owner last exported a zero-access key for a phone or Outlook. Server-written. */
143
+ zeroAccessKeysExportedAt?: number;
138
144
  /** How the caller reaches this mailbox: `"owner"` (theirs) or `"delegate"` (shared with them). Set by the plain list and read of
139
145
  * mailboxes - not in an administration-scope answer - so a client can label the shared ones; see `isSharedWithMe()`. */
140
146
  accessRole?: "owner" | "delegate";
@@ -263,6 +269,19 @@ export function autoProvisionMailbox(
263
269
  * `null` clears them - `JSON.stringify()` drops an `undefined` value entirely, which would instead leave
264
270
  * the old value in place.
265
271
  */
272
+ /** The data types zero-access encryption can cover (restapi `specs/search.md` §3 *What It Covers*). */
273
+ export type ZeroAccessDataType = "mail" | "calendar" | "contacts" | "tasks";
274
+
275
+ /** `Mailbox.zeroAccessTypes`: per data type, whether zero-access covers it. A type left out is undecided: mail counts as on, the others
276
+ * as off, until the server decides them from the owner's devices. */
277
+ export type ZeroAccessTypes = Partial<Record<ZeroAccessDataType, boolean>>;
278
+
279
+ /** `Mailbox.syncClients`: the ActiveSync devices (each with the data types it syncs) and Outlook. */
280
+ export interface SyncClients {
281
+ activesync?: { devices: { id: string; model?: string; types: ZeroAccessDataType[]; lastSeenAt: number }[] };
282
+ mapi?: { lastSeenAt: number };
283
+ }
284
+
266
285
  export interface UpdateMailboxInput {
267
286
  uid: string;
268
287
  version: number;
@@ -289,6 +308,10 @@ export interface UpdateMailboxInput {
289
308
  autoSendReceiptsExternal?: boolean;
290
309
  /** Owner-only server-side (anybody else's value is dropped) - see `Mailbox.zeroAccessEnabled`. */
291
310
  zeroAccessEnabled?: boolean;
311
+ /** Owner-only server-side; a partial choice is merged into the stored one - see `Mailbox.zeroAccessTypes`. */
312
+ zeroAccessTypes?: ZeroAccessTypes;
313
+ /** The mailbox's own encryption preference, replaced whole - see `Mailbox.encryptPreference`. */
314
+ encryptPreference?: EncryptionPreference;
292
315
  /** Trusted-caller-only server-side. `null` (or `""`) unassigns the mailbox's escrow scope; the
293
316
  * referenced `EscrowScope` must exist otherwise. See `Mailbox.escrowScopeId`. */
294
317
  escrowScopeId?: string | null;
@@ -1094,6 +1117,10 @@ export interface AssembleDraftInput {
1094
1117
  html: string;
1095
1118
  /** See `AssembleIntent`. */
1096
1119
  intent?: AssembleIntent;
1120
+ /** The addresses this copy is delivered to, when not all of `to`/`cc`/`bcc`: one copy of a message sent in two end-to-end
1121
+ * formats (an S/MIME and an OpenPGP copy, each listing every recipient) goes only to the recipients of its own format. Each
1122
+ * must be one of the recipients. */
1123
+ deliverTo?: string[];
1097
1124
  }
1098
1125
 
1099
1126
  /**
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { ApiClient, withClient, withClientRaw } from "../util/api.js";
13
13
  import { RequestListParams, buildRequestListQuery } from "../util/apiQuery.js";
14
+ import type { Mailbox } from "./mailApi.js";
14
15
 
15
16
  export type { RequestListParams };
16
17
 
@@ -79,3 +80,26 @@ export function listImportRequests(params: RequestListParams = {}, client?: ApiC
79
80
  export function getImportRequest(uid: string, client?: ApiClient): Promise<MailboxImportRequest> {
80
81
  return withClient(client, `/mail/mailbox-import-requests/${encodeURIComponent(uid)}`);
81
82
  }
83
+
84
+ /** Why server-side import is refused for a mailbox whose mail is zero-access (restapi answers such an upload 409). */
85
+ export const ZERO_ACCESS_IMPORT_MESSAGE =
86
+ "Importing an archive isn't available while zero-access encryption protects this mailbox's mail: the server would have to keep " +
87
+ "the uploaded archive readable until the import ran, which zero-access encryption doesn't allow.";
88
+
89
+ /**
90
+ * Whether `mailbox`'s mail is stored zero-access encrypted, so restapi refuses a server-side import into it (`specs/search.md` §3: the
91
+ * uploaded archive would wait readable in the blob store). Decided as restapi's `zeroAccessStatus()` decides it for mail: the
92
+ * administrator's "Allow zero-access encryption" (`allowZeroAccess`; unknown counts as allowed, so an import is never offered only to be
93
+ * refused), the owner's switch (unset is on), mail among the protected types (undecided is on), and a key that isn't revoked.
94
+ */
95
+ export function mailIsZeroAccess(
96
+ mailbox: Pick<Mailbox, "zeroAccessEnabled" | "zeroAccessTypes" | "zeroAccessKeys">,
97
+ allowZeroAccess: boolean | undefined,
98
+ ): boolean {
99
+ return (
100
+ allowZeroAccess !== false &&
101
+ mailbox.zeroAccessEnabled !== false &&
102
+ mailbox.zeroAccessTypes?.mail !== false &&
103
+ (mailbox.zeroAccessKeys ?? []).some((key) => !key.revokedAt)
104
+ );
105
+ }
@@ -30,6 +30,10 @@ export interface IndexWeekEntries {
30
30
  latestEntryAt: string;
31
31
  /** The week has closed and its grace period has passed: a client may compact it. */
32
32
  compactable: boolean;
33
+ /** When the week becomes (or became) compactable - where the platform stagger starts. Absent from a server before it. */
34
+ compactableAt?: string;
35
+ /** Size of the week's pending sealed entries (bytes of base64). Absent from a server before it. */
36
+ pendingBytes?: number;
33
37
  }
34
38
 
35
39
  export interface SegmentManifest {
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { ApiClient, withClient } from "../util/api.js";
9
9
  import { ListParams, SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
10
+ import { guardSealedMove } from "../zeroAccess/sealedMoves.js";
11
+ import { protectedRecordWrite } from "../zeroAccess/sealedRecords.js";
10
12
 
11
13
  export type TaskPriority = "low" | "normal" | "high";
12
14
 
@@ -31,6 +33,9 @@ export interface Task {
31
33
  myDay?: boolean;
32
34
  /** The uid of the user this task is assigned to, if any. */
33
35
  assignedTo?: string;
36
+ /** The title and notes sealed to the mailbox's zero-access key while it protects tasks (`title` is then a placeholder) - opened with
37
+ * `openSealedRecords()`. Server-written. */
38
+ sealedFields?: Record<string, string> | null;
34
39
  }
35
40
 
36
41
  /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
@@ -78,13 +83,22 @@ export interface UpdateTaskInput {
78
83
  taskListUid?: string;
79
84
  myDay?: boolean;
80
85
  assignedTo?: string;
86
+ /** A move to another mailbox, with `folderUid` there - refused for a protected task (`SealedMoveError`). */
87
+ mailboxUid?: string;
88
+ folderUid?: string;
81
89
  }
82
90
 
83
- export function updateTask(input: UpdateTaskInput, client?: ApiClient): Promise<Task> {
84
- return withClient(client, `/mail/tasks/${encodeURIComponent(input.uid)}`, {
85
- method: "PUT",
86
- body: JSON.stringify(input),
87
- });
91
+ /** Saves `input`. One moving the task to another mailbox (`mailboxUid`) is refused with `SealedMoveError` when the task is protected -
92
+ * before sending anything when `current` (the task as this device has it) says so, else from the server's refusal. With `current`, a
93
+ * protected task's sealed fields go only when they changed from it, and an emptied one is named in `clearedFields` when `current` was
94
+ * opened here (`protectedRecordWrite()`). */
95
+ export function updateTask(input: UpdateTaskInput, client?: ApiClient, current?: Task): Promise<Task> {
96
+ return guardSealedMove(current, input.mailboxUid, () =>
97
+ withClient<Task>(client, `/mail/tasks/${encodeURIComponent(input.uid)}`, {
98
+ method: "PUT",
99
+ body: JSON.stringify(protectedRecordWrite(current, input)),
100
+ }),
101
+ );
88
102
  }
89
103
 
90
104
  /** Toggles a task's `completed` flag in place — same pattern as `mailApi.ts`'s `setMessageRead`. */
@@ -0,0 +1,50 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * What kind of client this is, for work that should prefer one over another - the search index's week compaction, which a phone
7
+ * should leave to a computer (restapi `specs/search.md` §8 *Who compacts*).
8
+ *
9
+ * - `"desktop"`: a native app on a computer (`tauri-client`, `electron-client`), which installs `window.rapidmx`.
10
+ * - `"mobile"`: a native app on a phone or tablet (the host sets `window.rapidmx.formFactor: "mobile"`), or a phone or tablet browser.
11
+ * - `"web"`: any other browser.
12
+ */
13
+ export type ClientPlatform = "desktop" | "web" | "mobile";
14
+
15
+ interface NavigatorHints {
16
+ userAgent?: string;
17
+ userAgentData?: { mobile?: boolean };
18
+ connection?: { type?: string; saveData?: boolean };
19
+ }
20
+
21
+ const MOBILE_USER_AGENT = /Android|iPhone|iPad|iPod|Mobile/i;
22
+
23
+ function navigatorHints(): NavigatorHints | undefined {
24
+ return typeof navigator === "undefined" ? undefined : navigator;
25
+ }
26
+
27
+ /** Whether this browser runs on a phone or tablet, by its User-Agent Client Hints, else its user agent string. */
28
+ function isMobileBrowser(): boolean {
29
+ const hints = navigatorHints();
30
+ if (typeof hints?.userAgentData?.mobile === "boolean") {
31
+ return hints.userAgentData.mobile;
32
+ }
33
+ return MOBILE_USER_AGENT.test(hints?.userAgent ?? "");
34
+ }
35
+
36
+ /** This client's platform (see the module comment). */
37
+ export function clientPlatform(): ClientPlatform {
38
+ const host = typeof window === "undefined" ? undefined : (window as { rapidmx?: { formFactor?: string } }).rapidmx;
39
+ if (host) {
40
+ return host.formFactor === "mobile" ? "mobile" : "desktop";
41
+ }
42
+ return isMobileBrowser() ? "mobile" : "web";
43
+ }
44
+
45
+ /** Whether the connection is one to spare: cellular, or the user asked to save data (Network Information API, where the browser
46
+ * has it - absent, the connection is taken as unconstrained). */
47
+ export function isConstrainedConnection(): boolean {
48
+ const connection = navigatorHints()?.connection;
49
+ return connection?.saveData === true || connection?.type === "cellular";
50
+ }