@brass-build/client 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/AGENTS.md +124 -45
  2. package/CHANGELOG.md +80 -4
  3. package/README.md +666 -179
  4. package/dist/api-fetch.d.ts +8 -0
  5. package/dist/api-fetch.d.ts.map +1 -1
  6. package/dist/api-fetch.js +35 -8
  7. package/dist/api-fetch.js.map +1 -1
  8. package/dist/batched-get.d.ts +11 -0
  9. package/dist/batched-get.d.ts.map +1 -0
  10. package/dist/batched-get.js +92 -0
  11. package/dist/batched-get.js.map +1 -0
  12. package/dist/call-service.d.ts +4 -0
  13. package/dist/call-service.d.ts.map +1 -1
  14. package/dist/call-service.js +66 -3
  15. package/dist/call-service.js.map +1 -1
  16. package/dist/client.d.ts +48 -2
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +918 -115
  19. package/dist/client.js.map +1 -1
  20. package/dist/document-checkpoints.d.ts +25 -2
  21. package/dist/document-checkpoints.d.ts.map +1 -1
  22. package/dist/document-checkpoints.js +258 -61
  23. package/dist/document-checkpoints.js.map +1 -1
  24. package/dist/document-commands.d.ts.map +1 -1
  25. package/dist/document-commands.js +5 -3
  26. package/dist/document-commands.js.map +1 -1
  27. package/dist/document-contents.d.ts +10 -0
  28. package/dist/document-contents.d.ts.map +1 -1
  29. package/dist/document-contents.js +23 -1
  30. package/dist/document-contents.js.map +1 -1
  31. package/dist/document-set-subscription.d.ts +56 -0
  32. package/dist/document-set-subscription.d.ts.map +1 -0
  33. package/dist/document-set-subscription.js +240 -0
  34. package/dist/document-set-subscription.js.map +1 -0
  35. package/dist/document-signals.d.ts +6 -0
  36. package/dist/document-signals.d.ts.map +1 -1
  37. package/dist/document-signals.js +31 -1
  38. package/dist/document-signals.js.map +1 -1
  39. package/dist/document-streams.d.ts +36 -2
  40. package/dist/document-streams.d.ts.map +1 -1
  41. package/dist/document-streams.js +69 -18
  42. package/dist/document-streams.js.map +1 -1
  43. package/dist/document-thumbnail-capture.d.ts +12 -2
  44. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  45. package/dist/document-thumbnail-capture.js +103 -27
  46. package/dist/document-thumbnail-capture.js.map +1 -1
  47. package/dist/documents-namespace.d.ts +92 -10
  48. package/dist/documents-namespace.d.ts.map +1 -1
  49. package/dist/documents-namespace.js +285 -39
  50. package/dist/documents-namespace.js.map +1 -1
  51. package/dist/documents.d.ts +173 -8
  52. package/dist/documents.d.ts.map +1 -1
  53. package/dist/documents.js +1778 -310
  54. package/dist/documents.js.map +1 -1
  55. package/dist/errors.d.ts +3 -3
  56. package/dist/errors.d.ts.map +1 -1
  57. package/dist/errors.js.map +1 -1
  58. package/dist/fetch-retry.d.ts +1 -0
  59. package/dist/fetch-retry.d.ts.map +1 -1
  60. package/dist/fetch-retry.js +2 -0
  61. package/dist/fetch-retry.js.map +1 -1
  62. package/dist/file-choice.d.ts +2 -0
  63. package/dist/file-choice.d.ts.map +1 -0
  64. package/dist/file-choice.js +27 -0
  65. package/dist/file-choice.js.map +1 -0
  66. package/dist/guest-link.d.ts +18 -1
  67. package/dist/guest-link.d.ts.map +1 -1
  68. package/dist/guest-link.js +329 -1
  69. package/dist/guest-link.js.map +1 -1
  70. package/dist/handle-types.d.ts +67 -5
  71. package/dist/handle-types.d.ts.map +1 -1
  72. package/dist/handles.d.ts +15 -19
  73. package/dist/handles.d.ts.map +1 -1
  74. package/dist/handles.js +231 -76
  75. package/dist/handles.js.map +1 -1
  76. package/dist/hosted-ui.d.ts +61 -0
  77. package/dist/hosted-ui.d.ts.map +1 -1
  78. package/dist/hosted-ui.js +94 -42
  79. package/dist/hosted-ui.js.map +1 -1
  80. package/dist/import-contract.d.ts +74 -0
  81. package/dist/import-contract.d.ts.map +1 -0
  82. package/dist/import-contract.js +163 -0
  83. package/dist/import-contract.js.map +1 -0
  84. package/dist/import-guest.d.ts +9 -0
  85. package/dist/import-guest.d.ts.map +1 -0
  86. package/dist/import-guest.js +155 -0
  87. package/dist/import-guest.js.map +1 -0
  88. package/dist/import-report.d.ts +17 -0
  89. package/dist/import-report.d.ts.map +1 -0
  90. package/dist/import-report.js +7 -0
  91. package/dist/import-report.js.map +1 -0
  92. package/dist/importer-wait.d.ts +2 -0
  93. package/dist/importer-wait.d.ts.map +1 -0
  94. package/dist/importer-wait.js +5 -0
  95. package/dist/importer-wait.js.map +1 -0
  96. package/dist/importer.d.ts +18 -4
  97. package/dist/importer.d.ts.map +1 -1
  98. package/dist/importer.js +322 -105
  99. package/dist/importer.js.map +1 -1
  100. package/dist/in-development.d.ts +2 -0
  101. package/dist/in-development.d.ts.map +1 -0
  102. package/dist/in-development.js +10 -0
  103. package/dist/in-development.js.map +1 -0
  104. package/dist/index.d.ts +16 -8
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +24 -8
  107. package/dist/index.js.map +1 -1
  108. package/dist/init-keys.d.ts +1 -1
  109. package/dist/init-keys.d.ts.map +1 -1
  110. package/dist/init-keys.js +6 -1
  111. package/dist/init-keys.js.map +1 -1
  112. package/dist/input-documents.d.ts +67 -0
  113. package/dist/input-documents.d.ts.map +1 -0
  114. package/dist/input-documents.js +693 -0
  115. package/dist/input-documents.js.map +1 -0
  116. package/dist/inputs-status.d.ts +80 -0
  117. package/dist/inputs-status.d.ts.map +1 -0
  118. package/dist/inputs-status.js +460 -0
  119. package/dist/inputs-status.js.map +1 -0
  120. package/dist/inputs.d.ts +2 -0
  121. package/dist/inputs.d.ts.map +1 -0
  122. package/dist/inputs.js +793 -0
  123. package/dist/inputs.js.map +1 -0
  124. package/dist/internal.d.ts +6 -1
  125. package/dist/internal.d.ts.map +1 -1
  126. package/dist/internal.js +4 -1
  127. package/dist/internal.js.map +1 -1
  128. package/dist/join-content.d.ts +33 -0
  129. package/dist/join-content.d.ts.map +1 -0
  130. package/dist/join-content.js +114 -0
  131. package/dist/join-content.js.map +1 -0
  132. package/dist/join-stamp.d.ts +3 -0
  133. package/dist/join-stamp.d.ts.map +1 -0
  134. package/dist/join-stamp.js +12 -0
  135. package/dist/join-stamp.js.map +1 -0
  136. package/dist/manifest-drift.d.ts +9 -0
  137. package/dist/manifest-drift.d.ts.map +1 -0
  138. package/dist/manifest-drift.js +46 -0
  139. package/dist/manifest-drift.js.map +1 -0
  140. package/dist/open-document.d.ts +109 -2
  141. package/dist/open-document.d.ts.map +1 -1
  142. package/dist/open-document.js +1822 -811
  143. package/dist/open-document.js.map +1 -1
  144. package/dist/page-restore.d.ts +12 -0
  145. package/dist/page-restore.d.ts.map +1 -0
  146. package/dist/page-restore.js +23 -0
  147. package/dist/page-restore.js.map +1 -0
  148. package/dist/placement.d.ts +13 -0
  149. package/dist/placement.d.ts.map +1 -0
  150. package/dist/placement.js +34 -0
  151. package/dist/placement.js.map +1 -0
  152. package/dist/presence.d.ts +1 -0
  153. package/dist/presence.d.ts.map +1 -1
  154. package/dist/presence.js +16 -4
  155. package/dist/presence.js.map +1 -1
  156. package/dist/read-only-document.d.ts +87 -0
  157. package/dist/read-only-document.d.ts.map +1 -0
  158. package/dist/read-only-document.js +4 -0
  159. package/dist/read-only-document.js.map +1 -0
  160. package/dist/record-paging.d.ts +63 -6
  161. package/dist/record-paging.d.ts.map +1 -1
  162. package/dist/record-paging.js +122 -29
  163. package/dist/record-paging.js.map +1 -1
  164. package/dist/references.d.ts +40 -0
  165. package/dist/references.d.ts.map +1 -0
  166. package/dist/references.js +135 -0
  167. package/dist/references.js.map +1 -0
  168. package/dist/request-lane.d.ts +5 -0
  169. package/dist/request-lane.d.ts.map +1 -0
  170. package/dist/request-lane.js +48 -0
  171. package/dist/request-lane.js.map +1 -0
  172. package/dist/sealed-arrow.d.ts +170 -0
  173. package/dist/sealed-arrow.d.ts.map +1 -0
  174. package/dist/sealed-arrow.js +769 -0
  175. package/dist/sealed-arrow.js.map +1 -0
  176. package/dist/sealed-cache.d.ts +21 -0
  177. package/dist/sealed-cache.d.ts.map +1 -0
  178. package/dist/sealed-cache.js +247 -0
  179. package/dist/sealed-cache.js.map +1 -0
  180. package/dist/sealed-encode.d.ts +87 -0
  181. package/dist/sealed-encode.d.ts.map +1 -0
  182. package/dist/sealed-encode.js +876 -0
  183. package/dist/sealed-encode.js.map +1 -0
  184. package/dist/sealed-handles.d.ts +45 -0
  185. package/dist/sealed-handles.d.ts.map +1 -0
  186. package/dist/sealed-handles.js +408 -0
  187. package/dist/sealed-handles.js.map +1 -0
  188. package/dist/sealed-store.d.ts +242 -0
  189. package/dist/sealed-store.d.ts.map +1 -0
  190. package/dist/sealed-store.js +1155 -0
  191. package/dist/sealed-store.js.map +1 -0
  192. package/dist/serializer.d.ts +6 -15
  193. package/dist/serializer.d.ts.map +1 -1
  194. package/dist/session-toolbar.d.ts +20 -5
  195. package/dist/session-toolbar.d.ts.map +1 -1
  196. package/dist/session-toolbar.js +419 -67
  197. package/dist/session-toolbar.js.map +1 -1
  198. package/dist/session.d.ts +38 -1
  199. package/dist/session.d.ts.map +1 -1
  200. package/dist/session.js +562 -79
  201. package/dist/session.js.map +1 -1
  202. package/dist/sha256.d.ts +3 -0
  203. package/dist/sha256.d.ts.map +1 -0
  204. package/dist/sha256.js +74 -0
  205. package/dist/sha256.js.map +1 -0
  206. package/dist/stable-handles.d.ts +19 -0
  207. package/dist/stable-handles.d.ts.map +1 -0
  208. package/dist/stable-handles.js +193 -0
  209. package/dist/stable-handles.js.map +1 -0
  210. package/dist/staged-seal.d.ts +2 -0
  211. package/dist/staged-seal.d.ts.map +1 -0
  212. package/dist/staged-seal.js +76 -0
  213. package/dist/staged-seal.js.map +1 -0
  214. package/dist/subscriber-delivery.d.ts +8 -0
  215. package/dist/subscriber-delivery.d.ts.map +1 -0
  216. package/dist/subscriber-delivery.js +31 -0
  217. package/dist/subscriber-delivery.js.map +1 -0
  218. package/dist/tail-op-import.d.ts +10 -0
  219. package/dist/tail-op-import.d.ts.map +1 -0
  220. package/dist/tail-op-import.js +33 -0
  221. package/dist/tail-op-import.js.map +1 -0
  222. package/dist/topic-lifecycle.d.ts +6 -0
  223. package/dist/topic-lifecycle.d.ts.map +1 -1
  224. package/dist/topic-lifecycle.js +32 -2
  225. package/dist/topic-lifecycle.js.map +1 -1
  226. package/dist/trial-replace-dialog.d.ts +10 -0
  227. package/dist/trial-replace-dialog.d.ts.map +1 -0
  228. package/dist/trial-replace-dialog.js +153 -0
  229. package/dist/trial-replace-dialog.js.map +1 -0
  230. package/dist/types.d.ts +8 -1
  231. package/dist/types.d.ts.map +1 -1
  232. package/dist/undo.d.ts +60 -5
  233. package/dist/undo.d.ts.map +1 -1
  234. package/dist/undo.js +344 -75
  235. package/dist/undo.js.map +1 -1
  236. package/dist/version-bar.d.ts +1 -0
  237. package/dist/version-bar.d.ts.map +1 -1
  238. package/dist/version-bar.js +2 -1
  239. package/dist/version-bar.js.map +1 -1
  240. package/dist/version.d.ts +2 -2
  241. package/dist/version.js +1 -1
  242. package/dist/ws-client.d.ts +21 -0
  243. package/dist/ws-client.d.ts.map +1 -1
  244. package/dist/ws-client.js +192 -8
  245. package/dist/ws-client.js.map +1 -1
  246. package/dist/ws-frame-limit.d.ts +1 -0
  247. package/dist/ws-frame-limit.d.ts.map +1 -1
  248. package/dist/ws-frame-limit.js.map +1 -1
  249. package/docs/app-manifest.md +121 -17
  250. package/docs/custom-chrome.md +24 -16
  251. package/docs/hosted-ui-advanced.md +38 -6
  252. package/docs/imported-documents.md +104 -51
  253. package/docs/presence.md +16 -4
  254. package/docs/schemas.md +8 -5
  255. package/docs/services.md +6 -6
  256. package/docs/streams.md +139 -150
  257. package/docs/text-fields.md +26 -19
  258. package/package.json +5 -2
  259. package/src/api-fetch.ts +53 -3
  260. package/src/auth-flow.ts +1 -1
  261. package/src/batched-get.ts +117 -0
  262. package/src/call-service.ts +78 -3
  263. package/src/client.ts +1022 -112
  264. package/src/document-checkpoints.ts +307 -65
  265. package/src/document-commands.ts +5 -5
  266. package/src/document-contents.ts +27 -1
  267. package/src/document-set-subscription.ts +274 -0
  268. package/src/document-signals.ts +47 -5
  269. package/src/document-streams.ts +85 -16
  270. package/src/document-thumbnail-capture.ts +118 -32
  271. package/src/documents-namespace.ts +539 -84
  272. package/src/documents.ts +2017 -348
  273. package/src/errors.ts +17 -6
  274. package/src/fetch-retry.ts +5 -0
  275. package/src/file-choice.ts +26 -0
  276. package/src/guest-link.ts +362 -1
  277. package/src/handle-types.ts +73 -47
  278. package/src/handles.ts +253 -83
  279. package/src/hosted-ui.ts +211 -50
  280. package/src/import-contract.ts +227 -0
  281. package/src/import-guest.ts +193 -0
  282. package/src/import-report.ts +24 -0
  283. package/src/importer-wait.ts +4 -0
  284. package/src/importer.ts +386 -131
  285. package/src/in-development.ts +10 -0
  286. package/src/index.ts +76 -16
  287. package/src/init-keys.ts +6 -1
  288. package/src/input-documents.ts +819 -0
  289. package/src/inputs-status.ts +598 -0
  290. package/src/inputs.ts +1090 -0
  291. package/src/internal.ts +7 -0
  292. package/src/join-content.ts +135 -0
  293. package/src/join-stamp.ts +12 -0
  294. package/src/manifest-drift.ts +54 -0
  295. package/src/open-document.ts +2346 -930
  296. package/src/page-restore.ts +38 -0
  297. package/src/placement.ts +54 -0
  298. package/src/presence.ts +21 -5
  299. package/src/read-only-document.ts +133 -0
  300. package/src/record-paging.ts +147 -25
  301. package/src/references.ts +207 -0
  302. package/src/request-lane.ts +59 -0
  303. package/src/sealed-arrow.ts +843 -0
  304. package/src/sealed-cache.ts +267 -0
  305. package/src/sealed-encode.ts +1015 -0
  306. package/src/sealed-handles.ts +486 -0
  307. package/src/sealed-store.ts +1330 -0
  308. package/src/serializer.ts +8 -20
  309. package/src/session-toolbar.ts +463 -72
  310. package/src/session.ts +740 -99
  311. package/src/sha256.ts +65 -0
  312. package/src/stable-handles.ts +235 -0
  313. package/src/staged-seal.ts +90 -0
  314. package/src/subscriber-delivery.ts +37 -0
  315. package/src/tail-op-import.ts +30 -0
  316. package/src/topic-lifecycle.ts +38 -2
  317. package/src/trial-replace-dialog.ts +163 -0
  318. package/src/types.ts +6 -6
  319. package/src/undo.ts +386 -73
  320. package/src/version-bar.ts +5 -1
  321. package/src/version.ts +1 -1
  322. package/src/ws-client.ts +208 -16
  323. package/src/ws-frame-limit.ts +1 -0
