@mdbase-dev/sdk 0.1.0-beta.169 → 0.1.0-beta.175

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 (162) hide show
  1. package/README.md +91 -3
  2. package/dist/app-host/account-key-host.d.ts +128 -0
  3. package/dist/app-host/account-key-host.js +449 -0
  4. package/dist/app-host/account-key-host.js.map +1 -0
  5. package/dist/app-host/app-environment.d.ts +3 -4
  6. package/dist/app-host/app-environment.js +6 -6
  7. package/dist/app-host/app-environment.js.map +1 -1
  8. package/dist/app-host/app-origin.d.ts +5 -2
  9. package/dist/app-host/app-origin.js +10 -6
  10. package/dist/app-host/app-origin.js.map +1 -1
  11. package/dist/app-host/cloud-copy-bootstrap.js +2 -2
  12. package/dist/app-host/cloud-copy-bootstrap.js.map +1 -1
  13. package/dist/app-host/cloud-copy-host.d.ts +69 -5
  14. package/dist/app-host/cloud-copy-host.js +289 -20
  15. package/dist/app-host/cloud-copy-host.js.map +1 -1
  16. package/dist/app-host/cloud-copy-persistence-idb.d.ts +0 -2
  17. package/dist/app-host/cloud-copy-persistence-idb.js +2 -2
  18. package/dist/app-host/cloud-copy-persistence-idb.js.map +1 -1
  19. package/dist/app-host/cp-authority.js +2 -2
  20. package/dist/app-host/cp-authority.js.map +1 -1
  21. package/dist/app-host/cp-people.js +2 -2
  22. package/dist/app-host/cp-people.js.map +1 -1
  23. package/dist/app-host/cp-problem.d.ts +1 -1
  24. package/dist/app-host/cp-problem.js +4 -0
  25. package/dist/app-host/cp-problem.js.map +1 -1
  26. package/dist/app-host/device-key-idb.d.ts +0 -2
  27. package/dist/app-host/device-key-idb.js +1 -1
  28. package/dist/app-host/device-key-idb.js.map +1 -1
  29. package/dist/app-host/device-key-provider.d.ts +0 -3
  30. package/dist/app-host/device-key-provider.js +1 -1
  31. package/dist/app-host/device-key-provider.js.map +1 -1
  32. package/dist/app-host/device-registration.js +2 -2
  33. package/dist/app-host/device-registration.js.map +1 -1
  34. package/dist/app-host/http-log.d.ts +2 -6
  35. package/dist/app-host/http-log.js +12 -45
  36. package/dist/app-host/http-log.js.map +1 -1
  37. package/dist/app-host/index.d.ts +4 -2
  38. package/dist/app-host/index.js +2 -1
  39. package/dist/app-host/index.js.map +1 -1
  40. package/dist/app-host/installation-sign-in.d.ts +19 -1
  41. package/dist/app-host/installation-sign-in.js +149 -30
  42. package/dist/app-host/installation-sign-in.js.map +1 -1
  43. package/dist/app-host/installation-store.d.ts +0 -2
  44. package/dist/app-host/installation-store.js +4 -4
  45. package/dist/app-host/installation-store.js.map +1 -1
  46. package/dist/app-host/log-push.js +2 -2
  47. package/dist/app-host/log-push.js.map +1 -1
  48. package/dist/app-host/noise-idb.d.ts +0 -3
  49. package/dist/app-host/noise-idb.js +1 -1
  50. package/dist/app-host/noise-idb.js.map +1 -1
  51. package/dist/app-host/private-bootstrap.js +2 -2
  52. package/dist/app-host/private-bootstrap.js.map +1 -1
  53. package/dist/app-host/private-persistence-idb.d.ts +0 -2
  54. package/dist/app-host/private-persistence-idb.js +1 -1
  55. package/dist/app-host/private-persistence-idb.js.map +1 -1
  56. package/dist/app-host/private-persistence.js +2 -2
  57. package/dist/app-host/private-persistence.js.map +1 -1
  58. package/dist/app-host/scoped-sign-in-session.d.ts +4 -0
  59. package/dist/app-host/scoped-sign-in-session.js +13 -1
  60. package/dist/app-host/scoped-sign-in-session.js.map +1 -1
  61. package/dist/app-host/spare-nonces.d.ts +27 -0
  62. package/dist/app-host/spare-nonces.js +58 -0
  63. package/dist/app-host/spare-nonces.js.map +1 -0
  64. package/dist/app-host/unsynced-collection.js +8 -3
  65. package/dist/app-host/unsynced-collection.js.map +1 -1
  66. package/dist/app-host/wasm-runtime.d.ts +14 -0
  67. package/dist/app-host/wasm-runtime.js +45 -0
  68. package/dist/app-host/wasm-runtime.js.map +1 -1
  69. package/dist/app-host/web-collection.d.ts +4 -1
  70. package/dist/app-host/web-collection.js +214 -125
  71. package/dist/app-host/web-collection.js.map +1 -1
  72. package/dist/app-host/web-sign-in-problem.js +6 -1
  73. package/dist/app-host/web-sign-in-problem.js.map +1 -1
  74. package/dist/app-host/web-sign-in-session.d.ts +46 -4
  75. package/dist/app-host/web-sign-in-session.js +88 -15
  76. package/dist/app-host/web-sign-in-session.js.map +1 -1
  77. package/dist/application/asset-loader.d.ts +0 -1
  78. package/dist/application/asset-loader.js +2 -2
  79. package/dist/application/asset-loader.js.map +1 -1
  80. package/dist/application/asset-pins.json +4 -4
  81. package/dist/application/browser-selection.js +1 -1
  82. package/dist/application/browser-selection.js.map +1 -1
  83. package/dist/application/collection-selection.d.ts +10 -0
  84. package/dist/application/collection-selection.js +58 -11
  85. package/dist/application/collection-selection.js.map +1 -1
  86. package/dist/application/declaration.js +2 -2
  87. package/dist/application/declaration.js.map +1 -1
  88. package/dist/application/entry.d.ts +19 -0
  89. package/dist/application/entry.js +81 -38
  90. package/dist/application/entry.js.map +1 -1
  91. package/dist/application/front-door.d.ts +6 -1
  92. package/dist/application/front-door.js +5 -0
  93. package/dist/application/front-door.js.map +1 -1
  94. package/dist/application/local-build.d.ts +4 -2
  95. package/dist/application/local-build.js +4 -2
  96. package/dist/application/local-build.js.map +1 -1
  97. package/dist/application/manifest.d.ts +1 -0
  98. package/dist/application/manifest.js +8 -3
  99. package/dist/application/manifest.js.map +1 -1
  100. package/dist/application/native-assets.js +3 -4
  101. package/dist/application/native-assets.js.map +1 -1
  102. package/dist/application/packaged-assets.d.ts +1 -1
  103. package/dist/application/packaged-assets.js +6 -3
  104. package/dist/application/packaged-assets.js.map +1 -1
  105. package/dist/application/phase.d.ts +21 -1
  106. package/dist/application/phase.js +12 -1
  107. package/dist/application/phase.js.map +1 -1
  108. package/dist/application/portal-reservation.js +2 -2
  109. package/dist/application/portal-reservation.js.map +1 -1
  110. package/dist/application/registration.d.ts +3 -0
  111. package/dist/application/registration.js +7 -4
  112. package/dist/application/registration.js.map +1 -1
  113. package/dist/application/return-url.d.ts +1 -1
  114. package/dist/application/return-url.js +6 -7
  115. package/dist/application/return-url.js.map +1 -1
  116. package/dist/application/runtime.d.ts +7 -1
  117. package/dist/application/runtime.js +53 -4
  118. package/dist/application/runtime.js.map +1 -1
  119. package/dist/application/startup-timing.d.ts +20 -0
  120. package/dist/application/startup-timing.js +58 -0
  121. package/dist/application/startup-timing.js.map +1 -0
  122. package/dist/application/ui-connections.js +17 -4
  123. package/dist/application/ui-connections.js.map +1 -1
  124. package/dist/application/worker-client.d.ts +3 -0
  125. package/dist/application/worker-client.js +27 -2
  126. package/dist/application/worker-client.js.map +1 -1
  127. package/dist/application/worker-host.js +51 -3
  128. package/dist/application/worker-host.js.map +1 -1
  129. package/dist/application/worker-protocol.d.ts +7 -2
  130. package/dist/application/worker-protocol.js +10 -1
  131. package/dist/application/worker-protocol.js.map +1 -1
  132. package/dist/application/worker-session.d.ts +1 -1
  133. package/dist/application/worker-session.js +5 -1
  134. package/dist/application/worker-session.js.map +1 -1
  135. package/dist/application/worker.js +8969 -2561
  136. package/dist/application/worker.js.map +1 -1
  137. package/dist/browser/mdbase-sdk.js +8174 -3680
  138. package/dist/browser/mdbase-sdk.min.js +78 -78
  139. package/dist/describe-error.d.ts +2 -2
  140. package/dist/describe-error.js +11 -2
  141. package/dist/describe-error.js.map +1 -1
  142. package/dist/index.d.ts +1 -1
  143. package/dist/local-build.d.ts +7 -4
  144. package/dist/local-build.js +7 -4
  145. package/dist/local-build.js.map +1 -1
  146. package/dist/native/provenance.json +5 -5
  147. package/dist/native/runtime.wasm +0 -0
  148. package/dist/private-account.js +13 -18
  149. package/dist/private-account.js.map +1 -1
  150. package/dist/testing/application.d.ts +3 -1
  151. package/dist/testing/application.js +8 -1
  152. package/dist/testing/application.js.map +1 -1
  153. package/dist/testing/test-app.d.ts +9 -0
  154. package/dist/testing/test-app.js +13 -0
  155. package/dist/testing/test-app.js.map +1 -1
  156. package/dist/transport/control-route.d.ts +2 -1
  157. package/dist/transport/control-route.js +4 -1
  158. package/dist/transport/control-route.js.map +1 -1
  159. package/dist/transport/relay.d.ts +7 -0
  160. package/dist/transport/relay.js +3 -1
  161. package/dist/transport/relay.js.map +1 -1
  162. package/package.json +1 -1
