@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/src/client.ts CHANGED
@@ -11,22 +11,39 @@ import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers
11
11
  import { ensureWasmReady } from './wasm-runtime.js';
12
12
  import { decodeIdToken } from './jwt.js';
13
13
  import { createEmitter } from './emitter.js';
14
+ import { adoptSealedCacheOwner } from './sealed-cache.js';
14
15
  import {
15
16
  clearParkedDocuments,
16
17
  createOpenDocument,
18
+ type AnonymousUploadAccept,
19
+ type AnonymousUploadTypes,
20
+ type GuestImportCalls,
21
+ type ClaimAnonymousDocumentArgs,
22
+ type ClaimDocumentResult,
17
23
  type CreateDocumentArgs,
18
24
  type CreateDocumentOptions,
19
25
  type CreateDocumentResult,
20
26
  type DocumentFactory,
27
+ type LinkCredential,
21
28
  type OpenDocumentByIdOptions,
22
29
  type OpenedDocument,
23
30
  } from './open-document.js';
24
31
  import { askSchemaConflict } from './schema-conflict-dialog.js';
32
+ import { askReplaceTrial } from './trial-replace-dialog.js';
25
33
  import { BrassError } from './errors.js';
34
+ import { inDevelopment } from './in-development.js';
35
+ import { importerWaitRejections } from './importer-wait.js';
36
+ import { checkManifestDrift } from './manifest-drift.js';
37
+ import { createApiFetch } from './api-fetch.js';
38
+ import { chooseFile } from './file-choice.js';
26
39
  import {
27
40
  createHostedUiLauncher,
28
41
  trackHostedUiActivations,
29
42
  type DocumentHistoryResult,
43
+ type ChooseInputOptions,
44
+ type ChooseInputsOptions,
45
+ type DocumentFamilies,
46
+ type InputReference,
30
47
  type ExportSerializeOutcome,
31
48
  type HostedUiLauncher,
32
49
  type HostedUiTheme,
@@ -44,8 +61,31 @@ import {
44
61
  startExportGuest,
45
62
  type ExportGuestController,
46
63
  } from './export-guest.js';
47
- import { readGuestLinkToken } from './guest-link.js';
64
+ import {
65
+ isImportMode,
66
+ startImportGuest,
67
+ type ImportGuestController,
68
+ } from './import-guest.js';
69
+ import {
70
+ clearClaimSecret,
71
+ readClaimedLink,
72
+ storeClaimedLink,
73
+ clearGuestLinkToken,
74
+ clearUnsavedWork,
75
+ markClaimIntent,
76
+ readClaimSecret,
77
+ readGuestLinkToken,
78
+ readUnsavedWork,
79
+ storeUnsavedWork,
80
+ takeClaimIntent,
81
+ markTrialEdited,
82
+ trialWasEdited,
83
+ writeGuestLinkToken,
84
+ type UnsavedWorkEntry,
85
+ } from './guest-link.js';
48
86
  import { consumeLaunchOrganizationId } from './launch-context.js';
87
+ import { onPageRestored, restoredPageIsStale } from './page-restore.js';
88
+ import { placeThroughHostedSurfaces } from './placement.js';
49
89
  import {
50
90
  callService,
51
91
  normalizeServiceHost,
@@ -100,6 +140,7 @@ import {
100
140
  } from './documents-namespace.js';
101
141
  import {
102
142
  createDocumentSession,
143
+ type ClaimedLinkTarget,
103
144
  type DocumentSession,
104
145
  } from './session.js';
105
146
  import {
@@ -135,6 +176,22 @@ import type {
135
176
  } from './types.js';
136
177
 
137
178
  const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
179
+
180
+ const NOT_IMPORTED_MESSAGE = 'Not imported';
181
+ const IMPORT_FAILED_MESSAGE = 'This file could not be imported';
182
+
183
+ // The rejection of an open whose import did not land, naming why when this
184
+ // client stopped waiting for it.
185
+ function notImported(doc: Document): BrassError {
186
+ return new BrassError(doc._importAbandoned ?? NOT_IMPORTED_MESSAGE, {
187
+ status: 0,
188
+ code: 'not_imported',
189
+ });
190
+ }
191
+ // How long an open waits for `registerImporter` before it rejects
192
+ // `not_imported`, and when a development build first reports the wait.
193
+ const IMPORTER_WAIT_CEILING_MS = 60_000;
194
+ const IMPORTER_WAIT_WARN_MS = 5_000;
138
195
  // How long `signOut` waits for the revocation before it navigates. The page
139
196
  // it lands on finishes an unrevoked sign-out itself, so waiting longer than
140
197
  // this only delays the sign-out.
@@ -212,6 +269,7 @@ export function resolveBaseUrls(options: BrassInitOptions): ResolvedBaseUrls {
212
269
  }
213
270
  // Refresh slightly before expiry so callers never see a stale access token.
214
271
  const REFRESH_LEEWAY_MS = 60_000;
272
+
215
273
  // App id format. The server mints `brass_app_` + random chars; some
216
274
  // real ids carry a `test`/`live`/`internal` infix. The optional infix
217
275
  // group accepts both shapes, and the permissive tail keeps minor
@@ -235,8 +293,19 @@ function refreshUnavailableError(): BrassError {
235
293
  return new BrassError('The sign-in service is unavailable', { status: 503 });
236
294
  }
237
295
 
296
+ // A link token that names a document created without an account, as the
297
+ // probe read it: what the claim needs, plus the name the destination dialog
298
+ // is pre-filled with.
299
+ interface AnonymousLinkClaim {
300
+ token: string;
301
+ claimSecret: string;
302
+ docId: string;
303
+ name?: string;
304
+ }
305
+
238
306
  export class BrassClient {
239
307
  private appId: string | null = null;
308
+ private initFailure: BrassError | null = null;
240
309
  private authBaseUrl = DEFAULT_AUTH_BASE_URL;
241
310
  private apiBaseUrl = DEFAULT_API_BASE_URL;
242
311
  private apiBaseUrlConfigured = false;
@@ -252,9 +321,8 @@ export class BrassClient {
252
321
  // (no `prefers-color-scheme: dark` in its CSS). `'auto'` follows the
253
322
  // OS preference; `'dark'` pins dark.
254
323
  private hostedUiTheme: HostedUiTheme = 'light';
255
- // See `BrassInitOptions.openIncompatibleDocuments`. When set, every open
256
- // carries the answer the dialog would otherwise ask for.
257
- private openIncompatibleDocuments = false;
324
+ // See `BrassInitOptions.open`: the mode the session opens documents in.
325
+ private openReadOnly = false;
258
326
  // Doc-selection-in-URL sync. On by default; off via
259
327
  // `syncDocToUrl: false`. Param names configurable via `docUrlParam` /
260
328
  // `branchUrlParam`.
@@ -283,6 +351,10 @@ export class BrassClient {
283
351
  // on `authStatus`.
284
352
  private bootstrapped = false;
285
353
  private listeners = new Set<UserChangeListener>();
354
+ // The session's own listener. It reacts to sign-in and sign-out, and is
355
+ // left out of a guest-mode change: the session starts that change itself,
356
+ // mid-open, and would otherwise begin a second open of the same document.
357
+ private readonly sessionUserListeners = new WeakSet<UserChangeListener>();
286
358
  // Init-failure listeners. Swallow a listener's throw so one bad handler
287
359
  // cannot block the others or mask the original error (already logged).
288
360
  private readonly errorListeners = createEmitter<Error>({ onError: () => {} });
@@ -294,7 +366,7 @@ export class BrassClient {
294
366
  private authEpoch = 0;
295
367
  private initialized = false;
296
368
  // Incognito / third-party-cookie fallback state. Both null in a normal
297
- // browser, where the cross-site `brass_auth` cookie carries the session
369
+ // browser, where the cross-site `__Host-brass_auth` cookie carries the session
298
370
  // and `/refresh` works without a body.
299
371
  // - `pendingAuthCode`: the one-time `brass_code` pulled off the
300
372
  // landing URL by bootstrap(), consumed at most once by refresh() if
@@ -315,13 +387,22 @@ export class BrassClient {
315
387
  private documentSession: DocumentSession | null = null;
316
388
  private hostedUiLauncher: HostedUiLauncher | null = null;
317
389
  private documentFactory: DocumentFactory | null = null;
318
- // The `?brass_link=` token when this page was opened as an anonymous
319
- // link guest; null on the ordinary signed-in path. Set in `init`.
390
+ // The `?brass_link=` token the page's document opens through, whoever is
391
+ // signed in; null when the page is not on a link. Set in `init`.
320
392
  private guestLinkTokenValue: string | null = null;
393
+ private watchingPageRestore = false;
321
394
  // The organization this page was launched from (`?brass_org=`), or null
322
395
  // when the app was reached some other way. A create that names no
323
396
  // destination of its own lands here; see `launch-context.ts`.
324
397
  private launchOrganizationId: string | null = null;
398
+ // The install-consent dialog in flight per organization, so concurrent
399
+ // denials share one. Keyed by organization because that is what an install
400
+ // is granted on, and cleared when the dialog settles so a later denial
401
+ // (a declined install, a second organization) raises its own.
402
+ private readonly installConsentByOrganization = new Map<
403
+ string,
404
+ Promise<boolean>
405
+ >();
325
406
  // The organization this page has resolved to act in, and the name the
326
407
  // server gave it. Held for the life of the page load so repeated acts
327
408
  // resolve once; dropped on sign-out, and replaced by a later switch.
@@ -373,21 +454,67 @@ export class BrassClient {
373
454
  // iframe (`isExportMode`): serializes a handed-in snapshot and never
374
455
  // authenticates. `registerSerializer` forwards to it.
375
456
  private exportGuest: ExportGuestController | null = null;
457
+ // Set only when Brass loaded this bundle to run its importer headless
458
+ // (`isImportMode`). `registerImporter` forwards to it.
459
+ private importGuest: ImportGuestController | null = null;
460
+ private importer: DocumentImporter | null = null;
461
+ // Opens of a file this app imports, waiting for `registerImporter`.
462
+ private readonly importerWaiters: Array<() => void> = [];
463
+ // An open gave up waiting for `registerImporter`, so a registration after
464
+ // it is late and says so once.
465
+ private importerWaitExpired = false;
376
466
  // Open documents by id, so an export request can hand the serializer
377
467
  // the right Document. Dropped when a doc goes; a re-open overwrites.
378
468
  private openDocuments = new Map<string, Document>();
379
469
  // The app's import backend (`Brass.registerImporter`) plus the claim
380
470
  // protocol run per opened document. The deps read the documents
381
471
  // namespace lazily, so attempts (always post-open) build it on demand.
472
+ // A guest imports only the trial document it started from a file, through
473
+ // that document's edit link, which is the page's credential.
382
474
  private readonly importRunner: ImportRunner = createImportRunner({
383
- claimImport: (docId) => this.docsInternal().claimImport(docId),
384
- completeImport: (docId) => this.docsInternal().completeImport(docId),
475
+ claimImport: (docId) => {
476
+ const guest = this.guestImports(docId);
477
+ return guest ? guest.claimImport() : this.docsInternal().claimImport(docId);
478
+ },
479
+ stageImport: (docId, bundle) => {
480
+ const guest = this.guestImports(docId);
481
+ return guest ? guest.stageImport(bundle) : this.docsInternal().stageImport(docId, bundle);
482
+ },
483
+ completeImport: (docId, report, uploadKey) => {
484
+ const guest = this.guestImports(docId);
485
+ return guest
486
+ ? guest.completeImport(report, uploadKey)
487
+ : this.docsInternal().completeImport(docId, report, uploadKey);
488
+ },
489
+ failImport: (docId, message) => {
490
+ const guest = this.guestImports(docId);
491
+ return guest ? guest.failImport(message) : this.docsInternal().failImport(docId, message);
492
+ },
385
493
  reportError: (error) => this.fireError(error),
386
494
  });
495
+
496
+ private guestImports(docId: string): GuestImportCalls | null {
497
+ const token = this.guestLinkTokenValue;
498
+ return token === null ? null : this.getDocumentFactory().guestImports(token, docId);
499
+ }
500
+
501
+ // Whether this page runs a registered importer for an open document: one
502
+ // whose join offers this app the import (`awaits_import`), or whose file is
503
+ // still being stored, which the runner waits out before it claims. Any other
504
+ // document has nothing to import, and the platform refuses a claim on it.
505
+ // A guest imports only into a trial document it started from a file (a trial
506
+ // carries an expiry).
507
+ private importsInto(doc: Document): boolean {
508
+ const offered = doc._awaitsImport || doc._workStatusNow?.pending === true;
509
+ if (this.guestLinkTokenValue === null) return offered;
510
+ return doc.expiresAt !== null && offered;
511
+ }
387
512
  // One socket carrying every document topic this client subscribes,
388
513
  // built on first open so an app that never opens a document never
389
514
  // connects.
390
515
  private docWsClient: BrassWsClient | null = null;
516
+ // One socket per link a document on this page opened through, by token.
517
+ private readonly linkWsClients = new Map<string, BrassWsClient>();
391
518
 
392
519
  // init ALWAYS resolves: failures (bad/missing app_id) fire onError +
393
520
  // console.error rather than rejecting. Otherwise
@@ -401,6 +528,7 @@ export class BrassClient {
401
528
  // the click handler that opens a surface, so it would miss the press that
402
529
  // opened it and have nothing to hand focus back to on dismissal.
403
530
  trackHostedUiActivations();
531
+ this.initFailure = null;
404
532
  // The WASM this package runs on is fetched from here, so the download
405
533
  // overlaps the auth round-trip below and is usually ready before anything
406
534
  // asks for it. It is deliberately NOT awaited here: an app's sign-in
@@ -481,9 +609,7 @@ export class BrassClient {
481
609
  this.apiBaseUrlConfigured = urls.apiBaseUrlConfigured;
482
610
  this.wsBaseUrl = urls.wsBaseUrl;
483
611
  this._dashboardBaseUrl = urls.dashboardBaseUrl;
484
- if (options.openIncompatibleDocuments === true) {
485
- this.openIncompatibleDocuments = true;
486
- }
612
+ this.openReadOnly = options.open?.readOnly === true;
487
613
  if (options.hostedUiTheme) {
488
614
  this.hostedUiTheme = options.hostedUiTheme;
489
615
  }
@@ -510,23 +636,80 @@ export class BrassClient {
510
636
  if (this.serializer) this.exportGuest.setSerializer(this.serializer);
511
637
  return;
512
638
  }
639
+ if (typeof window !== 'undefined' && isImportMode(window.location.search)) {
640
+ this.importGuest = startImportGuest();
641
+ if (this.importer) this.importGuest.setImporter(this.importer);
642
+ return;
643
+ }
513
644
  const guestToken =
514
645
  typeof window !== 'undefined'
515
646
  ? readGuestLinkToken(window.location.search)
516
647
  : null;
517
648
  if (guestToken) {
518
- // Anonymous "anyone with the link" open. The token IS the
519
- // credential, so there is no auth bootstrap: the session opens the
520
- // document read-only through it. `authStatus` reports `'guest'` and
521
- // the finally block's initial `onUserChange` emit drives the
522
- // read-only boot (the session's guest-aware handler calls start()).
649
+ // Only a sign-in this tab started from this token claims a document
650
+ // created without an account, and only in the browser holding the
651
+ // mint's claim secret. `brass_code` on the URL is not enough: the
652
+ // cookie session resolves whether or not the code is real, so a
653
+ // crafted link carrying one would claim into whoever opens it. A
654
+ // stored hint or session pointer says nothing about whether the
655
+ // person meant to keep THIS document either, so a signed-in browser
656
+ // opening the link cold stays signed in and leaves the document
657
+ // temporary; the strip's Keep is the deliberate act that claims it.
658
+ //
659
+ // Who the person is comes from the session either way. The link is
660
+ // the credential for this document alone: its reads, writes and
661
+ // socket use the link, and a link open carries the person's own
662
+ // credential beside it (`currentLinkCredential`).
663
+ if (signInReturned()) {
664
+ const appId = this.requireAppId();
665
+ const claimSecret =
666
+ takeClaimIntent(appId) === guestToken
667
+ ? readClaimSecret(appId, guestToken)
668
+ : null;
669
+ await this.bootstrap();
670
+ if (this.user !== null) {
671
+ const anonymous = await this.probeAnonymousLink(guestToken);
672
+ const claim =
673
+ anonymous !== null && claimSecret !== null
674
+ ? { ...anonymous, claimSecret }
675
+ : null;
676
+ if (anonymous !== null && claim === null) {
677
+ this.reportUncatchable(
678
+ new BrassError(
679
+ 'Only the browser that started this work can keep it. It stays open here until it expires.',
680
+ ),
681
+ );
682
+ }
683
+ if (claim !== null) {
684
+ if (await this.claimAnonymousLink(claim)) {
685
+ await this.hydrateProfile();
686
+ return;
687
+ }
688
+ // Refused, or the person declined to place it: `claimAnonymousLink`
689
+ // has entered guest mode on the token.
690
+ return;
691
+ }
692
+ }
693
+ } else {
694
+ await this.bootstrap();
695
+ }
696
+ // The token is the document's credential: the session opens the
697
+ // document through it. `authStatus` reports the person (`'guest'`
698
+ // with nobody signed in) and the finally block's initial
699
+ // `onUserChange` emit drives the boot (the session's guest-aware
700
+ // handler calls start()).
523
701
  this.guestLinkTokenValue = guestToken;
702
+ await this.hydrateProfile();
524
703
  return;
525
704
  }
526
705
  await this.bootstrap();
527
706
  await this.hydrateProfile();
528
707
  } catch (err) {
529
708
  const error = err instanceof Error ? err : new Error(String(err));
709
+ // A failure before `initialized` is the app's configuration (a missing
710
+ // or malformed app id, a hosted bundle without its runtime config), so
711
+ // it holds for the page's life and the session reports it as its error.
712
+ if (!this.initialized) this.initFailure = new BrassError(error.message);
530
713
  this.reportUncatchable(error);
531
714
  // Deliberately do not re-throw: callers using
532
715
  // `void Brass.init(...).then(setup)` should see .then() fire so the
@@ -549,10 +732,28 @@ export class BrassClient {
549
732
  // mean the person is signed out, and an app that reads it as that
550
733
  // renders a signed-out UI or calls `signIn()`, which navigates the
551
734
  // frame away from the export it was loaded to run.
552
- if (this.exportGuest === null) {
735
+ if (!this.isHeadlessGuest()) {
736
+ // Before anything opens: bytes kept on this device for whoever used
737
+ // it last are dropped unless that was this same identity.
738
+ adoptSealedCacheOwner(this.user?.sub ?? null);
553
739
  for (const listener of Array.from(this.listeners)) {
554
740
  listener(this.user);
555
741
  }
742
+ if (this.initialized && !this.watchingPageRestore) {
743
+ this.watchingPageRestore = true;
744
+ onPageRestored(() => void this.reloadIfRestoredStale());
745
+ }
746
+ if (this.user !== null && this.appId && typeof window !== 'undefined') {
747
+ checkManifestDrift({
748
+ appId: this.appId,
749
+ origin: window.location.origin,
750
+ apiFetch: createApiFetch({
751
+ apiBaseUrl: this.apiBaseUrl,
752
+ getAccessToken: () => this.getAccessToken(),
753
+ getAppAssertion: () => this.getAppAssertion(),
754
+ }),
755
+ });
756
+ }
556
757
  }
557
758
  }
558
759
  }
@@ -569,7 +770,9 @@ export class BrassClient {
569
770
  if (!this.initialized || !this.appId) {
570
771
  this.reportUncatchable(
571
772
  new Error(
572
- this.initialized
773
+ this.initFailure
774
+ ? this.initFailure.message
775
+ : this.initialized
573
776
  ? 'Brass.init was called without an app ID'
574
777
  : 'Call Brass.init(appId) first',
575
778
  ),
@@ -583,7 +786,8 @@ export class BrassClient {
583
786
  // An export frame stays where it is. It runs a serializer over bytes
584
787
  // Brass handed it and never calls the API, so navigating to sign-in
585
788
  // would abandon the export mid-flight for a session it has no use for.
586
- if (this.exportGuest !== null) return;
789
+ if (this.isHeadlessGuest()) return;
790
+ if (this.guestLinkTokenValue !== null) markClaimIntent(appId, this.guestLinkTokenValue);
587
791
  window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
588
792
  }
589
793
 
@@ -635,6 +839,10 @@ export class BrassClient {
635
839
  // load's resolution go with the session.
636
840
  clearActingOrganization(appId);
637
841
  this.retireActingOrganizationState();
842
+ // And the guest documents this browser made, with the secrets that claim
843
+ // them, so the next person here is neither offered one nor able to keep it.
844
+ clearUnsavedWork(appId);
845
+ clearClaimSecret(appId);
638
846
  const signedOutUrl = buildSignedOutOfAppUrl(this.authBaseUrl, appId, window.location.href);
639
847
  // On a Brass-hosted origin, route the sign-out through the edge's
640
848
  // /__brass/leave so the first-party brass_site gate cookie is cleared
@@ -671,8 +879,8 @@ export class BrassClient {
671
879
  // render from it.
672
880
  get authStatus(): AuthStatus {
673
881
  if (!this.bootstrapped) return 'unknown';
674
- if (this.guestLinkTokenValue !== null) return 'guest';
675
- return this.user !== null ? 'signed-in' : 'signed-out';
882
+ if (this.user !== null) return 'signed-in';
883
+ return this.guestLinkTokenValue !== null ? 'guest' : 'signed-out';
676
884
  }
677
885
 
678
886
  // The organization this app is acting in, as far as this page load already
@@ -688,7 +896,7 @@ export class BrassClient {
688
896
  }
689
897
 
690
898
  get isGuest(): boolean {
691
- return this.guestLinkTokenValue !== null;
899
+ return this.authStatus === 'guest';
692
900
  }
693
901
 
694
902
  // The resolved dashboard origin after `init` (the option's value if
@@ -707,7 +915,7 @@ export class BrassClient {
707
915
  // a flicker through the initial `null`. Post-init subscribers get an
708
916
  // immediate emit with the current value, the usual "register and
709
917
  // read current state" idiom.
710
- if (this.bootstrapped && this.exportGuest === null) {
918
+ if (this.bootstrapped && !this.isHeadlessGuest()) {
711
919
  listener(this.user);
712
920
  }
713
921
  return () => {
@@ -745,6 +953,8 @@ export class BrassClient {
745
953
  apiBaseUrl: this.apiBaseUrl,
746
954
  getAccessToken: () => this.getAccessToken(),
747
955
  getAppAssertion: () => this.getAppAssertion(),
956
+ getSocket: () =>
957
+ this.appId === null ? undefined : this.getDocWsClient(this.appId),
748
958
  });
749
959
  }
750
960
  return this.documentsNamespace;
@@ -759,13 +969,36 @@ export class BrassClient {
759
969
  this.documentSession = createDocumentSession({
760
970
  isSignedIn: () => this.user !== null,
761
971
  authStatus: () => this.authStatus,
762
- onUserChange: (listener) =>
763
- this.onUserChange((user) => listener(user !== null)),
972
+ initFailure: () => this.initFailure,
973
+ onUserChange: (listener) => {
974
+ const wrapped: UserChangeListener = (user) => listener(user !== null);
975
+ this.sessionUserListeners.add(wrapped);
976
+ return this.onUserChange(wrapped);
977
+ },
764
978
  openDocumentById: (docId, options, consent) =>
765
- this.openDocumentInternal(docId, options, consent),
979
+ this.asOwnAccess(() => this.openDocumentInternal(docId, options, consent)),
766
980
  guestLinkToken: () => this.guestLinkTokenValue,
981
+ openReadOnly: () => this.openReadOnly,
767
982
  openDocumentByLink: (token, options) =>
768
983
  this.openByLinkInternal(token, options),
984
+ mintAnonymousDocument: (args) =>
985
+ this.getDocumentFactory().mintAnonymousDocument(args),
986
+ enterGuestMode: (token) => this.enterGuestMode(token),
987
+ chooseAnonymousFile: () => this.chooseAnonymousFile(),
988
+ signedOutUseRefusal: () => this.signedOutUseRefusal(),
989
+ appOffersNew: () => this.appOffersNew(),
990
+ mintAnonymousDocumentFromFile: (args) =>
991
+ this.getDocumentFactory().mintAnonymousDocumentFromFile(args),
992
+ unsavedWork: () => this.readLiveUnsavedWork(),
993
+ clearUnsavedWork: () => {
994
+ if (this.appId !== null) clearUnsavedWork(this.appId);
995
+ },
996
+ leaveGuestMode: () => this.leaveGuestMode(),
997
+ followClaimedLink: (token) => this.followClaimedLink(token),
998
+ markTrialEdited: (token, expiresAt) => markTrialEdited(token, expiresAt),
999
+ trialEdited: (token) => trialWasEdited(token),
1000
+ confirmReplaceTrial: (expiresAt) => askReplaceTrial(expiresAt),
1001
+ signIn: () => this.signIn(),
769
1002
  createDocumentId: (args) => this.createDocumentIdWithConsent(args),
770
1003
  selectDocument: () => this.selectDocumentInternal(),
771
1004
  selectCreateDestination: (options) =>
@@ -875,7 +1108,15 @@ export class BrassClient {
875
1108
  onUserChange: (listener) => this.onUserChange(listener),
876
1109
  signIn: () => this.signIn(),
877
1110
  signOut: () => this.signOut(),
878
- dashboardBaseUrl: this._dashboardBaseUrl,
1111
+ canKeep: () => this.canKeepTemporaryWork(),
1112
+ keep: () => {
1113
+ void this.keepTemporaryWork();
1114
+ },
1115
+ // Both environment-dependent values are read at use rather than copied
1116
+ // in here: a piece can be mounted before `init()` resolves, and until it
1117
+ // does these hold the production defaults. See `AccountMenuDeps`.
1118
+ dashboardBaseUrl: () => this._dashboardBaseUrl,
1119
+ appId: () => this.appId ?? undefined,
879
1120
  getActingOrganization: () => this.visibleActingOrganization(),
880
1121
  subscribeActingOrganization: (listener) => {
881
1122
  this.actingOrganizationListeners.add(listener);
@@ -892,9 +1133,10 @@ export class BrassClient {
892
1133
  void this.switchActingOrganization();
893
1134
  },
894
1135
  getDocument: (docId) => this.documents.get(docId),
1136
+ onLink: () => this.guestLinkTokenValue !== null,
895
1137
  // The version-preview bar is a hosted embed; it takes the same theme
896
1138
  // as the other hosted surfaces.
897
- hostedUiTheme: this.hostedUiTheme,
1139
+ hostedUiTheme: () => this.hostedUiTheme,
898
1140
  };
899
1141
  }
900
1142
 
@@ -922,6 +1164,33 @@ export class BrassClient {
922
1164
  return result;
923
1165
  }
924
1166
 
1167
+ // Opens the hosted picker for a SET of documents and resolves with every
1168
+ // one the person chose: `[]` when they took out everything `selected`
1169
+ // held, `null` when they dismissed it. The set is confined
1170
+ // to one organization. `options.accept` narrows what the picker offers to
1171
+ // this request alone, which is how an app that has read a file names the
1172
+ // siblings it is missing; `options.prompt` says why it is asking; and
1173
+ // `options.selected` names what the app already holds, so a later call
1174
+ // edits that set and resolves with the whole of it.
1175
+ //
1176
+ // Opening is unchanged: pass each ref's `doc_id` to `openDocumentById`. The
1177
+ // URL carries one document, so a set is the app's to hold; nothing here
1178
+ // writes to it. Throws before init.
1179
+ async chooseInputs(
1180
+ families: DocumentFamilies,
1181
+ options?: ChooseInputsOptions,
1182
+ ): Promise<InputReference[] | null> {
1183
+ return this.getHostedUiLauncher().chooseInputs(families, options);
1184
+ }
1185
+
1186
+ // One file as a reference, from the ordinary Open list. Throws before init.
1187
+ async chooseInput(
1188
+ families: DocumentFamilies,
1189
+ options?: ChooseInputOptions,
1190
+ ): Promise<InputReference | null> {
1191
+ return this.getHostedUiLauncher().chooseInput(families, options);
1192
+ }
1193
+
925
1194
  // The value the most recent `selectDocument()` resolved with
926
1195
  // (per-app sessionStorage), or `null` if nothing was picked this
927
1196
  // tab. The session's boot restore reads it as the bare-URL
@@ -1014,7 +1283,15 @@ export class BrassClient {
1014
1283
  docId: string,
1015
1284
  options?: OpenDocumentByIdOptions,
1016
1285
  ): Promise<OpenedDocument> {
1017
- const opened = await this.openDocumentInternal(docId, options);
1286
+ // A frozen copy is read beside whatever the page shows, usually as one of
1287
+ // many, so it claims no URL and raises no consent dialog of its own.
1288
+ if (options?.frozen === true) {
1289
+ return this.reopensBeside(
1290
+ await this.openDocumentInternal(docId, options, 'skip', true),
1291
+ options,
1292
+ );
1293
+ }
1294
+ const opened = this.reopensBeside(await this.openDocumentInternal(docId, options), options);
1018
1295
  // The URL names the document the reader is looking at, and the session
1019
1296
  // owns that whenever it holds one: this open is detached, so claiming the
1020
1297
  // URL would leave the address bar naming a document the session is not
@@ -1025,6 +1302,32 @@ export class BrassClient {
1025
1302
  return opened;
1026
1303
  }
1027
1304
 
1305
+ // `doc.reopen` for a copy this method opened: the same open in the asked
1306
+ // mode, on the branch the copy is on, after which this copy closes.
1307
+ private reopensBeside(
1308
+ doc: OpenedDocument,
1309
+ options: OpenDocumentByIdOptions | undefined,
1310
+ ): OpenedDocument {
1311
+ doc._setReopener(async (mode) => {
1312
+ const { readOnly: _readOnly, frozen: _frozen, version: _version, ...rest } = options ?? {};
1313
+ const { readOnly: _alias, readOnlyReason: _reason, ...documentOptions } =
1314
+ rest.documentOptions ?? {};
1315
+ // A copy opened at a stream carries the stream's line as `branchId`,
1316
+ // which the join refuses as a branch, so it reopens on the branch the
1317
+ // open named.
1318
+ const branch = rest.stream !== undefined ? rest.branch : (doc.branchId ?? undefined);
1319
+ const next = await this.openDocumentById(doc.id, {
1320
+ ...rest,
1321
+ documentOptions,
1322
+ ...(branch !== undefined ? { branch } : {}),
1323
+ ...(mode === 'frozen' ? { frozen: true } : { readOnly: mode === 'read' }),
1324
+ });
1325
+ doc.close();
1326
+ return next;
1327
+ });
1328
+ return doc;
1329
+ }
1330
+
1028
1331
  // Open a document READ-ONLY from a public-link token (the "anyone with
1029
1332
  // the link" anonymous path). No sign-in: the token is the credential and
1030
1333
  // the server serves a read-only, member-stripped snapshot with no live
@@ -1034,35 +1337,197 @@ export class BrassClient {
1034
1337
  token: string,
1035
1338
  options?: OpenDocumentByIdOptions,
1036
1339
  ): Promise<OpenedDocument> {
1037
- const factory = this.getDocumentFactory();
1038
- const opened = await factory.openByLink(token, options);
1039
- this.trackOpenDocument(opened);
1340
+ const opened = await this.importedByLink(await this.joinByLink(token, options), token, options);
1341
+ // A document the platform will remove was created without an account,
1342
+ // and its token is the only way back to it once this tab is gone. Every
1343
+ // guest open of one lands here (a create while signed out, the boot of
1344
+ // its URL, a shared link to one), so this is where the app's signed-out
1345
+ // gate learns of it (`session.unsavedWork`).
1346
+ // A token this browser has claimed is no longer unsaved work, whatever
1347
+ // the open answered: a page the browser kept from before the claim can
1348
+ // still open through it.
1349
+ const appId = this.requireAppId();
1350
+ if (opened.expiresAt !== null && readClaimedLink(appId, token) === null) {
1351
+ storeUnsavedWork(appId, token, opened.expiresAt);
1352
+ }
1040
1353
  return opened;
1041
1354
  }
1042
1355
 
1356
+ private async joinByLink(
1357
+ token: string,
1358
+ options: OpenDocumentByIdOptions | undefined,
1359
+ ): Promise<OpenedDocument> {
1360
+ const opened = await this.getDocumentFactory().openByLink(token, options);
1361
+ // A link the person opened as themselves was tracked by that open.
1362
+ if (this.openDocuments.get(opened.id) !== opened) this.trackOpenDocument(opened);
1363
+ return opened;
1364
+ }
1365
+
1366
+ // A trial document started from a file is imported by this page before the
1367
+ // open resolves, as `openDocumentInternal` imports a member's: an open whose
1368
+ // import does not land rejects rather than resolving the empty document.
1369
+ private async importedByLink(
1370
+ opened: OpenedDocument,
1371
+ token: string,
1372
+ options: OpenDocumentByIdOptions | undefined,
1373
+ ): Promise<OpenedDocument> {
1374
+ if (!opened._awaitsImport || !this.importsInto(opened)) return opened;
1375
+ if (await this.importBeforeResolving(opened, options?.frozen === true, options?.onSnapshotProgress)) {
1376
+ return opened;
1377
+ }
1378
+ const imported = await this.joinByLink(token, options);
1379
+ if (!imported._awaitsImport) return imported;
1380
+ imported._reopen();
1381
+ imported.close();
1382
+ throw notImported(opened);
1383
+ }
1384
+
1043
1385
  // The open path shared by the public `openDocumentById` and the
1044
1386
  // session. It does NOT write the URL: the public method pins the id
1045
1387
  // (`replaceState`) for direct multi-doc callers, while the session
1046
1388
  // owns its own push / replace history writes so a single-doc open
1047
1389
  // becomes a back-navigable entry without the two layers double-writing.
1390
+ //
1391
+ // A file this app imports and nobody has imported yet is imported before the
1392
+ // open resolves, in every mode. The platform refuses the open of a file only
1393
+ // another app imports (`not_imported`), so a join that still awaits an
1394
+ // import names this app as the importer.
1048
1395
  private async openDocumentInternal(
1049
1396
  docId: string,
1050
1397
  options?: OpenDocumentByIdOptions,
1051
1398
  consent: 'offer' | 'skip' = 'offer',
1399
+ // A copy read beside whatever the page shows (one of a set a pass reads,
1400
+ // or a frozen copy) is not the session's copy of its id, so it is not
1401
+ // tracked as the open copy of that id.
1402
+ beside = false,
1403
+ ): Promise<OpenedDocument> {
1404
+ const opened = await this.joinDocument(docId, options, consent, beside);
1405
+ if (!opened._awaitsImport || !this.importsInto(opened)) return opened;
1406
+ if (await this.importBeforeResolving(opened, options?.frozen === true, options?.onSnapshotProgress)) {
1407
+ return opened;
1408
+ }
1409
+ const imported = await this.joinDocument(docId, options, 'skip', beside);
1410
+ if (!imported._awaitsImport) return imported;
1411
+ // The import ended without landing (the claim was refused, or this client
1412
+ // stopped waiting on another window's), which the app's error handler has
1413
+ // already been told.
1414
+ imported._reopen();
1415
+ imported.close();
1416
+ throw notImported(opened);
1417
+ }
1418
+
1419
+ // Runs this app's importer on a document opened before its import, and
1420
+ // resolves once the import has ended: true when the copy now holds the
1421
+ // import (a frozen copy, reloaded in place), false when the open joins again
1422
+ // for it. Rejects with the import's error when it failed.
1423
+ private async importBeforeResolving(
1424
+ doc: OpenedDocument,
1425
+ frozen: boolean,
1426
+ progress: OpenDocumentByIdOptions['onSnapshotProgress'],
1427
+ ): Promise<boolean> {
1428
+ // An open that ran before the app registered its importer waits for it.
1429
+ if (this.importer === null && !(await this.waitForImporter(doc))) {
1430
+ if (doc._isLive) {
1431
+ doc._reopen();
1432
+ doc.close();
1433
+ }
1434
+ // The message reaches the app's users, so the cause goes to the console.
1435
+ console.warn(
1436
+ `[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.`,
1437
+ );
1438
+ const refused = new BrassError(NOT_IMPORTED_MESSAGE, { status: 0, code: 'not_imported' });
1439
+ importerWaitRejections.add(refused);
1440
+ throw refused;
1441
+ }
1442
+ // A tracked copy had its attempt when it was tracked or when the importer
1443
+ // was registered, so a call for the same open is a no-op. A frozen copy
1444
+ // takes the import under the same handles.
1445
+ void this.importRunner.attempt(doc, frozen ? { reloadInPlace: true } : undefined);
1446
+ await new Promise<void>((resolve) => {
1447
+ const stops: Array<() => void> = [];
1448
+ let settled = false;
1449
+ const done = (): void => {
1450
+ if (settled) return;
1451
+ settled = true;
1452
+ queueMicrotask(() => stops.forEach((stop) => stop()));
1453
+ resolve();
1454
+ };
1455
+ stops.push(doc.onGone(done));
1456
+ stops.push(
1457
+ doc.onStatus((status) => {
1458
+ // A settled status from the work that stored the file starts the
1459
+ // import, whose hold is set before this listener hears that status.
1460
+ if (!status.pending && doc._importHold === null) done();
1461
+ // The import's own progress is what the opener shows while it waits.
1462
+ else progress?.(0, null, 'importing', status.message);
1463
+ }),
1464
+ );
1465
+ });
1466
+ if (doc._isLive && doc._contentLoadFailed) {
1467
+ const message = doc._workStatusNow?.message;
1468
+ doc._reopen();
1469
+ doc.close();
1470
+ throw new BrassError(message !== undefined && message !== '' ? message : IMPORT_FAILED_MESSAGE, {
1471
+ status: 0,
1472
+ });
1473
+ }
1474
+ if (frozen && doc._isLive && doc._goneReason === null) {
1475
+ // A reload the importer started is shared.
1476
+ await doc.reload();
1477
+ if (doc._isLive) return true;
1478
+ }
1479
+ // Retired as moved rather than parked, so the join that follows builds a
1480
+ // copy from the imported content instead of taking this empty one back.
1481
+ if (doc._isLive) {
1482
+ doc._reopen();
1483
+ doc.close();
1484
+ }
1485
+ return false;
1486
+ }
1487
+
1488
+ // Resolves true when `registerImporter` runs, false at the ceiling.
1489
+ private waitForImporter(doc: OpenedDocument): Promise<boolean> {
1490
+ return new Promise<boolean>((resolve) => {
1491
+ const warning = inDevelopment()
1492
+ ? setTimeout(() => {
1493
+ const dot = doc.name?.lastIndexOf('.') ?? -1;
1494
+ const type = dot > 0 ? `${doc.name!.slice(dot)} files` : 'this file';
1495
+ console.warn(
1496
+ `[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.`,
1497
+ );
1498
+ }, IMPORTER_WAIT_WARN_MS)
1499
+ : undefined;
1500
+ const settle = (registered: boolean): void => {
1501
+ clearTimeout(warning);
1502
+ clearTimeout(ceiling);
1503
+ const at = this.importerWaiters.indexOf(waiter);
1504
+ if (at !== -1) this.importerWaiters.splice(at, 1);
1505
+ resolve(registered);
1506
+ };
1507
+ const waiter = (): void => settle(true);
1508
+ const ceiling = setTimeout(() => {
1509
+ this.importerWaitExpired = true;
1510
+ settle(false);
1511
+ }, IMPORTER_WAIT_CEILING_MS);
1512
+ this.importerWaiters.push(waiter);
1513
+ });
1514
+ }
1515
+
1516
+ private async joinDocument(
1517
+ docId: string,
1518
+ options: OpenDocumentByIdOptions | undefined,
1519
+ consent: 'offer' | 'skip',
1520
+ beside: boolean,
1052
1521
  ): Promise<OpenedDocument> {
1053
1522
  const factory = this.getDocumentFactory();
1054
1523
  // After getDocumentFactory() resolves, requireAppId() has run and
1055
1524
  // this.appId is a string.
1056
1525
  const appId = this.appId as string;
1057
- // An app that declared it opens these carries the answer on every open,
1058
- // so nothing is asked and nothing is refused.
1059
- const withConsent: OpenDocumentByIdOptions | undefined = this
1060
- .openIncompatibleDocuments
1061
- ? { ...options, acceptSchemaConflict: true }
1062
- : options;
1063
1526
  try {
1064
- const opened = await factory.open(docId, withConsent);
1065
- this.trackOpenDocument(opened);
1527
+ const opened = await factory.open(docId, options);
1528
+ // The tracked set is the live copy of each document, keyed by id; a
1529
+ // copy read beside it must not replace it there.
1530
+ if (!beside) this.trackOpenDocument(opened);
1066
1531
  return opened;
1067
1532
  } catch (err) {
1068
1533
  // The platform refuses to join a document this app's schema cannot
@@ -1074,10 +1539,10 @@ export class BrassClient {
1074
1539
  const { openAnyway } = await askSchemaConflict(err.message, err.title);
1075
1540
  if (!openAnyway) throw err;
1076
1541
  const opened = await factory.open(docId, {
1077
- ...withConsent,
1542
+ ...options,
1078
1543
  acceptSchemaConflict: true,
1079
1544
  });
1080
- this.trackOpenDocument(opened);
1545
+ if (!beside) this.trackOpenDocument(opened);
1081
1546
  return opened;
1082
1547
  }
1083
1548
  // A denial because the document's org has no install of this app
@@ -1090,7 +1555,7 @@ export class BrassClient {
1090
1555
  // before they touch anything.
1091
1556
  if (consent === 'offer' && (await this.offerInstallConsent(err))) {
1092
1557
  const opened = await factory.open(docId, options);
1093
- this.trackOpenDocument(opened);
1558
+ if (!beside) this.trackOpenDocument(opened);
1094
1559
  return opened;
1095
1560
  }
1096
1561
  if (isAppNotInstalled(err)) throw err;
@@ -1122,7 +1587,9 @@ export class BrassClient {
1122
1587
 
1123
1588
  async createDocument(options?: CreateDocumentOptions): Promise<OpenedDocument> {
1124
1589
  const opened = await this.createDocumentInternal(options);
1125
- this.maybeWriteDocIdToUrl(opened.id);
1590
+ // A document minted without an account is reached through the link
1591
+ // token the URL already carries, never through its id.
1592
+ if (this.guestLinkTokenValue === null) this.maybeWriteDocIdToUrl(opened.id);
1126
1593
  return opened;
1127
1594
  }
1128
1595
 
@@ -1132,18 +1599,34 @@ export class BrassClient {
1132
1599
  options?: CreateDocumentOptions,
1133
1600
  ): Promise<OpenedDocument> {
1134
1601
  const factory = this.getDocumentFactory();
1135
- try {
1136
- const opened = await factory.create(options);
1137
- this.trackOpenDocument(opened);
1138
- return opened;
1139
- } catch (err) {
1140
- if (await this.offerInstallConsent(err)) {
1602
+ // No account: mint the document without one and open it through its
1603
+ // link token, which becomes this page's credential.
1604
+ if (this.authStatus === 'signed-out') {
1605
+ const minted = await factory.mintAnonymousDocument(
1606
+ options?.name !== undefined ? { name: options.name } : {},
1607
+ );
1608
+ this.enterGuestMode(minted.linkToken);
1609
+ return this.openByLinkInternal(
1610
+ minted.linkToken,
1611
+ options?.documentOptions
1612
+ ? { documentOptions: options.documentOptions }
1613
+ : undefined,
1614
+ );
1615
+ }
1616
+ return this.asOwnAccess(async () => {
1617
+ try {
1141
1618
  const opened = await factory.create(options);
1142
1619
  this.trackOpenDocument(opened);
1143
1620
  return opened;
1621
+ } catch (err) {
1622
+ if (await this.offerInstallConsent(err)) {
1623
+ const opened = await factory.create(options);
1624
+ this.trackOpenDocument(opened);
1625
+ return opened;
1626
+ }
1627
+ throw err;
1144
1628
  }
1145
- throw err;
1146
- }
1629
+ });
1147
1630
  }
1148
1631
 
1149
1632
  // Create-only for the session: classify the outcome (created / needs a
@@ -1165,15 +1648,388 @@ export class BrassClient {
1165
1648
  }
1166
1649
  }
1167
1650
 
1651
+ // The claim's install-consent retry, the same reactive install
1652
+ // `createDocumentIdWithConsent` gives a create.
1653
+ private async claimAnonymousDocumentWithConsent(
1654
+ docId: string,
1655
+ args: ClaimAnonymousDocumentArgs,
1656
+ ): Promise<ClaimDocumentResult> {
1657
+ const factory = this.getDocumentFactory();
1658
+ try {
1659
+ return await factory.claimAnonymousDocument(docId, args);
1660
+ } catch (err) {
1661
+ if (await this.offerInstallConsent(err)) {
1662
+ return factory.claimAnonymousDocument(docId, args);
1663
+ }
1664
+ throw err;
1665
+ }
1666
+ }
1667
+
1668
+ // The app's upload-types read, once per page. A failed read is dropped so
1669
+ // the next asks again.
1670
+ private anonymousUploadTypes(): Promise<AnonymousUploadTypes> {
1671
+ if (this.anonymousUploadTypesRead === null) {
1672
+ const read = this.getDocumentFactory().anonymousUploadTypes();
1673
+ this.anonymousUploadTypesRead = read;
1674
+ read.catch(() => {
1675
+ if (this.anonymousUploadTypesRead === read) this.anonymousUploadTypesRead = null;
1676
+ });
1677
+ }
1678
+ return this.anonymousUploadTypesRead;
1679
+ }
1680
+ private anonymousUploadTypesRead: Promise<AnonymousUploadTypes> | null = null;
1681
+
1682
+ // Whether this app offers New, from its row on the platform. A failed read
1683
+ // offers New, and the create reports at the press.
1684
+ private async appOffersNew(): Promise<boolean> {
1685
+ try {
1686
+ return (await this.anonymousUploadTypes()).offersNew;
1687
+ } catch {
1688
+ return true;
1689
+ }
1690
+ }
1691
+
1692
+ // The file types a visitor can start a document of this app from.
1693
+ private async anonymousUploadAccept(): Promise<AnonymousUploadAccept> {
1694
+ const read = await this.anonymousUploadTypes();
1695
+ if ('refusal' in read) throw read.refusal;
1696
+ return read.accept;
1697
+ }
1698
+
1699
+ // The refusal this app meets without an account, read off the same
1700
+ // upload-types read a signed-out pick makes, so a later Upload reuses it.
1701
+ // Only the `sign_in_required` answer withdraws New and Open; any
1702
+ // other failure leaves them to report at the press.
1703
+ private async signedOutUseRefusal(): Promise<BrassError | null> {
1704
+ try {
1705
+ await this.anonymousUploadAccept();
1706
+ return null;
1707
+ } catch (err) {
1708
+ return err instanceof BrassError && err.code === 'sign_in_required' ? err : null;
1709
+ }
1710
+ }
1711
+
1712
+ // The file a signed-out pick starts a document from. An app that can start
1713
+ // from no file type refuses rather than opening a dialog every file in
1714
+ // which would be refused. The types are read at the press, inside the
1715
+ // several seconds a browser lets that press open a file dialog.
1716
+ private async chooseAnonymousFile(): Promise<File | null> {
1717
+ const { accept, maxBytes } = await this.anonymousUploadAccept();
1718
+ if (accept === '') {
1719
+ throw new BrassError('This app cannot open a file without an account. Sign in to open one.', {
1720
+ status: 400,
1721
+ });
1722
+ }
1723
+ const file = await chooseFile(accept);
1724
+ if (file !== null && file.size > maxBytes) {
1725
+ const mb = Math.floor(maxBytes / (1024 * 1024));
1726
+ throw new BrassError(
1727
+ `That file is too large to open without an account (limit ${mb} MB). Sign in to import it.`,
1728
+ { status: 400 },
1729
+ );
1730
+ }
1731
+ return file;
1732
+ }
1733
+
1734
+ // Make a link token the open document's credential, with the URL carrying
1735
+ // it so a reload and a sign-in both return to the document. With nobody
1736
+ // signed in `authStatus` reads `'guest'`; a signed-in person who did not
1737
+ // keep the document stays signed in.
1738
+ private enterGuestMode(token: string): void {
1739
+ const credentialChanged = this.guestLinkTokenValue !== token;
1740
+ this.guestLinkTokenValue = token;
1741
+ writeGuestLinkToken(token);
1742
+ // A guest socket is admitted to the one document its link names, so a
1743
+ // socket opened under another credential cannot carry this one.
1744
+ if (credentialChanged) this.docWsClient?.reconnect();
1745
+ this.emitGuestModeChange();
1746
+ }
1747
+
1748
+ // `authStatus` moved between 'signed-out' (or 'signed-in') and 'guest'
1749
+ // without a sign-in or sign-out, which is still an auth change to an app
1750
+ // that renders its shells off `onUserChange`.
1751
+ private emitGuestModeChange(): void {
1752
+ // With someone signed in, a link coming or going leaves `authStatus` as it
1753
+ // was, so there is nothing to announce.
1754
+ if (!this.bootstrapped || this.isHeadlessGuest() || this.user !== null) return;
1755
+ for (const listener of Array.from(this.listeners)) {
1756
+ if (this.sessionUserListeners.has(listener)) continue;
1757
+ listener(this.user);
1758
+ }
1759
+ }
1760
+
1761
+ // Take a token back out of the page's credential and URL after it failed
1762
+ // to open, so `authStatus` reads `'signed-out'` again. Only the session's
1763
+ // continue path reaches this, from a page that held no session.
1764
+ private leaveGuestMode(): void {
1765
+ this.guestLinkTokenValue = null;
1766
+ clearGuestLinkToken();
1767
+ this.emitGuestModeChange();
1768
+ }
1769
+
1770
+ // Where a refused link leads this person, when their own claim revoked it.
1771
+ // For the claimant the page leaves the link signed in, with the document's
1772
+ // id on the URL. A page with no session, in the browser that made the
1773
+ // claim, leaves the link signed out with the id on the URL, so the sign-in
1774
+ // the app offers returns to the document. Anyone else keeps the refusal.
1775
+ private async followClaimedLink(token: string): Promise<ClaimedLinkTarget> {
1776
+ if (this.guestLinkTokenValue !== token) return null;
1777
+ const tokens = this.tokens;
1778
+ if (this.user === null || tokens === null) {
1779
+ const claimedHere = this.appId === null ? null : readClaimedLink(this.appId, token);
1780
+ if (claimedHere === null) return null;
1781
+ this.forgetUnsavedWorkFor(token);
1782
+ this.leaveClaimedLink(claimedHere);
1783
+ this.emitGuestModeChange();
1784
+ return 'sign-in';
1785
+ }
1786
+ const docId = await this.readClaimedLink(token, tokens).catch(() => null);
1787
+ if (docId === null || this.guestLinkTokenValue !== token) return null;
1788
+ if (this.appId !== null) storeClaimedLink(this.appId, token, docId);
1789
+ this.forgetUnsavedWorkFor(token);
1790
+ this.leaveClaimedLink(docId);
1791
+ await this.hydrateProfile();
1792
+ this.docWsClient?.reconnect();
1793
+ this.emitGuestModeChange();
1794
+ return { docId };
1795
+ }
1796
+
1797
+ // The way back to unsaved work, unless its token is one this browser has
1798
+ // claimed: that work has an owner and a document id, and a page that opened
1799
+ // the token from before the claim may have written it back.
1800
+ private readLiveUnsavedWork(): UnsavedWorkEntry | null {
1801
+ if (this.appId === null) return null;
1802
+ const entry = readUnsavedWork(this.appId);
1803
+ if (entry === null || readClaimedLink(this.appId, entry.token) === null) return entry;
1804
+ clearUnsavedWork(this.appId);
1805
+ return null;
1806
+ }
1807
+
1808
+ private forgetUnsavedWorkFor(token: string): void {
1809
+ if (this.appId !== null && readUnsavedWork(this.appId)?.token === token) {
1810
+ clearUnsavedWork(this.appId);
1811
+ }
1812
+ }
1813
+
1814
+ private leaveClaimedLink(docId: string): void {
1815
+ this.guestLinkTokenValue = null;
1816
+ clearGuestLinkToken();
1817
+ this.maybeWriteDocIdToUrl(docId);
1818
+ }
1819
+
1820
+ private async readClaimedLink(token: string, tokens: TokenSet): Promise<string | null> {
1821
+ const headers: Record<string, string> = {
1822
+ authorization: `Bearer ${tokens.accessToken}`,
1823
+ };
1824
+ applyClientVersionHeader(headers);
1825
+ if (tokens.appAssertion) headers['x-brass-app-assertion'] = tokens.appAssertion;
1826
+ const response = await fetch(
1827
+ `${this.apiBaseUrl}/links/${encodeURIComponent(token)}/claimed`,
1828
+ { method: 'GET', headers },
1829
+ );
1830
+ if (!response.ok) return null;
1831
+ const body = (await response.json()) as { doc_id?: unknown };
1832
+ return typeof body.doc_id === 'string' && body.doc_id !== '' ? body.doc_id : null;
1833
+ }
1834
+
1835
+ // A page back from the back-forward cache, checked against what may have
1836
+ // moved while it was frozen; see `page-restore.ts`.
1837
+ private async reloadIfRestoredStale(): Promise<void> {
1838
+ const appId = this.appId;
1839
+ if (appId === null) return;
1840
+ const stale = await restoredPageIsStale(
1841
+ {
1842
+ guestLinkToken: this.guestLinkTokenValue,
1843
+ signedIn: this.user !== null,
1844
+ sessionHinted: readLoginHint(appId),
1845
+ },
1846
+ (token) => this.linkStillOpen(token),
1847
+ );
1848
+ if (stale) window.location.reload();
1849
+ }
1850
+
1851
+ private async linkStillOpen(token: string): Promise<boolean | null> {
1852
+ try {
1853
+ const response = await fetch(
1854
+ `${this.apiBaseUrl}/links/${encodeURIComponent(token)}/view`,
1855
+ { method: 'GET', cache: 'no-store' },
1856
+ );
1857
+ if (response.ok) return true;
1858
+ return response.status === 403 || response.status === 404 || response.status === 410
1859
+ ? false
1860
+ : null;
1861
+ } catch {
1862
+ return null;
1863
+ }
1864
+ }
1865
+
1866
+ // Whether the signed-in person can keep the temporary document this page is
1867
+ // on: only the browser that made it holds the secret its claim takes.
1868
+ private canKeepTemporaryWork(): boolean {
1869
+ const token = this.guestLinkTokenValue;
1870
+ if (token === null || this.user === null || this.appId === null) return false;
1871
+ if (this.session.document?.expiresAt == null) return false;
1872
+ return readClaimSecret(this.appId, token) !== null;
1873
+ }
1874
+
1875
+ // Keep the temporary document this page is on, signed in, without leaving
1876
+ // the page: the claim a sign-in from the page would make, then the document
1877
+ // opened by id. The claim evicts the link's own connections, so the page
1878
+ // leaves the link before it reopens rather than following the eviction.
1879
+ private async keepTemporaryWork(): Promise<boolean> {
1880
+ const token = this.guestLinkTokenValue;
1881
+ if (token === null || this.user === null || !this.canKeepTemporaryWork()) return false;
1882
+ const appId = this.requireAppId();
1883
+ const claimSecret = readClaimSecret(appId, token);
1884
+ const anonymous = await this.probeAnonymousLink(token);
1885
+ if (claimSecret === null || anonymous === null) return false;
1886
+ if (!(await this.claimAnonymousLink({ ...anonymous, claimSecret }))) return false;
1887
+ this.leaveLinkForOwnAccess();
1888
+ try {
1889
+ await this.session.open(anonymous.docId);
1890
+ } catch (err) {
1891
+ this.reportUncatchable(err instanceof Error ? err : new Error(String(err)));
1892
+ }
1893
+ return true;
1894
+ }
1895
+
1896
+ // A signed-in person on a link page who opens or creates another document
1897
+ // does so as themselves, so the page stops being on the link: the URL drops
1898
+ // it and the socket reconnects with their own credential.
1899
+ private leaveLinkForOwnAccess(): void {
1900
+ if (this.guestLinkTokenValue === null || this.user === null) return;
1901
+ this.guestLinkTokenValue = null;
1902
+ clearGuestLinkToken();
1903
+ }
1904
+
1905
+ // Run an open or create the person makes as themselves, off the link. One
1906
+ // that fails leaves the page where it was, still on the link: the document
1907
+ // it shows is the link's, and the URL is what brings a reload back to it.
1908
+ private async asOwnAccess<T>(run: () => Promise<T>): Promise<T> {
1909
+ const token = this.user !== null ? this.guestLinkTokenValue : null;
1910
+ this.leaveLinkForOwnAccess();
1911
+ try {
1912
+ return await run();
1913
+ } catch (err) {
1914
+ if (token !== null && this.guestLinkTokenValue === null && this.user !== null) {
1915
+ this.guestLinkTokenValue = token;
1916
+ writeGuestLinkToken(token);
1917
+ }
1918
+ throw err;
1919
+ }
1920
+ }
1921
+
1922
+ // The signed-in person's credential, carried beside a link on its open: an
1923
+ // app that opens links only for people signed in to it asks for it. Read
1924
+ // through `getAccessToken`, which renews a token set near its expiry.
1925
+ private async currentLinkCredential(): Promise<LinkCredential | null> {
1926
+ if (this.user === null || (await this.getAccessToken()) === null) return null;
1927
+ const tokens = this.tokens;
1928
+ if (this.user === null || tokens === null || tokens.appAssertion === undefined) return null;
1929
+ return { accessToken: tokens.accessToken, appAssertion: tokens.appAssertion };
1930
+ }
1931
+
1932
+ // Whether a link token names a document created without an account, read
1933
+ // off `GET /links/{token}/view` (no snapshot, no credential). Null for an
1934
+ // ordinary share link, and for a token the server no longer answers.
1935
+ private async probeAnonymousLink(
1936
+ token: string,
1937
+ ): Promise<Omit<AnonymousLinkClaim, 'claimSecret'> | null> {
1938
+ try {
1939
+ const response = await fetch(
1940
+ `${this.apiBaseUrl}/links/${encodeURIComponent(token)}/view`,
1941
+ { method: 'GET' },
1942
+ );
1943
+ if (!response.ok) return null;
1944
+ const body = (await response.json()) as {
1945
+ doc_id?: unknown;
1946
+ anonymous?: unknown;
1947
+ name?: unknown;
1948
+ };
1949
+ if (body.anonymous !== true || typeof body.doc_id !== 'string') {
1950
+ return null;
1951
+ }
1952
+ return {
1953
+ token,
1954
+ docId: body.doc_id,
1955
+ ...(typeof body.name === 'string' ? { name: body.name } : {}),
1956
+ };
1957
+ } catch {
1958
+ return null;
1959
+ }
1960
+ }
1961
+
1962
+ // Claim a document created without an account into the signed-in person's
1963
+ // organization, through the placement loop a create runs. On success the
1964
+ // claimed id replaces the token in the URL and is stashed as the last
1965
+ // selection, so the session's restore and an app reading `?doc=` both
1966
+ // find it. A refused or dismissed claim reports through `onError` and
1967
+ // enters guest mode on the token, editable until it expires; a reload
1968
+ // carries no code, so it does not claim again.
1969
+ private async claimAnonymousLink(claim: AnonymousLinkClaim): Promise<boolean> {
1970
+ const appId = this.requireAppId();
1971
+ let chosenName: string | undefined;
1972
+ let result: ClaimDocumentResult | null = null;
1973
+ try {
1974
+ result = await placeThroughHostedSurfaces(
1975
+ this.getHostedUiLauncher(),
1976
+ (destination) =>
1977
+ this.claimAnonymousDocumentWithConsent(claim.docId, {
1978
+ linkToken: claim.token,
1979
+ claimSecret: claim.claimSecret,
1980
+ ...(destination !== undefined ? { destination } : {}),
1981
+ ...(chosenName !== undefined ? { name: chosenName } : {}),
1982
+ }),
1983
+ claim.name,
1984
+ (chosen) => {
1985
+ chosenName = chosen.name;
1986
+ },
1987
+ );
1988
+ } catch (err) {
1989
+ this.reportUncatchable(err instanceof Error ? err : new Error(String(err)));
1990
+ }
1991
+ if (result === null) {
1992
+ this.enterGuestMode(claim.token);
1993
+ return false;
1994
+ }
1995
+ clearGuestLinkToken();
1996
+ // Claimed, the document is reached by id like any other, so there is
1997
+ // no unsaved work to come back to.
1998
+ clearUnsavedWork(appId);
1999
+ clearClaimSecret(appId, claim.token);
2000
+ storeClaimedLink(appId, claim.token, claim.docId);
2001
+ this.maybeWriteDocIdToUrl(claim.docId);
2002
+ if (result.record) storeLastDocument(appId, result.record);
2003
+ return true;
2004
+ }
2005
+
1168
2006
  // The `app_not_installed` denial names the org; open the hosted
1169
2007
  // install-consent dialog for it so the person decides: an admin (or a
1170
2008
  // member of an `open`-policy org) installs, a member under `request`
1171
2009
  // sends the admin request from the same dialog. Nothing is installed
1172
2010
  // or requested implicitly. Resolves true only when an install landed,
1173
2011
  // in which case the caller retries the denied call once.
1174
- private async offerInstallConsent(err: unknown): Promise<boolean> {
1175
- if (!isAppNotInstalled(err) || !err.organizationId) return false;
2012
+ private offerInstallConsent(err: unknown): Promise<boolean> {
2013
+ if (!isAppNotInstalled(err) || !err.organizationId) return Promise.resolve(false);
1176
2014
  const organizationId = err.organizationId;
2015
+ // One dialog per organization, however many calls were refused. An app
2016
+ // that opens a SET hits the same denial once per document, and each raise
2017
+ // is a modal: the reader answers the one on top and finds another behind
2018
+ // it, for work they already consented to. The install a dialog lands is
2019
+ // the whole answer for every caller waiting on it, and each still retries
2020
+ // its own denied call afterwards.
2021
+ const inFlight = this.installConsentByOrganization.get(organizationId);
2022
+ if (inFlight) return inFlight;
2023
+ const raised = this.raiseInstallConsent(organizationId).finally(() => {
2024
+ this.installConsentByOrganization.delete(organizationId);
2025
+ });
2026
+ this.installConsentByOrganization.set(organizationId, raised);
2027
+ return raised;
2028
+ }
2029
+
2030
+ // The dialog itself, held apart so the memo above is the only thing that
2031
+ // decides whether one opens.
2032
+ private async raiseInstallConsent(organizationId: string): Promise<boolean> {
1177
2033
  try {
1178
2034
  const result = await this.getHostedUiLauncher().installApp(organizationId);
1179
2035
  if (result === null) {
@@ -1232,7 +2088,7 @@ export class BrassClient {
1232
2088
  // Register the app's export backend. The platform calls it when a
1233
2089
  // hosted surface downloads an app-owned (blob) document: it receives
1234
2090
  // the open Document and returns the source file's bytes, the inverse of
1235
- // the parse the app ran on `doc.sourceBlob()`. The app never invokes it
2091
+ // the app's importer. The app never invokes it
1236
2092
  // and never delivers the bytes itself; the platform stages + downloads
1237
2093
  // what it returns. One serializer per app.
1238
2094
  registerSerializer(serializer: DocumentSerializer): void {
@@ -1242,20 +2098,39 @@ export class BrassClient {
1242
2098
  this.exportGuest?.setSerializer(serializer);
1243
2099
  }
1244
2100
 
1245
- // Register the app's import backend: parses a stored source file into
1246
- // the document's records. The SDK claims the import with the platform
1247
- // on each open, so exactly one client runs it; the other open clients
1248
- // receive the records live. One importer per app; a later registration
2101
+ // Register the app's import backend, `(file) => { streams, report? }`.
2102
+ // The SDK claims the import with the platform on each open, so exactly one
2103
+ // client runs it, and the platform seals what it returns as the document's
2104
+ // first content. One importer per app; a later registration
1249
2105
  // replaces the earlier. Documents already open (registration raced the
1250
- // first open) get their attempt here.
2106
+ // first open) get their attempt here, and so do inputs whose open stopped
2107
+ // waiting for it.
1251
2108
  registerImporter(importer: DocumentImporter): void {
2109
+ this.importer = importer;
1252
2110
  this.importRunner.setImporter(importer);
1253
- if (this.guestLinkTokenValue !== null) return;
2111
+ for (const registered of this.importerWaiters.splice(0)) registered();
2112
+ if (this.importGuest !== null) {
2113
+ this.importGuest.setImporter(importer);
2114
+ return;
2115
+ }
2116
+ if (this.importerWaitExpired) {
2117
+ this.importerWaitExpired = false;
2118
+ console.warn(
2119
+ `[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.`,
2120
+ );
2121
+ }
1254
2122
  for (const doc of this.liveOpenDocuments()) {
1255
- void this.importRunner.attempt(doc);
2123
+ if (this.importsInto(doc)) void this.importRunner.attempt(doc);
2124
+ doc._importerRegistered();
1256
2125
  }
1257
2126
  }
1258
2127
 
2128
+ // A bundle Brass loaded to run its serializer or importer headless holds
2129
+ // no session and emits none.
2130
+ private isHeadlessGuest(): boolean {
2131
+ return this.exportGuest !== null || this.importGuest !== null;
2132
+ }
2133
+
1259
2134
  // Track an opened document so an export request can resolve its id to
1260
2135
  // the live Document. A re-open overwrites.
1261
2136
  private trackOpenDocument(doc: OpenedDocument): void {
@@ -1266,11 +2141,8 @@ export class BrassClient {
1266
2141
  }
1267
2142
  });
1268
2143
  // Every open path funnels through here, so this is where a registered
1269
- // importer gets its per-document attempt. A guest open is read-only
1270
- // with no API credential, so no import runs for it.
1271
- if (this.guestLinkTokenValue === null) {
1272
- void this.importRunner.attempt(doc);
1273
- }
2144
+ // importer gets its per-document attempt.
2145
+ if (this.importsInto(doc)) void this.importRunner.attempt(doc);
1274
2146
  }
1275
2147
 
1276
2148
  // The tracked documents still open, dropping the rest as it goes.
@@ -1322,7 +2194,7 @@ export class BrassClient {
1322
2194
  const serialized = await this.serializeForExport(docId);
1323
2195
  if (!serialized.ok) {
1324
2196
  throw new BrassError(
1325
- `This app could not write the document out to publish it: ${serialized.message}`,
2197
+ `This app could not write this out as a file to publish it: ${serialized.message}`,
1326
2198
  { status: 400 },
1327
2199
  );
1328
2200
  }
@@ -1349,7 +2221,7 @@ export class BrassClient {
1349
2221
  this.liveOpenDocument(docId) ??
1350
2222
  (sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
1351
2223
  if (!doc) {
1352
- return { ok: false, code: 'no_document', message: 'Document is not open.' };
2224
+ return { ok: false, code: 'no_document', message: 'This is not open in the app.' };
1353
2225
  }
1354
2226
  try {
1355
2227
  // A serializer runs detached, with no subscription to have been fed by,
@@ -1400,8 +2272,11 @@ export class BrassClient {
1400
2272
  apiBaseUrl: this.apiBaseUrl,
1401
2273
  getAccessToken: () => this.getAccessToken(),
1402
2274
  getAppAssertion: () => this.getAppAssertion(),
2275
+ getLinkCredential: () => this.currentLinkCredential(),
2276
+ openAsMember: (docId, options) => this.openDocumentInternal(docId, options, 'skip'),
1403
2277
  identity: () => this.user?.sub ?? null,
1404
2278
  ...(wsClient ? { wsClient } : {}),
2279
+ linkWsClient: (token) => this.getLinkWsClient(appId, token),
1405
2280
  branchProvider: {
1406
2281
  list: (docId) => docsInternal.listBranches(docId),
1407
2282
  create: (docId, input) => docsInternal.createBranch(docId, input),
@@ -1414,11 +2289,20 @@ export class BrassClient {
1414
2289
  create: (docId, branchId, name) =>
1415
2290
  docsInternal.createStream(docId, branchId, name),
1416
2291
  },
1417
- thumbnailUploader: (doc, image, width, height) =>
1418
- docsNs.setThumbnail(doc, { image, width, height }),
1419
- checkpointCreator: (doc) => docsInternal.createCheckpoint(doc),
2292
+ thumbnailUploader: (doc, image, width, height, version) =>
2293
+ version === undefined
2294
+ ? docsNs.setThumbnail(doc, { image, width, height })
2295
+ : docsInternal.setThumbnailAt(doc, { image, width, height }, version),
2296
+ checkpointCreator: (doc, anchor) =>
2297
+ docsInternal.createCheckpoint(doc, anchor),
1420
2298
  drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
1421
2299
  sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
2300
+ provisionalInputsPoster: (docId, body, opts) =>
2301
+ docsInternal.postProvisionalInputs(docId, body, opts),
2302
+ inputsRuntime: (docId) => ({
2303
+ readKey: (key, query) => docsInternal.readInputKeyStatus(docId, key, query),
2304
+ open: (inputId, options) => this.openDocumentById(inputId, options),
2305
+ }),
1422
2306
  hostedUiProvider: {
1423
2307
  documentHistory: (docId, options) =>
1424
2308
  this.documentHistory(docId, options),
@@ -1451,7 +2335,7 @@ export class BrassClient {
1451
2335
  // the active-doc swap consistent (URL sync, command list
1452
2336
  // re-render, branch-list refresh on the new doc).
1453
2337
  openSiblingDoc: async (docId, options) => {
1454
- await this.session.open(docId, options);
2338
+ await this.session._openSibling(docId, options);
1455
2339
  },
1456
2340
  selectBranch: (docId, options) =>
1457
2341
  this.getHostedUiLauncher().selectBranch(docId, options),
@@ -1481,18 +2365,33 @@ export class BrassClient {
1481
2365
  wsBaseUrl: this.wsBaseUrl,
1482
2366
  appId,
1483
2367
  getAccessToken: () => this.getAccessToken(),
1484
- // Anonymous link guest: with no access token the socket connects with
1485
- // this link token and the server admits a read-only doc subscription.
1486
- getGuestLinkToken: () => this.guestLinkTokenValue,
1487
- // Attests the socket with the same assertion the HTTP calls carry so
1488
- // the server enforces Layer 2 on op-write; a first-party client
1489
- // resolves null here and connects un-gated.
2368
+ // With nobody signed in the page socket connects with the link token;
2369
+ // a signed-in person's carries their own credential, and a document
2370
+ // opened through a link uses that link's socket (`getLinkWsClient`).
2371
+ getGuestLinkToken: () => (this.user === null ? this.guestLinkTokenValue : null),
2372
+ // Attests the socket with the same assertion the HTTP calls carry.
1490
2373
  getAppAssertion: () => this.getAppAssertion(),
1491
2374
  fireError: (error) => this.fireError(error),
1492
2375
  });
1493
2376
  return this.docWsClient;
1494
2377
  }
1495
2378
 
2379
+ private getLinkWsClient(appId: string, token: string): BrassWsClient | undefined {
2380
+ if (!this.wsBaseUrl) return undefined;
2381
+ let client = this.linkWsClients.get(token);
2382
+ if (client === undefined) {
2383
+ client = new BrassWsClient({
2384
+ wsBaseUrl: this.wsBaseUrl,
2385
+ appId,
2386
+ getAccessToken: () => Promise.resolve(null),
2387
+ getGuestLinkToken: () => token,
2388
+ fireError: (error) => this.fireError(error),
2389
+ });
2390
+ this.linkWsClients.set(token, client);
2391
+ }
2392
+ return client;
2393
+ }
2394
+
1496
2395
  // Resolves null only when the server answered that this app has no live
1497
2396
  // session, and rejects with a 503 `BrassError` when a `/refresh` found no
1498
2397
  // answer at all. The two outcomes drive opposite handling: null is the
@@ -1502,13 +2401,11 @@ export class BrassClient {
1502
2401
  // blipped, and hides the transient from every layer that self-heals on it.
1503
2402
  async getAccessToken(): Promise<string | null> {
1504
2403
  this.requireInit();
1505
- // On a `?brass_link=` page the link token is the credential and `init`
1506
- // runs no auth bootstrap, so answer null rather than probing /refresh.
1507
- // A visitor with no session would draw a 401 the browser logs; one who
1508
- // holds a session for this app would get a real token, and a caller
1509
- // that prefers a token over the link token (the socket does) would then
1510
- // present membership where the page's whole authority is the link.
1511
- if (this.guestLinkTokenValue !== null) return null;
2404
+ // A `?brass_link=` page with nobody signed in answers null rather than
2405
+ // probing /refresh, which would draw a 401 the browser logs. A signed-in
2406
+ // person's token serves what they do as themselves; the link document's
2407
+ // own requests and the socket carry the link instead.
2408
+ if (this.guestLinkTokenValue !== null && this.user === null) return null;
1512
2409
  const current = this.tokens;
1513
2410
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1514
2411
  return current.accessToken;
@@ -1656,7 +2553,8 @@ export class BrassClient {
1656
2553
  // document picked through the hosted picker already named its organization
1657
2554
  // on the way in, which answers with no request at all.
1658
2555
  private activeDocumentOrganizationId(): Promise<string | null> {
1659
- // A link guest holds no API credential, so there is nothing to read with.
2556
+ // A document opened through a link is read through the link alone, so its
2557
+ // record is not the person's to read.
1660
2558
  if (this.guestLinkTokenValue !== null) return Promise.resolve(null);
1661
2559
  const docId = this.session.document?.id;
1662
2560
  if (!docId) return Promise.resolve(null);
@@ -1979,10 +2877,9 @@ export class BrassClient {
1979
2877
 
1980
2878
  async getAppAssertion(): Promise<string | null> {
1981
2879
  this.requireInit();
1982
- // Paired with `getAccessToken`: a link guest holds no token to assert
1983
- // an acting app against, and reaching /refresh here would authenticate
1984
- // the page the same way.
1985
- if (this.guestLinkTokenValue !== null) return null;
2880
+ // Paired with `getAccessToken`: a link page with nobody signed in holds
2881
+ // no token to assert an acting app against.
2882
+ if (this.guestLinkTokenValue !== null && this.user === null) return null;
1986
2883
  const current = this.tokens;
1987
2884
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1988
2885
  return current.appAssertion ?? null;
@@ -1997,16 +2894,15 @@ export class BrassClient {
1997
2894
  const appId = this.requireAppId();
1998
2895
  // Every successful sign-in 302s back to return_to with the
1999
2896
  // platform-session cookie set AND a one-time `brass_code` query
2000
- // parameter. The cookie
2001
- // is the primary channel; the code is the incognito / Safari ITP /
2002
- // post-third-party-cookie fallback for when the browser drops the
2003
- // cross-site cookie on our /refresh XHR.
2897
+ // parameter. The first refresh exchanges the code with the cookie
2898
+ // alongside, and the server answers in cookie mode when the cookie came
2899
+ // with it, or hands back the fallback pointer when the browser dropped
2900
+ // it (incognito / Safari ITP / post-third-party-cookie browsers).
2004
2901
  //
2005
2902
  // Pull the code off the URL immediately (before any /refresh)
2006
2903
  // and strip it via history.replaceState so it never lingers in the
2007
2904
  // address bar, browser history, or a referrer header. It is held in
2008
- // memory and consumed at most once, only if the cookie refresh
2009
- // turns out to fail (see refresh()).
2905
+ // memory and consumed at most once (see runRefresh()).
2010
2906
  //
2011
2907
  // No app-controlled silent-mint path: the SDK never auto-fires
2012
2908
  // Brass.signIn() based on a URL hint. New app approvals always
@@ -2114,12 +3010,12 @@ export class BrassClient {
2114
3010
  return this.inflightRefresh;
2115
3011
  }
2116
3012
 
2117
- // Picks the credential channel for a single /refresh round-trip. The
2118
- // cookie path is primary and untouched for browsers that send the
2119
- // third-party cookie; the one-time code is consumed only when that
2120
- // cookie path resolved no session. Once a fallback session
2121
- // token is in hand (the cross-site cookie is proven unavailable for
2122
- // this page's life), every later refresh uses it directly.
3013
+ // Picks the credential channel for a single /refresh round-trip. A landed
3014
+ // one-time code goes first, on the same credentialed request as the cookie,
3015
+ // so a browser that drops the cross-site cookie signs in on one request
3016
+ // rather than after a cookie refresh it was always going to refuse. Once a
3017
+ // fallback session token is in hand (the cross-site cookie is proven
3018
+ // unavailable for this page's life), every later refresh uses it directly.
2123
3019
  //
2124
3020
  // A rung falls through to the next only on `no_session`. Each one discards
2125
3021
  // a credential as it passes (the stale sid, the one-time code), and an
@@ -2140,15 +3036,15 @@ export class BrassClient {
2140
3036
  this.sessionToken = null;
2141
3037
  clearSessionToken(appId);
2142
3038
  }
2143
- const viaCookie = await refreshTokens(this.authBaseUrl, appId);
2144
- if (viaCookie.status !== 'no_session') return viaCookie;
2145
- // Cookie channel resolved no session. Fall back to the one-time code if
2146
- // we landed with one. Clear it first: it is single-use server-side, so a
3039
+ // Clear the code before spending it: it is single-use server-side, so a
2147
3040
  // failed exchange must not be retried.
2148
3041
  const code = this.pendingAuthCode;
2149
- if (!code) return viaCookie;
2150
- this.pendingAuthCode = null;
2151
- return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
3042
+ if (code) {
3043
+ this.pendingAuthCode = null;
3044
+ const viaCode = await refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
3045
+ if (viaCode.status !== 'no_session') return viaCode;
3046
+ }
3047
+ return refreshTokens(this.authBaseUrl, appId);
2152
3048
  }
2153
3049
 
2154
3050
  private setTokens(next: TokenSet | null): void {
@@ -2203,8 +3099,10 @@ export class BrassClient {
2203
3099
  // it hears the sign-out and a close PARKS the Document. A parked Document
2204
3100
  // answers the next open of its key out of memory, with no token and no
2205
3101
  // request, so one left behind here is the previous person's document
2206
- // handed to whoever signs in next.
3102
+ // handed to whoever signs in next. Frozen bytes kept on the device go with
3103
+ // it for the same reason.
2207
3104
  if (nextUser === null) clearParkedDocuments();
3105
+ adoptSealedCacheOwner(nextUser?.sub ?? null);
2208
3106
  }
2209
3107
 
2210
3108
  private async hydrateProfile(): Promise<void> {
@@ -2273,6 +3171,18 @@ export class BrassClient {
2273
3171
  // works and the proxy's code went unused). Best-effort: any failure
2274
3172
  // (exotic URL, history API unavailable) just yields null and the SDK
2275
3173
  // falls back to the cookie-only path.
3174
+ // Whether a sign-in this page started has just returned: the auth proxy
3175
+ // appends `brass_code` to `return_to` on every successful sign-in. Read
3176
+ // without consuming, since `bootstrap` spends it.
3177
+ function signInReturned(): boolean {
3178
+ if (typeof window === 'undefined') return false;
3179
+ try {
3180
+ return new URL(window.location.href).searchParams.has('brass_code');
3181
+ } catch {
3182
+ return false;
3183
+ }
3184
+ }
3185
+
2276
3186
  function consumeAuthCodeParam(): string | null {
2277
3187
  try {
2278
3188
  const url = new URL(window.location.href);