package/dist/client.js CHANGED
@@ -3,14 +3,24 @@ import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers
3
3
  import { ensureWasmReady } from './wasm-runtime.js';
4
4
  import { decodeIdToken } from './jwt.js';
5
5
  import { createEmitter } from './emitter.js';
6
+ import { adoptSealedCacheOwner } from './sealed-cache.js';
6
7
  import { clearParkedDocuments, createOpenDocument, } from './open-document.js';
7
8
  import { askSchemaConflict } from './schema-conflict-dialog.js';
9
+ import { askReplaceTrial } from './trial-replace-dialog.js';
8
10
  import { BrassError } from './errors.js';
11
+ import { inDevelopment } from './in-development.js';
12
+ import { importerWaitRejections } from './importer-wait.js';
13
+ import { checkManifestDrift } from './manifest-drift.js';
14
+ import { createApiFetch } from './api-fetch.js';
15
+ import { chooseFile } from './file-choice.js';
9
16
  import { createHostedUiLauncher, trackHostedUiActivations, } from './hosted-ui.js';
10
17
  import { createImportRunner, } from './importer.js';
11
18
  import { isExportMode, startExportGuest, } from './export-guest.js';
12
- import { readGuestLinkToken } from './guest-link.js';
19
+ import { isImportMode, startImportGuest, } from './import-guest.js';
20
+ import { clearClaimSecret, readClaimedLink, storeClaimedLink, clearGuestLinkToken, clearUnsavedWork, markClaimIntent, readClaimSecret, readGuestLinkToken, readUnsavedWork, storeUnsavedWork, takeClaimIntent, markTrialEdited, trialWasEdited, writeGuestLinkToken, } from './guest-link.js';
13
21
  import { consumeLaunchOrganizationId } from './launch-context.js';
22
+ import { onPageRestored, restoredPageIsStale } from './page-restore.js';
23
+ import { placeThroughHostedSurfaces } from './placement.js';
14
24
  import { callService, normalizeServiceHost, listAppServices, } from './call-service.js';
15
25
  import { clearAll as clearLastSelections, readLastDocument, storeLastDocument, } from './selection-persistence.js';
16
26
  import { clearActingOrganization, fetchActingOrganization, readActingOrganization, resolveActingOrganization, switchActingOrganization, storeActingOrganization, visibleOrganization, } from './acting-organization.js';