@@ -23,6 +23,19 @@ export class AppInstallationSignInError extends Error {
23
23
  const fail = (reason) => new AppInstallationSignInError(reason);
24
24
  // Preserve definite HTTP status provenance even when its body cannot be parsed.
25
25
  const nonOkResponses = new WeakSet();
26
+ // A Connect server from before Private device access parses START strictly and
27
+ // answers the one optional field it does not know with exactly this refusal
28
+ // (400 invalid_request naming the key). Nothing was stored for that request, so
29
+ // START may be repeated ONCE without the field: the app then asks only for cloud
30
+ // copies, as an older SDK would. Any other refusal stays a refusal.
31
+ const privateDeviceFieldRefused = new WeakSet();
32
+ function unknownPrivateDeviceField(status, error) {
33
+ if (status !== 400 || !error || typeof error !== "object" || Array.isArray(error))
34
+ return false;
35
+ const { code, message } = error;
36
+ return code === "invalid_request" && typeof message === "string" && message.length <= 512 &&
37
+ /unrecognized key/i.test(message) && message.includes("requested_private_device");
38
+ }
26
39
  const hex = (b) => Array.from(b, v => v.toString(16).padStart(2, "0")).join("");
27
40
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
28
41
  function id(v) { if (typeof v !== "string" || !UUID.test(v) || uuidToBytes(v).every(b => b === 0))
@@ -95,6 +108,11 @@ function timerChannel(value) {
95
108
  return Object.freeze({ grantId: id(row.grantId), kind: row.kind, targetDigest: text(row.targetDigest, /^[0-9a-f]{64}$/),
96
109
  channelId: row.channelId === null ? null : id(row.channelId), pending: row.pending });
97
110
  }
111
+ /** True for a non-empty listing in which no row carries `mode`, although it was asked for. */
112
+ function modeUnanswered(value) {
113
+ const rows = value && typeof value === "object" && !Array.isArray(value) ? value.collections : undefined;
114
+ return Array.isArray(rows) && rows.length > 0 && rows.every(row => !row || typeof row !== "object" || !("mode" in row));
115
+ }
98
116
  /** Strictly parsed CP display/scope metadata, never native readiness. */
99
117
  function collectionListing(value) {
100
118
  if (!Array.isArray(value) || value.length > 1000)
@@ -102,13 +120,15 @@ function collectionListing(value) {
102
120
  const seen = new Set();
103
121
  return Object.freeze(value.map(value => {
104
122
  const has = (key) => value && typeof value === "object" && key in value ? `,${key}` : "";
105
- const r = object(value, `collection_id,display_name,role${has("availability")}${has("file_actions")}`), collectionId = id(r.collection_id);
123
+ const r = object(value, `collection_id,display_name,role${has("availability")}${has("file_actions")}${has("mode")}`), collectionId = id(r.collection_id);
124
+ if ("mode" in r && r.mode !== "cloud_copy" && r.mode !== "private")
125
+ throw fail("response");
106
126
  if (seen.has(collectionId) || typeof r.display_name !== "string" || !r.display_name.length || r.display_name.length > 1024 || (r.role !== "owner" && r.role !== "editor" && r.role !== "viewer"))
107
127
  throw fail("response");
108
128
  const availability = parseCollectionAvailability(r.availability);
109
129
  const allowed = "file_actions" in r ? fileActions(r.file_actions) : null;
110
130
  seen.add(collectionId);
111
- return Object.freeze({ collectionId, displayName: r.display_name, role: r.role, ...(availability ? { availability } : {}), ...(allowed ? { fileActions: allowed } : {}) });
131
+ return Object.freeze({ collectionId, displayName: r.display_name, role: r.role, ...(availability ? { availability } : {}), ...(allowed ? { fileActions: allowed } : {}), ...(r.mode === "private" ? { mode: "private" } : {}) });
112
132
  }));
113
133
  }
114
134
  const FILE_ACTIONS = ["list", "read", "add", "replace", "move", "delete"];
@@ -167,6 +187,8 @@ export class AppProtectedInstallationSignIn {
167
187
  throw fail("binding");
168
188
  if (p.freshRequestedCreateCollections !== undefined && typeof p.freshRequestedCreateCollections !== "boolean")
169
189
  throw fail("binding");
190
+ if (p.freshRequestedPrivateDevice !== undefined && typeof p.freshRequestedPrivateDevice !== "boolean")
191
+ throw fail("binding");
170
192
  if (p.recoveryFor !== undefined) {
171
193
  const binding = object(p.recoveryFor, "accountId,collectionId");
172
194
  id(binding.accountId);
@@ -177,18 +199,16 @@ export class AppProtectedInstallationSignIn {
177
199
  if (selection !== undefined) {
178
200
  if (!isAppEnvironmentSelection(selection) || selection.environment !== p.environment ||
179
201
  selection.appOrigin !== p.origin || selection.cpOrigin !== p.cpOrigin ||
180
- (p.allowLoopbackHttp === true && !selection.allowLoopbackHttp) ||
181
- (p.allowNativeLoopbackHttp === true && !selection.allowNativeLoopbackHttp))
202
+ (p.allowLoopbackHttp === true && !selection.allowLoopbackHttp))
182
203
  throw fail("binding");
183
204
  }
184
205
  // The application supplies its exact origin; the origin-bound store and
185
206
  // CP registry authorize it. No brand-specific client allowlist.
186
- opened = await AppInstallationStore.open({ ...p, allowLoopbackHttp: selection?.allowLoopbackHttp ?? p.allowLoopbackHttp,
187
- allowNativeLoopbackHttp: selection?.allowNativeLoopbackHttp ?? p.allowNativeLoopbackHttp }, () => {
207
+ opened = await AppInstallationStore.open({ ...p, allowLoopbackHttp: selection?.allowLoopbackHttp ?? p.allowLoopbackHttp }, () => {
188
208
  const bytes = crypto.getRandomValues(new Uint8Array(32));
189
209
  try {
190
210
  const secret = `pair_${btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
191
- return new TextEncoder().encode(JSON.stringify({ version: 3, renewal: null, requestedCreateCollections: p.requestedCreateCollections ?? p.freshRequestedCreateCollections ?? false, collectionIds: [], createCollections: false, consentRequest: null, requestId: crypto.randomUUID(), installationId: crypto.randomUUID(), deviceId: crypto.randomUUID(), secret, appId: p.appId, ...(p.owner === undefined ? {} : { owner: p.owner }), kind: p.installationKind ?? "app-runtime", origin: p.origin, cpOrigin: p.cpOrigin, environment: p.environment, selection: null, confirmed: false, nativeStarted: false, proof: null, token: null, ...(p.recoveryFor ? { recoveryFor: p.recoveryFor } : {}) }));
211
+ return new TextEncoder().encode(JSON.stringify({ version: 3, renewal: null, requestedCreateCollections: p.requestedCreateCollections ?? p.freshRequestedCreateCollections ?? false, collectionIds: [], createCollections: false, consentRequest: null, ...(p.freshRequestedPrivateDevice === true ? { requestedPrivateDevice: true } : {}), requestId: crypto.randomUUID(), installationId: crypto.randomUUID(), deviceId: crypto.randomUUID(), secret, appId: p.appId, ...(p.owner === undefined ? {} : { owner: p.owner }), kind: p.installationKind ?? "app-runtime", origin: p.origin, cpOrigin: p.cpOrigin, environment: p.environment, selection: null, confirmed: false, nativeStarted: false, proof: null, token: null, ...(p.recoveryFor ? { recoveryFor: p.recoveryFor } : {}) }));
192
212
  }
193
213
  finally {
194
214
  bytes.fill(0);
@@ -216,7 +236,7 @@ export class AppProtectedInstallationSignIn {
216
236
  const fields = `${base},requestedCreateCollections,collectionIds,createCollections,consentRequest`;
217
237
  const r = legacy ? { ...object(parsed, base), version: 3, renewal: null, requestedCreateCollections: false, collectionIds: [], createCollections: false, consentRequest: null }
218
238
  : parsed?.version === 2 ? { ...object(parsed, fields), version: 3, renewal: null }
219
- : object(parsed, `${fields},renewal${Object.hasOwn(parsed, "collectionSelection") ? ",collectionSelection" : ""}${Object.hasOwn(parsed, "timerConsent") ? ",timerConsent" : ""}${Object.hasOwn(parsed, "timerChannel") ? ",timerChannel" : ""}${Object.hasOwn(parsed, "owner") ? ",owner" : ""}${Object.hasOwn(parsed, "collectionListing") ? ",collectionListing" : ""}${Object.hasOwn(parsed, "collectionRecoveries") ? ",collectionRecoveries" : ""}${Object.hasOwn(parsed, "recoveryFor") ? ",recoveryFor" : ""}${Object.hasOwn(parsed, "nativeAuthorization") ? ",nativeAuthorization" : ""}${Object.hasOwn(parsed, "nativeSelection") ? ",nativeSelection" : ""}${Object.hasOwn(parsed, "nativeGrants") ? ",nativeGrants" : ""}`);
239
+ : object(parsed, `${fields},renewal${Object.hasOwn(parsed, "collectionSelection") ? ",collectionSelection" : ""}${Object.hasOwn(parsed, "timerConsent") ? ",timerConsent" : ""}${Object.hasOwn(parsed, "timerChannel") ? ",timerChannel" : ""}${Object.hasOwn(parsed, "owner") ? ",owner" : ""}${Object.hasOwn(parsed, "collectionListing") ? ",collectionListing" : ""}${Object.hasOwn(parsed, "collectionRecoveries") ? ",collectionRecoveries" : ""}${Object.hasOwn(parsed, "recoveryFor") ? ",recoveryFor" : ""}${Object.hasOwn(parsed, "nativeAuthorization") ? ",nativeAuthorization" : ""}${Object.hasOwn(parsed, "nativeSelection") ? ",nativeSelection" : ""}${Object.hasOwn(parsed, "nativeGrants") ? ",nativeGrants" : ""}${Object.hasOwn(parsed, "requestedPrivateDevice") ? ",requestedPrivateDevice" : ""}${Object.hasOwn(parsed, "privateDeviceCollectionIds") ? ",privateDeviceCollectionIds" : ""}`);
220
240
  if (r.version !== 3 || r.appId !== p.appId || r.owner !== p.owner || r.kind !== (p.installationKind ?? "app-runtime") || r.origin !== p.origin || r.cpOrigin !== p.cpOrigin || r.environment !== p.environment || typeof r.confirmed !== "boolean" || typeof r.nativeStarted !== "boolean")
221
241
  throw fail("recovery_required");
222
242
  const s = r;
@@ -224,16 +244,28 @@ export class AppProtectedInstallationSignIn {
224
244
  (p.requestedCreateCollections !== undefined && p.requestedCreateCollections !== s.requestedCreateCollections))
225
245
  throw fail("binding");
226
246
  s.collectionIds = this.collectionIds(s.collectionIds);
247
+ // Private device consent: stored only as an explicit true / non-empty subset.
248
+ if (s.requestedPrivateDevice !== undefined && s.requestedPrivateDevice !== true)
249
+ throw fail("recovery_required");
250
+ if (s.privateDeviceCollectionIds !== undefined) {
251
+ const scopes = this.collectionIds(s.privateDeviceCollectionIds);
252
+ if (!scopes.length || !s.token || scopes.some(c => !s.collectionIds.includes(c)))
253
+ throw fail("recovery_required");
254
+ s.privateDeviceCollectionIds = scopes;
255
+ }
227
256
  if (s.recoveryFor !== undefined) {
228
257
  const binding = object(s.recoveryFor, "accountId,collectionId");
229
258
  id(binding.accountId);
230
259
  id(binding.collectionId);
231
260
  }
232
261
  if ((s.recoveryFor === undefined) !== (p.recoveryFor === undefined) || s.recoveryFor?.accountId !== p.recoveryFor?.accountId || s.recoveryFor?.collectionId !== p.recoveryFor?.collectionId || (s.recoveryFor && (s.requestedCreateCollections || s.createCollections ||
233
- (s.selection && s.selection.accountId !== s.recoveryFor.accountId) || (s.token && (s.nativeSelection ? s.collectionIds.length !== 0 || s.nativeSelection.collectionId !== s.recoveryFor.collectionId : s.collectionIds.length !== 1 || s.collectionIds[0] !== s.recoveryFor.collectionId || s.collectionSelection?.selectedCollectionId !== s.recoveryFor.collectionId)))))
262
+ (s.selection && s.selection.accountId !== s.recoveryFor.accountId) || (s.token && (s.nativeSelection ? s.collectionIds.length !== 0 || s.nativeSelection.collectionId !== s.recoveryFor.collectionId
263
+ : !AppProtectedInstallationSignIn.recovered(s.recoveryFor.collectionId, s.collectionIds, s.privateDeviceCollectionIds ?? [], s.collectionSelection))))))
234
264
  throw fail("recovery_required");
235
265
  if (s.consentRequest !== null) {
236
- const c = object(s.consentRequest, `requestId,secret,requestedCreateCollections,challenge,completed,addedCollectionIds,approvedCreateCollections${portalKeys(s.consentRequest, true)}`);
266
+ const c = object(s.consentRequest, `requestId,secret,requestedCreateCollections,challenge,completed,addedCollectionIds,approvedCreateCollections${Object.hasOwn(s.consentRequest, "requestedPrivateDevice") ? ",requestedPrivateDevice" : ""}${portalKeys(s.consentRequest, true)}`);
267
+ if (c.requestedPrivateDevice !== undefined && c.requestedPrivateDevice !== true)
268
+ throw fail("recovery_required");
237
269
  portalMetadata(c, true);
238
270
  id(c.requestId);
239
271
  text(c.secret, /^pair_[A-Za-z0-9_-]{43}$/);
@@ -339,6 +371,10 @@ export class AppProtectedInstallationSignIn {
339
371
  }
340
372
  return s;
341
373
  }
374
+ /** The row's mode agrees with the consent recorded for that collection. */
375
+ modeConsented(c) {
376
+ return (c.mode === "private") === (this.state.privateDeviceCollectionIds?.includes(c.collectionId) === true);
377
+ }
342
378
  static collectionIds(value) {
343
379
  if (!Array.isArray(value) || value.length > 1000)
344
380
  throw fail("response");
@@ -357,7 +393,7 @@ export class AppProtectedInstallationSignIn {
357
393
  view() {
358
394
  this.check();
359
395
  const s = this.state, identity = s.consentRequest?.portalConfirmed ? s.consentRequest : s.selection;
360
- return Object.freeze({ requestId: s.requestId, installationId: s.installationId, deviceId: s.deviceId, appId: s.appId, kind: s.kind, verificationUri: `${s.cpOrigin}/pair/${s.requestId}`, state: s.token ? "paired" : s.proof ? "awaiting_approval" : s.confirmed ? "account_confirmed" : s.selection ? "account_selected" : "pending", accountId: s.selection?.accountId ?? null, accountEmail: identity?.accountEmail ?? null, ...(identity?.accountProvider ? { accountProvider: identity.accountProvider, accountLogin: identity.accountLogin } : {}), collectionSelection: s.collectionSelection ? Object.freeze({ ...s.collectionSelection, createdCollectionIds: Object.freeze([...s.collectionSelection.createdCollectionIds]) }) : null, nativeSelection: s.nativeSelection && s.nativeGrants?.some(c => c.authorization_request_id === s.nativeSelection.authorizationRequestId && c.collection_id === s.nativeSelection.collectionId) ? Object.freeze({ ...s.nativeSelection }) : null, nativeGrants: Object.freeze([...(s.nativeGrants ?? [])]), ...(s.timerConsent ? { timerConsent: s.timerConsent } : {}), collectionIds: Object.freeze([...s.collectionIds]), createCollections: s.createCollections, requestedCreateCollections: s.requestedCreateCollections });
396
+ return Object.freeze({ requestId: s.requestId, installationId: s.installationId, deviceId: s.deviceId, appId: s.appId, kind: s.kind, verificationUri: `${s.cpOrigin}/pair/${s.requestId}`, state: s.token ? "paired" : s.proof ? "awaiting_approval" : s.confirmed ? "account_confirmed" : s.selection ? "account_selected" : "pending", accountId: s.selection?.accountId ?? null, accountEmail: identity?.accountEmail ?? null, ...(identity?.accountProvider ? { accountProvider: identity.accountProvider, accountLogin: identity.accountLogin } : {}), collectionSelection: s.collectionSelection ? Object.freeze({ ...s.collectionSelection, createdCollectionIds: Object.freeze([...s.collectionSelection.createdCollectionIds]) }) : null, nativeSelection: s.nativeSelection && s.nativeGrants?.some(c => c.authorization_request_id === s.nativeSelection.authorizationRequestId && c.collection_id === s.nativeSelection.collectionId) ? Object.freeze({ ...s.nativeSelection }) : null, nativeGrants: Object.freeze([...(s.nativeGrants ?? [])]), ...(s.timerConsent ? { timerConsent: s.timerConsent } : {}), collectionIds: Object.freeze([...s.collectionIds]), createCollections: s.createCollections, requestedCreateCollections: s.requestedCreateCollections, ...(s.privateDeviceCollectionIds?.length ? { privateDeviceCollectionIds: Object.freeze([...s.privateDeviceCollectionIds]) } : {}) });
361
397
  }
362
398
  /** Persist before exposing changed selection/proof/credential or native ACK. */
363
399
  async save(next) {
@@ -454,7 +490,10 @@ export class AppProtectedInstallationSignIn {
454
490
  throw fail(error.code === "installation_pairing_expired" ? "expired" : "refused");
455
491
  }
456
492
  const server = appServerProblem(response.status, value, response.headers);
457
- throw new AppInstallationSignInError(appServerOutcome(server) ?? "refused", server);
493
+ const refusal = new AppInstallationSignInError(appServerOutcome(server) ?? "refused", server);
494
+ if (path === "/v1/pairing-requests" && unknownPrivateDeviceField(response.status, error))
495
+ privateDeviceFieldRefused.add(refusal);
496
+ throw refusal;
458
497
  }
459
498
  return { status: response.status, value: value };
460
499
  }
@@ -517,7 +556,20 @@ export class AppProtectedInstallationSignIn {
517
556
  const s = this.state;
518
557
  if (s.recoveryFor && hint !== null && hint !== s.recoveryFor.collectionId)
519
558
  throw fail("binding");
520
- const { status, value: v } = await this.json("/v1/pairing-requests", false, { installation: { app_id: s.appId, request_id: s.requestId, pairing_secret: s.secret, installation_id: s.installationId, device_id: s.deviceId, kind: s.kind, requested_create_collections: s.requestedCreateCollections, ...(s.recoveryFor ? { recovery_collection_id: s.recoveryFor.collectionId } : {}), ...(s.renewal ? { renewal: { request_id: s.renewal.requestId, pairing_secret: s.renewal.secret } } : {}) }, ...(hint ? { collection_hint: hint } : {}) });
559
+ let reply;
560
+ try {
561
+ reply = await this.json("/v1/pairing-requests", false, { installation: { app_id: s.appId, request_id: s.requestId, pairing_secret: s.secret, installation_id: s.installationId, device_id: s.deviceId, kind: s.kind, requested_create_collections: s.requestedCreateCollections, ...(s.requestedPrivateDevice === true ? { requested_private_device: true } : {}), ...(s.recoveryFor ? { recovery_collection_id: s.recoveryFor.collectionId } : {}), ...(s.renewal ? { renewal: { request_id: s.renewal.requestId, pairing_secret: s.renewal.secret } } : {}) }, ...(hint ? { collection_hint: hint } : {}) });
562
+ }
563
+ catch (error) {
564
+ if (s.requestedPrivateDevice !== true || !(error instanceof AppInstallationSignInError) || !privateDeviceFieldRefused.has(error))
565
+ throw error;
566
+ // The stored terms change BEFORE the repeat, so a receipt that later
567
+ // names a Private device scope is refused: this request never asked.
568
+ const { requestedPrivateDevice: _dropped, ...withoutPrivateDevice } = s;
569
+ await this.save(withoutPrivateDevice);
570
+ return await this.startRequest(hint);
571
+ }
572
+ const { status, value: v } = reply;
521
573
  if (status !== 200 && status !== 201)
522
574
  throw fail("response");
523
575
  object(v, `pairing_id,pairing_secret,verification_uri,expires_in,installation_device,app_id,app_origin,app_name${this.recoveryTargetKeys()}`);
@@ -573,9 +625,12 @@ export class AppProtectedInstallationSignIn {
573
625
  if (this.state.token)
574
626
  return this.view();
575
627
  const { status, value: v } = await this.json(`/v1/pairing-requests/${this.state.requestId}/exchange`, true);
628
+ // A window renewed after a denial waits for the SAME account to be chosen
629
+ // again (Connect keeps its account, connector and challenge, and selected()
630
+ // still binds them), so it may read pending while this state holds the selection.
576
631
  if (v.status === "pending") {
577
632
  object(v, "status");
578
- if (status !== 202 || this.state.selection)
633
+ if (status !== 202 || (this.state.selection && !this.state.renewal))
579
634
  throw fail("response");
580
635
  return this.view();
581
636
  }
@@ -593,13 +648,20 @@ export class AppProtectedInstallationSignIn {
593
648
  const scoped = Object.hasOwn(v, "collection_ids") || Object.hasOwn(v, "create_collections");
594
649
  const picked = Object.hasOwn(v, "selected_collection_id") || Object.hasOwn(v, "created_collection_ids");
595
650
  const native = Object.hasOwn(v, "native_selection");
596
- object(v, `${keys},connector,token,registration${scoped ? ",collection_ids,create_collections" : ""}${picked ? ",selected_collection_id,created_collection_ids" : ""}${native ? ",native_selection" : ""}${timerKeys(v)}`);
651
+ const device = Object.hasOwn(v, "private_device_collection_ids");
652
+ object(v, `${keys},connector,token,registration${scoped ? ",collection_ids,create_collections" : ""}${device ? ",private_device_collection_ids" : ""}${picked ? ",selected_collection_id,created_collection_ids" : ""}${native ? ",native_selection" : ""}${timerKeys(v)}`);
597
653
  if (status !== 200 || v.status !== "paired" || !this.state.confirmed || !this.state.proof)
598
654
  throw fail("response");
599
655
  const selection = this.selected(v), connector = object(v.connector, "id,name"), r = object(v.registration, "device_id,sign_pk,kem_pk,noise_pk");
600
656
  if (connector.id !== selection.connectorId || connector.name !== this.options.appName || r.device_id !== this.state.deviceId || r.sign_pk !== this.state.proof.sign_pk || r.kem_pk !== this.state.proof.kem_pk || r.noise_pk !== this.state.proof.noise_pk)
601
657
  throw fail("binding");
602
- const collectionIds = scoped ? AppProtectedInstallationSignIn.collectionIds(v.collection_ids) : [];
658
+ const cloudIds = scoped ? AppProtectedInstallationSignIn.collectionIds(v.collection_ids) : [];
659
+ // Private device scopes: an explicit list, disjoint from the cloud-copy
660
+ // one, and only for a request that asked. Absent means none.
661
+ const privateDeviceCollectionIds = device ? AppProtectedInstallationSignIn.collectionIds(v.private_device_collection_ids) : [];
662
+ if (privateDeviceCollectionIds.length && (!scoped || this.state.requestedPrivateDevice !== true || privateDeviceCollectionIds.some(c => cloudIds.includes(c))))
663
+ throw fail("response");
664
+ const collectionIds = AppProtectedInstallationSignIn.collectionIds([...cloudIds, ...privateDeviceCollectionIds]);
603
665
  const createCollections = scoped ? v.create_collections : false;
604
666
  if (typeof createCollections !== "boolean" || (createCollections && !this.state.requestedCreateCollections))
605
667
  throw fail("response");
@@ -609,17 +671,28 @@ export class AppProtectedInstallationSignIn {
609
671
  throw fail("response");
610
672
  if (this.state.recoveryFor && nativeSelection && nativeSelection.collectionId !== this.state.recoveryFor.collectionId)
611
673
  throw fail("binding");
612
- if (this.state.recoveryFor && !nativeSelection && (createCollections || collectionIds.length !== 1 || collectionIds[0] !== this.state.recoveryFor.collectionId ||
613
- collectionSelection?.selectedCollectionId !== this.state.recoveryFor.collectionId || collectionSelection.createdCollectionIds.length !== 0))
674
+ if (this.state.recoveryFor && !nativeSelection && (createCollections ||
675
+ !AppProtectedInstallationSignIn.recovered(this.state.recoveryFor.collectionId, collectionIds, privateDeviceCollectionIds, collectionSelection)))
614
676
  throw fail("binding");
615
677
  if (v.timer_consent === true && !collectionSelection)
616
678
  throw fail("response");
679
+ // Device access approved without a pick: Connect never names a Private
680
+ // device collection as the selection, so this receipt carries none.
681
+ const deviceOnly = !collectionSelection && privateDeviceCollectionIds.length > 0;
617
682
  const reminder = timerConsent(v, this.state.requestId, collectionIds, selection.portalConfirmed === true, collectionSelection?.selectedCollectionId);
618
683
  const token = text(v.token, /^idev_[A-Za-z0-9_-]{43}$/);
619
- await this.save({ ...this.state, selection, token, collectionIds, createCollections, nativeSelection, ...(nativeSelection ? { collectionSelection: undefined } : collectionSelection ? { collectionSelection } : {}), ...(reminder ? { timerConsent: reminder } : {}) });
684
+ await this.save({ ...this.state, selection, token, collectionIds, createCollections, privateDeviceCollectionIds: privateDeviceCollectionIds.length ? privateDeviceCollectionIds : undefined, nativeSelection, ...(nativeSelection || deviceOnly ? { collectionSelection: undefined } : collectionSelection ? { collectionSelection } : {}), ...(reminder ? { timerConsent: reminder } : {}) });
620
685
  return this.view();
621
686
  });
622
687
  }
688
+ /** A recovery slot holds exactly its one collection: the selected cloud copy,
689
+ * or the one Private device scope (which Connect never names as a selection). */
690
+ static recovered(collection, ids, device, selection) {
691
+ if (ids.length !== 1 || ids[0] !== collection)
692
+ return false;
693
+ return device.length ? device.length === 1 && device[0] === collection && selection === undefined
694
+ : selection?.selectedCollectionId === collection && selection.createdCollectionIds.length === 0;
695
+ }
623
696
  parseNativeSelection(value, requestId) {
624
697
  const n = object(value, "authorization_request_id,collection_id"), a = this.state.nativeAuthorization;
625
698
  if (!a || a.requestId !== requestId || id(n.authorization_request_id) !== a.authorizationId)
@@ -679,7 +752,7 @@ export class AppProtectedInstallationSignIn {
679
752
  collectionConsentView() {
680
753
  this.check();
681
754
  const r = this.state.consentRequest;
682
- return r ? Object.freeze({ requestId: r.requestId, verificationUri: `${this.state.cpOrigin}/pair/${r.requestId}`, state: r.completed ? "scope_updated" : "pending", requestedCreateCollections: r.requestedCreateCollections, addedCollectionIds: Object.freeze([...r.addedCollectionIds]), approvedCreateCollections: r.approvedCreateCollections, accountEmail: r.accountEmail ?? null, ...(r.accountProvider ? { accountProvider: r.accountProvider, accountLogin: r.accountLogin } : {}), accountSelectionConfirmed: r.portalConfirmed === true }) : null;
755
+ return r ? Object.freeze({ requestId: r.requestId, verificationUri: `${this.state.cpOrigin}/pair/${r.requestId}`, state: r.completed ? "scope_updated" : "pending", requestedCreateCollections: r.requestedCreateCollections, addedCollectionIds: Object.freeze([...r.addedCollectionIds]), approvedCreateCollections: r.approvedCreateCollections, ...(r.requestedPrivateDevice === true ? { requestedPrivateDevice: true } : {}), accountEmail: r.accountEmail ?? null, ...(r.accountProvider ? { accountProvider: r.accountProvider, accountLogin: r.accountLogin } : {}), accountSelectionConfirmed: r.portalConfirmed === true }) : null;
683
756
  }
684
757
  consentGuard(installation) {
685
758
  const callback = installation.isCurrent;
@@ -707,7 +780,7 @@ export class AppProtectedInstallationSignIn {
707
780
  /** Explicit additive consent, not renewal/registration/account selection.
708
781
  * A new secret/request is protected BEFORE HTTP; unknown outcomes only permit
709
782
  * an explicit repeat of that exact request, never new keys or a new actor. */
710
- startCollectionConsent(installation, requestedCreateCollections = false, options = {}) {
783
+ startCollectionConsent(installation, requestedCreateCollections = false, options = {}, requestedPrivateDevice = false) {
711
784
  const check = this.consentGuard(installation);
712
785
  let hint;
713
786
  try {
@@ -718,14 +791,15 @@ export class AppProtectedInstallationSignIn {
718
791
  }
719
792
  const attempt = async () => {
720
793
  check();
721
- if (this.state.recoveryFor || typeof requestedCreateCollections !== "boolean")
794
+ if (this.state.recoveryFor || typeof requestedCreateCollections !== "boolean" || typeof requestedPrivateDevice !== "boolean")
722
795
  throw fail("binding");
723
796
  let r = this.state.consentRequest;
724
797
  if (!r || r.completed) {
725
798
  const bytes = crypto.getRandomValues(new Uint8Array(32));
726
799
  try {
727
800
  const secret = `pair_${btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")}`;
728
- r = { requestId: crypto.randomUUID(), secret, requestedCreateCollections, challenge: null, completed: false, addedCollectionIds: [], approvedCreateCollections: false };
801
+ // A window still open keeps the terms it was stored with (below); only a new one asks for device access.
802
+ r = { requestId: crypto.randomUUID(), secret, requestedCreateCollections, challenge: null, completed: false, addedCollectionIds: [], approvedCreateCollections: false, ...(requestedPrivateDevice ? { requestedPrivateDevice: true } : {}) };
729
803
  await this.save({ ...this.state, consentRequest: r });
730
804
  check();
731
805
  }
@@ -736,8 +810,23 @@ export class AppProtectedInstallationSignIn {
736
810
  else if (r.requestedCreateCollections !== requestedCreateCollections)
737
811
  throw fail("binding");
738
812
  const s = this.state, token = s.token, req = r;
739
- const { status, value: v } = await this.consentWindow(req.requestId, () => this.json("/v1/pairing-requests", false, { installation: { app_id: s.appId, request_id: req.requestId, pairing_secret: req.secret, installation_id: s.installationId, device_id: s.deviceId, kind: s.kind, requested_create_collections: req.requestedCreateCollections, reconsent: true }, ...(hint ? { collection_hint: hint } : {}) }, { bearer: token }));
813
+ let reply;
814
+ try {
815
+ reply = await this.consentWindow(req.requestId, () => this.json("/v1/pairing-requests", false, { installation: { app_id: s.appId, request_id: req.requestId, pairing_secret: req.secret, installation_id: s.installationId, device_id: s.deviceId, kind: s.kind, requested_create_collections: req.requestedCreateCollections, ...(req.requestedPrivateDevice === true ? { requested_private_device: true } : {}), reconsent: true }, ...(hint ? { collection_hint: hint } : {}) }, { bearer: token }));
816
+ }
817
+ catch (error) {
818
+ if (req.requestedPrivateDevice !== true || !(error instanceof AppInstallationSignInError) || !privateDeviceFieldRefused.has(error))
819
+ throw error;
820
+ // Same single repeat as START: this window is re-stored as one that
821
+ // never asked for Private device access before it is sent again.
822
+ check();
823
+ const { requestedPrivateDevice: _dropped, ...withoutPrivateDevice } = req;
824
+ await this.save({ ...this.state, consentRequest: withoutPrivateDevice });
825
+ check();
826
+ return await attempt();
827
+ }
740
828
  check();
829
+ const { status, value: v } = reply;
741
830
  object(v, "pairing_id,pairing_secret,verification_uri,expires_in,installation_device,app_id,app_origin,app_name");
742
831
  let uri = null;
743
832
  if ((status !== 200 && status !== 201) || v.pairing_id !== r.requestId || v.pairing_secret !== r.secret || (uri = verificationUri(v.verification_uri, `${s.cpOrigin}/pair/${r.requestId}`, hint)) === null || v.installation_device !== true || v.app_id !== s.appId || v.app_origin !== s.origin || v.app_name !== this.options.appName || !Number.isSafeInteger(v.expires_in) || v.expires_in < 0 || v.expires_in > 600 || token !== this.state.token)
@@ -776,7 +865,10 @@ export class AppProtectedInstallationSignIn {
776
865
  const done = v.status === "scope_updated";
777
866
  const picked = done && (Object.hasOwn(v, "selected_collection_id") || Object.hasOwn(v, "created_collection_ids"));
778
867
  const native = done && Object.hasOwn(v, "native_selection");
779
- object(v, `${keys}${done ? ",added_collection_ids,approved_create_collections" : ""}${picked ? ",selected_collection_id,created_collection_ids" : ""}${native ? ",native_selection" : ""}${done ? timerKeys(v) : ""}`);
868
+ // The contract names the receipt list `private_device_collection_ids`; an
869
+ // additive receipt may mirror `added_collection_ids`. Exactly one is read.
870
+ const deviceKey = !done ? null : Object.hasOwn(v, "added_private_device_collection_ids") ? "added_private_device_collection_ids" : Object.hasOwn(v, "private_device_collection_ids") ? "private_device_collection_ids" : null;
871
+ object(v, `${keys}${done ? ",added_collection_ids,approved_create_collections" : ""}${deviceKey ? `,${deviceKey}` : ""}${picked ? ",selected_collection_id,created_collection_ids" : ""}${native ? ",native_selection" : ""}${done ? timerKeys(v) : ""}`);
780
872
  if ((!done && (status !== 202 || v.status !== "awaiting_approval")) || (done && status !== 200) || v.request_id !== r.requestId || v.account_id !== s.selection.accountId || v.connector_id !== s.selection.connectorId || v.device_id !== s.deviceId || v.installation_id !== s.installationId || v.kind !== s.kind || v.app_id !== s.appId || v.app_origin !== s.origin || v.approval_mode !== s.selection.approvalMode || !Number.isSafeInteger(v.expires_at) || v.expires_at < 0)
781
873
  throw fail("binding");
782
874
  const confirmation = portalMetadata(v);
@@ -792,7 +884,15 @@ export class AppProtectedInstallationSignIn {
792
884
  check();
793
885
  return this.collectionConsentView();
794
886
  }
795
- const addedCollectionIds = AppProtectedInstallationSignIn.collectionIds(v.added_collection_ids);
887
+ const addedCloudIds = AppProtectedInstallationSignIn.collectionIds(v.added_collection_ids);
888
+ const addedPrivateIds = deviceKey ? AppProtectedInstallationSignIn.collectionIds(v[deviceKey]) : [];
889
+ // Disjoint lists; only a window that asked; a scope keeps the mode it was approved with.
890
+ if (addedPrivateIds.length && (r.requestedPrivateDevice !== true || addedPrivateIds.some(c => addedCloudIds.includes(c) || (s.collectionIds.includes(c) && !s.privateDeviceCollectionIds?.includes(c)))))
891
+ throw fail("response");
892
+ if (addedCloudIds.some(c => s.privateDeviceCollectionIds?.includes(c)))
893
+ throw fail("response");
894
+ const addedCollectionIds = AppProtectedInstallationSignIn.collectionIds([...addedCloudIds, ...addedPrivateIds]);
895
+ const privateDeviceCollectionIds = [...new Set([...(s.privateDeviceCollectionIds ?? []), ...addedPrivateIds])];
796
896
  if (typeof v.approved_create_collections !== "boolean" || (v.approved_create_collections && !r.requestedCreateCollections))
797
897
  throw fail("response");
798
898
  const collectionIds = AppProtectedInstallationSignIn.collectionIds([...new Set([...s.collectionIds, ...addedCollectionIds])]);
@@ -803,7 +903,8 @@ export class AppProtectedInstallationSignIn {
803
903
  if (v.timer_consent === true && !collectionSelection)
804
904
  throw fail("response");
805
905
  const reminder = timerConsent(v, r.requestId, addedCollectionIds, confirmation.portalConfirmed === true, collectionSelection?.selectedCollectionId);
806
- await this.save({ ...s, collectionIds, nativeSelection, ...(nativeSelection ? { collectionSelection: undefined } : collectionSelection ? { collectionSelection } : {}), ...(reminder ? { timerConsent: reminder } : {}), createCollections: s.createCollections || v.approved_create_collections, consentRequest: { ...r, ...confirmation, challenge, completed: true, addedCollectionIds, approvedCreateCollections: v.approved_create_collections } });
906
+ // A device-only receipt has no pick: an earlier request's pick is not this one's.
907
+ await this.save({ ...s, collectionIds, privateDeviceCollectionIds: privateDeviceCollectionIds.length ? privateDeviceCollectionIds : undefined, nativeSelection, ...(nativeSelection || (!collectionSelection && addedPrivateIds.length > 0) ? { collectionSelection: undefined } : collectionSelection ? { collectionSelection } : {}), ...(reminder ? { timerConsent: reminder } : {}), createCollections: s.createCollections || v.approved_create_collections, consentRequest: { ...r, ...confirmation, challenge, completed: true, addedCollectionIds, approvedCreateCollections: v.approved_create_collections } });
807
908
  check();
808
909
  return this.collectionConsentView();
809
910
  });
@@ -815,13 +916,29 @@ export class AppProtectedInstallationSignIn {
815
916
  return this.exclusive(async () => {
816
917
  check();
817
918
  let reply;
919
+ // `mode` is asked for only by an app that declares Private device access
920
+ // AND holds such a scope: only then can a Private row be offered at all
921
+ // (modeConsented). Every other installation sends today's exact request;
922
+ // a server from before this feature matches `include=file_actions` exactly
923
+ // and would silently drop the file actions from any other spelling.
924
+ const mode = this.options.freshRequestedPrivateDevice === true && (this.state.privateDeviceCollectionIds?.length ?? 0) > 0;
925
+ const today = "/v1/next/collections?include=file_actions", ask = (path) => this.json(path, false, undefined, { bearer: this.state.token, get: true });
818
926
  try {
819
- reply = await this.json("/v1/next/collections?include=file_actions", false, undefined, { bearer: this.state.token, get: true });
927
+ reply = await ask(mode ? `${today},mode` : today);
928
+ // A server from before this feature (one rolled back under an SDK that
929
+ // holds Private scopes) answers the spelling it does not know with bare
930
+ // rows: no `mode` and no file actions. A server that knows `mode` puts
931
+ // it on every row. So rows that all lack it are asked for once more,
932
+ // with today's exact request, and the file actions survive the rollback.
933
+ if (mode && reply.status === 200 && modeUnanswered(reply.value)) {
934
+ check();
935
+ reply = await ask(today);
936
+ }
820
937
  }
821
938
  catch (error) {
822
939
  check();
823
940
  if (error instanceof AppInstallationSignInError && error.reason === "unavailable" && !error.server && !nonOkResponses.has(error) && this.state.collectionListing !== undefined)
824
- return Object.freeze(collectionListing(this.state.collectionListing).filter(c => this.state.collectionIds.includes(c.collectionId)).map(c => Object.freeze({ ...c, fileActions: Object.freeze([]) })));
941
+ return Object.freeze(collectionListing(this.state.collectionListing).filter(c => this.state.collectionIds.includes(c.collectionId) && this.modeConsented(c)).map(c => Object.freeze({ ...c, fileActions: Object.freeze([]) })));
825
942
  if (error instanceof AppInstallationSignInError && (error.server || nonOkResponses.has(error)) && this.state.collectionListing !== undefined) {
826
943
  const state = { ...this.state };
827
944
  delete state.collectionListing;
@@ -835,7 +952,9 @@ export class AppProtectedInstallationSignIn {
835
952
  object(v, "collections");
836
953
  if (status !== 200)
837
954
  throw fail("response");
838
- const collections = collectionListing(v.collections);
955
+ // A listed mode never adds consent: a Private row is offered only with
956
+ // this installation's Private device scope, a cloud-copy row never with one.
957
+ const collections = Object.freeze(collectionListing(v.collections).filter(c => this.modeConsented(c)));
839
958
  // Receipt references narrow replay; metadata cannot add consent or infer a role.
840
959
  const approved = v.collections.filter(c => this.state.collectionIds.includes(c.collection_id)).map(c => { const metadata = { ...c }; delete metadata.file_actions; return metadata; });
841
960
  const state = { ...this.state };