@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/session.ts CHANGED
@@ -14,14 +14,18 @@ import type {
14
14
  CommandSurface,
15
15
  Document,
16
16
  DocumentOptions,
17
+ ReopenMode,
17
18
  } from './documents.js';
18
19
  import type {
19
20
  CreateDocumentArgs,
20
21
  CreateDocumentOptions,
21
22
  CreateDocumentResult,
23
+ MintedAnonymousDocument,
22
24
  OpenDocumentByIdOptions,
23
25
  OpenedDocument,
24
26
  } from './open-document.js';
27
+ import type { ReadOnlyDocument } from './read-only-document.js';
28
+ import { placeThroughHostedSurfaces } from './placement.js';
25
29
  import type {
26
30
  CreateDestinationResult,
27
31
  DocumentHistoryResult,
@@ -34,6 +38,7 @@ import type { UrlSelection } from './doc-url-sync.js';
34
38
  import type { AuthStatus } from './types.js';
35
39
  import { BrassError } from './errors.js';
36
40
  import { makeLiveCommand } from './document-commands.js';
41
+ import { appCallbackError } from './subscriber-delivery.js';
37
42
 
38
43
  // A command in `session.commands`. The SDK owns each command's `run` /
39
44
  // `enabled` / `running` plus a `surface` placement hint; the app places each
@@ -49,7 +54,7 @@ export type {
49
54
  // Coarse lifecycle state: 'unknown' (auth not yet determined), 'signed-out',
50
55
  // 'empty' (signed in, no document), 'loading' (open in flight), 'ready',
51
56
  // 'error' (a user-initiated open terminally failed with nothing to fall back
52
- // to). 'unknown' is distinct from 'signed-out' so a status-gated app renders a
57
+ // to, or `Brass.init` rejected its configuration). 'unknown' is distinct from 'signed-out' so a status-gated app renders a
53
58
  // neutral state during boot instead of flashing the sign-in surface at a user
54
59
  // who turns out to be signed in. 'error' is distinct from 'empty' so a
55
60
  // document the user navigated to but cannot open (deleted, or an access the
@@ -68,6 +73,10 @@ export interface SessionOpenOptions {
68
73
  branch?: string;
69
74
  // Open a historical version read-only (`document.readOnly` is true).
70
75
  version?: number;
76
+ /** Open this document read-only (`true`) or for editing (`false`), whatever
77
+ * the mode set at `Brass.init` (`open: { readOnly }`). Unset, that mode
78
+ * decides. */
79
+ readOnly?: boolean;
71
80
  // Forwarded to the Document materialization (e.g. the undo merge
72
81
  // interval). Matches `documentOptions` on `OpenDocumentByIdOptions` /
73
82
  // `CreateDocumentOptions`.
@@ -92,7 +101,8 @@ export interface DocumentSession {
92
101
  // evolve the wording without any app change); an app supplies its own
93
102
  // recovery affordances around it and does not author the copy. `.status`
94
103
  // is there for logging or an override the app absolutely needs. Cleared on
95
- // the next open / create / close.
104
+ // the next open / create / close. Also set, with no `status`, when
105
+ // `Brass.init` rejected its configuration (a missing app id).
96
106
  readonly error: BrassError | null;
97
107
  // While `status` is `'loading'`, a progress message for the open in flight
98
108
  // (e.g. `Downloading... 62%`), or null. Populated as a presigned snapshot
@@ -119,15 +129,19 @@ export interface DocumentSession {
119
129
  // any, else the empty state. Safe before sign-in (resolves null). Also runs
120
130
  // automatically on first sign-in.
121
131
  start(): Promise<Document | null>;
132
+ // With no id, open the hosted picker and make the picked document active;
133
+ // null on dismiss. Signed out, ask for a file instead and open the document
134
+ // made from it with no account, as `create` does for an empty one.
135
+ open(): Promise<Document | null>;
122
136
  // Open a document by id and make it active.
137
+ open(docId: string, options: SessionOpenOptions & { readOnly: true }): Promise<ReadOnlyDocument>;
123
138
  open(docId: string, options?: SessionOpenOptions): Promise<Document>;
124
139
  // Create a document and make it active. Create-in-place: with no explicit
125
140
  // destination it lands as a sibling of the active one. A cold create (no
126
141
  // active doc) in a single-org caller lands loose; a multi-org caller is
127
142
  // asked via the hosted destination dialog, resolving null on dismiss.
128
143
  create(options?: CreateDocumentOptions): Promise<Document | null>;
129
- // Open the hosted picker and make the picked document active; null on
130
- // dismiss.
144
+ /** @deprecated Use `open()` with no id. */
131
145
  pick(): Promise<Document | null>;
132
146
  // Close the active document and return to the empty state.
133
147
  close(): void;
@@ -160,10 +174,19 @@ export interface DocumentSession {
160
174
  * delete request failed, so a later remote delete of the same doc is not
161
175
  * misread as local. */
162
176
  _clearLocalDelete(docId: string): void;
177
+ /** @internal Open what the hosted history and branch surfaces switch to
178
+ * from the bound document, in the mode that document was opened in. */
179
+ _openSibling(docId: string, options?: SessionOpenOptions): Promise<Document>;
163
180
  }
164
181
 
165
182
  // Dependency seam. The session is a pure coordinator over the public SDK
166
183
  // surface; injecting these keeps it unit-testable and breaks the import cycle.
184
+ export type ClaimedLinkTarget = { docId: string } | 'sign-in' | null;
185
+
186
+ // Thrown through a guest open whose link leads its claimant to sign in: the
187
+ // open ends with no document and no error, on the signed-out surface.
188
+ class SignInToFollowLink extends Error {}
189
+
167
190
  export interface DocumentSessionDeps {
168
191
  isSignedIn(): boolean;
169
192
  // The tri-state auth lifecycle, so `status()` reports `'unknown'` during
@@ -226,16 +249,69 @@ export interface DocumentSessionDeps {
226
249
  lastSelectedDocument(): SelectDocumentResult | null;
227
250
  documents: Pick<DocumentsNamespace, 'get' | 'setThumbnail'>;
228
251
  reportError(error: Error): void;
252
+ // The configuration failure that stopped `Brass.init`, or null. Nothing can
253
+ // sign in or open while it holds, so it is the session's error.
254
+ initFailure?(): BrassError | null;
229
255
  // Anonymous "anyone with the link" boot. When the page was opened via a
230
256
  // public-link token (`?brass_link=`), `guestLinkToken` returns it and the
231
257
  // session boots by opening that document read-only through
232
258
  // `openDocumentByLink` instead of the signed-in URL / last-selected
233
259
  // restore. Absent (returns null) in the ordinary signed-in case.
234
260
  guestLinkToken?(): string | null;
261
+ // Whether the app set `open: { readOnly: true }` at init, which opens every
262
+ // document the session opens (other than one it creates) read-only.
263
+ openReadOnly?(): boolean;
235
264
  openDocumentByLink?(
236
265
  token: string,
237
266
  options?: OpenDocumentByIdOptions,
238
267
  ): Promise<OpenedDocument>;
268
+ // A create while signed out mints a document with no account
269
+ // (`POST /anonymous-documents`); `enterGuestMode` then makes its link
270
+ // token the page's credential (`guestLinkToken` returns it, the URL
271
+ // carries it) so the session opens it through `openDocumentByLink`.
272
+ mintAnonymousDocument?(args: {
273
+ name?: string;
274
+ }): Promise<MintedAnonymousDocument>;
275
+ // A pick while signed out asks for a file instead (`chooseAnonymousFile`,
276
+ // the browser's dialog filtered to what the app can start from; null on
277
+ // dismiss) and mints the document from it.
278
+ chooseAnonymousFile?(): Promise<File | null>;
279
+ mintAnonymousDocumentFromFile?(args: {
280
+ file: File;
281
+ onUploadProgress?: (loaded: number, total: number) => void;
282
+ }): Promise<MintedAnonymousDocument>;
283
+ enterGuestMode?(token: string): void;
284
+ // Whether the app may be used without an account, read once the page is
285
+ // signed out: the refusal the server answers for an app that is not
286
+ // public, or null. New and Open are offered until it answers.
287
+ signedOutUseRefusal?(): Promise<BrassError | null>;
288
+ // Whether this app offers New: false when its published manifest says
289
+ // `"access": { "create": false }`.
290
+ appOffersNew?(): Promise<boolean>;
291
+ // The last document created without an account on this app, as the
292
+ // client stashed it when a guest open resolved one (`readUnsavedWork`):
293
+ // null once expired or cleared. `clearUnsavedWork` forgets it after the
294
+ // link is refused for good, and `leaveGuestMode` takes the token back
295
+ // out of the page's credential and URL after such a refusal, so the
296
+ // session reports signed out again rather than a guest with nothing open.
297
+ unsavedWork?(): { token: string; expiresAt: Date } | null;
298
+ clearUnsavedWork?(): void;
299
+ leaveGuestMode?(): void;
300
+ // A link the platform refused with 410 may be one this person's own claim
301
+ // revoked. `followClaimedLink` takes the page out of guest mode for the
302
+ // account that claimed it, signed in, and resolves the document's id; in the
303
+ // browser that claimed it with no session, signed out and `'sign-in'`; and
304
+ // null for anyone else, leaving the page the guest it was.
305
+ followClaimedLink?(token: string): Promise<ClaimedLinkTarget>;
306
+ // New and Upload ask before replacing a document made without an account
307
+ // that this browser has edited (`markTrialEdited` records an edit,
308
+ // `trialEdited` reads it back across reloads). `confirmReplaceTrial` asks,
309
+ // and its `'sign-in'` answer starts `signIn`, which keeps the document by
310
+ // claiming it into the account.
311
+ markTrialEdited?(token: string, expiresAt: Date): void;
312
+ trialEdited?(token: string): boolean;
313
+ confirmReplaceTrial?(expiresAt: Date): Promise<'sign-in' | 'leave' | 'cancel'>;
314
+ signIn?(): void;
239
315
  }
240
316
 
241
317
  // Built-in orphan command labels (no active doc needed). Sentence case, ASCII
@@ -245,6 +321,9 @@ export interface DocumentSessionDeps {
245
321
  const SESSION_LABELS = {
246
322
  new: 'New',
247
323
  open: 'Open...',
324
+ // Without an account there is nothing to browse, so Open takes a file from
325
+ // the reader's own machine.
326
+ upload: 'Upload...',
248
327
  } as const;
249
328
 
250
329
  function toError(err: unknown): Error {
@@ -254,15 +333,19 @@ function toError(err: unknown): Error {
254
333
  // Fallback copy for a failed open with no server message to render (a
255
334
  // network drop, no HTTP response). The HTTP case carries the server's own
256
335
  // message instead, so the platform evolves the wording without an app change.
257
- const OPEN_FAILED_MESSAGE = 'This document could not be opened';
336
+ const OPEN_FAILED_MESSAGE = 'This could not be opened';
337
+ // The session holds a live copy, so a frozen one is opened beside it.
338
+ const FROZEN_IN_SESSION =
339
+ 'The session holds a live copy of its document. Open a frozen copy with Brass.openDocumentById';
340
+ const PREPARING_MESSAGE = 'Preparing for editing...';
258
341
 
259
342
  // Copy parked on `session.error` when the open document is evicted remotely
260
343
  // (a `doc_gone` the local user did not initiate). Matches the wording a
261
- // post-eviction op frame degrades to ("the document is no longer available"),
344
+ // post-eviction op frame degrades to ("this is no longer available"),
262
345
  // so a delete observed live and a fresh visit to the same deleted id read the
263
346
  // same. A reload re-opens the id, hits the server 404, and swaps in the
264
347
  // server-authored copy.
265
- const DOCUMENT_GONE_MESSAGE = 'This document is no longer available';
348
+ const DOCUMENT_GONE_MESSAGE = 'This is no longer available';
266
349
 
267
350
  // The error parked on `session.error` for a failed open, shaped so its
268
351
  // `.message` is always presentable copy an app can render verbatim. An HTTP
@@ -309,6 +392,9 @@ export function createDocumentSession(
309
392
  // changes, so a per-commit `doc.subscribe` that doesn't move a command's
310
393
  // enabled state stays quiet.
311
394
  let lastSignature: string | null = null;
395
+ // Counts the documents bound, so a new copy of the same id (a reopen for
396
+ // editing, a reload) changes the signature and reaches subscribers.
397
+ let bindCount = 0;
312
398
  // Outstanding `whenReady()` promises, settled in `emit()`: resolve once a
313
399
  // document binds, reject if the user signs out before one ever does.
314
400
  const whenReadyPending: Array<{
@@ -345,15 +431,73 @@ export function createDocumentSession(
345
431
  // misread as local.
346
432
  const localDeletes = new Set<string>();
347
433
 
434
+ // Set for the length of an open started without an account (see `status`).
435
+ let accountFreeOpen = false;
436
+
437
+ // Documents the session opened read-only by request, as opposed to ones a
438
+ // reader's access made read-only. A reopen of one (a move, a reload) keeps
439
+ // that mode.
440
+ const readOnlyOpens = new WeakSet<Document>();
441
+
442
+ // The read intent of one open: the caller's ask where it made one, else the
443
+ // mode the app set at init.
444
+ function inMode<T extends OpenDocumentByIdOptions>(
445
+ options: T,
446
+ asked: boolean | undefined,
447
+ ): T {
448
+ const readOnly = asked ?? (deps.openReadOnly?.() === true ? true : undefined);
449
+ return readOnly === undefined ? options : { ...options, readOnly };
450
+ }
451
+
452
+ // Opens that asked to read, keyed by the open's own promise, so `activate`
453
+ // records the document it binds from one in `readOnlyOpens`.
454
+ const readOnlyAsks = new WeakSet<Promise<OpenedDocument>>();
455
+
456
+ function remember(
457
+ opened: Promise<OpenedDocument>,
458
+ options: OpenDocumentByIdOptions,
459
+ ): Promise<OpenedDocument> {
460
+ if (options.readOnly ?? options.documentOptions?.readOnly === true) {
461
+ readOnlyAsks.add(opened);
462
+ }
463
+ return opened;
464
+ }
465
+
466
+ function openById(
467
+ docId: string,
468
+ options: OpenDocumentByIdOptions,
469
+ consent?: 'offer' | 'skip',
470
+ ): Promise<OpenedDocument> {
471
+ const opened =
472
+ consent === undefined
473
+ ? deps.openDocumentById(docId, options)
474
+ : deps.openDocumentById(docId, options, consent);
475
+ return remember(opened, options);
476
+ }
477
+
478
+ function openByLink(
479
+ openGuest: NonNullable<DocumentSessionDeps['openDocumentByLink']>,
480
+ token: string,
481
+ options: OpenDocumentByIdOptions,
482
+ ): Promise<OpenedDocument> {
483
+ return remember(openGuest(token, options), options);
484
+ }
485
+
348
486
  function status(): SessionStatus {
349
487
  // 'unknown' before auth settles; the onUserChange handler re-fires
350
488
  // (start() / emit()) and flips this to its resolved value.
351
489
  if (deps.authStatus() === 'unknown') return 'unknown';
490
+ if (deps.initFailure?.()) return 'error';
352
491
  // A guest (opened via `?brass_link=`) is not signed in but still has a
353
492
  // read-only document to load, so it never reports 'signed-out' (which
354
493
  // would render the app's sign-in surface instead of the document).
355
494
  const isGuest = deps.guestLinkToken?.() != null;
356
- if (!deps.isSignedIn() && !isGuest) return 'signed-out';
495
+ // An open started without an account loads before there is a link to be
496
+ // a guest on (a pick sends its file first), and the visitor is watching
497
+ // that load, not the sign-in surface.
498
+ if (!deps.isSignedIn() && !isGuest && !(loading && accountFreeOpen)) {
499
+ return 'signed-out';
500
+ }
357
501
  if (loading) return 'loading';
358
502
  if (document) return 'ready';
359
503
  if (openError) return 'error';
@@ -374,15 +518,26 @@ export function createDocumentSession(
374
518
  const load = loading ? `${loadingMessage ?? ''}:${loadingProgress ?? ''}` : '';
375
519
  // `openError` message joins the signature so a switch from one failed open
376
520
  // to a different one (same 'error' status, no bound doc) still re-fires.
377
- const err = openError ? `${openError.status ?? ''}:${openError.message}` : '';
521
+ const shown = openError ?? deps.initFailure?.() ?? null;
522
+ const err = shown ? `${shown.status ?? ''}:${shown.message}` : '';
378
523
  // The previewed version and the browsing flag are their own terms because
379
524
  // nothing else in this signature follows them: a preview -> preview step
380
525
  // holds the id, the name, the status and every command's enabled state
381
526
  // (edit affordances are already disabled by the first checkout), so
382
527
  // without these the step is a silent emit and the chrome rendering the
383
528
  // preview keeps showing the version the reader stepped away from.
384
- const ver = `${document?.version ?? ''}:${document?.versionBrowsing ? 1 : 0}`;
385
- return `${status()}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${ver}|${cmds}`;
529
+ const ver = `${document?.previewVersion ?? ''}:${document?.versionBrowsing ? 1 : 0}`;
530
+ // The expiry moves once, to null, when the document is claimed; the
531
+ // chrome naming it has to hear that.
532
+ const expiry = document?.expiresAt?.getTime() ?? '';
533
+ return `${status()}|${bindCount}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${ver}|${expiry}|${cmds}`;
534
+ }
535
+
536
+ // A listener that throws usually leaves the app showing nothing, so the
537
+ // app's `onError` hears about it as well as the console.
538
+ function reportListenerThrow(err: unknown): void {
539
+ console.error('[Brass] session listener threw:', err);
540
+ deps.reportError(appCallbackError(err));
386
541
  }
387
542
 
388
543
  function emit(): void {
@@ -401,7 +556,7 @@ export function createDocumentSession(
401
556
  listener(session);
402
557
  } catch (err) {
403
558
  // A throwing listener must not strand the others.
404
- console.error('[Brass] session listener threw:', err);
559
+ reportListenerThrow(err);
405
560
  }
406
561
  }
407
562
  }
@@ -419,11 +574,13 @@ export function createDocumentSession(
419
574
  );
420
575
  const pending = whenReadyPending.splice(0, whenReadyPending.length);
421
576
  for (const { reject } of pending) reject(error);
422
- } else if (current === 'error' && openError) {
423
- // A user-initiated open failed terminally; reject rather than hang the
424
- // awaiter forever. Same error object the session surfaces on `.error`.
577
+ } else if (current === 'error' && session.error) {
578
+ // A user-initiated open failed terminally, or init could not start;
579
+ // reject rather than hang the awaiter forever. Same error object the
580
+ // session surfaces on `.error`.
581
+ const failure = session.error;
425
582
  const pending = whenReadyPending.splice(0, whenReadyPending.length);
426
- for (const { reject } of pending) reject(openError);
583
+ for (const { reject } of pending) reject(failure);
427
584
  }
428
585
  // No branch for 'empty': it is both the idle pre-open state (an awaiter
429
586
  // legitimately waits for a future open) and the post-close state. A close
@@ -447,7 +604,7 @@ export function createDocumentSession(
447
604
  // active doc), with a session `emit` on each running flip.
448
605
  function buildOrphanCommand(
449
606
  id: string,
450
- label: string,
607
+ label: string | (() => string),
451
608
  group: CommandGroup,
452
609
  surface: CommandSurface,
453
610
  isEnabled: () => boolean,
@@ -463,22 +620,81 @@ export function createDocumentSession(
463
620
  );
464
621
  }
465
622
 
623
+ // New and Open start a document without an account where the app can mint
624
+ // one, so a visitor's own buttons and the menu run the same commands.
625
+ // A failed init leaves the page signed out with nothing either command
626
+ // could reach, so neither is offered.
627
+ const withoutAccount = (): boolean => {
628
+ if (deps.initFailure?.()) return false;
629
+ const status = deps.authStatus();
630
+ return status === 'signed-out' || status === 'guest';
631
+ };
632
+ // The server refuses every account-free route for an app that does not
633
+ // offer signed-out use, so New and Open are withdrawn once it says so
634
+ // rather than offered only to show that refusal. Read lazily, from the
635
+ // first check made while signed out, and settled for the page.
636
+ let signedOutRefusal: BrassError | null = null;
637
+ let signedOutProbe: Promise<void> | null = null;
638
+ const refusedWithoutAccount = (): boolean => {
639
+ if (signedOutProbe === null && deps.signedOutUseRefusal) {
640
+ const read = deps.signedOutUseRefusal;
641
+ signedOutProbe = (async () => {
642
+ try {
643
+ signedOutRefusal = await read();
644
+ } catch {
645
+ signedOutRefusal = null;
646
+ }
647
+ if (signedOutRefusal !== null) emit();
648
+ })();
649
+ }
650
+ return signedOutRefusal !== null;
651
+ };
652
+ const canStartWithoutAccount = (): boolean =>
653
+ withoutAccount() &&
654
+ !refusedWithoutAccount() &&
655
+ deps.mintAnonymousDocument !== undefined &&
656
+ deps.enterGuestMode !== undefined &&
657
+ deps.openDocumentByLink !== undefined;
658
+ const canPickWithoutAccount = (): boolean =>
659
+ canStartWithoutAccount() &&
660
+ deps.chooseAnonymousFile !== undefined &&
661
+ deps.mintAnonymousDocumentFromFile !== undefined;
662
+
663
+ // An app that creates no documents offers no New. Read lazily, from the
664
+ // first check, and settled for the page; until it answers, New is offered.
665
+ let offersNew = true;
666
+ let offersNewProbe: Promise<void> | null = null;
667
+ const appOffersNew = (): boolean => {
668
+ if (offersNewProbe === null && deps.appOffersNew) {
669
+ const read = deps.appOffersNew;
670
+ offersNewProbe = (async () => {
671
+ try {
672
+ offersNew = await read();
673
+ } catch {
674
+ offersNew = true;
675
+ }
676
+ if (!offersNew) emit();
677
+ })();
678
+ }
679
+ return offersNew;
680
+ };
681
+
466
682
  const cmdNew = buildOrphanCommand(
467
683
  'new',
468
684
  SESSION_LABELS.new,
469
685
  'session',
470
686
  'menu',
471
- deps.isSignedIn,
687
+ () => appOffersNew() && (deps.isSignedIn() || canStartWithoutAccount()),
472
688
  async () => {
473
689
  await create();
474
690
  },
475
691
  );
476
692
  const cmdOpen = buildOrphanCommand(
477
693
  'open',
478
- SESSION_LABELS.open,
694
+ () => (withoutAccount() ? SESSION_LABELS.upload : SESSION_LABELS.open),
479
695
  'session',
480
696
  'menu',
481
- deps.isSignedIn,
697
+ () => deps.isSignedIn() || canPickWithoutAccount(),
482
698
  async () => {
483
699
  await pick();
484
700
  },
@@ -521,6 +737,7 @@ export function createDocumentSession(
521
737
  sessionCanvasUnregister = null;
522
738
  }
523
739
  const previous = document;
740
+ if (next !== previous) bindCount += 1;
524
741
  document = next;
525
742
  documentName = name;
526
743
  if (next === null) {
@@ -533,6 +750,19 @@ export function createDocumentSession(
533
750
  // A close failure (already-closed, freed WASM) is non-fatal.
534
751
  }
535
752
  }
753
+ if (next && next !== previous) {
754
+ // `reopen` on the bound copy goes through the session, so the new copy
755
+ // binds in its place. A copy the session no longer holds reopens the
756
+ // way the layer that opened it would.
757
+ const outside = next._reopenerFn;
758
+ next._setReopener((mode) =>
759
+ document === next
760
+ ? reopenBound(next, mode)
761
+ : outside !== null
762
+ ? outside(mode)
763
+ : Promise.reject(new BrassError('This document cannot be reopened', { status: 0 })),
764
+ );
765
+ }
536
766
  if (next) {
537
767
  // `subscribe` fires on every change including local mutations (the
538
768
  // session's listeners drive UI that re-renders on local edits). The
@@ -544,6 +774,7 @@ export function createDocumentSession(
544
774
  // needs to drop a specified-but-default branch; re-sync in place so
545
775
  // the pinned branch settles once the default is known.
546
776
  if (event.kind === 'branches') syncUrlForActive('replace');
777
+ if (event.kind === 'change' && event.source !== 'remote') noteTrialEdit(next);
547
778
  // Take the doc's name whenever it moves. A rename reaches the doc
548
779
  // from the server's `doc_meta` frame, which sets it directly, and
549
780
  // `documentName` is what the emit signature reads: without this the
@@ -580,24 +811,45 @@ export function createDocumentSession(
580
811
  // keeps its topic subscription and its LoroDoc until it is closed, so
581
812
  // every move would otherwise leave one resident for the life of the
582
813
  // page, taking a copy of every op the reopened document receives.
814
+ //
815
+ // A guest holds no access by id, so it re-opens through its link,
816
+ // as its first open did.
817
+ //
818
+ // A copy that is already read-only (a reload onto converted or
819
+ // sealed content, or one discarding a refused change) stays bound
820
+ // until the re-opened one replaces it, so the reader keeps seeing
821
+ // the document instead of a loading surface between two copies of
822
+ // it. Nothing can be written to it in the meantime. An editable one
823
+ // is detached as any other open detaches its predecessor.
583
824
  const branch = urlBranch(next);
584
825
  const evicted = next;
585
- document = null;
586
- documentName = null;
826
+ const keepBound = next.readOnly;
827
+ const mode = readOnlyOpens.has(next) ? { readOnly: true } : {};
828
+ const guestToken = deps.guestLinkToken?.();
829
+ const openGuest = deps.openDocumentByLink;
830
+ if (!keepBound) {
831
+ document = null;
832
+ documentName = null;
833
+ }
587
834
  void activate(
588
835
  (onSnapshotProgress) =>
589
- deps.openDocumentById(
590
- id,
591
- {
592
- ...withDefaults(),
593
- ...(branch ? { branch } : {}),
594
- onSnapshotProgress,
595
- },
596
- 'skip',
597
- ),
836
+ guestToken && openGuest
837
+ ? openByLink(openGuest, guestToken, { ...mode, onSnapshotProgress })
838
+ : openById(
839
+ id,
840
+ {
841
+ ...withDefaults(),
842
+ ...(branch ? { branch } : {}),
843
+ ...mode,
844
+ onSnapshotProgress,
845
+ },
846
+ 'skip',
847
+ ),
598
848
  null,
599
849
  'skip',
600
850
  branch,
851
+ true,
852
+ keepBound,
601
853
  )
602
854
  .catch(() => markGoneRemote())
603
855
  .finally(() => closeQuietly(evicted));
@@ -703,6 +955,8 @@ export function createDocumentSession(
703
955
  async function activate(
704
956
  load: (
705
957
  onSnapshotProgress: (loaded: number, total: number | null) => void,
958
+ onUploadProgress: (loaded: number, total: number) => void,
959
+ releasePrevious: () => void,
706
960
  ) => Promise<OpenedDocument>,
707
961
  knownName: string | null,
708
962
  urlNav: UrlNav = 'push',
@@ -713,9 +967,16 @@ export function createDocumentSession(
713
967
  // self-heal that should settle silently on empty (the user asked for
714
968
  // nothing specific).
715
969
  surfaceOpenError = true,
970
+ // Re-opening the document already bound: it stays bound, and the session
971
+ // stays 'ready', until the new copy replaces it. A failure detaches it as
972
+ // though it had been detached at the start.
973
+ keepBound = false,
974
+ // With `keepBound`: a failure leaves the bound document in place and only
975
+ // rejects the caller.
976
+ keepOnFailure = false,
716
977
  ): Promise<Document> {
717
978
  const generation = ++openGeneration;
718
- loading = true;
979
+ loading = !keepBound;
719
980
  loadingMessage = null;
720
981
  loadingProgress = null;
721
982
  // A new open attempt supersedes any prior failure's error state.
@@ -726,11 +987,13 @@ export function createDocumentSession(
726
987
  // 'loading' until the new doc binds; a failed open restores the previous
727
988
  // binding. The doc-event/gone subscriptions stay attached (`setDocument`
728
989
  // re-points them, and the restore path watches the same doc).
729
- const previousDocument = document;
730
- const previousName = documentName;
990
+ let previousDocument = keepBound ? null : document;
991
+ const previousName = keepBound ? null : documentName;
731
992
  const previousSpecifiedBranch = activeSpecifiedBranch;
732
- document = null;
733
- documentName = null;
993
+ if (!keepBound) {
994
+ document = null;
995
+ documentName = null;
996
+ }
734
997
  // What the join's spilled ops have cost so far, and what the whole
735
998
  // download was declared to be. Carried across the two phases so the pair
736
999
  // reads as one figure. Per open, because a second open of the same
@@ -743,9 +1006,20 @@ export function createDocumentSession(
743
1006
  const onSnapshotProgress = (
744
1007
  loaded: number,
745
1008
  total: number | null,
746
- phase?: 'snapshot' | 'tail',
747
- ): void => {
748
- if (generation !== openGeneration) return;
1009
+ phase?: 'snapshot' | 'tail' | 'preparing' | 'importing',
1010
+ message?: string,
1011
+ ): boolean => {
1012
+ if (generation !== openGeneration) return false;
1013
+ // The platform is making the document editable, or replacing a
1014
+ // reader's content, and the open waits for it with nothing bound
1015
+ // meanwhile. A replacement is described in the platform's own words.
1016
+ if (phase === 'preparing' || phase === 'importing') {
1017
+ loadingProgress = null;
1018
+ loadingMessage =
1019
+ phase === 'preparing' ? PREPARING_MESSAGE : message !== undefined && message !== '' ? message : 'Loading...';
1020
+ emit();
1021
+ return true;
1022
+ }
749
1023
  // One download, reported once. A document whose ops spilled to storage
750
1024
  // fetches them from the join before the snapshot, so a load has two
751
1025
  // stretches of bytes arriving over the network with a reader watching.
@@ -790,6 +1064,7 @@ export function createDocumentSession(
790
1064
  loadingMessage = `Downloading... ${(loaded / 1_000_000).toFixed(1)} MB`;
791
1065
  }
792
1066
  emit();
1067
+ return true;
793
1068
  };
794
1069
  // Give up on this open. A warm swap (previous doc present) rebinds it and
795
1070
  // only rejects the caller, so the working surface is never clobbered; with
@@ -798,6 +1073,13 @@ export function createDocumentSession(
798
1073
  // and read as the app's document-less home. `display` is what the app
799
1074
  // renders; it defaults to the platform's own open-failure copy.
800
1075
  function abandonOpen(err: unknown, display?: BrassError): never {
1076
+ if (keepOnFailure && generation === openGeneration) {
1077
+ loading = false;
1078
+ loadingMessage = null;
1079
+ loadingProgress = null;
1080
+ emit();
1081
+ throw err;
1082
+ }
801
1083
  if (generation === openGeneration) {
802
1084
  loading = false;
803
1085
  loadingMessage = null;
@@ -805,7 +1087,10 @@ export function createDocumentSession(
805
1087
  // 'replace' restores the URL without planting an entry for an open
806
1088
  // that never happened.
807
1089
  activeSpecifiedBranch = previousSpecifiedBranch;
808
- if (surfaceOpenError && previousDocument === null) {
1090
+ if (err instanceof SignInToFollowLink) {
1091
+ // The URL names the document the sign-in returns to.
1092
+ setDocument(previousDocument, previousName, 'skip');
1093
+ } else if (surfaceOpenError && previousDocument === null) {
809
1094
  openError = display ?? toDisplayError(err);
810
1095
  // Leave the URL pointing at the doc that failed ('skip', not the
811
1096
  // 'replace' below that clears it for a null document): the error
@@ -822,12 +1107,33 @@ export function createDocumentSession(
822
1107
  }
823
1108
  throw err;
824
1109
  }
1110
+ // A file the open sends before there is a document to read.
1111
+ const onUploadProgress = (loaded: number, total: number): void => {
1112
+ if (generation !== openGeneration) return;
1113
+ loadingProgress = total > 0 ? Math.min(1, loaded / total) : null;
1114
+ loadingMessage =
1115
+ loadingProgress === null
1116
+ ? 'Uploading...'
1117
+ : `Uploading... ${Math.floor(loadingProgress * 100)}%`;
1118
+ emit();
1119
+ };
1120
+ // A load that moves the page onto another document's link takes the
1121
+ // outgoing document's credential with it, so that document closes then
1122
+ // rather than after the bind, and a failed open has nothing to rebind.
1123
+ const releasePrevious = (): void => {
1124
+ closeQuietly(previousDocument);
1125
+ previousDocument = null;
1126
+ };
825
1127
  let doc: OpenedDocument;
1128
+ let askedToRead = false;
826
1129
  try {
827
- doc = await load(onSnapshotProgress);
1130
+ const opening = load(onSnapshotProgress, onUploadProgress, releasePrevious);
1131
+ askedToRead = readOnlyAsks.has(opening);
1132
+ doc = await opening;
828
1133
  } catch (err) {
829
1134
  abandonOpen(err);
830
1135
  }
1136
+ if (askedToRead) readOnlyOpens.add(doc);
831
1137
  if (generation !== openGeneration) {
832
1138
  // A newer open / close superseded this one. Discard the orphan (so its
833
1139
  // WS subscription doesn't leak) plus the doc this generation detached.
@@ -835,13 +1141,14 @@ export function createDocumentSession(
835
1141
  closeQuietly(previousDocument);
836
1142
  return doc;
837
1143
  }
838
- // A doc opened mid-preparation (drive-stub hydration, upload extraction)
839
- // arrives empty + read-only with a pending blocking status; the SDK
840
- // imports the content and emits the terminal status when the work
841
- // finishes. Hold the session in 'loading' (surfacing the server message)
842
- // and bind only on that terminal status, so the editor's first paint sees
843
- // the content. A pending push is non-blocking and binds immediately.
844
- const work = doc.status;
1144
+ // An open by id resolves with the document's content or rejects, waiting
1145
+ // out any import first. A trial document opened through its link can
1146
+ // arrive empty while the visitor's file is imported, with a pending
1147
+ // blocking status; hold the session in 'loading' (surfacing the server
1148
+ // message) and bind only on the terminal status, so the editor's first
1149
+ // paint sees the content. A pending push is non-blocking and binds
1150
+ // immediately.
1151
+ const work = doc._workStatusNow;
845
1152
  if (work !== null && work.pending && work.blocking !== false) {
846
1153
  loadingMessage = work.message;
847
1154
  loadingProgress = work.progress ?? null;
@@ -884,7 +1191,7 @@ export function createDocumentSession(
884
1191
  if (doc._contentLoadFailed) {
885
1192
  closeQuietly(doc);
886
1193
  const failure = new BrassError(
887
- doc.status?.message ?? OPEN_FAILED_MESSAGE,
1194
+ doc._workStatusNow?.message ?? OPEN_FAILED_MESSAGE,
888
1195
  { status: 0 },
889
1196
  );
890
1197
  abandonOpen(failure, failure);
@@ -923,16 +1230,26 @@ export function createDocumentSession(
923
1230
  const openGuest = deps.openDocumentByLink;
924
1231
  try {
925
1232
  return await activate(
926
- (onSnapshotProgress) => openGuest(guestToken, { onSnapshotProgress }),
1233
+ (onSnapshotProgress) =>
1234
+ openByLink(openGuest, guestToken, inMode({ onSnapshotProgress }, undefined)).catch(
1235
+ async (err: unknown) => {
1236
+ const claimed = await claimedDocumentOf(guestToken, err);
1237
+ if (claimed === null) throw err;
1238
+ if (claimed === 'sign-in') throw new SignInToFollowLink();
1239
+ return openById(claimed.docId, inMode({ onSnapshotProgress }, undefined), 'skip');
1240
+ },
1241
+ ),
927
1242
  null,
928
1243
  'skip',
929
1244
  null,
930
1245
  );
931
- } catch {
1246
+ } catch (err) {
1247
+ forgetUnsavedWorkIfGone(guestToken, err);
932
1248
  return null;
933
1249
  }
934
1250
  }
935
- if (!deps.isSignedIn() || document) return document;
1251
+ if (document) return document;
1252
+ if (!deps.isSignedIn()) return restoreUnsavedWork();
936
1253
  // A pick handed back by the hosted picker's redirect fallback opens
937
1254
  // before the ordinary URL / last-selection restore, and independently of
938
1255
  // `syncDocToUrl`. `takePickSelection` strips the one-shot params as it
@@ -945,12 +1262,15 @@ export function createDocumentSession(
945
1262
  // navigation, so it must not plant a history entry.
946
1263
  return await activate(
947
1264
  (onSnapshotProgress) =>
948
- deps.openDocumentById(
1265
+ openById(
949
1266
  sel.docId,
950
- {
951
- ...(sel.branchId ? { branch: sel.branchId } : {}),
952
- onSnapshotProgress,
953
- },
1267
+ inMode(
1268
+ {
1269
+ ...(sel.branchId ? { branch: sel.branchId } : {}),
1270
+ onSnapshotProgress,
1271
+ },
1272
+ undefined,
1273
+ ),
954
1274
  'skip',
955
1275
  ),
956
1276
  null,
@@ -972,7 +1292,7 @@ export function createDocumentSession(
972
1292
  try {
973
1293
  return await activate(
974
1294
  (onSnapshotProgress) =>
975
- deps.openDocumentById(last.doc_id, { onSnapshotProgress }, 'skip'),
1295
+ openById(last.doc_id, inMode({ onSnapshotProgress }, undefined), 'skip'),
976
1296
  last.name,
977
1297
  'replace',
978
1298
  null,
@@ -988,6 +1308,73 @@ export function createDocumentSession(
988
1308
  return null;
989
1309
  }
990
1310
 
1311
+ // Whether a failed link open says the link itself is gone. The view route
1312
+ // answers `404` for a token it does not know, `410` for one that expired
1313
+ // or was revoked and `403` for one its organization's policy now refuses;
1314
+ // anything else (no response, a shed request) says nothing about the link.
1315
+ function linkGone(err: unknown): boolean {
1316
+ return (
1317
+ err instanceof BrassError &&
1318
+ (err.status === 403 || err.status === 404 || err.status === 410)
1319
+ );
1320
+ }
1321
+
1322
+ // The document a revoked link leads this person to, when their own claim
1323
+ // revoked it. Asked only on a 410, the one refusal a claim produces.
1324
+ async function claimedDocumentOf(token: string, err: unknown): Promise<ClaimedLinkTarget> {
1325
+ if (!(err instanceof BrassError) || err.status !== 410 || !deps.followClaimedLink) {
1326
+ return null;
1327
+ }
1328
+ try {
1329
+ return await deps.followClaimedLink(token);
1330
+ } catch {
1331
+ return null;
1332
+ }
1333
+ }
1334
+
1335
+ // A stashed token the platform no longer answers names nothing to
1336
+ // continue, so it is forgotten where the refusal is heard, whether the
1337
+ // open was the boot of its URL or the restore below.
1338
+ function forgetUnsavedWorkIfGone(token: string, err: unknown): void {
1339
+ if (!linkGone(err)) return;
1340
+ if (deps.unsavedWork?.()?.token === token) deps.clearUnsavedWork?.();
1341
+ }
1342
+
1343
+ // A signed-out boot on a URL that names no document reopens the last
1344
+ // document this browser made without an account on this app, the way a
1345
+ // signed-in boot reopens the last document picked.
1346
+ async function restoreUnsavedWork(): Promise<Document | null> {
1347
+ if (deps.readUrlSelection()) return null;
1348
+ const entry = deps.unsavedWork?.();
1349
+ if (!entry || !deps.enterGuestMode || !deps.openDocumentByLink) return null;
1350
+ const openGuest = deps.openDocumentByLink;
1351
+ const { token } = entry;
1352
+ // The same path a reload of the document's URL takes: the token becomes
1353
+ // the page's credential and the URL carries it, then the link opens.
1354
+ deps.enterGuestMode(token);
1355
+ try {
1356
+ return await activate(
1357
+ (onSnapshotProgress) =>
1358
+ openByLink(
1359
+ openGuest,
1360
+ token,
1361
+ inMode({ ...withDefaults({}), onSnapshotProgress }, undefined),
1362
+ ),
1363
+ null,
1364
+ 'skip',
1365
+ );
1366
+ } catch (err) {
1367
+ // The token did not open, so it is not this page's credential after
1368
+ // all: the page is signed out again, and the stash is kept unless the
1369
+ // link is gone for good, so the next boot tries it again.
1370
+ deps.leaveGuestMode?.();
1371
+ openError = null;
1372
+ forgetUnsavedWorkIfGone(token, err);
1373
+ emit();
1374
+ return null;
1375
+ }
1376
+ }
1377
+
991
1378
  function start(): Promise<Document | null> {
992
1379
  if (startPromise) return startPromise;
993
1380
  startPromise = doStart().finally(() => {
@@ -1005,20 +1392,133 @@ export function createDocumentSession(
1005
1392
  docId: string,
1006
1393
  options?: SessionOpenOptions,
1007
1394
  ): Promise<Document> {
1395
+ if ((options as { frozen?: unknown } | undefined)?.frozen === true) {
1396
+ return Promise.reject(new BrassError(FROZEN_IN_SESSION, { status: 0 }));
1397
+ }
1398
+ const asked = options?.readOnly ?? options?.documentOptions?.readOnly;
1008
1399
  return activate(
1009
1400
  (onSnapshotProgress) =>
1010
- deps.openDocumentById(docId, {
1011
- ...withDefaults(options),
1012
- onSnapshotProgress,
1013
- }),
1401
+ openById(
1402
+ docId,
1403
+ inMode({ ...withDefaults(options), onSnapshotProgress }, asked),
1404
+ ),
1014
1405
  null,
1015
1406
  'push',
1016
1407
  options?.branch ?? null,
1017
1408
  );
1018
1409
  }
1019
1410
 
1411
+ // A switch the hosted history or branch surfaces make from the bound
1412
+ // document (another branch, a version, a fork) opens in the mode that
1413
+ // document was opened in, so a copy opened to read stays one and a copy
1414
+ // reopened to edit stays one.
1415
+ function openSibling(docId: string, options?: SessionOpenOptions): Promise<Document> {
1416
+ const current = document;
1417
+ const readOnly =
1418
+ current === null || current.id !== docId
1419
+ ? undefined
1420
+ : readOnlyOpens.has(current)
1421
+ ? true
1422
+ : current._openMode === 'edit'
1423
+ ? false
1424
+ : undefined;
1425
+ return open(docId, { ...options, ...(readOnly !== undefined ? { readOnly } : {}) });
1426
+ }
1427
+
1428
+ // `reopen` on the bound document: the new copy replaces it the way a move's
1429
+ // reopen does, kept on screen until it binds and kept bound if the reopen
1430
+ // fails. The session holds a live copy, so a frozen one is the caller's to
1431
+ // open beside it.
1432
+ async function reopenBound(current: Document, mode: ReopenMode): Promise<OpenedDocument> {
1433
+ if (mode === 'frozen') {
1434
+ throw new BrassError(FROZEN_IN_SESSION, { status: 0 });
1435
+ }
1436
+ const id = current.id;
1437
+ if (id === null) throw new BrassError('No document is open', { status: 0 });
1438
+ const branch = urlBranch(current);
1439
+ const guestToken = deps.guestLinkToken?.();
1440
+ const openGuest = deps.openDocumentByLink;
1441
+ const readOnly = mode === 'read';
1442
+ const next = await activate(
1443
+ (onSnapshotProgress) =>
1444
+ guestToken && openGuest
1445
+ ? openByLink(openGuest, guestToken, {
1446
+ ...withDefaults(),
1447
+ readOnly,
1448
+ onSnapshotProgress,
1449
+ })
1450
+ : openById(id, {
1451
+ ...withDefaults(),
1452
+ ...(branch ? { branch } : {}),
1453
+ readOnly,
1454
+ onSnapshotProgress,
1455
+ }),
1456
+ documentName,
1457
+ 'skip',
1458
+ activeSpecifiedBranch,
1459
+ true,
1460
+ true,
1461
+ true,
1462
+ );
1463
+ // A newer open, reopen or close took the session while this one joined,
1464
+ // and closed the copy it produced.
1465
+ if (document !== next) {
1466
+ throw new BrassError('The reopen was replaced by a later open of the session', {
1467
+ status: 0,
1468
+ });
1469
+ }
1470
+ return next as OpenedDocument;
1471
+ }
1472
+
1473
+ // A create fails in two regimes, and only the later one reaches the
1474
+ // session. Once `activate` has the open, `abandonOpen` parks the failure on
1475
+ // `session.error` for a status-gated app to render. Everything before that
1476
+ // (minting the account-less document, resolving the destination) throws with
1477
+ // the returned promise as its only report, and the documented way to call
1478
+ // this from a button floats that promise, so the click reads as ignored.
1479
+ // Parking here under `abandonOpen`'s own condition gives both regimes one
1480
+ // surface. The generation check is what keeps a failure from clobbering a
1481
+ // newer open that started while this one was still resolving a destination.
1482
+ // The trial token this page last recorded an edit for, so a burst of
1483
+ // edits writes the marker once.
1484
+ let trialEditNoted: string | null = null;
1485
+ function noteTrialEdit(doc: Document): void {
1486
+ const token = deps.guestLinkToken?.();
1487
+ if (!token || doc.expiresAt === null || trialEditNoted === token) return;
1488
+ trialEditNoted = token;
1489
+ deps.markTrialEdited?.(token, doc.expiresAt);
1490
+ }
1491
+
1492
+ // Whether New or Upload may replace the document on screen. A document
1493
+ // made without an account that this browser edited is kept only at its
1494
+ // link, so the visitor is asked first, and signing in is what keeps it.
1495
+ async function mayLeaveTrial(): Promise<boolean> {
1496
+ const token = deps.guestLinkToken?.();
1497
+ const doc = document;
1498
+ if (!token || !doc || doc.expiresAt === null) return true;
1499
+ if (!deps.confirmReplaceTrial || !deps.trialEdited?.(token)) return true;
1500
+ const answer = await deps.confirmReplaceTrial(doc.expiresAt);
1501
+ if (answer === 'sign-in') deps.signIn?.();
1502
+ return answer === 'leave';
1503
+ }
1504
+
1020
1505
  async function create(
1021
1506
  options?: CreateDocumentOptions,
1507
+ ): Promise<Document | null> {
1508
+ const generation = openGeneration;
1509
+ try {
1510
+ return await createUnguarded(options);
1511
+ } catch (err) {
1512
+ if (generation === openGeneration && document === null && openError === null) {
1513
+ openError = toDisplayError(err);
1514
+ emit();
1515
+ }
1516
+ throw err;
1517
+ }
1518
+ }
1519
+
1520
+ async function createUnguarded(
1521
+ options?: CreateDocumentOptions,
1022
1522
  ): Promise<Document | null> {
1023
1523
  // Placement is expressed by context, never an org this layer names.
1024
1524
  // Create-in-place: a New doc anchors on the active one via `sibling_of`,
@@ -1037,29 +1537,62 @@ export function createDocumentSession(
1037
1537
  // one. The server owns the single-vs-multi decision: a `needs-*` outcome
1038
1538
  // is where we open the corresponding hosted surface and retry with the
1039
1539
  // opaque destination it returns, relayed verbatim.
1040
- let args = base;
1041
- let knownName = options?.name ?? null;
1042
- let result: CreateDocumentResult;
1043
- for (;;) {
1044
- result = await deps.createDocumentId(args);
1045
- if (result.kind === 'created') break;
1046
- if (result.kind === 'needs-organization') {
1047
- const acquired = await deps.acquireOrganization();
1048
- if (!acquired) return null;
1049
- args = { ...args, destination: { organization_id: acquired.organization_id } };
1050
- continue;
1051
- }
1052
- // needs-destination
1053
- const destination = await deps.selectCreateDestination(
1054
- options?.name !== undefined ? { name: options.name } : undefined,
1540
+ // No account: the document is minted without one and opened through
1541
+ // its link token, which the URL then carries so a reload and a sign-in
1542
+ // both find it again.
1543
+ if (
1544
+ withoutAccount() &&
1545
+ deps.mintAnonymousDocument &&
1546
+ deps.enterGuestMode &&
1547
+ deps.openDocumentByLink
1548
+ ) {
1549
+ if (!(await mayLeaveTrial())) return null;
1550
+ const minted = await deps.mintAnonymousDocument(
1551
+ options?.name !== undefined ? { name: options.name } : {},
1055
1552
  );
1056
- if (destination === null) return null;
1057
- // The dialog's final name wins on both the wire and the display: the
1058
- // user saw and could edit it.
1059
- args = { ...args, destination, name: destination.name };
1060
- knownName = destination.name ?? knownName;
1553
+ const enterGuestMode = deps.enterGuestMode;
1554
+ const openGuest = deps.openDocumentByLink;
1555
+ accountFreeOpen = true;
1556
+ try {
1557
+ return await activate(
1558
+ (onSnapshotProgress, _onUploadProgress, releasePrevious) => {
1559
+ releasePrevious();
1560
+ enterGuestMode(minted.linkToken);
1561
+ return openGuest(minted.linkToken, {
1562
+ ...withDefaults(
1563
+ options?.documentOptions
1564
+ ? { documentOptions: options.documentOptions }
1565
+ : undefined,
1566
+ ),
1567
+ onSnapshotProgress,
1568
+ });
1569
+ },
1570
+ options?.name ?? null,
1571
+ 'skip',
1572
+ );
1573
+ } finally {
1574
+ accountFreeOpen = false;
1575
+ }
1061
1576
  }
1062
1577
 
1578
+ let args = base;
1579
+ let knownName = options?.name ?? null;
1580
+ const result = await placeThroughHostedSurfaces(
1581
+ deps,
1582
+ (destination) =>
1583
+ deps.createDocumentId(
1584
+ destination === undefined ? args : { ...args, destination },
1585
+ ),
1586
+ options?.name,
1587
+ (chosen) => {
1588
+ // The dialog's final name wins on both the wire and the display: the
1589
+ // user saw and could edit it.
1590
+ args = { ...args, name: chosen.name };
1591
+ knownName = chosen.name;
1592
+ },
1593
+ );
1594
+ if (result === null) return null;
1595
+
1063
1596
  const docId = result.docId;
1064
1597
  const openOptions = options?.documentOptions
1065
1598
  ? { documentOptions: options.documentOptions }
@@ -1070,7 +1603,7 @@ export function createDocumentSession(
1070
1603
  // stored name once the record binds.
1071
1604
  return activate(
1072
1605
  (onSnapshotProgress) =>
1073
- deps.openDocumentById(docId, {
1606
+ openById(docId, {
1074
1607
  ...withDefaults(openOptions),
1075
1608
  onSnapshotProgress,
1076
1609
  }),
@@ -1078,16 +1611,78 @@ export function createDocumentSession(
1078
1611
  );
1079
1612
  }
1080
1613
 
1614
+ // The upload runs inside the open, so the app renders its loading surface
1615
+ // with the upload's progress rather than an unchanged screen. A failure
1616
+ // lands on `session.error` the way a failed create's does.
1617
+ async function pickAnonymousFile(
1618
+ choose: () => Promise<File | null>,
1619
+ mint: NonNullable<DocumentSessionDeps['mintAnonymousDocumentFromFile']>,
1620
+ enterGuestMode: (token: string) => void,
1621
+ openGuest: NonNullable<DocumentSessionDeps['openDocumentByLink']>,
1622
+ ): Promise<Document | null> {
1623
+ const generation = openGeneration;
1624
+ let file: File | null;
1625
+ try {
1626
+ file = await choose();
1627
+ } catch (err) {
1628
+ // Before `activate` there is no open to abandon, so the refusal is
1629
+ // parked here under `create`'s condition.
1630
+ if (generation === openGeneration && document === null && openError === null) {
1631
+ openError = toDisplayError(err);
1632
+ emit();
1633
+ }
1634
+ throw err;
1635
+ }
1636
+ if (file === null) return null;
1637
+ accountFreeOpen = true;
1638
+ try {
1639
+ return await activate(
1640
+ async (onSnapshotProgress, onUploadProgress, releasePrevious) => {
1641
+ const minted = await mint({ file, onUploadProgress });
1642
+ releasePrevious();
1643
+ enterGuestMode(minted.linkToken);
1644
+ return openGuest(minted.linkToken, {
1645
+ ...withDefaults(),
1646
+ onSnapshotProgress,
1647
+ });
1648
+ },
1649
+ null,
1650
+ 'skip',
1651
+ );
1652
+ } finally {
1653
+ accountFreeOpen = false;
1654
+ emit();
1655
+ }
1656
+ }
1657
+
1081
1658
  async function pick(): Promise<Document | null> {
1659
+ if (
1660
+ withoutAccount() &&
1661
+ deps.chooseAnonymousFile &&
1662
+ deps.mintAnonymousDocumentFromFile &&
1663
+ deps.enterGuestMode &&
1664
+ deps.openDocumentByLink
1665
+ ) {
1666
+ if (!(await mayLeaveTrial())) return null;
1667
+ return pickAnonymousFile(
1668
+ deps.chooseAnonymousFile,
1669
+ deps.mintAnonymousDocumentFromFile,
1670
+ deps.enterGuestMode,
1671
+ deps.openDocumentByLink,
1672
+ );
1673
+ }
1082
1674
  const result = await deps.selectDocument();
1083
1675
  if (!result) return null;
1084
- const opts = {
1085
- ...withDefaults(),
1086
- ...(result.branch_id ? { branch: result.branch_id } : {}),
1087
- };
1676
+ const opts = inMode(
1677
+ {
1678
+ ...withDefaults(),
1679
+ ...(result.branch_id ? { branch: result.branch_id } : {}),
1680
+ },
1681
+ undefined,
1682
+ );
1088
1683
  return activate(
1089
1684
  (onSnapshotProgress) =>
1090
- deps.openDocumentById(result.doc_id, { ...opts, onSnapshotProgress }),
1685
+ openById(result.doc_id, { ...opts, onSnapshotProgress }),
1091
1686
  result.name,
1092
1687
  'push',
1093
1688
  result.branch_id ?? null,
@@ -1182,13 +1777,16 @@ export function createDocumentSession(
1182
1777
  }
1183
1778
  void activate(
1184
1779
  (onSnapshotProgress) =>
1185
- deps.openDocumentById(
1780
+ openById(
1186
1781
  sel.docId,
1187
- {
1188
- ...withDefaults(),
1189
- ...(sel.branchId ? { branch: sel.branchId } : {}),
1190
- onSnapshotProgress,
1191
- },
1782
+ inMode(
1783
+ {
1784
+ ...withDefaults(),
1785
+ ...(sel.branchId ? { branch: sel.branchId } : {}),
1786
+ onSnapshotProgress,
1787
+ },
1788
+ undefined,
1789
+ ),
1192
1790
  'skip',
1193
1791
  ),
1194
1792
  null,
@@ -1220,12 +1818,22 @@ export function createDocumentSession(
1220
1818
  // (opened via `?brass_link=`) is never signed in, so the initial
1221
1819
  // resolution arrives here with `signedIn === false`; it must boot the
1222
1820
  // read-only link document rather than tear down to the sign-in surface.
1821
+ //
1822
+ // The first resolution to signed-out is the boot of a signed-out page, which
1823
+ // restores the unsaved work this browser made (`restoreUnsavedWork`); a
1824
+ // later sign-out lands on the sign-in surface.
1825
+ let authResolved = false;
1223
1826
  deps.onUserChange((signedIn) => {
1827
+ const boot = !authResolved;
1828
+ authResolved = true;
1224
1829
  if (signedIn || deps.guestLinkToken?.() != null) {
1225
1830
  if (!document) void start();
1226
1831
  else emit();
1227
1832
  } else {
1228
1833
  teardownForSignOut();
1834
+ // Deferred: a session made after `init` settled hears this resolution
1835
+ // while it is still being built.
1836
+ if (boot) queueMicrotask(() => void start());
1229
1837
  }
1230
1838
  });
1231
1839
 
@@ -1240,7 +1848,17 @@ export function createDocumentSession(
1240
1848
  return status();
1241
1849
  },
1242
1850
  get error(): BrassError | null {
1243
- return status() === 'error' ? openError : null;
1851
+ // Not gated on `status() === 'error'`. A signed-out visitor's failed
1852
+ // create is the case that gating loses: 'signed-out' outranks the error
1853
+ // in `status()` (the app must keep rendering its sign-in surface, which
1854
+ // is where the retry lives), so gating here left the try-before-signing-in
1855
+ // failure with no channel at all.
1856
+ // What makes ungating safe is that a parked error never coexists with a
1857
+ // bound document: `activate` clears it on entry and is the only path that
1858
+ // binds a non-null one, and `abandonOpen` parks only when there was no
1859
+ // document to fall back to. So this can never show a stale failure beside
1860
+ // a working document. A new write to `openError` has to hold that.
1861
+ return openError ?? deps.initFailure?.() ?? null;
1244
1862
  },
1245
1863
  get loadingMessage(): string | null {
1246
1864
  return loading ? loadingMessage : null;
@@ -1259,19 +1877,40 @@ export function createDocumentSession(
1259
1877
  try {
1260
1878
  listener(session);
1261
1879
  } catch (err) {
1262
- console.error('[Brass] session listener threw:', err);
1880
+ reportListenerThrow(err);
1263
1881
  }
1264
1882
  return () => {
1265
1883
  listeners.delete(listener);
1266
1884
  };
1267
1885
  },
1268
1886
  start,
1269
- open,
1887
+ open: ((docId?: string, options?: SessionOpenOptions) =>
1888
+ docId === undefined ? pick() : open(docId, options)) as DocumentSession['open'],
1270
1889
  create,
1271
1890
  pick,
1272
1891
  close,
1273
1892
  async run(commandId): Promise<void> {
1274
1893
  const command = allCommands().find((c) => c.id === commandId);
1894
+ // An app's own signed-out button may still run a withdrawn New or Open,
1895
+ // and the reader is owed the reason rather than a press that does
1896
+ // nothing.
1897
+ if (
1898
+ command &&
1899
+ command.group === 'session' &&
1900
+ !command.enabled &&
1901
+ withoutAccount() &&
1902
+ signedOutRefusal !== null
1903
+ ) {
1904
+ // A parked error never sits beside a bound document, so a guest
1905
+ // reading one hears the refusal through `onError` instead.
1906
+ if (document === null) {
1907
+ openError = signedOutRefusal;
1908
+ emit();
1909
+ } else {
1910
+ deps.reportError(signedOutRefusal);
1911
+ }
1912
+ return;
1913
+ }
1275
1914
  if (command) await command.run();
1276
1915
  },
1277
1916
  useCanvas(source): () => void {
@@ -1311,8 +1950,9 @@ export function createDocumentSession(
1311
1950
  }
1312
1951
  // Already-terminal open failure: reject now rather than wait for a bind
1313
1952
  // that isn't coming.
1314
- if (status() === 'error' && openError) {
1315
- return Promise.reject(openError);
1953
+ const failure = session.error;
1954
+ if (status() === 'error' && failure) {
1955
+ return Promise.reject(failure);
1316
1956
  }
1317
1957
  return new Promise((resolve, reject) => {
1318
1958
  whenReadyPending.push({ resolve, reject });
@@ -1330,6 +1970,7 @@ export function createDocumentSession(
1330
1970
  _clearLocalDelete(docId: string): void {
1331
1971
  localDeletes.delete(docId);
1332
1972
  },
1973
+ _openSibling: openSibling,
1333
1974
  };
1334
1975
 
1335
1976
  return session;