@brass-build/client 0.1.0 → 0.3.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 (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
package/src/client.ts CHANGED
@@ -2,12 +2,15 @@ import {
2
2
  buildSignInUrl,
3
3
  buildSignedOutOfAppUrl,
4
4
  refreshTokens,
5
- type RefreshResult,
5
+ revokeAppApproval,
6
+ type RefreshCredential,
7
+ type RefreshOutcome,
6
8
  type TokenSet,
7
9
  } from './auth-flow.js';
8
10
  import { decodeIdToken } from './jwt.js';
9
11
  import { createEmitter } from './emitter.js';
10
12
  import {
13
+ clearParkedDocuments,
11
14
  createOpenDocument,
12
15
  type CreateDocumentArgs,
13
16
  type CreateDocumentOptions,
@@ -28,7 +31,7 @@ import {
28
31
  type SelectDocumentResult,
29
32
  } from './hosted-ui.js';
30
33
  import type { Document } from './documents.js';
31
- import type { DocumentSerializer } from './serializer.js';
34
+ import type { DocumentSerializer, StoredDocument } from './serializer.js';
32
35
  import {
33
36
  createImportRunner,
34
37
  type DocumentImporter,
@@ -73,6 +76,8 @@ import {
73
76
  createDocumentsNamespace,
74
77
  type DocumentsNamespace,
75
78
  type DocumentsInternalNamespace,
79
+ type PushToDriveInput,
80
+ type PushToDriveResult,
76
81
  } from './documents-namespace.js';
77
82
  import {
78
83
  createDocumentSession,
@@ -111,6 +116,10 @@ import type {
111
116
  } from './types.js';
112
117
 
113
118
  const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
119
+ // How long `signOut` waits for the revocation before it navigates. The page
120
+ // it lands on finishes an unrevoked sign-out itself, so waiting longer than
121
+ // this only delays the sign-out.
122
+ const SIGN_OUT_REVOKE_DEADLINE_MS = 4000;
114
123
  // The data API has its own host, NOT the dashboard origin: the
115
124
  // dashboard (app.brass.build) is a static SPA, the data API is a
116
125
  // separate gateway behind api.brass.build.
@@ -146,11 +155,22 @@ export interface ResolvedBaseUrls {
146
155
 
147
156
  // Resolve the four base URLs from init options. Zero config applies all
148
157
  // four production origins (including `wsBaseUrl`, so documents and
149
- // presence work with nothing passed). Naming ANY `*BaseUrl` fills
150
- // nothing from production: an app that overrides `apiBaseUrl` and
151
- // inherits a production `wsBaseUrl` would connect the WS to the wrong
152
- // stack and silently drop events, whereas an unfilled `wsBaseUrl` fails
153
- // loudly on subscribe. Pure so the precedence is unit-testable.
158
+ // presence work with nothing passed).
159
+ //
160
+ // Naming ANY `*BaseUrl` withholds the production `wsBaseUrl`: an app that
161
+ // overrides `apiBaseUrl` and inherited a production socket would connect to
162
+ // the wrong stack and silently drop events, where an unfilled `wsBaseUrl`
163
+ // fails loudly on subscribe.
164
+ //
165
+ // `authBaseUrl`, `apiBaseUrl` and `dashboardBaseUrl` still fall back to their
166
+ // production defaults, so a caller who names some but not all of them reaches
167
+ // production for the rest. `dashboardBaseUrl` is the one that goes wrong
168
+ // quietly: every hosted surface (the picker, share, the version bar) is framed
169
+ // from it, so an app on another stack frames production's, which either
170
+ // refuses to render or hands back ids the configured API cannot open. Name it
171
+ // alongside the others.
172
+ //
173
+ // Pure so the precedence is unit-testable.
154
174
  export function resolveBaseUrls(options: BrassInitOptions): ResolvedBaseUrls {
155
175
  const anyUrlOverride = Boolean(
156
176
  options.authBaseUrl || options.apiBaseUrl || options.wsBaseUrl || options.dashboardBaseUrl,
@@ -179,6 +199,23 @@ const REFRESH_LEEWAY_MS = 60_000;
179
199
  // server-side format bumps from stranding SDK consumers.
180
200
  const APP_ID_PATTERN = /^brass_app_(?:(?:test|live|internal)_)?[A-Za-z0-9_-]+$/;
181
201
 
202
+ // What one `/refresh` settled, applied to the client's own state. Carries the
203
+ // `no_session` / `unavailable` split `RefreshOutcome` draws, because the two
204
+ // are opposite answers for a caller: `no_session` is the server saying this
205
+ // app has none, `unavailable` is the absence of an answer.
206
+ type RefreshSettlement =
207
+ | { status: 'ok'; tokens: TokenSet }
208
+ | { status: 'no_session' }
209
+ | { status: 'unavailable' };
210
+
211
+ // The rejection for `unavailable`. A 503 status is what makes the outcome
212
+ // legible to the layers that branch on one: an app's own retry, and the
213
+ // selection self-heal that clears a persisted doc id on a definitive 403 /
214
+ // 404 and leaves a transient alone.
215
+ function refreshUnavailableError(): BrassError {
216
+ return new BrassError('The sign-in service is unavailable', { status: 503 });
217
+ }
218
+
182
219
  export class BrassClient {
183
220
  private appId: string | null = null;
184
221
  private authBaseUrl = DEFAULT_AUTH_BASE_URL;
@@ -230,7 +267,12 @@ export class BrassClient {
230
267
  // Init-failure listeners. Swallow a listener's throw so one bad handler
231
268
  // cannot block the others or mask the original error (already logged).
232
269
  private readonly errorListeners = createEmitter<Error>({ onError: () => {} });
233
- private inflightRefresh: Promise<TokenSet | null> | null = null;
270
+ private inflightRefresh: Promise<RefreshSettlement> | null = null;
271
+ // Bumped by every `signOut`, and captured by a `/refresh` when it starts.
272
+ // A refresh that settles against a moved epoch drops its answer: the
273
+ // credentials it would write were cleared by a sign-out while it was in
274
+ // flight, and re-writing them signs the person back in.
275
+ private authEpoch = 0;
234
276
  private initialized = false;
235
277
  // Incognito / third-party-cookie fallback state. Both null in a normal
236
278
  // browser, where the cross-site `brass_auth` cookie carries the session
@@ -238,7 +280,7 @@ export class BrassClient {
238
280
  // - `pendingAuthCode`: the one-time `brass_code` pulled off the
239
281
  // landing URL by bootstrap(), consumed at most once by refresh() if
240
282
  // the cookie channel is unavailable.
241
- // - `sessionToken`: the platform-session pointer from a code
283
+ // - `sessionToken`: the app-bound session pointer from a code
242
284
  // exchange. Once set, every refresh() uses it instead of the
243
285
  // cookie. Mirrored into sessionStorage so a reload in a
244
286
  // cookie-blocked browser hydrates it; see session-persistence.ts.
@@ -280,8 +322,9 @@ export class BrassClient {
280
322
  completeImport: (docId) => this.docsInternal().completeImport(docId),
281
323
  reportError: (error) => this.fireError(error),
282
324
  });
283
- // Separate from the projects namespace's WS client: one connection
284
- // per surface.
325
+ // One socket carrying every document topic this client subscribes,
326
+ // built on first open so an app that never opens a document never
327
+ // connects.
285
328
  private docWsClient: BrassWsClient | null = null;
286
329
 
287
330
  // init ALWAYS resolves: failures (bad/missing app_id) fire onError +
@@ -439,7 +482,7 @@ export class BrassClient {
439
482
  // renders a signed-out UI or calls `signIn()`, which navigates the
440
483
  // frame away from the export it was loaded to run.
441
484
  if (this.exportGuest === null) {
442
- for (const listener of this.listeners) {
485
+ for (const listener of Array.from(this.listeners)) {
443
486
  listener(this.user);
444
487
  }
445
488
  }
@@ -458,23 +501,34 @@ export class BrassClient {
458
501
  window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
459
502
  }
460
503
 
461
- // Per-app sign-out. Top-level navigation to a Brass-hosted confirmation
462
- // page that:
463
- // 1. Revokes this app's server-side approval (subsequent /refresh
464
- // calls for this app return 401 even on cookie reuse).
465
- // 2. Surfaces the email the user is still signed in to Brass as,
466
- // and offers a one-click "Sign out of Brass too" link.
504
+ // Per-app sign-out. Revokes this app's server-side approval (subsequent
505
+ // /refresh calls for this app return 401 even on cookie reuse), then makes
506
+ // a top-level navigation to a Brass-hosted page that surfaces the email
507
+ // the user is still signed in to Brass as, and offers a one-click "Sign
508
+ // out of Brass too" link.
467
509
  //
468
- // Navigation (not a background fetch) so the user gets a visible
469
- // confirmation, closing the trust gap of "did sign-out actually
470
- // happen?" The decision to ALSO sign out of the platform belongs to
471
- // the user, made on the Brass-hosted page; the calling app cannot
472
- // trigger platform-level sign-out from its own UI.
510
+ // The navigation is what closes the trust gap of "did sign-out actually
511
+ // happen?"; the revocation rides the credentialed route because the page
512
+ // answers whoever arrives at it and so writes nothing on arrival. The
513
+ // decision to ALSO sign out of the platform belongs to the user, made on
514
+ // the Brass-hosted page; the calling app cannot trigger platform-level
515
+ // sign-out from its own UI.
473
516
  signOut(): void {
474
517
  const appId = this.requireAppId();
475
518
  if (typeof window === 'undefined') {
476
519
  throw new Error('Brass.signOut must be called in a browser');
477
520
  }
521
+ // Read before the clears below drop it: where the cross-site cookie is
522
+ // blocked, this pointer is the revocation's only credential.
523
+ const credential: RefreshCredential = this.sessionToken
524
+ ? { kind: 'sessionToken', sessionToken: this.sessionToken }
525
+ : { kind: 'cookie' };
526
+ // Fence any `/refresh` already on the wire: landing after the clears
527
+ // below, it writes back the token set, the login hint and the session
528
+ // pointer, and re-emits the signed-in user. The hint and the pointer
529
+ // outlive the page, so that resurrects the session on the next load
530
+ // too, and the navigation below can be delayed or cancelled.
531
+ this.authEpoch += 1;
478
532
  this.setTokens(null);
479
533
  // Drop any fallback-mode credential too, so a stale session token
480
534
  // can't resurrect the session on the next getAccessToken(). Clear
@@ -496,12 +550,22 @@ export class BrassClient {
496
550
  // on the way to the confirmation page. Harmless when the app is not
497
551
  // gated (the cookie simply isn't there). Off a hosted origin there is
498
552
  // no such cookie, so go straight to the confirmation page.
499
- if (isBrassHostedHost(window.location.hostname)) {
500
- const leave = `${window.location.origin}/__brass/leave?return_to=${encodeURIComponent(signedOutUrl)}`;
501
- window.location.assign(leave);
502
- } else {
503
- window.location.assign(signedOutUrl);
504
- }
553
+ const target = isBrassHostedHost(window.location.hostname)
554
+ ? `${window.location.origin}/__brass/leave?return_to=${encodeURIComponent(signedOutUrl)}`
555
+ : signedOutUrl;
556
+ // Leave once the revocation settles, so the page reports a sign-out that
557
+ // already happened rather than offering the button that finishes it.
558
+ let left = false;
559
+ const leave = (): void => {
560
+ if (left) return;
561
+ left = true;
562
+ window.location.assign(target);
563
+ };
564
+ const deadline = setTimeout(leave, SIGN_OUT_REVOKE_DEADLINE_MS);
565
+ void revokeAppApproval(this.authBaseUrl, appId, credential).then(() => {
566
+ clearTimeout(deadline);
567
+ leave();
568
+ });
505
569
  }
506
570
 
507
571
  get currentUser(): BrassUser | null {
@@ -563,10 +627,11 @@ export class BrassClient {
563
627
  this.errorListeners.emit(error);
564
628
  }
565
629
 
566
- // The raw HTTP metadata + grant namespace (create / get / move /
567
- // grantAccess); the editable surface stays on
568
- // `Brass.openDocumentById` / `Brass.createDocument`. Throws "Call
569
- // Brass.init(appId) first" before init.
630
+ // The metadata + thumbnail namespace for a document the app already
631
+ // holds an id for (`get` / `setThumbnail` / `getThumbnail`); the
632
+ // editable surface stays on `Brass.openDocumentById` /
633
+ // `Brass.createDocument`. Throws "Call Brass.init(appId) first" before
634
+ // init.
570
635
  get documents(): DocumentsNamespace {
571
636
  return this.docsInternal();
572
637
  }
@@ -713,9 +778,8 @@ export class BrassClient {
713
778
  dashboardBaseUrl: this._dashboardBaseUrl,
714
779
  getDocument: (docId) => this.documents.get(docId),
715
780
  // The version-preview bar is a hosted embed; it takes the same theme
716
- // and fallback session pointer as the other hosted surfaces.
781
+ // as the other hosted surfaces.
717
782
  hostedUiTheme: this.hostedUiTheme,
718
- getSessionToken: () => this.sessionToken,
719
783
  };
720
784
  }
721
785
 
@@ -788,11 +852,6 @@ export class BrassClient {
788
852
  if (active) this.session._noteLocalDelete(docId);
789
853
  else this.session._clearLocalDelete(docId);
790
854
  },
791
- // Hand our fallback platform-session pointer to the embed so it can
792
- // authenticate the dashboard when its own framed cookie is blocked
793
- // (incognito / Safari ITP). Null in a normal browser, where the
794
- // embed's cross-site cookie carries the session on its own.
795
- getSessionToken: () => this.sessionToken,
796
855
  // Name the user this app is acting as, so the embed can refuse to
797
856
  // act as anyone else. The embed authenticates from the dashboard
798
857
  // origin's platform-session cookie, which moves when a second
@@ -837,7 +896,13 @@ export class BrassClient {
837
896
  options?: OpenDocumentByIdOptions,
838
897
  ): Promise<OpenedDocument> {
839
898
  const opened = await this.openDocumentInternal(docId, options);
840
- this.maybeWriteDocIdToUrl(opened.id);
899
+ // The URL names the document the reader is looking at, and the session
900
+ // owns that whenever it holds one: this open is detached, so claiming the
901
+ // URL would leave the address bar naming a document the session is not
902
+ // showing, and a reload would swap the reader onto it. An app with no
903
+ // session document still gets the sync, which is the read-mostly caller
904
+ // this method exists for.
905
+ if (this.session.document === null) this.maybeWriteDocIdToUrl(opened.id);
841
906
  return opened;
842
907
  }
843
908
 
@@ -845,7 +910,7 @@ export class BrassClient {
845
910
  // the link" anonymous path). No sign-in: the token is the credential and
846
911
  // the server serves a read-only, member-stripped snapshot with no live
847
912
  // subscription. `Brass.session` drives this from the URL on boot when the
848
- // page carries `?brass_link=`; there is no app-facing entry point.
913
+ // page carries `?brass_link=`.
849
914
  private async openByLinkInternal(
850
915
  token: string,
851
916
  options?: OpenDocumentByIdOptions,
@@ -1023,13 +1088,13 @@ export class BrassClient {
1023
1088
  registerImporter(importer: DocumentImporter): void {
1024
1089
  this.importRunner.setImporter(importer);
1025
1090
  if (this.guestLinkTokenValue !== null) return;
1026
- for (const doc of this.openDocuments.values()) {
1091
+ for (const doc of this.liveOpenDocuments()) {
1027
1092
  void this.importRunner.attempt(doc);
1028
1093
  }
1029
1094
  }
1030
1095
 
1031
1096
  // Track an opened document so an export request can resolve its id to
1032
- // the live Document. Drop it when the doc goes; a re-open overwrites.
1097
+ // the live Document. A re-open overwrites.
1033
1098
  private trackOpenDocument(doc: OpenedDocument): void {
1034
1099
  this.openDocuments.set(doc.id, doc);
1035
1100
  doc.onGone(() => {
@@ -1045,24 +1110,89 @@ export class BrassClient {
1045
1110
  }
1046
1111
  }
1047
1112
 
1113
+ // The tracked documents still open, dropping the rest as it goes.
1114
+ // `onGone` covers only a server-side eviction, and two ordinary paths
1115
+ // retire a Document without one: an app's `close()` (which parks it, so
1116
+ // the public surface throws while the instance lives on in the park
1117
+ // cache) and that cache's LRU eviction (a full teardown, no listener
1118
+ // left to fire). Reading through this is what keeps a retired wrapper
1119
+ // out of an answer, and what keeps the set from growing for the life of
1120
+ // the page.
1121
+ private liveOpenDocuments(): Document[] {
1122
+ const live: Document[] = [];
1123
+ for (const [docId, doc] of this.openDocuments) {
1124
+ if (doc._isLive) live.push(doc);
1125
+ else this.openDocuments.delete(docId);
1126
+ }
1127
+ return live;
1128
+ }
1129
+
1130
+ private liveOpenDocument(docId: string): Document | null {
1131
+ const doc = this.openDocuments.get(docId);
1132
+ if (!doc) return null;
1133
+ if (doc._isLive) return doc;
1134
+ this.openDocuments.delete(docId);
1135
+ return null;
1136
+ }
1137
+
1138
+ // Publish a document to a drive, supplying the bytes when the platform has
1139
+ // no writer for it.
1140
+ //
1141
+ // Where the export of this document runs in THIS app, the SDK runs the
1142
+ // registered serializer in process and the push carries its bytes, rather
1143
+ // than sending the reader through the hosted export frame. That is what
1144
+ // makes a publish from inside an app one click, and it is the same on the
1145
+ // publish that creates the drive file and on every push after it: the
1146
+ // caller's own app either writes this document or it does not.
1147
+ //
1148
+ // Everything else is the route's, including refusing bytes from an app the
1149
+ // exporter setting does not name, which it does by name rather than the
1150
+ // client predicting it.
1151
+ private async pushDocumentToDrive(
1152
+ doc: Document,
1153
+ input: PushToDriveInput,
1154
+ ): Promise<PushToDriveResult> {
1155
+ const docId = doc.id;
1156
+ if (docId === null || !doc._exportRunsHere) {
1157
+ return await this.docsInternal().pushToDrive(doc, input);
1158
+ }
1159
+ const serialized = await this.serializeForExport(docId);
1160
+ if (!serialized.ok) {
1161
+ throw new BrassError(
1162
+ `This app could not write the document out to publish it: ${serialized.message}`,
1163
+ { status: 400 },
1164
+ );
1165
+ }
1166
+ return await this.docsInternal().pushToDrive(doc, {
1167
+ ...input,
1168
+ bytes: serialized.bytes,
1169
+ });
1170
+ }
1171
+
1048
1172
  // Run the registered serializer for an export request from a hosted
1049
1173
  // surface. Resolves the doc from the open set (the active session doc is
1050
1174
  // the fallback for an app that drives it through the session), then hands
1051
1175
  // it to the serializer. A missing serializer is the ordinary "this app
1052
- // does not export" outcome, not an error.
1176
+ // does not export" outcome, not an error. A document the app has closed
1177
+ // reports `no_document`: running the serializer against it would throw
1178
+ // out of the app's own record reads and report `serializer_failed`, which
1179
+ // names the app's code for a state the platform is holding.
1053
1180
  private async serializeForExport(docId: string): Promise<ExportSerializeOutcome> {
1054
1181
  if (!this.serializer) {
1055
1182
  return { ok: false, code: 'no_serializer', message: 'No serializer registered.' };
1056
1183
  }
1057
1184
  const sessionDoc = this.documentSession?.document ?? null;
1058
1185
  const doc =
1059
- this.openDocuments.get(docId) ??
1060
- (sessionDoc && sessionDoc.id === docId ? sessionDoc : null);
1186
+ this.liveOpenDocument(docId) ??
1187
+ (sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
1061
1188
  if (!doc) {
1062
1189
  return { ok: false, code: 'no_document', message: 'Document is not open.' };
1063
1190
  }
1064
1191
  try {
1065
- const bytes = await this.serializer(doc);
1192
+ // A serializer runs detached, with no subscription to have been fed by,
1193
+ // and it needs the document once, so it is handed the containers whole
1194
+ // (`StoredDocument`) rather than the fed surface an app holds.
1195
+ const bytes = await this.serializer(doc as StoredDocument & Document);
1066
1196
  return { ok: true, bytes };
1067
1197
  } catch (err) {
1068
1198
  return {
@@ -1107,6 +1237,7 @@ export class BrassClient {
1107
1237
  apiBaseUrl: this.apiBaseUrl,
1108
1238
  getAccessToken: () => this.getAccessToken(),
1109
1239
  getAppAssertion: () => this.getAppAssertion(),
1240
+ identity: () => this.user?.sub ?? null,
1110
1241
  ...(wsClient ? { wsClient } : {}),
1111
1242
  branchProvider: {
1112
1243
  list: (docId) => docsInternal.listBranches(docId),
@@ -1123,8 +1254,7 @@ export class BrassClient {
1123
1254
  thumbnailUploader: (doc, image, width, height) =>
1124
1255
  docsNs.setThumbnail(doc, { image, width, height }),
1125
1256
  checkpointCreator: (doc) => docsInternal.createCheckpoint(doc),
1126
- drivePusher: (doc, input) =>
1127
- docsInternal.pushToDrive(doc, input),
1257
+ drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
1128
1258
  sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
1129
1259
  hostedUiProvider: {
1130
1260
  documentHistory: (docId, options) =>
@@ -1200,29 +1330,50 @@ export class BrassClient {
1200
1330
  return this.docWsClient;
1201
1331
  }
1202
1332
 
1333
+ // Resolves null only when the server answered that this app has no live
1334
+ // session, and rejects with a 503 `BrassError` when a `/refresh` found no
1335
+ // answer at all. The two outcomes drive opposite handling: null is the
1336
+ // signed-out state a caller renders a sign-in CTA for, where a rejection
1337
+ // is a momentary outage the caller retries. Collapsing them into null
1338
+ // reports a 401-shaped "Not signed in" to a signed-in user whose network
1339
+ // blipped, and hides the transient from every layer that self-heals on it.
1203
1340
  async getAccessToken(): Promise<string | null> {
1204
1341
  this.requireInit();
1342
+ // On a `?brass_link=` page the link token is the credential and `init`
1343
+ // runs no auth bootstrap, so answer null rather than probing /refresh.
1344
+ // A visitor with no session would draw a 401 the browser logs; one who
1345
+ // holds a session for this app would get a real token, and a caller
1346
+ // that prefers a token over the link token (the socket does) would then
1347
+ // present membership where the page's whole authority is the link.
1348
+ if (this.guestLinkTokenValue !== null) return null;
1205
1349
  const current = this.tokens;
1206
1350
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1207
1351
  return current.accessToken;
1208
1352
  }
1209
- const refreshed = await this.refresh();
1210
- return refreshed?.accessToken ?? null;
1353
+ const settled = await this.refresh();
1354
+ if (settled.status === 'unavailable') throw refreshUnavailableError();
1355
+ return settled.status === 'ok' ? settled.tokens.accessToken : null;
1211
1356
  }
1212
1357
 
1213
1358
  // The acting-app assertion paired with the current access token, or
1214
1359
  // null when the proxy issued none. Mirrors `getAccessToken`'s
1215
1360
  // freshness check so the two stay in lockstep: a request builder that
1216
1361
  // calls `getAccessToken()` then this reads the same (possibly just
1217
- // refreshed) token set without forcing a second refresh.
1362
+ // refreshed) token set without forcing a second refresh. Rejects on a
1363
+ // transient outage for the same reason `getAccessToken` does.
1218
1364
  async getAppAssertion(): Promise<string | null> {
1219
1365
  this.requireInit();
1366
+ // Paired with `getAccessToken`: a link guest holds no token to assert
1367
+ // an acting app against, and reaching /refresh here would authenticate
1368
+ // the page the same way.
1369
+ if (this.guestLinkTokenValue !== null) return null;
1220
1370
  const current = this.tokens;
1221
1371
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1222
1372
  return current.appAssertion ?? null;
1223
1373
  }
1224
- const refreshed = await this.refresh();
1225
- return refreshed?.appAssertion ?? null;
1374
+ const settled = await this.refresh();
1375
+ if (settled.status === 'unavailable') throw refreshUnavailableError();
1376
+ return settled.status === 'ok' ? (settled.tokens.appAssertion ?? null) : null;
1226
1377
  }
1227
1378
 
1228
1379
  private async bootstrap(): Promise<void> {
@@ -1230,7 +1381,7 @@ export class BrassClient {
1230
1381
  const appId = this.requireAppId();
1231
1382
  // Every successful sign-in 302s back to return_to with the
1232
1383
  // platform-session cookie set AND a one-time `brass_code` query
1233
- // parameter (see infra/lambda/auth/token-redirect.ts). The cookie
1384
+ // parameter. The cookie
1234
1385
  // is the primary channel; the code is the incognito / Safari ITP /
1235
1386
  // post-third-party-cookie fallback for when the browser drops the
1236
1387
  // cross-site cookie on our /refresh XHR.
@@ -1243,8 +1394,7 @@ export class BrassClient {
1243
1394
  //
1244
1395
  // No app-controlled silent-mint path: the SDK never auto-fires
1245
1396
  // Brass.signIn() based on a URL hint. New app approvals always
1246
- // require an explicit click on the auth-hosted picker (see the
1247
- // adaptive /sign-in branch in infra/lambda/auth/sign-in.ts).
1397
+ // require an explicit click on the auth-hosted picker.
1248
1398
  // Same treatment for the launching organization: read it off the URL
1249
1399
  // and strip it here, so a client-side navigation later in the session
1250
1400
  // cannot lose it and the address bar stays the app's own.
@@ -1300,19 +1450,27 @@ export class BrassClient {
1300
1450
  this.pendingAuthCode = null;
1301
1451
  }
1302
1452
 
1303
- private async refresh(): Promise<TokenSet | null> {
1453
+ private async refresh(): Promise<RefreshSettlement> {
1304
1454
  if (this.inflightRefresh) return this.inflightRefresh;
1305
1455
  const appId = this.requireAppId();
1306
- this.inflightRefresh = (async () => {
1307
- const result = await this.runRefresh(appId);
1456
+ const epoch = this.authEpoch;
1457
+ this.inflightRefresh = (async (): Promise<RefreshSettlement> => {
1458
+ const outcome = await this.runRefresh(appId);
1459
+ // No answer means no change: the token set, the login hint, and any
1460
+ // fallback session token stay as they are, and a later attempt settles
1461
+ // the session.
1462
+ if (outcome.status === 'unavailable') return { status: 'unavailable' };
1463
+ // A sign-out landed while this round-trip was in flight, so whatever
1464
+ // it resolved belongs to a session the person has left. Report it as
1465
+ // no session and write nothing: the caller waiting on this token gets
1466
+ // the signed-out answer the sign-out established.
1467
+ if (this.authEpoch !== epoch) return { status: 'no_session' };
1468
+ const result = outcome.status === 'ok' ? outcome.result : null;
1308
1469
  this.setTokens(result?.tokens ?? null);
1309
1470
  // Remember whether a live session exists for this app so the next
1310
1471
  // cold load knows whether to probe /refresh (see bootstrap). A
1311
- // success proves a session; an empty result means none (or it's
1312
- // gone), so drop the hint and the next signed-out load stays
1313
- // silent. A transient network failure also clears it; the cost is
1314
- // one extra sign-in click on the next load, self-healing on the
1315
- // next success.
1472
+ // success proves a session; `no_session` is the server saying there
1473
+ // is none, so drop the hint and the next signed-out load stays silent.
1316
1474
  if (result) {
1317
1475
  storeLoginHint(appId);
1318
1476
  } else {
@@ -1323,12 +1481,11 @@ export class BrassClient {
1323
1481
  // Mirror into sessionStorage so a reload on this tab can
1324
1482
  // hydrate it (the cookie channel is unavailable in this
1325
1483
  // browser by construction; we only get here via the code or
1326
- // sid channels). The server-side `approved_apps` gate stays
1327
- // the security boundary; the stored sid is no more sensitive
1328
- // than the cookie it mirrors.
1484
+ // sid channels). The pointer is bound to this app id, so what
1485
+ // is stored authenticates this app and nothing else.
1329
1486
  storeSessionToken(appId, result.sessionToken);
1330
1487
  }
1331
- return result?.tokens ?? null;
1488
+ return result ? { status: 'ok', tokens: result.tokens } : { status: 'no_session' };
1332
1489
  })().finally(() => {
1333
1490
  this.inflightRefresh = null;
1334
1491
  });
@@ -1338,16 +1495,20 @@ export class BrassClient {
1338
1495
  // Picks the credential channel for a single /refresh round-trip. The
1339
1496
  // cookie path is primary and untouched for browsers that send the
1340
1497
  // third-party cookie; the one-time code is consumed only when that
1341
- // cookie path genuinely came back empty. Once a fallback session
1498
+ // cookie path resolved no session. Once a fallback session
1342
1499
  // token is in hand (the cross-site cookie is proven unavailable for
1343
1500
  // this page's life), every later refresh uses it directly.
1344
- private async runRefresh(appId: string): Promise<RefreshResult | null> {
1501
+ //
1502
+ // A rung falls through to the next only on `no_session`. Each one discards
1503
+ // a credential as it passes (the stale sid, the one-time code), and an
1504
+ // `unavailable` says nothing about the credential it just tried.
1505
+ private async runRefresh(appId: string): Promise<RefreshOutcome> {
1345
1506
  if (this.sessionToken) {
1346
1507
  const viaSession = await refreshTokens(this.authBaseUrl, appId, {
1347
1508
  kind: 'sessionToken',
1348
1509
  sessionToken: this.sessionToken,
1349
1510
  });
1350
- if (viaSession) return viaSession;
1511
+ if (viaSession.status !== 'no_session') return viaSession;
1351
1512
  // Stored sid is stale (server-side revoke, row expiry). Drop both
1352
1513
  // the in-memory copy and the sessionStorage mirror, then fall
1353
1514
  // through to the cookie / code paths. Without this, a hydrated-
@@ -1358,19 +1519,32 @@ export class BrassClient {
1358
1519
  clearSessionToken(appId);
1359
1520
  }
1360
1521
  const viaCookie = await refreshTokens(this.authBaseUrl, appId);
1361
- if (viaCookie) return viaCookie;
1362
- // Cookie channel produced nothing. Fall back to the one-time code if
1363
- // we landed with one. Clear it first: it is single-use server-side,
1364
- // so a failed exchange must not be retried.
1522
+ if (viaCookie.status !== 'no_session') return viaCookie;
1523
+ // Cookie channel resolved no session. Fall back to the one-time code if
1524
+ // we landed with one. Clear it first: it is single-use server-side, so a
1525
+ // failed exchange must not be retried.
1365
1526
  const code = this.pendingAuthCode;
1527
+ if (!code) return viaCookie;
1366
1528
  this.pendingAuthCode = null;
1367
- if (!code) return null;
1368
1529
  return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
1369
1530
  }
1370
1531
 
1371
1532
  private setTokens(next: TokenSet | null): void {
1372
- this.tokens = next;
1373
1533
  const nextUser = next ? decodeIdToken(next.idToken) : null;
1534
+ // A different person on this page. `/refresh` resolves whichever platform
1535
+ // session the browser holds, so the identity behind a page that never
1536
+ // navigated can become someone else's (another tab signs out and a second
1537
+ // person signs in). Run the retirement first, as its own transition, so
1538
+ // what the previous identity left open is retired before the new one is
1539
+ // announced rather than inherited by it.
1540
+ if (
1541
+ this.user !== null &&
1542
+ nextUser !== null &&
1543
+ this.user.sub !== nextUser.sub
1544
+ ) {
1545
+ this.setTokens(null);
1546
+ }
1547
+ this.tokens = next;
1374
1548
  if (sameUser(this.user, nextUser)) return;
1375
1549
  this.user = nextUser;
1376
1550
  // Suppress emits while init is still in flight: init's finally
@@ -1379,9 +1553,17 @@ export class BrassClient {
1379
1553
  // fire here (null -> user) AND fire again from init's finally
1380
1554
  // (the same user), double-emitting on every signed-in app load.
1381
1555
  if (!this.bootstrapped) return;
1382
- for (const listener of this.listeners) {
1556
+ // Copied before the walk: a listener that detaches another would otherwise
1557
+ // remove it before its turn, and a `Set` never visits such an entry.
1558
+ for (const listener of Array.from(this.listeners)) {
1383
1559
  listener(nextUser);
1384
1560
  }
1561
+ // After the listeners, because the session closes the active document as
1562
+ // it hears the sign-out and a close PARKS the Document. A parked Document
1563
+ // answers the next open of its key out of memory, with no token and no
1564
+ // request, so one left behind here is the previous person's document
1565
+ // handed to whoever signs in next.
1566
+ if (nextUser === null) clearParkedDocuments();
1385
1567
  }
1386
1568
 
1387
1569
  private async hydrateProfile(): Promise<void> {
@@ -29,6 +29,9 @@ export interface CheckpointSchedulerHost {
29
29
  currentSeq(): number | null;
30
30
  // Seal the current undo step. Safe to call when undo is absent.
31
31
  sealUndoStep(): void;
32
+ // Resolve `true` once every op this client has committed locally has been
33
+ // acked; `false` when the document went, or the acks stopped arriving.
34
+ whenLocalOpsSaved(): Promise<boolean>;
32
35
  }
33
36
 
34
37
  export class CheckpointScheduler {
@@ -65,6 +68,26 @@ export class CheckpointScheduler {
65
68
  // arriving during the await start a fresh undo step rather than
66
69
  // folding into the pre-checkpoint one.
67
70
  this.host.sealUndoStep();
71
+ // The anchor is a SERVER seq, and an op the app just committed holds one
72
+ // only once it is acked, so a checkpoint sealed with writes outstanding
73
+ // sits below the work the caller is saving: an app that writes and then
74
+ // saves a version (the file importer's one-undo-step seal is this shape)
75
+ // marks a point its own edits are missing from, and restoring it loses
76
+ // them. Waiting for the acks is what makes the marker describe the state
77
+ // the caller has.
78
+ const saved = await this.host.whenLocalOpsSaved();
79
+ if (!this.host.isAttached()) {
80
+ throw new Error('Document is closed');
81
+ }
82
+ // Sealing anyway is the failure this wait exists to prevent, and it fails
83
+ // quietly: the marker looks like a saved version and restores to a state
84
+ // missing the work the caller asked to save. Refusing hands the app a
85
+ // state it can act on instead.
86
+ if (!saved) {
87
+ throw new Error(
88
+ 'createCheckpoint: the document has changes that are not saved yet',
89
+ );
90
+ }
68
91
  const result = await this._creator();
69
92
  // Record the seq so a pending auto-checkpoint at the same version
70
93
  // skips its fire. The server Put is unconditional (last-write-wins),
@@ -116,6 +139,13 @@ export class CheckpointScheduler {
116
139
  private async _fireAuto(): Promise<void> {
117
140
  if (!this.host.isAutoAllowed()) return;
118
141
  if (this._creator === null) return;
142
+ // Same anchor rule as `create`. The debounce usually outlasts the acks,
143
+ // so this ordinarily resolves at once; what it covers is a burst whose
144
+ // last ops are still draining when the timer fires. Best-effort here, so
145
+ // a seal that cannot confirm the writes skips and the next local edit
146
+ // reschedules it.
147
+ if (!(await this.host.whenLocalOpsSaved())) return;
148
+ if (!this.host.isAutoAllowed()) return;
119
149
  const seq = this.host.currentSeq();
120
150
  if (seq === null) return;
121
151
  // Already checkpointed at this exact version (manual seal a moment