@@ -24,6 +34,20 @@ import { createSessionToolbar, createSessionHeader, createFileMenu, createDocume
24
34
  import { BrassWsClient } from './ws-client.js';
25
35
  import { fetchHostedRuntime, readInjectedRuntime, isBrassHostedHost, HOSTED_RUNTIME_PATH, } from './hosted-runtime.js';
26
36
  const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
37
+ const NOT_IMPORTED_MESSAGE = 'Not imported';
38
+ const IMPORT_FAILED_MESSAGE = 'This file could not be imported';
39
+ // The rejection of an open whose import did not land, naming why when this
40
+ // client stopped waiting for it.
41
+ function notImported(doc) {
42
+ return new BrassError(doc._importAbandoned ?? NOT_IMPORTED_MESSAGE, {
43
+ status: 0,
44
+ code: 'not_imported',
45
+ });
46
+ }
47
+ // How long an open waits for `registerImporter` before it rejects
48
+ // `not_imported`, and when a development build first reports the wait.
49
+ const IMPORTER_WAIT_CEILING_MS = 60_000;
50
+ const IMPORTER_WAIT_WARN_MS = 5_000;
27
51
  // How long `signOut` waits for the revocation before it navigates. The page
28
52
  // it lands on finishes an unrevoked sign-out itself, so waiting longer than
29
53
  // this only delays the sign-out.
@@ -99,6 +123,7 @@ function refreshUnavailableError() {
99
123
  }
100
124
  export class BrassClient {
101
125
  appId = null;
126
+ initFailure = null;
102
127
  authBaseUrl = DEFAULT_AUTH_BASE_URL;
103
128
  apiBaseUrl = DEFAULT_API_BASE_URL;
104
129
  apiBaseUrlConfigured = false;
@@ -114,9 +139,8 @@ export class BrassClient {
114
139
  // (no `prefers-color-scheme: dark` in its CSS). `'auto'` follows the
115
140
  // OS preference; `'dark'` pins dark.
116
141
  hostedUiTheme = 'light';
117
- // See `BrassInitOptions.openIncompatibleDocuments`. When set, every open
118
- // carries the answer the dialog would otherwise ask for.
119
- openIncompatibleDocuments = false;
142
+ // See `BrassInitOptions.open`: the mode the session opens documents in.
143
+ openReadOnly = false;
120
144
  // Doc-selection-in-URL sync. On by default; off via
121
145
  // `syncDocToUrl: false`. Param names configurable via `docUrlParam` /
122
146
  // `branchUrlParam`.
@@ -145,6 +169,10 @@ export class BrassClient {
145
169
  // on `authStatus`.
146
170
  bootstrapped = false;
147
171
  listeners = new Set();
172
+ // The session's own listener. It reacts to sign-in and sign-out, and is
173
+ // left out of a guest-mode change: the session starts that change itself,
174
+ // mid-open, and would otherwise begin a second open of the same document.
175
+ sessionUserListeners = new WeakSet();
148
176
  // Init-failure listeners. Swallow a listener's throw so one bad handler
149
177
  // cannot block the others or mask the original error (already logged).
150
178
  errorListeners = createEmitter({ onError: () => { } });
@@ -156,7 +184,7 @@ export class BrassClient {
156
184
  authEpoch = 0;
157
185
  initialized = false;
158
186
  // Incognito / third-party-cookie fallback state. Both null in a normal
159
- // browser, where the cross-site `brass_auth` cookie carries the session
187
+ // browser, where the cross-site `__Host-brass_auth` cookie carries the session
160
188
  // and `/refresh` works without a body.
161
189
  // - `pendingAuthCode`: the one-time `brass_code` pulled off the
162
190
  // landing URL by bootstrap(), consumed at most once by refresh() if
@@ -175,13 +203,19 @@ export class BrassClient {
175
203
  documentSession = null;
176
204
  hostedUiLauncher = null;
177
205
  documentFactory = null;
178
- // The `?brass_link=` token when this page was opened as an anonymous
179
- // link guest; null on the ordinary signed-in path. Set in `init`.
206
+ // The `?brass_link=` token the page's document opens through, whoever is
207
+ // signed in; null when the page is not on a link. Set in `init`.
180
208
  guestLinkTokenValue = null;
209
+ watchingPageRestore = false;
181
210
  // The organization this page was launched from (`?brass_org=`), or null
182
211
  // when the app was reached some other way. A create that names no
183
212
  // destination of its own lands here; see `launch-context.ts`.
184
213
  launchOrganizationId = null;
214
+ // The install-consent dialog in flight per organization, so concurrent
215
+ // denials share one. Keyed by organization because that is what an install
216
+ // is granted on, and cleared when the dialog settles so a later denial
217
+ // (a declined install, a second organization) raises its own.
218
+ installConsentByOrganization = new Map();
185
219
  // The organization this page has resolved to act in, and the name the
186
220
  // server gave it. Held for the life of the page load so repeated acts
187
221
  // resolve once; dropped on sign-out, and replaced by a later switch.
@@ -219,21 +253,66 @@ export class BrassClient {
219
253
  // iframe (`isExportMode`): serializes a handed-in snapshot and never
220
254
  // authenticates. `registerSerializer` forwards to it.
221
255
  exportGuest = null;
256
+ // Set only when Brass loaded this bundle to run its importer headless
257
+ // (`isImportMode`). `registerImporter` forwards to it.
258
+ importGuest = null;
259
+ importer = null;
260
+ // Opens of a file this app imports, waiting for `registerImporter`.
261
+ importerWaiters = [];
262
+ // An open gave up waiting for `registerImporter`, so a registration after
263
+ // it is late and says so once.
264
+ importerWaitExpired = false;
222
265
  // Open documents by id, so an export request can hand the serializer
223
266
  // the right Document. Dropped when a doc goes; a re-open overwrites.
224
267
  openDocuments = new Map();
225
268
  // The app's import backend (`Brass.registerImporter`) plus the claim
226
269
  // protocol run per opened document. The deps read the documents
227
270
  // namespace lazily, so attempts (always post-open) build it on demand.
271
+ // A guest imports only the trial document it started from a file, through
272
+ // that document's edit link, which is the page's credential.
228
273
  importRunner = createImportRunner({
229
- claimImport: (docId) => this.docsInternal().claimImport(docId),
230
- completeImport: (docId) => this.docsInternal().completeImport(docId),
274
+ claimImport: (docId) => {
275
+ const guest = this.guestImports(docId);
276
+ return guest ? guest.claimImport() : this.docsInternal().claimImport(docId);
277
+ },
278
+ stageImport: (docId, bundle) => {
279
+ const guest = this.guestImports(docId);
280
+ return guest ? guest.stageImport(bundle) : this.docsInternal().stageImport(docId, bundle);
281
+ },
282
+ completeImport: (docId, report, uploadKey) => {
283
+ const guest = this.guestImports(docId);
284
+ return guest
285
+ ? guest.completeImport(report, uploadKey)
286
+ : this.docsInternal().completeImport(docId, report, uploadKey);
287
+ },
288
+ failImport: (docId, message) => {
289
+ const guest = this.guestImports(docId);
290
+ return guest ? guest.failImport(message) : this.docsInternal().failImport(docId, message);
291
+ },
231
292
  reportError: (error) => this.fireError(error),
232
293
  });
294
+ guestImports(docId) {
295
+ const token = this.guestLinkTokenValue;
296
+ return token === null ? null : this.getDocumentFactory().guestImports(token, docId);
297
+ }
298
+ // Whether this page runs a registered importer for an open document: one
299
+ // whose join offers this app the import (`awaits_import`), or whose file is
300
+ // still being stored, which the runner waits out before it claims. Any other
301
+ // document has nothing to import, and the platform refuses a claim on it.
302
+ // A guest imports only into a trial document it started from a file (a trial
303
+ // carries an expiry).
304
+ importsInto(doc) {
305
+ const offered = doc._awaitsImport || doc._workStatusNow?.pending === true;
306
+ if (this.guestLinkTokenValue === null)
307
+ return offered;
308
+ return doc.expiresAt !== null && offered;
309
+ }
233
310
  // One socket carrying every document topic this client subscribes,
234
311
  // built on first open so an app that never opens a document never
235
312
  // connects.
236
313
  docWsClient = null;
314
+ // One socket per link a document on this page opened through, by token.
315
+ linkWsClients = new Map();
237
316
  // init ALWAYS resolves: failures (bad/missing app_id) fire onError +
238
317
  // console.error rather than rejecting. Otherwise
239
318
  // `void Brass.init(typo).then(setup)` would never run .then() (init
@@ -246,6 +325,7 @@ export class BrassClient {
246
325
  // the click handler that opens a surface, so it would miss the press that
247
326
  // opened it and have nothing to hand focus back to on dismissal.
248
327
  trackHostedUiActivations();
328
+ this.initFailure = null;
249
329
  // The WASM this package runs on is fetched from here, so the download
250
330
  // overlaps the auth round-trip below and is usually ready before anything
251
331
  // asks for it. It is deliberately NOT awaited here: an app's sign-in
@@ -316,9 +396,7 @@ export class BrassClient {
316
396
  this.apiBaseUrlConfigured = urls.apiBaseUrlConfigured;
317
397
  this.wsBaseUrl = urls.wsBaseUrl;
318
398
  this._dashboardBaseUrl = urls.dashboardBaseUrl;
319
- if (options.openIncompatibleDocuments === true) {
320
- this.openIncompatibleDocuments = true;
321
- }
399
+ this.openReadOnly = options.open?.readOnly === true;
322
400
  if (options.hostedUiTheme) {
323
401
  this.hostedUiTheme = options.hostedUiTheme;
324
402
  }
@@ -346,16 +424,65 @@ export class BrassClient {
346
424
  this.exportGuest.setSerializer(this.serializer);
347
425
  return;
348
426
  }
427
+ if (typeof window !== 'undefined' && isImportMode(window.location.search)) {
428
+ this.importGuest = startImportGuest();
429
+ if (this.importer)
430
+ this.importGuest.setImporter(this.importer);
431
+ return;
432
+ }
349
433
  const guestToken = typeof window !== 'undefined'
350
434
  ? readGuestLinkToken(window.location.search)
351
435
  : null;
352
436
  if (guestToken) {
353
- // Anonymous "anyone with the link" open. The token IS the
354
- // credential, so there is no auth bootstrap: the session opens the
355
- // document read-only through it. `authStatus` reports `'guest'` and
356
- // the finally block's initial `onUserChange` emit drives the
357
- // read-only boot (the session's guest-aware handler calls start()).
437
+ // Only a sign-in this tab started from this token claims a document
438
+ // created without an account, and only in the browser holding the
439
+ // mint's claim secret. `brass_code` on the URL is not enough: the
440
+ // cookie session resolves whether or not the code is real, so a
441
+ // crafted link carrying one would claim into whoever opens it. A
442
+ // stored hint or session pointer says nothing about whether the
443
+ // person meant to keep THIS document either, so a signed-in browser
444
+ // opening the link cold stays signed in and leaves the document
445
+ // temporary; the strip's Keep is the deliberate act that claims it.
446
+ //
447
+ // Who the person is comes from the session either way. The link is
448
+ // the credential for this document alone: its reads, writes and
449
+ // socket use the link, and a link open carries the person's own
450
+ // credential beside it (`currentLinkCredential`).
451
+ if (signInReturned()) {
452
+ const appId = this.requireAppId();
453
+ const claimSecret = takeClaimIntent(appId) === guestToken
454
+ ? readClaimSecret(appId, guestToken)
455
+ : null;
456
+ await this.bootstrap();
457
+ if (this.user !== null) {
458
+ const anonymous = await this.probeAnonymousLink(guestToken);
459
+ const claim = anonymous !== null && claimSecret !== null
460
+ ? { ...anonymous, claimSecret }
461
+ : null;
462
+ if (anonymous !== null && claim === null) {
463
+ this.reportUncatchable(new BrassError('Only the browser that started this work can keep it. It stays open here until it expires.'));
464
+ }
465
+ if (claim !== null) {
466
+ if (await this.claimAnonymousLink(claim)) {
467
+ await this.hydrateProfile();
468
+ return;
469
+ }
470
+ // Refused, or the person declined to place it: `claimAnonymousLink`
471
+ // has entered guest mode on the token.
472
+ return;
473
+ }
474
+ }
475
+ }
476
+ else {
477
+ await this.bootstrap();
478
+ }
479
+ // The token is the document's credential: the session opens the
480
+ // document through it. `authStatus` reports the person (`'guest'`
481
+ // with nobody signed in) and the finally block's initial
482
+ // `onUserChange` emit drives the boot (the session's guest-aware
483
+ // handler calls start()).
358
484
  this.guestLinkTokenValue = guestToken;
485
+ await this.hydrateProfile();
359
486
  return;
360
487
  }
361
488
  await this.bootstrap();
@@ -363,6 +490,11 @@ export class BrassClient {
363
490
  }
364
491
  catch (err) {
365
492
  const error = err instanceof Error ? err : new Error(String(err));
493
+ // A failure before `initialized` is the app's configuration (a missing
494
+ // or malformed app id, a hosted bundle without its runtime config), so
495
+ // it holds for the page's life and the session reports it as its error.
496
+ if (!this.initialized)
497
+ this.initFailure = new BrassError(error.message);
366
498
  this.reportUncatchable(error);
367
499
  // Deliberately do not re-throw: callers using
368
500
  // `void Brass.init(...).then(setup)` should see .then() fire so the
@@ -386,10 +518,28 @@ export class BrassClient {
386
518
  // mean the person is signed out, and an app that reads it as that
387
519
  // renders a signed-out UI or calls `signIn()`, which navigates the
388
520
  // frame away from the export it was loaded to run.
389
- if (this.exportGuest === null) {
521
+ if (!this.isHeadlessGuest()) {
522
+ // Before anything opens: bytes kept on this device for whoever used
523
+ // it last are dropped unless that was this same identity.
524
+ adoptSealedCacheOwner(this.user?.sub ?? null);
390
525
  for (const listener of Array.from(this.listeners)) {
391
526
  listener(this.user);
392
527
  }
528
+ if (this.initialized && !this.watchingPageRestore) {
529
+ this.watchingPageRestore = true;
530
+ onPageRestored(() => void this.reloadIfRestoredStale());
531
+ }
532
+ if (this.user !== null && this.appId && typeof window !== 'undefined') {
533
+ checkManifestDrift({
534
+ appId: this.appId,
535
+ origin: window.location.origin,
536
+ apiFetch: createApiFetch({
537
+ apiBaseUrl: this.apiBaseUrl,
538
+ getAccessToken: () => this.getAccessToken(),
539
+ getAppAssertion: () => this.getAppAssertion(),
540
+ }),
541
+ });
542
+ }
393
543
  }
394
544
  }
395
545
  }
@@ -403,9 +553,11 @@ export class BrassClient {
403
553
  // raised over a decision the app did not get wrong. The error channel
404
554
  // carries it to the same places, and to the listener the app registered.
405
555
  if (!this.initialized || !this.appId) {
406
- this.reportUncatchable(new Error(this.initialized
407
- ? 'Brass.init was called without an app ID'
408
- : 'Call Brass.init(appId) first'));
556
+ this.reportUncatchable(new Error(this.initFailure
557
+ ? this.initFailure.message
558
+ : this.initialized
559
+ ? 'Brass.init was called without an app ID'
560
+ : 'Call Brass.init(appId) first'));
409
561
  return;
410
562
  }
411
563
  const appId = this.appId;
@@ -415,8 +567,10 @@ export class BrassClient {
415
567
  // An export frame stays where it is. It runs a serializer over bytes
416
568
  // Brass handed it and never calls the API, so navigating to sign-in
417
569
  // would abandon the export mid-flight for a session it has no use for.
418
- if (this.exportGuest !== null)
570
+ if (this.isHeadlessGuest())
419
571
  return;
572
+ if (this.guestLinkTokenValue !== null)
573
+ markClaimIntent(appId, this.guestLinkTokenValue);
420
574
  window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
421
575
  }
422
576
  // Per-app sign-out. Revokes this app's server-side approval (subsequent
@@ -467,6 +621,10 @@ export class BrassClient {
467
621
  // load's resolution go with the session.
468
622
  clearActingOrganization(appId);
469
623
  this.retireActingOrganizationState();
624
+ // And the guest documents this browser made, with the secrets that claim
625
+ // them, so the next person here is neither offered one nor able to keep it.
626
+ clearUnsavedWork(appId);
627
+ clearClaimSecret(appId);
470
628
  const signedOutUrl = buildSignedOutOfAppUrl(this.authBaseUrl, appId, window.location.href);
471
629
  // On a Brass-hosted origin, route the sign-out through the edge's
472
630
  // /__brass/leave so the first-party brass_site gate cookie is cleared
@@ -503,9 +661,9 @@ export class BrassClient {
503
661
  get authStatus() {
504
662
  if (!this.bootstrapped)
505
663
  return 'unknown';
506
- if (this.guestLinkTokenValue !== null)
507
- return 'guest';
508
- return this.user !== null ? 'signed-in' : 'signed-out';
664
+ if (this.user !== null)
665
+ return 'signed-in';
666
+ return this.guestLinkTokenValue !== null ? 'guest' : 'signed-out';
509
667
  }
510
668
  // The organization this app is acting in, as far as this page load already
511
669
  // knows. Synchronous, so nothing is resolved to answer it: the open
@@ -517,7 +675,7 @@ export class BrassClient {
517
675
  this.launchOrganizationId);
518
676
  }
519
677
  get isGuest() {
520
- return this.guestLinkTokenValue !== null;
678
+ return this.authStatus === 'guest';
521
679
  }
522
680
  // The resolved dashboard origin after `init` (the option's value if
523
681
  // provided; the SDK default otherwise), the origin the Hosted UI iframes
@@ -534,7 +692,7 @@ export class BrassClient {
534
692
  // a flicker through the initial `null`. Post-init subscribers get an
535
693
  // immediate emit with the current value, the usual "register and
536
694
  // read current state" idiom.
537
- if (this.bootstrapped && this.exportGuest === null) {
695
+ if (this.bootstrapped && !this.isHeadlessGuest()) {
538
696
  listener(this.user);
539
697
  }
540
698
  return () => {
@@ -568,6 +726,7 @@ export class BrassClient {
568
726
  apiBaseUrl: this.apiBaseUrl,
569
727
  getAccessToken: () => this.getAccessToken(),
570
728
  getAppAssertion: () => this.getAppAssertion(),
729
+ getSocket: () => this.appId === null ? undefined : this.getDocWsClient(this.appId),
571
730
  });
572
731
  }
573
732
  return this.documentsNamespace;
@@ -581,10 +740,33 @@ export class BrassClient {
581
740
  this.documentSession = createDocumentSession({
582
741
  isSignedIn: () => this.user !== null,
583
742
  authStatus: () => this.authStatus,
584
- onUserChange: (listener) => this.onUserChange((user) => listener(user !== null)),
585
- openDocumentById: (docId, options, consent) => this.openDocumentInternal(docId, options, consent),
743
+ initFailure: () => this.initFailure,
744
+ onUserChange: (listener) => {
745
+ const wrapped = (user) => listener(user !== null);
746
+ this.sessionUserListeners.add(wrapped);
747
+ return this.onUserChange(wrapped);
748
+ },
749
+ openDocumentById: (docId, options, consent) => this.asOwnAccess(() => this.openDocumentInternal(docId, options, consent)),
586
750
  guestLinkToken: () => this.guestLinkTokenValue,
751
+ openReadOnly: () => this.openReadOnly,
587
752
  openDocumentByLink: (token, options) => this.openByLinkInternal(token, options),
753
+ mintAnonymousDocument: (args) => this.getDocumentFactory().mintAnonymousDocument(args),
754
+ enterGuestMode: (token) => this.enterGuestMode(token),
755
+ chooseAnonymousFile: () => this.chooseAnonymousFile(),
756
+ signedOutUseRefusal: () => this.signedOutUseRefusal(),
757
+ appOffersNew: () => this.appOffersNew(),
758
+ mintAnonymousDocumentFromFile: (args) => this.getDocumentFactory().mintAnonymousDocumentFromFile(args),
759
+ unsavedWork: () => this.readLiveUnsavedWork(),
760
+ clearUnsavedWork: () => {
761
+ if (this.appId !== null)
762
+ clearUnsavedWork(this.appId);
763
+ },
764
+ leaveGuestMode: () => this.leaveGuestMode(),
765
+ followClaimedLink: (token) => this.followClaimedLink(token),
766
+ markTrialEdited: (token, expiresAt) => markTrialEdited(token, expiresAt),
767
+ trialEdited: (token) => trialWasEdited(token),
768
+ confirmReplaceTrial: (expiresAt) => askReplaceTrial(expiresAt),
769
+ signIn: () => this.signIn(),
588
770
  createDocumentId: (args) => this.createDocumentIdWithConsent(args),
589
771
  selectDocument: () => this.selectDocumentInternal(),
590
772
  selectCreateDestination: (options) => this.getHostedUiLauncher().selectCreateDestination(options),
@@ -675,7 +857,15 @@ export class BrassClient {
675
857
  onUserChange: (listener) => this.onUserChange(listener),
676
858
  signIn: () => this.signIn(),
677
859
  signOut: () => this.signOut(),
678
- dashboardBaseUrl: this._dashboardBaseUrl,
860
+ canKeep: () => this.canKeepTemporaryWork(),
861
+ keep: () => {
862
+ void this.keepTemporaryWork();
863
+ },
864
+ // Both environment-dependent values are read at use rather than copied
865
+ // in here: a piece can be mounted before `init()` resolves, and until it
866
+ // does these hold the production defaults. See `AccountMenuDeps`.
867
+ dashboardBaseUrl: () => this._dashboardBaseUrl,
868
+ appId: () => this.appId ?? undefined,
679
869
  getActingOrganization: () => this.visibleActingOrganization(),
680
870
  subscribeActingOrganization: (listener) => {
681
871
  this.actingOrganizationListeners.add(listener);
@@ -692,9 +882,10 @@ export class BrassClient {
692
882
  void this.switchActingOrganization();
693
883
  },
694
884
  getDocument: (docId) => this.documents.get(docId),
885
+ onLink: () => this.guestLinkTokenValue !== null,
695
886
  // The version-preview bar is a hosted embed; it takes the same theme
696
887
  // as the other hosted surfaces.
697
- hostedUiTheme: this.hostedUiTheme,
888
+ hostedUiTheme: () => this.hostedUiTheme,
698
889
  };
699
890
  }
700
891
  // Opens the hosted document picker in a modal iframe and resolves with
@@ -721,6 +912,25 @@ export class BrassClient {
721
912
  storeLastDocument(appId, result);
722
913
  return result;
723
914
  }
915
+ // Opens the hosted picker for a SET of documents and resolves with every
916
+ // one the person chose: `[]` when they took out everything `selected`
917
+ // held, `null` when they dismissed it. The set is confined
918
+ // to one organization. `options.accept` narrows what the picker offers to
919
+ // this request alone, which is how an app that has read a file names the
920
+ // siblings it is missing; `options.prompt` says why it is asking; and
921
+ // `options.selected` names what the app already holds, so a later call
922
+ // edits that set and resolves with the whole of it.
923
+ //
924
+ // Opening is unchanged: pass each ref's `doc_id` to `openDocumentById`. The
925
+ // URL carries one document, so a set is the app's to hold; nothing here
926
+ // writes to it. Throws before init.
927
+ async chooseInputs(families, options) {
928
+ return this.getHostedUiLauncher().chooseInputs(families, options);
929
+ }
930
+ // One file as a reference, from the ordinary Open list. Throws before init.
931
+ async chooseInput(families, options) {
932
+ return this.getHostedUiLauncher().chooseInput(families, options);
933
+ }
724
934
  // The value the most recent `selectDocument()` resolved with
725
935
  // (per-app sessionStorage), or `null` if nothing was picked this
726
936
  // tab. The session's boot restore reads it as the bare-URL
@@ -804,7 +1014,12 @@ export class BrassClient {
804
1014
  // observable via `Brass.onError` if the caller wants to surface
805
1015
  // "live editing unavailable" in the UI.
806
1016
  async openDocumentById(docId, options) {
807
- const opened = await this.openDocumentInternal(docId, options);
1017
+ // A frozen copy is read beside whatever the page shows, usually as one of
1018
+ // many, so it claims no URL and raises no consent dialog of its own.
1019
+ if (options?.frozen === true) {
1020
+ return this.reopensBeside(await this.openDocumentInternal(docId, options, 'skip', true), options);
1021
+ }
1022
+ const opened = this.reopensBeside(await this.openDocumentInternal(docId, options), options);
808
1023
  // The URL names the document the reader is looking at, and the session
809
1024
  // owns that whenever it holds one: this open is detached, so claiming the
810
1025
  // URL would leave the address bar naming a document the session is not
@@ -815,36 +1030,203 @@ export class BrassClient {
815
1030
  this.maybeWriteDocIdToUrl(opened.id);
816
1031
  return opened;
817
1032
  }
1033
+ // `doc.reopen` for a copy this method opened: the same open in the asked
1034
+ // mode, on the branch the copy is on, after which this copy closes.
1035
+ reopensBeside(doc, options) {
1036
+ doc._setReopener(async (mode) => {
1037
+ const { readOnly: _readOnly, frozen: _frozen, version: _version, ...rest } = options ?? {};
1038
+ const { readOnly: _alias, readOnlyReason: _reason, ...documentOptions } = rest.documentOptions ?? {};
1039
+ // A copy opened at a stream carries the stream's line as `branchId`,
1040
+ // which the join refuses as a branch, so it reopens on the branch the
1041
+ // open named.
1042
+ const branch = rest.stream !== undefined ? rest.branch : (doc.branchId ?? undefined);
1043
+ const next = await this.openDocumentById(doc.id, {
1044
+ ...rest,
1045
+ documentOptions,
1046
+ ...(branch !== undefined ? { branch } : {}),
1047
+ ...(mode === 'frozen' ? { frozen: true } : { readOnly: mode === 'read' }),
1048
+ });
1049
+ doc.close();
1050
+ return next;
1051
+ });
1052
+ return doc;
1053
+ }
818
1054
  // Open a document READ-ONLY from a public-link token (the "anyone with
819
1055
  // the link" anonymous path). No sign-in: the token is the credential and
820
1056
  // the server serves a read-only, member-stripped snapshot with no live
821
1057
  // subscription. `Brass.session` drives this from the URL on boot when the
822
1058
  // page carries `?brass_link=`.
823
1059
  async openByLinkInternal(token, options) {
824
- const factory = this.getDocumentFactory();
825
- const opened = await factory.openByLink(token, options);
826
- this.trackOpenDocument(opened);
1060
+ const opened = await this.importedByLink(await this.joinByLink(token, options), token, options);
1061
+ // A document the platform will remove was created without an account,
1062
+ // and its token is the only way back to it once this tab is gone. Every
1063
+ // guest open of one lands here (a create while signed out, the boot of
1064
+ // its URL, a shared link to one), so this is where the app's signed-out
1065
+ // gate learns of it (`session.unsavedWork`).
1066
+ // A token this browser has claimed is no longer unsaved work, whatever
1067
+ // the open answered: a page the browser kept from before the claim can
1068
+ // still open through it.
1069
+ const appId = this.requireAppId();
1070
+ if (opened.expiresAt !== null && readClaimedLink(appId, token) === null) {
1071
+ storeUnsavedWork(appId, token, opened.expiresAt);
1072
+ }
1073
+ return opened;
1074
+ }
1075
+ async joinByLink(token, options) {
1076
+ const opened = await this.getDocumentFactory().openByLink(token, options);
1077
+ // A link the person opened as themselves was tracked by that open.
1078
+ if (this.openDocuments.get(opened.id) !== opened)
1079
+ this.trackOpenDocument(opened);
827
1080
  return opened;
828
1081
  }
1082
+ // A trial document started from a file is imported by this page before the
1083
+ // open resolves, as `openDocumentInternal` imports a member's: an open whose
1084
+ // import does not land rejects rather than resolving the empty document.
1085
+ async importedByLink(opened, token, options) {
1086
+ if (!opened._awaitsImport || !this.importsInto(opened))
1087
+ return opened;
1088
+ if (await this.importBeforeResolving(opened, options?.frozen === true, options?.onSnapshotProgress)) {
1089
+ return opened;
1090
+ }
1091
+ const imported = await this.joinByLink(token, options);
1092
+ if (!imported._awaitsImport)
1093
+ return imported;
1094
+ imported._reopen();
1095
+ imported.close();
1096
+ throw notImported(opened);
1097
+ }
829
1098
  // The open path shared by the public `openDocumentById` and the
830
1099
  // session. It does NOT write the URL: the public method pins the id
831
1100
  // (`replaceState`) for direct multi-doc callers, while the session
832
1101
  // owns its own push / replace history writes so a single-doc open
833
1102
  // becomes a back-navigable entry without the two layers double-writing.
834
- async openDocumentInternal(docId, options, consent = 'offer') {
1103
+ //
1104
+ // A file this app imports and nobody has imported yet is imported before the
1105
+ // open resolves, in every mode. The platform refuses the open of a file only
1106
+ // another app imports (`not_imported`), so a join that still awaits an
1107
+ // import names this app as the importer.
1108
+ async openDocumentInternal(docId, options, consent = 'offer',
1109
+ // A copy read beside whatever the page shows (one of a set a pass reads,
1110
+ // or a frozen copy) is not the session's copy of its id, so it is not
1111
+ // tracked as the open copy of that id.
1112
+ beside = false) {
1113
+ const opened = await this.joinDocument(docId, options, consent, beside);
1114
+ if (!opened._awaitsImport || !this.importsInto(opened))
1115
+ return opened;
1116
+ if (await this.importBeforeResolving(opened, options?.frozen === true, options?.onSnapshotProgress)) {
1117
+ return opened;
1118
+ }
1119
+ const imported = await this.joinDocument(docId, options, 'skip', beside);
1120
+ if (!imported._awaitsImport)
1121
+ return imported;
1122
+ // The import ended without landing (the claim was refused, or this client
1123
+ // stopped waiting on another window's), which the app's error handler has
1124
+ // already been told.
1125
+ imported._reopen();
1126
+ imported.close();
1127
+ throw notImported(opened);
1128
+ }
1129
+ // Runs this app's importer on a document opened before its import, and
1130
+ // resolves once the import has ended: true when the copy now holds the
1131
+ // import (a frozen copy, reloaded in place), false when the open joins again
1132
+ // for it. Rejects with the import's error when it failed.
1133
+ async importBeforeResolving(doc, frozen, progress) {
1134
+ // An open that ran before the app registered its importer waits for it.
1135
+ if (this.importer === null && !(await this.waitForImporter(doc))) {
1136
+ if (doc._isLive) {
1137
+ doc._reopen();
1138
+ doc.close();
1139
+ }
1140
+ // The message reaches the app's users, so the cause goes to the console.
1141
+ console.warn(`[brass] An open failed with not_imported: this app's manifest declares an importer for the file and Brass.registerImporter was not called within ${IMPORTER_WAIT_CEILING_MS / 1000}s.`);
1142
+ const refused = new BrassError(NOT_IMPORTED_MESSAGE, { status: 0, code: 'not_imported' });
1143
+ importerWaitRejections.add(refused);
1144
+ throw refused;
1145
+ }
1146
+ // A tracked copy had its attempt when it was tracked or when the importer
1147
+ // was registered, so a call for the same open is a no-op. A frozen copy
1148
+ // takes the import under the same handles.
1149
+ void this.importRunner.attempt(doc, frozen ? { reloadInPlace: true } : undefined);
1150
+ await new Promise((resolve) => {
1151
+ const stops = [];
1152
+ let settled = false;
1153
+ const done = () => {
1154
+ if (settled)
1155
+ return;
1156
+ settled = true;
1157
+ queueMicrotask(() => stops.forEach((stop) => stop()));
1158
+ resolve();
1159
+ };
1160
+ stops.push(doc.onGone(done));
1161
+ stops.push(doc.onStatus((status) => {
1162
+ // A settled status from the work that stored the file starts the
1163
+ // import, whose hold is set before this listener hears that status.
1164
+ if (!status.pending && doc._importHold === null)
1165
+ done();
1166
+ // The import's own progress is what the opener shows while it waits.
1167
+ else
1168
+ progress?.(0, null, 'importing', status.message);
1169
+ }));
1170
+ });
1171
+ if (doc._isLive && doc._contentLoadFailed) {
1172
+ const message = doc._workStatusNow?.message;
1173
+ doc._reopen();
1174
+ doc.close();
1175
+ throw new BrassError(message !== undefined && message !== '' ? message : IMPORT_FAILED_MESSAGE, {
1176
+ status: 0,
1177
+ });
1178
+ }
1179
+ if (frozen && doc._isLive && doc._goneReason === null) {
1180
+ // A reload the importer started is shared.
1181
+ await doc.reload();
1182
+ if (doc._isLive)
1183
+ return true;
1184
+ }
1185
+ // Retired as moved rather than parked, so the join that follows builds a
1186
+ // copy from the imported content instead of taking this empty one back.
1187
+ if (doc._isLive) {
1188
+ doc._reopen();
1189
+ doc.close();
1190
+ }
1191
+ return false;
1192
+ }
1193
+ // Resolves true when `registerImporter` runs, false at the ceiling.
1194
+ waitForImporter(doc) {
1195
+ return new Promise((resolve) => {
1196
+ const warning = inDevelopment()
1197
+ ? setTimeout(() => {
1198
+ const dot = doc.name?.lastIndexOf('.') ?? -1;
1199
+ const type = dot > 0 ? `${doc.name.slice(dot)} files` : 'this file';
1200
+ console.warn(`[brass] An open is waiting for an importer: this app's manifest declares one for ${type}, and Brass.registerImporter has not been called. The open fails with not_imported after ${IMPORTER_WAIT_CEILING_MS / 1000}s.`);
1201
+ }, IMPORTER_WAIT_WARN_MS)
1202
+ : undefined;
1203
+ const settle = (registered) => {
1204
+ clearTimeout(warning);
1205
+ clearTimeout(ceiling);
1206
+ const at = this.importerWaiters.indexOf(waiter);
1207
+ if (at !== -1)
1208
+ this.importerWaiters.splice(at, 1);
1209
+ resolve(registered);
1210
+ };
1211
+ const waiter = () => settle(true);
1212
+ const ceiling = setTimeout(() => {
1213
+ this.importerWaitExpired = true;
1214
+ settle(false);
1215
+ }, IMPORTER_WAIT_CEILING_MS);
1216
+ this.importerWaiters.push(waiter);
1217
+ });
1218
+ }
1219
+ async joinDocument(docId, options, consent, beside) {
835
1220
  const factory = this.getDocumentFactory();
836
1221
  // After getDocumentFactory() resolves, requireAppId() has run and
837
1222
  // this.appId is a string.
838
1223
  const appId = this.appId;
839
- // An app that declared it opens these carries the answer on every open,
840
- // so nothing is asked and nothing is refused.
841
- const withConsent = this
842
- .openIncompatibleDocuments
843
- ? { ...options, acceptSchemaConflict: true }
844
- : options;
845
1224
  try {
846
- const opened = await factory.open(docId, withConsent);
847
- this.trackOpenDocument(opened);
1225
+ const opened = await factory.open(docId, options);
1226
+ // The tracked set is the live copy of each document, keyed by id; a
1227
+ // copy read beside it must not replace it there.
1228
+ if (!beside)
1229
+ this.trackOpenDocument(opened);
848
1230
  return opened;
849
1231
  }
850
1232
  catch (err) {
@@ -858,10 +1240,11 @@ export class BrassClient {
858
1240
  if (!openAnyway)
859
1241
  throw err;
860
1242
  const opened = await factory.open(docId, {
861
- ...withConsent,
1243
+ ...options,
862
1244
  acceptSchemaConflict: true,
863
1245
  });
864
- this.trackOpenDocument(opened);
1246
+ if (!beside)
1247
+ this.trackOpenDocument(opened);
865
1248
  return opened;
866
1249
  }
867
1250
  // A denial because the document's org has no install of this app
@@ -874,7 +1257,8 @@ export class BrassClient {
874
1257
  // before they touch anything.
875
1258
  if (consent === 'offer' && (await this.offerInstallConsent(err))) {
876
1259
  const opened = await factory.open(docId, options);
877
- this.trackOpenDocument(opened);
1260
+ if (!beside)
1261
+ this.trackOpenDocument(opened);
878
1262
  return opened;
879
1263
  }
880
1264
  if (isAppNotInstalled(err))
@@ -904,26 +1288,40 @@ export class BrassClient {
904
1288
  }
905
1289
  async createDocument(options) {
906
1290
  const opened = await this.createDocumentInternal(options);
907
- this.maybeWriteDocIdToUrl(opened.id);
1291
+ // A document minted without an account is reached through the link
1292
+ // token the URL already carries, never through its id.
1293
+ if (this.guestLinkTokenValue === null)
1294
+ this.maybeWriteDocIdToUrl(opened.id);
908
1295
  return opened;
909
1296
  }
910
1297
  // See `openDocumentInternal`: the create path the session uses, which
911
1298
  // leaves the URL to the session's own history writes.
912
1299
  async createDocumentInternal(options) {
913
1300
  const factory = this.getDocumentFactory();
914
- try {
915
- const opened = await factory.create(options);
916
- this.trackOpenDocument(opened);
917
- return opened;
1301
+ // No account: mint the document without one and open it through its
1302
+ // link token, which becomes this page's credential.
1303
+ if (this.authStatus === 'signed-out') {
1304
+ const minted = await factory.mintAnonymousDocument(options?.name !== undefined ? { name: options.name } : {});
1305
+ this.enterGuestMode(minted.linkToken);
1306
+ return this.openByLinkInternal(minted.linkToken, options?.documentOptions
1307
+ ? { documentOptions: options.documentOptions }
1308
+ : undefined);
918
1309
  }
919
- catch (err) {
920
- if (await this.offerInstallConsent(err)) {
1310
+ return this.asOwnAccess(async () => {
1311
+ try {
921
1312
  const opened = await factory.create(options);
922
1313
  this.trackOpenDocument(opened);
923
1314
  return opened;
924
1315
  }
925
- throw err;
926
- }
1316
+ catch (err) {
1317
+ if (await this.offerInstallConsent(err)) {
1318
+ const opened = await factory.create(options);
1319
+ this.trackOpenDocument(opened);
1320
+ return opened;
1321
+ }
1322
+ throw err;
1323
+ }
1324
+ });
927
1325
  }
928
1326
  // Create-only for the session: classify the outcome (created / needs a
929
1327
  // destination / needs an organization) so the session drives the picker /
@@ -942,16 +1340,370 @@ export class BrassClient {
942
1340
  throw err;
943
1341
  }
944
1342
  }
1343
+ // The claim's install-consent retry, the same reactive install
1344
+ // `createDocumentIdWithConsent` gives a create.
1345
+ async claimAnonymousDocumentWithConsent(docId, args) {
1346
+ const factory = this.getDocumentFactory();
1347
+ try {
1348
+ return await factory.claimAnonymousDocument(docId, args);
1349
+ }
1350
+ catch (err) {
1351
+ if (await this.offerInstallConsent(err)) {
1352
+ return factory.claimAnonymousDocument(docId, args);
1353
+ }
1354
+ throw err;
1355
+ }
1356
+ }
1357
+ // The app's upload-types read, once per page. A failed read is dropped so
1358
+ // the next asks again.
1359
+ anonymousUploadTypes() {
1360
+ if (this.anonymousUploadTypesRead === null) {
1361
+ const read = this.getDocumentFactory().anonymousUploadTypes();
1362
+ this.anonymousUploadTypesRead = read;
1363
+ read.catch(() => {
1364
+ if (this.anonymousUploadTypesRead === read)
1365
+ this.anonymousUploadTypesRead = null;
1366
+ });
1367
+ }
1368
+ return this.anonymousUploadTypesRead;
1369
+ }
1370
+ anonymousUploadTypesRead = null;
1371
+ // Whether this app offers New, from its row on the platform. A failed read
1372
+ // offers New, and the create reports at the press.
1373
+ async appOffersNew() {
1374
+ try {
1375
+ return (await this.anonymousUploadTypes()).offersNew;
1376
+ }
1377
+ catch {
1378
+ return true;
1379
+ }
1380
+ }
1381
+ // The file types a visitor can start a document of this app from.
1382
+ async anonymousUploadAccept() {
1383
+ const read = await this.anonymousUploadTypes();
1384
+ if ('refusal' in read)
1385
+ throw read.refusal;
1386
+ return read.accept;
1387
+ }
1388
+ // The refusal this app meets without an account, read off the same
1389
+ // upload-types read a signed-out pick makes, so a later Upload reuses it.
1390
+ // Only the `sign_in_required` answer withdraws New and Open; any
1391
+ // other failure leaves them to report at the press.
1392
+ async signedOutUseRefusal() {
1393
+ try {
1394
+ await this.anonymousUploadAccept();
1395
+ return null;
1396
+ }
1397
+ catch (err) {
1398
+ return err instanceof BrassError && err.code === 'sign_in_required' ? err : null;
1399
+ }
1400
+ }
1401
+ // The file a signed-out pick starts a document from. An app that can start
1402
+ // from no file type refuses rather than opening a dialog every file in
1403
+ // which would be refused. The types are read at the press, inside the
1404
+ // several seconds a browser lets that press open a file dialog.
1405
+ async chooseAnonymousFile() {
1406
+ const { accept, maxBytes } = await this.anonymousUploadAccept();
1407
+ if (accept === '') {
1408
+ throw new BrassError('This app cannot open a file without an account. Sign in to open one.', {
1409
+ status: 400,
1410
+ });
1411
+ }
1412
+ const file = await chooseFile(accept);
1413
+ if (file !== null && file.size > maxBytes) {
1414
+ const mb = Math.floor(maxBytes / (1024 * 1024));
1415
+ throw new BrassError(`That file is too large to open without an account (limit ${mb} MB). Sign in to import it.`, { status: 400 });
1416
+ }
1417
+ return file;
1418
+ }
1419
+ // Make a link token the open document's credential, with the URL carrying
1420
+ // it so a reload and a sign-in both return to the document. With nobody
1421
+ // signed in `authStatus` reads `'guest'`; a signed-in person who did not
1422
+ // keep the document stays signed in.
1423
+ enterGuestMode(token) {
1424
+ const credentialChanged = this.guestLinkTokenValue !== token;
1425
+ this.guestLinkTokenValue = token;
1426
+ writeGuestLinkToken(token);
1427
+ // A guest socket is admitted to the one document its link names, so a
1428
+ // socket opened under another credential cannot carry this one.
1429
+ if (credentialChanged)
1430
+ this.docWsClient?.reconnect();
1431
+ this.emitGuestModeChange();
1432
+ }
1433
+ // `authStatus` moved between 'signed-out' (or 'signed-in') and 'guest'
1434
+ // without a sign-in or sign-out, which is still an auth change to an app
1435
+ // that renders its shells off `onUserChange`.
1436
+ emitGuestModeChange() {
1437
+ // With someone signed in, a link coming or going leaves `authStatus` as it
1438
+ // was, so there is nothing to announce.
1439
+ if (!this.bootstrapped || this.isHeadlessGuest() || this.user !== null)
1440
+ return;
1441
+ for (const listener of Array.from(this.listeners)) {
1442
+ if (this.sessionUserListeners.has(listener))
1443
+ continue;
1444
+ listener(this.user);
1445
+ }
1446
+ }
1447
+ // Take a token back out of the page's credential and URL after it failed
1448
+ // to open, so `authStatus` reads `'signed-out'` again. Only the session's
1449
+ // continue path reaches this, from a page that held no session.
1450
+ leaveGuestMode() {
1451
+ this.guestLinkTokenValue = null;
1452
+ clearGuestLinkToken();
1453
+ this.emitGuestModeChange();
1454
+ }
1455
+ // Where a refused link leads this person, when their own claim revoked it.
1456
+ // For the claimant the page leaves the link signed in, with the document's
1457
+ // id on the URL. A page with no session, in the browser that made the
1458
+ // claim, leaves the link signed out with the id on the URL, so the sign-in
1459
+ // the app offers returns to the document. Anyone else keeps the refusal.
1460
+ async followClaimedLink(token) {
1461
+ if (this.guestLinkTokenValue !== token)
1462
+ return null;
1463
+ const tokens = this.tokens;
1464
+ if (this.user === null || tokens === null) {
1465
+ const claimedHere = this.appId === null ? null : readClaimedLink(this.appId, token);
1466
+ if (claimedHere === null)
1467
+ return null;
1468
+ this.forgetUnsavedWorkFor(token);
1469
+ this.leaveClaimedLink(claimedHere);
1470
+ this.emitGuestModeChange();
1471
+ return 'sign-in';
1472
+ }
1473
+ const docId = await this.readClaimedLink(token, tokens).catch(() => null);
1474
+ if (docId === null || this.guestLinkTokenValue !== token)
1475
+ return null;
1476
+ if (this.appId !== null)
1477
+ storeClaimedLink(this.appId, token, docId);
1478
+ this.forgetUnsavedWorkFor(token);
1479
+ this.leaveClaimedLink(docId);
1480
+ await this.hydrateProfile();
1481
+ this.docWsClient?.reconnect();
1482
+ this.emitGuestModeChange();
1483
+ return { docId };
1484
+ }
1485
+ // The way back to unsaved work, unless its token is one this browser has
1486
+ // claimed: that work has an owner and a document id, and a page that opened
1487
+ // the token from before the claim may have written it back.
1488
+ readLiveUnsavedWork() {
1489
+ if (this.appId === null)
1490
+ return null;
1491
+ const entry = readUnsavedWork(this.appId);
1492
+ if (entry === null || readClaimedLink(this.appId, entry.token) === null)
1493
+ return entry;
1494
+ clearUnsavedWork(this.appId);
1495
+ return null;
1496
+ }
1497
+ forgetUnsavedWorkFor(token) {
1498
+ if (this.appId !== null && readUnsavedWork(this.appId)?.token === token) {
1499
+ clearUnsavedWork(this.appId);
1500
+ }
1501
+ }
1502
+ leaveClaimedLink(docId) {
1503
+ this.guestLinkTokenValue = null;
1504
+ clearGuestLinkToken();
1505
+ this.maybeWriteDocIdToUrl(docId);
1506
+ }
1507
+ async readClaimedLink(token, tokens) {
1508
+ const headers = {
1509
+ authorization: `Bearer ${tokens.accessToken}`,
1510
+ };
1511
+ applyClientVersionHeader(headers);
1512
+ if (tokens.appAssertion)
1513
+ headers['x-brass-app-assertion'] = tokens.appAssertion;
1514
+ const response = await fetch(`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/claimed`, { method: 'GET', headers });
1515
+ if (!response.ok)
1516
+ return null;
1517
+ const body = (await response.json());
1518
+ return typeof body.doc_id === 'string' && body.doc_id !== '' ? body.doc_id : null;
1519
+ }
1520
+ // A page back from the back-forward cache, checked against what may have
1521
+ // moved while it was frozen; see `page-restore.ts`.
1522
+ async reloadIfRestoredStale() {
1523
+ const appId = this.appId;
1524
+ if (appId === null)
1525
+ return;
1526
+ const stale = await restoredPageIsStale({
1527
+ guestLinkToken: this.guestLinkTokenValue,
1528
+ signedIn: this.user !== null,
1529
+ sessionHinted: readLoginHint(appId),
1530
+ }, (token) => this.linkStillOpen(token));
1531
+ if (stale)
1532
+ window.location.reload();
1533
+ }
1534
+ async linkStillOpen(token) {
1535
+ try {
1536
+ const response = await fetch(`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/view`, { method: 'GET', cache: 'no-store' });
1537
+ if (response.ok)
1538
+ return true;
1539
+ return response.status === 403 || response.status === 404 || response.status === 410
1540
+ ? false
1541
+ : null;
1542
+ }
1543
+ catch {
1544
+ return null;
1545
+ }
1546
+ }
1547
+ // Whether the signed-in person can keep the temporary document this page is
1548
+ // on: only the browser that made it holds the secret its claim takes.
1549
+ canKeepTemporaryWork() {
1550
+ const token = this.guestLinkTokenValue;
1551
+ if (token === null || this.user === null || this.appId === null)
1552
+ return false;
1553
+ if (this.session.document?.expiresAt == null)
1554
+ return false;
1555
+ return readClaimSecret(this.appId, token) !== null;
1556
+ }
1557
+ // Keep the temporary document this page is on, signed in, without leaving
1558
+ // the page: the claim a sign-in from the page would make, then the document
1559
+ // opened by id. The claim evicts the link's own connections, so the page
1560
+ // leaves the link before it reopens rather than following the eviction.
1561
+ async keepTemporaryWork() {
1562
+ const token = this.guestLinkTokenValue;
1563
+ if (token === null || this.user === null || !this.canKeepTemporaryWork())
1564
+ return false;
1565
+ const appId = this.requireAppId();
1566
+ const claimSecret = readClaimSecret(appId, token);
1567
+ const anonymous = await this.probeAnonymousLink(token);
1568
+ if (claimSecret === null || anonymous === null)
1569
+ return false;
1570
+ if (!(await this.claimAnonymousLink({ ...anonymous, claimSecret })))
1571
+ return false;
1572
+ this.leaveLinkForOwnAccess();
1573
+ try {
1574
+ await this.session.open(anonymous.docId);
1575
+ }
1576
+ catch (err) {
1577
+ this.reportUncatchable(err instanceof Error ? err : new Error(String(err)));
1578
+ }
1579
+ return true;
1580
+ }
1581
+ // A signed-in person on a link page who opens or creates another document
1582
+ // does so as themselves, so the page stops being on the link: the URL drops
1583
+ // it and the socket reconnects with their own credential.
1584
+ leaveLinkForOwnAccess() {
1585
+ if (this.guestLinkTokenValue === null || this.user === null)
1586
+ return;
1587
+ this.guestLinkTokenValue = null;
1588
+ clearGuestLinkToken();
1589
+ }
1590
+ // Run an open or create the person makes as themselves, off the link. One
1591
+ // that fails leaves the page where it was, still on the link: the document
1592
+ // it shows is the link's, and the URL is what brings a reload back to it.
1593
+ async asOwnAccess(run) {
1594
+ const token = this.user !== null ? this.guestLinkTokenValue : null;
1595
+ this.leaveLinkForOwnAccess();
1596
+ try {
1597
+ return await run();
1598
+ }
1599
+ catch (err) {
1600
+ if (token !== null && this.guestLinkTokenValue === null && this.user !== null) {
1601
+ this.guestLinkTokenValue = token;
1602
+ writeGuestLinkToken(token);
1603
+ }
1604
+ throw err;
1605
+ }
1606
+ }
1607
+ // The signed-in person's credential, carried beside a link on its open: an
1608
+ // app that opens links only for people signed in to it asks for it. Read
1609
+ // through `getAccessToken`, which renews a token set near its expiry.
1610
+ async currentLinkCredential() {
1611
+ if (this.user === null || (await this.getAccessToken()) === null)
1612
+ return null;
1613
+ const tokens = this.tokens;
1614
+ if (this.user === null || tokens === null || tokens.appAssertion === undefined)
1615
+ return null;
1616
+ return { accessToken: tokens.accessToken, appAssertion: tokens.appAssertion };
1617
+ }
1618
+ // Whether a link token names a document created without an account, read
1619
+ // off `GET /links/{token}/view` (no snapshot, no credential). Null for an
1620
+ // ordinary share link, and for a token the server no longer answers.
1621
+ async probeAnonymousLink(token) {
1622
+ try {
1623
+ const response = await fetch(`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/view`, { method: 'GET' });
1624
+ if (!response.ok)
1625
+ return null;
1626
+ const body = (await response.json());
1627
+ if (body.anonymous !== true || typeof body.doc_id !== 'string') {
1628
+ return null;
1629
+ }
1630
+ return {
1631
+ token,
1632
+ docId: body.doc_id,
1633
+ ...(typeof body.name === 'string' ? { name: body.name } : {}),
1634
+ };
1635
+ }
1636
+ catch {
1637
+ return null;
1638
+ }
1639
+ }
1640
+ // Claim a document created without an account into the signed-in person's
1641
+ // organization, through the placement loop a create runs. On success the
1642
+ // claimed id replaces the token in the URL and is stashed as the last
1643
+ // selection, so the session's restore and an app reading `?doc=` both
1644
+ // find it. A refused or dismissed claim reports through `onError` and
1645
+ // enters guest mode on the token, editable until it expires; a reload
1646
+ // carries no code, so it does not claim again.
1647
+ async claimAnonymousLink(claim) {
1648
+ const appId = this.requireAppId();
1649
+ let chosenName;
1650
+ let result = null;
1651
+ try {
1652
+ result = await placeThroughHostedSurfaces(this.getHostedUiLauncher(), (destination) => this.claimAnonymousDocumentWithConsent(claim.docId, {
1653
+ linkToken: claim.token,
1654
+ claimSecret: claim.claimSecret,
1655
+ ...(destination !== undefined ? { destination } : {}),
1656
+ ...(chosenName !== undefined ? { name: chosenName } : {}),
1657
+ }), claim.name, (chosen) => {
1658
+ chosenName = chosen.name;
1659
+ });
1660
+ }
1661
+ catch (err) {
1662
+ this.reportUncatchable(err instanceof Error ? err : new Error(String(err)));
1663
+ }
1664
+ if (result === null) {
1665
+ this.enterGuestMode(claim.token);
1666
+ return false;
1667
+ }
1668
+ clearGuestLinkToken();
1669
+ // Claimed, the document is reached by id like any other, so there is
1670
+ // no unsaved work to come back to.
1671
+ clearUnsavedWork(appId);
1672
+ clearClaimSecret(appId, claim.token);
1673
+ storeClaimedLink(appId, claim.token, claim.docId);
1674
+ this.maybeWriteDocIdToUrl(claim.docId);
1675
+ if (result.record)
1676
+ storeLastDocument(appId, result.record);
1677
+ return true;
1678
+ }
945
1679
  // The `app_not_installed` denial names the org; open the hosted
946
1680
  // install-consent dialog for it so the person decides: an admin (or a
947
1681
  // member of an `open`-policy org) installs, a member under `request`
948
1682
  // sends the admin request from the same dialog. Nothing is installed
949
1683
  // or requested implicitly. Resolves true only when an install landed,
950
1684
  // in which case the caller retries the denied call once.
951
- async offerInstallConsent(err) {
1685
+ offerInstallConsent(err) {
952
1686
  if (!isAppNotInstalled(err) || !err.organizationId)
953
- return false;
1687
+ return Promise.resolve(false);
954
1688
  const organizationId = err.organizationId;
1689
+ // One dialog per organization, however many calls were refused. An app
1690
+ // that opens a SET hits the same denial once per document, and each raise
1691
+ // is a modal: the reader answers the one on top and finds another behind
1692
+ // it, for work they already consented to. The install a dialog lands is
1693
+ // the whole answer for every caller waiting on it, and each still retries
1694
+ // its own denied call afterwards.
1695
+ const inFlight = this.installConsentByOrganization.get(organizationId);
1696
+ if (inFlight)
1697
+ return inFlight;
1698
+ const raised = this.raiseInstallConsent(organizationId).finally(() => {
1699
+ this.installConsentByOrganization.delete(organizationId);
1700
+ });
1701
+ this.installConsentByOrganization.set(organizationId, raised);
1702
+ return raised;
1703
+ }
1704
+ // The dialog itself, held apart so the memo above is the only thing that
1705
+ // decides whether one opens.
1706
+ async raiseInstallConsent(organizationId) {
955
1707
  try {
956
1708
  const result = await this.getHostedUiLauncher().installApp(organizationId);
957
1709
  if (result === null) {
@@ -1009,7 +1761,7 @@ export class BrassClient {
1009
1761
  // Register the app's export backend. The platform calls it when a
1010
1762
  // hosted surface downloads an app-owned (blob) document: it receives
1011
1763
  // the open Document and returns the source file's bytes, the inverse of
1012
- // the parse the app ran on `doc.sourceBlob()`. The app never invokes it
1764
+ // the app's importer. The app never invokes it
1013
1765
  // and never delivers the bytes itself; the platform stages + downloads
1014
1766
  // what it returns. One serializer per app.
1015
1767
  registerSerializer(serializer) {
@@ -1018,20 +1770,37 @@ export class BrassClient {
1018
1770
  // this registration to run it.
1019
1771
  this.exportGuest?.setSerializer(serializer);
1020
1772
  }
1021
- // Register the app's import backend: parses a stored source file into
1022
- // the document's records. The SDK claims the import with the platform
1023
- // on each open, so exactly one client runs it; the other open clients
1024
- // receive the records live. One importer per app; a later registration
1773
+ // Register the app's import backend, `(file) => { streams, report? }`.
1774
+ // The SDK claims the import with the platform on each open, so exactly one
1775
+ // client runs it, and the platform seals what it returns as the document's
1776
+ // first content. One importer per app; a later registration
1025
1777
  // replaces the earlier. Documents already open (registration raced the
1026
- // first open) get their attempt here.
1778
+ // first open) get their attempt here, and so do inputs whose open stopped
1779
+ // waiting for it.
1027
1780
  registerImporter(importer) {
1781
+ this.importer = importer;
1028
1782
  this.importRunner.setImporter(importer);
1029
- if (this.guestLinkTokenValue !== null)
1783
+ for (const registered of this.importerWaiters.splice(0))
1784
+ registered();
1785
+ if (this.importGuest !== null) {
1786
+ this.importGuest.setImporter(importer);
1030
1787
  return;
1788
+ }
1789
+ if (this.importerWaitExpired) {
1790
+ this.importerWaitExpired = false;
1791
+ console.warn(`[brass] Brass.registerImporter was called after an open had waited ${IMPORTER_WAIT_CEILING_MS / 1000}s for it and failed with not_imported. Call it before opening documents.`);
1792
+ }
1031
1793
  for (const doc of this.liveOpenDocuments()) {
1032
- void this.importRunner.attempt(doc);
1794
+ if (this.importsInto(doc))
1795
+ void this.importRunner.attempt(doc);
1796
+ doc._importerRegistered();
1033
1797
  }
1034
1798
  }
1799
+ // A bundle Brass loaded to run its serializer or importer headless holds
1800
+ // no session and emits none.
1801
+ isHeadlessGuest() {
1802
+ return this.exportGuest !== null || this.importGuest !== null;
1803
+ }
1035
1804
  // Track an opened document so an export request can resolve its id to
1036
1805
  // the live Document. A re-open overwrites.
1037
1806
  trackOpenDocument(doc) {
@@ -1042,11 +1811,9 @@ export class BrassClient {
1042
1811
  }
1043
1812
  });
1044
1813
  // Every open path funnels through here, so this is where a registered
1045
- // importer gets its per-document attempt. A guest open is read-only
1046
- // with no API credential, so no import runs for it.
1047
- if (this.guestLinkTokenValue === null) {
1814
+ // importer gets its per-document attempt.
1815
+ if (this.importsInto(doc))
1048
1816
  void this.importRunner.attempt(doc);
1049
- }
1050
1817
  }
1051
1818
  // The tracked documents still open, dropping the rest as it goes.
1052
1819
  // `onGone` covers only a server-side eviction, and two ordinary paths
@@ -1095,7 +1862,7 @@ export class BrassClient {
1095
1862
  }
1096
1863
  const serialized = await this.serializeForExport(docId);
1097
1864
  if (!serialized.ok) {
1098
- throw new BrassError(`This app could not write the document out to publish it: ${serialized.message}`, { status: 400 });
1865
+ throw new BrassError(`This app could not write this out as a file to publish it: ${serialized.message}`, { status: 400 });
1099
1866
  }
1100
1867
  return await this.docsInternal().pushToDrive(doc, {
1101
1868
  ...input,
@@ -1118,7 +1885,7 @@ export class BrassClient {
1118
1885
  const doc = this.liveOpenDocument(docId) ??
1119
1886
  (sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
1120
1887
  if (!doc) {
1121
- return { ok: false, code: 'no_document', message: 'Document is not open.' };
1888
+ return { ok: false, code: 'no_document', message: 'This is not open in the app.' };
1122
1889
  }
1123
1890
  try {
1124
1891
  // A serializer runs detached, with no subscription to have been fed by,
@@ -1170,8 +1937,11 @@ export class BrassClient {
1170
1937
  apiBaseUrl: this.apiBaseUrl,
1171
1938
  getAccessToken: () => this.getAccessToken(),
1172
1939
  getAppAssertion: () => this.getAppAssertion(),
1940
+ getLinkCredential: () => this.currentLinkCredential(),
1941
+ openAsMember: (docId, options) => this.openDocumentInternal(docId, options, 'skip'),
1173
1942
  identity: () => this.user?.sub ?? null,
1174
1943
  ...(wsClient ? { wsClient } : {}),
1944
+ linkWsClient: (token) => this.getLinkWsClient(appId, token),
1175
1945
  branchProvider: {
1176
1946
  list: (docId) => docsInternal.listBranches(docId),
1177
1947
  create: (docId, input) => docsInternal.createBranch(docId, input),
@@ -1183,10 +1953,17 @@ export class BrassClient {
1183
1953
  // wire unstamped while each layer's own tests stay green.
1184
1954
  create: (docId, branchId, name) => docsInternal.createStream(docId, branchId, name),
1185
1955
  },
1186
- thumbnailUploader: (doc, image, width, height) => docsNs.setThumbnail(doc, { image, width, height }),
1187
- checkpointCreator: (doc) => docsInternal.createCheckpoint(doc),
1956
+ thumbnailUploader: (doc, image, width, height, version) => version === undefined
1957
+ ? docsNs.setThumbnail(doc, { image, width, height })
1958
+ : docsInternal.setThumbnailAt(doc, { image, width, height }, version),
1959
+ checkpointCreator: (doc, anchor) => docsInternal.createCheckpoint(doc, anchor),
1188
1960
  drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
1189
1961
  sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
1962
+ provisionalInputsPoster: (docId, body, opts) => docsInternal.postProvisionalInputs(docId, body, opts),
1963
+ inputsRuntime: (docId) => ({
1964
+ readKey: (key, query) => docsInternal.readInputKeyStatus(docId, key, query),
1965
+ open: (inputId, options) => this.openDocumentById(inputId, options),
1966
+ }),
1190
1967
  hostedUiProvider: {
1191
1968
  documentHistory: (docId, options) => this.documentHistory(docId, options),
1192
1969
  documentSettings: (docId, options) => this.getHostedUiLauncher().documentSettings(docId, options),
@@ -1219,7 +1996,7 @@ export class BrassClient {
1219
1996
  // the active-doc swap consistent (URL sync, command list
1220
1997
  // re-render, branch-list refresh on the new doc).
1221
1998
  openSiblingDoc: async (docId, options) => {
1222
- await this.session.open(docId, options);
1999
+ await this.session._openSibling(docId, options);
1223
2000
  },
1224
2001
  selectBranch: (docId, options) => this.getHostedUiLauncher().selectBranch(docId, options),
1225
2002
  // `doc.resolveDrive()` opens the hosted resolution surface;
@@ -1247,17 +2024,32 @@ export class BrassClient {
1247
2024
  wsBaseUrl: this.wsBaseUrl,
1248
2025
  appId,
1249
2026
  getAccessToken: () => this.getAccessToken(),
1250
- // Anonymous link guest: with no access token the socket connects with
1251
- // this link token and the server admits a read-only doc subscription.
1252
- getGuestLinkToken: () => this.guestLinkTokenValue,
1253
- // Attests the socket with the same assertion the HTTP calls carry so
1254
- // the server enforces Layer 2 on op-write; a first-party client
1255
- // resolves null here and connects un-gated.
2027
+ // With nobody signed in the page socket connects with the link token;
2028
+ // a signed-in person's carries their own credential, and a document
2029
+ // opened through a link uses that link's socket (`getLinkWsClient`).
2030
+ getGuestLinkToken: () => (this.user === null ? this.guestLinkTokenValue : null),
2031
+ // Attests the socket with the same assertion the HTTP calls carry.
1256
2032
  getAppAssertion: () => this.getAppAssertion(),
1257
2033
  fireError: (error) => this.fireError(error),
1258
2034
  });
1259
2035
  return this.docWsClient;
1260
2036
  }
2037
+ getLinkWsClient(appId, token) {
2038
+ if (!this.wsBaseUrl)
2039
+ return undefined;
2040
+ let client = this.linkWsClients.get(token);
2041
+ if (client === undefined) {
2042
+ client = new BrassWsClient({
2043
+ wsBaseUrl: this.wsBaseUrl,
2044
+ appId,
2045
+ getAccessToken: () => Promise.resolve(null),
2046
+ getGuestLinkToken: () => token,
2047
+ fireError: (error) => this.fireError(error),
2048
+ });
2049
+ this.linkWsClients.set(token, client);
2050
+ }
2051
+ return client;
2052
+ }
1261
2053
  // Resolves null only when the server answered that this app has no live
1262
2054
  // session, and rejects with a 503 `BrassError` when a `/refresh` found no
1263
2055
  // answer at all. The two outcomes drive opposite handling: null is the
@@ -1267,13 +2059,11 @@ export class BrassClient {
1267
2059
  // blipped, and hides the transient from every layer that self-heals on it.
1268
2060
  async getAccessToken() {
1269
2061
  this.requireInit();
1270
- // On a `?brass_link=` page the link token is the credential and `init`
1271
- // runs no auth bootstrap, so answer null rather than probing /refresh.
1272
- // A visitor with no session would draw a 401 the browser logs; one who
1273
- // holds a session for this app would get a real token, and a caller
1274
- // that prefers a token over the link token (the socket does) would then
1275
- // present membership where the page's whole authority is the link.
1276
- if (this.guestLinkTokenValue !== null)
2062
+ // A `?brass_link=` page with nobody signed in answers null rather than
2063
+ // probing /refresh, which would draw a 401 the browser logs. A signed-in
2064
+ // person's token serves what they do as themselves; the link document's
2065
+ // own requests and the socket carry the link instead.
2066
+ if (this.guestLinkTokenValue !== null && this.user === null)
1277
2067
  return null;
1278
2068
  const current = this.tokens;
1279
2069
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
@@ -1414,7 +2204,8 @@ export class BrassClient {
1414
2204
  // document picked through the hosted picker already named its organization
1415
2205
  // on the way in, which answers with no request at all.
1416
2206
  activeDocumentOrganizationId() {
1417
- // A link guest holds no API credential, so there is nothing to read with.
2207
+ // A document opened through a link is read through the link alone, so its
2208
+ // record is not the person's to read.
1418
2209
  if (this.guestLinkTokenValue !== null)
1419
2210
  return Promise.resolve(null);
1420
2211
  const docId = this.session.document?.id;
@@ -1716,10 +2507,9 @@ export class BrassClient {
1716
2507
  }
1717
2508
  async getAppAssertion() {
1718
2509
  this.requireInit();
1719
- // Paired with `getAccessToken`: a link guest holds no token to assert
1720
- // an acting app against, and reaching /refresh here would authenticate
1721
- // the page the same way.
1722
- if (this.guestLinkTokenValue !== null)
2510
+ // Paired with `getAccessToken`: a link page with nobody signed in holds
2511
+ // no token to assert an acting app against.
2512
+ if (this.guestLinkTokenValue !== null && this.user === null)
1723
2513
  return null;
1724
2514
  const current = this.tokens;
1725
2515
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
@@ -1736,16 +2526,15 @@ export class BrassClient {
1736
2526
  const appId = this.requireAppId();
1737
2527
  // Every successful sign-in 302s back to return_to with the
1738
2528
  // platform-session cookie set AND a one-time `brass_code` query
1739
- // parameter. The cookie
1740
- // is the primary channel; the code is the incognito / Safari ITP /
1741
- // post-third-party-cookie fallback for when the browser drops the
1742
- // cross-site cookie on our /refresh XHR.
2529
+ // parameter. The first refresh exchanges the code with the cookie
2530
+ // alongside, and the server answers in cookie mode when the cookie came
2531
+ // with it, or hands back the fallback pointer when the browser dropped
2532
+ // it (incognito / Safari ITP / post-third-party-cookie browsers).
1743
2533
  //
1744
2534
  // Pull the code off the URL immediately (before any /refresh)
1745
2535
  // and strip it via history.replaceState so it never lingers in the
1746
2536
  // address bar, browser history, or a referrer header. It is held in
1747
- // memory and consumed at most once, only if the cookie refresh
1748
- // turns out to fail (see refresh()).
2537
+ // memory and consumed at most once (see runRefresh()).
1749
2538
  //
1750
2539
  // No app-controlled silent-mint path: the SDK never auto-fires
1751
2540
  // Brass.signIn() based on a URL hint. New app approvals always
@@ -1854,12 +2643,12 @@ export class BrassClient {
1854
2643
  });
1855
2644
  return this.inflightRefresh;
1856
2645
  }
1857
- // Picks the credential channel for a single /refresh round-trip. The
1858
- // cookie path is primary and untouched for browsers that send the
1859
- // third-party cookie; the one-time code is consumed only when that
1860
- // cookie path resolved no session. Once a fallback session
1861
- // token is in hand (the cross-site cookie is proven unavailable for
1862
- // this page's life), every later refresh uses it directly.
2646
+ // Picks the credential channel for a single /refresh round-trip. A landed
2647
+ // one-time code goes first, on the same credentialed request as the cookie,
2648
+ // so a browser that drops the cross-site cookie signs in on one request
2649
+ // rather than after a cookie refresh it was always going to refuse. Once a
2650
+ // fallback session token is in hand (the cross-site cookie is proven
2651
+ // unavailable for this page's life), every later refresh uses it directly.
1863
2652
  //
1864
2653
  // A rung falls through to the next only on `no_session`. Each one discards
1865
2654
  // a credential as it passes (the stale sid, the one-time code), and an
@@ -1881,17 +2670,16 @@ export class BrassClient {
1881
2670
  this.sessionToken = null;
1882
2671
  clearSessionToken(appId);
1883
2672
  }
1884
- const viaCookie = await refreshTokens(this.authBaseUrl, appId);
1885
- if (viaCookie.status !== 'no_session')
1886
- return viaCookie;
1887
- // Cookie channel resolved no session. Fall back to the one-time code if
1888
- // we landed with one. Clear it first: it is single-use server-side, so a
2673
+ // Clear the code before spending it: it is single-use server-side, so a
1889
2674
  // failed exchange must not be retried.
1890
2675
  const code = this.pendingAuthCode;
1891
- if (!code)
1892
- return viaCookie;
1893
- this.pendingAuthCode = null;
1894
- return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
2676
+ if (code) {
2677
+ this.pendingAuthCode = null;
2678
+ const viaCode = await refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
2679
+ if (viaCode.status !== 'no_session')
2680
+ return viaCode;
2681
+ }
2682
+ return refreshTokens(this.authBaseUrl, appId);
1895
2683
  }
1896
2684
  setTokens(next) {
1897
2685
  const nextUser = next ? decodeIdToken(next.idToken) : null;
@@ -1945,9 +2733,11 @@ export class BrassClient {
1945
2733
  // it hears the sign-out and a close PARKS the Document. A parked Document
1946
2734
  // answers the next open of its key out of memory, with no token and no
1947
2735
  // request, so one left behind here is the previous person's document
1948
- // handed to whoever signs in next.
2736
+ // handed to whoever signs in next. Frozen bytes kept on the device go with
2737
+ // it for the same reason.
1949
2738
  if (nextUser === null)
1950
2739
  clearParkedDocuments();
2740
+ adoptSealedCacheOwner(nextUser?.sub ?? null);
1951
2741
  }
1952
2742
  async hydrateProfile() {
1953
2743
  if (!this.user)
@@ -2013,6 +2803,19 @@ export class BrassClient {
2013
2803
  // works and the proxy's code went unused). Best-effort: any failure
2014
2804
  // (exotic URL, history API unavailable) just yields null and the SDK
2015
2805
  // falls back to the cookie-only path.
2806
+ // Whether a sign-in this page started has just returned: the auth proxy
2807
+ // appends `brass_code` to `return_to` on every successful sign-in. Read
2808
+ // without consuming, since `bootstrap` spends it.
2809
+ function signInReturned() {
2810
+ if (typeof window === 'undefined')
2811
+ return false;
2812
+ try {
2813
+ return new URL(window.location.href).searchParams.has('brass_code');
2814
+ }
2815
+ catch {
2816
+ return false;
2817
+ }
2818
+ }
2016
2819
  function consumeAuthCodeParam() {
2017
2820
  try {
2018
2821
  const url = new URL(window.location.href);