@brass-build/client 0.1.0 → 0.2.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 (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  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 +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
package/dist/client.js CHANGED
@@ -1,7 +1,7 @@
1
- import { buildSignInUrl, buildSignedOutOfAppUrl, refreshTokens, } from './auth-flow.js';
1
+ import { buildSignInUrl, buildSignedOutOfAppUrl, refreshTokens, revokeAppApproval, } from './auth-flow.js';
2
2
  import { decodeIdToken } from './jwt.js';
3
3
  import { createEmitter } from './emitter.js';
4
- import { createOpenDocument, } from './open-document.js';
4
+ import { clearParkedDocuments, createOpenDocument, } from './open-document.js';
5
5
  import { askSchemaConflict } from './schema-conflict-dialog.js';
6
6
  import { BrassError } from './errors.js';
7
7
  import { createHostedUiLauncher, trackHostedUiActivations, } from './hosted-ui.js';
@@ -20,6 +20,10 @@ import { createSessionToolbar, createSessionHeader, createFileMenu, createDocume
20
20
  import { BrassWsClient } from './ws-client.js';
21
21
  import { fetchHostedRuntime, readInjectedRuntime, isBrassHostedHost, HOSTED_RUNTIME_PATH, } from './hosted-runtime.js';
22
22
  const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
23
+ // How long `signOut` waits for the revocation before it navigates. The page
24
+ // it lands on finishes an unrevoked sign-out itself, so waiting longer than
25
+ // this only delays the sign-out.
26
+ const SIGN_OUT_REVOKE_DEADLINE_MS = 4000;
23
27
  // The data API has its own host, NOT the dashboard origin: the
24
28
  // dashboard (app.brass.build) is a static SPA, the data API is a
25
29
  // separate gateway behind api.brass.build.
@@ -41,11 +45,22 @@ const PROD_ORIGINS = {
41
45
  };
42
46
  // Resolve the four base URLs from init options. Zero config applies all
43
47
  // four production origins (including `wsBaseUrl`, so documents and
44
- // presence work with nothing passed). Naming ANY `*BaseUrl` fills
45
- // nothing from production: an app that overrides `apiBaseUrl` and
46
- // inherits a production `wsBaseUrl` would connect the WS to the wrong
47
- // stack and silently drop events, whereas an unfilled `wsBaseUrl` fails
48
- // loudly on subscribe. Pure so the precedence is unit-testable.
48
+ // presence work with nothing passed).
49
+ //
50
+ // Naming ANY `*BaseUrl` withholds the production `wsBaseUrl`: an app that
51
+ // overrides `apiBaseUrl` and inherited a production socket would connect to
52
+ // the wrong stack and silently drop events, where an unfilled `wsBaseUrl`
53
+ // fails loudly on subscribe.
54
+ //
55
+ // `authBaseUrl`, `apiBaseUrl` and `dashboardBaseUrl` still fall back to their
56
+ // production defaults, so a caller who names some but not all of them reaches
57
+ // production for the rest. `dashboardBaseUrl` is the one that goes wrong
58
+ // quietly: every hosted surface (the picker, share, the version bar) is framed
59
+ // from it, so an app on another stack frames production's, which either
60
+ // refuses to render or hands back ids the configured API cannot open. Name it
61
+ // alongside the others.
62
+ //
63
+ // Pure so the precedence is unit-testable.
49
64
  export function resolveBaseUrls(options) {
50
65
  const anyUrlOverride = Boolean(options.authBaseUrl || options.apiBaseUrl || options.wsBaseUrl || options.dashboardBaseUrl);
51
66
  const base = anyUrlOverride ? null : PROD_ORIGINS;
@@ -71,6 +86,13 @@ const REFRESH_LEEWAY_MS = 60_000;
71
86
  // group accepts both shapes, and the permissive tail keeps minor
72
87
  // server-side format bumps from stranding SDK consumers.
73
88
  const APP_ID_PATTERN = /^brass_app_(?:(?:test|live|internal)_)?[A-Za-z0-9_-]+$/;
89
+ // The rejection for `unavailable`. A 503 status is what makes the outcome
90
+ // legible to the layers that branch on one: an app's own retry, and the
91
+ // selection self-heal that clears a persisted doc id on a definitive 403 /
92
+ // 404 and leaves a transient alone.
93
+ function refreshUnavailableError() {
94
+ return new BrassError('The sign-in service is unavailable', { status: 503 });
95
+ }
74
96
  export class BrassClient {
75
97
  appId = null;
76
98
  authBaseUrl = DEFAULT_AUTH_BASE_URL;
@@ -123,6 +145,11 @@ export class BrassClient {
123
145
  // cannot block the others or mask the original error (already logged).
124
146
  errorListeners = createEmitter({ onError: () => { } });
125
147
  inflightRefresh = null;
148
+ // Bumped by every `signOut`, and captured by a `/refresh` when it starts.
149
+ // A refresh that settles against a moved epoch drops its answer: the
150
+ // credentials it would write were cleared by a sign-out while it was in
151
+ // flight, and re-writing them signs the person back in.
152
+ authEpoch = 0;
126
153
  initialized = false;
127
154
  // Incognito / third-party-cookie fallback state. Both null in a normal
128
155
  // browser, where the cross-site `brass_auth` cookie carries the session
@@ -130,7 +157,7 @@ export class BrassClient {
130
157
  // - `pendingAuthCode`: the one-time `brass_code` pulled off the
131
158
  // landing URL by bootstrap(), consumed at most once by refresh() if
132
159
  // the cookie channel is unavailable.
133
- // - `sessionToken`: the platform-session pointer from a code
160
+ // - `sessionToken`: the app-bound session pointer from a code
134
161
  // exchange. Once set, every refresh() uses it instead of the
135
162
  // cookie. Mirrored into sessionStorage so a reload in a
136
163
  // cookie-blocked browser hydrates it; see session-persistence.ts.
@@ -170,8 +197,9 @@ export class BrassClient {
170
197
  completeImport: (docId) => this.docsInternal().completeImport(docId),
171
198
  reportError: (error) => this.fireError(error),
172
199
  });
173
- // Separate from the projects namespace's WS client: one connection
174
- // per surface.
200
+ // One socket carrying every document topic this client subscribes,
201
+ // built on first open so an app that never opens a document never
202
+ // connects.
175
203
  docWsClient = null;
176
204
  // init ALWAYS resolves: failures (bad/missing app_id) fire onError +
177
205
  // console.error rather than rejecting. Otherwise
@@ -320,7 +348,7 @@ export class BrassClient {
320
348
  // renders a signed-out UI or calls `signIn()`, which navigates the
321
349
  // frame away from the export it was loaded to run.
322
350
  if (this.exportGuest === null) {
323
- for (const listener of this.listeners) {
351
+ for (const listener of Array.from(this.listeners)) {
324
352
  listener(this.user);
325
353
  }
326
354
  }
@@ -338,23 +366,34 @@ export class BrassClient {
338
366
  return;
339
367
  window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
340
368
  }
341
- // Per-app sign-out. Top-level navigation to a Brass-hosted confirmation
342
- // page that:
343
- // 1. Revokes this app's server-side approval (subsequent /refresh
344
- // calls for this app return 401 even on cookie reuse).
345
- // 2. Surfaces the email the user is still signed in to Brass as,
346
- // and offers a one-click "Sign out of Brass too" link.
369
+ // Per-app sign-out. Revokes this app's server-side approval (subsequent
370
+ // /refresh calls for this app return 401 even on cookie reuse), then makes
371
+ // a top-level navigation to a Brass-hosted page that surfaces the email
372
+ // the user is still signed in to Brass as, and offers a one-click "Sign
373
+ // out of Brass too" link.
347
374
  //
348
- // Navigation (not a background fetch) so the user gets a visible
349
- // confirmation, closing the trust gap of "did sign-out actually
350
- // happen?" The decision to ALSO sign out of the platform belongs to
351
- // the user, made on the Brass-hosted page; the calling app cannot
352
- // trigger platform-level sign-out from its own UI.
375
+ // The navigation is what closes the trust gap of "did sign-out actually
376
+ // happen?"; the revocation rides the credentialed route because the page
377
+ // answers whoever arrives at it and so writes nothing on arrival. The
378
+ // decision to ALSO sign out of the platform belongs to the user, made on
379
+ // the Brass-hosted page; the calling app cannot trigger platform-level
380
+ // sign-out from its own UI.
353
381
  signOut() {
354
382
  const appId = this.requireAppId();
355
383
  if (typeof window === 'undefined') {
356
384
  throw new Error('Brass.signOut must be called in a browser');
357
385
  }
386
+ // Read before the clears below drop it: where the cross-site cookie is
387
+ // blocked, this pointer is the revocation's only credential.
388
+ const credential = this.sessionToken
389
+ ? { kind: 'sessionToken', sessionToken: this.sessionToken }
390
+ : { kind: 'cookie' };
391
+ // Fence any `/refresh` already on the wire: landing after the clears
392
+ // below, it writes back the token set, the login hint and the session
393
+ // pointer, and re-emits the signed-in user. The hint and the pointer
394
+ // outlive the page, so that resurrects the session on the next load
395
+ // too, and the navigation below can be delayed or cancelled.
396
+ this.authEpoch += 1;
358
397
  this.setTokens(null);
359
398
  // Drop any fallback-mode credential too, so a stale session token
360
399
  // can't resurrect the session on the next getAccessToken(). Clear
@@ -376,13 +415,23 @@ export class BrassClient {
376
415
  // on the way to the confirmation page. Harmless when the app is not
377
416
  // gated (the cookie simply isn't there). Off a hosted origin there is
378
417
  // no such cookie, so go straight to the confirmation page.
379
- if (isBrassHostedHost(window.location.hostname)) {
380
- const leave = `${window.location.origin}/__brass/leave?return_to=${encodeURIComponent(signedOutUrl)}`;
381
- window.location.assign(leave);
382
- }
383
- else {
384
- window.location.assign(signedOutUrl);
385
- }
418
+ const target = isBrassHostedHost(window.location.hostname)
419
+ ? `${window.location.origin}/__brass/leave?return_to=${encodeURIComponent(signedOutUrl)}`
420
+ : signedOutUrl;
421
+ // Leave once the revocation settles, so the page reports a sign-out that
422
+ // already happened rather than offering the button that finishes it.
423
+ let left = false;
424
+ const leave = () => {
425
+ if (left)
426
+ return;
427
+ left = true;
428
+ window.location.assign(target);
429
+ };
430
+ const deadline = setTimeout(leave, SIGN_OUT_REVOKE_DEADLINE_MS);
431
+ void revokeAppApproval(this.authBaseUrl, appId, credential).then(() => {
432
+ clearTimeout(deadline);
433
+ leave();
434
+ });
386
435
  }
387
436
  get currentUser() {
388
437
  return this.user;
@@ -438,10 +487,11 @@ export class BrassClient {
438
487
  fireError(error) {
439
488
  this.errorListeners.emit(error);
440
489
  }
441
- // The raw HTTP metadata + grant namespace (create / get / move /
442
- // grantAccess); the editable surface stays on
443
- // `Brass.openDocumentById` / `Brass.createDocument`. Throws "Call
444
- // Brass.init(appId) first" before init.
490
+ // The metadata + thumbnail namespace for a document the app already
491
+ // holds an id for (`get` / `setThumbnail` / `getThumbnail`); the
492
+ // editable surface stays on `Brass.openDocumentById` /
493
+ // `Brass.createDocument`. Throws "Call Brass.init(appId) first" before
494
+ // init.
445
495
  get documents() {
446
496
  return this.docsInternal();
447
497
  }
@@ -564,9 +614,8 @@ export class BrassClient {
564
614
  dashboardBaseUrl: this._dashboardBaseUrl,
565
615
  getDocument: (docId) => this.documents.get(docId),
566
616
  // The version-preview bar is a hosted embed; it takes the same theme
567
- // and fallback session pointer as the other hosted surfaces.
617
+ // as the other hosted surfaces.
568
618
  hostedUiTheme: this.hostedUiTheme,
569
- getSessionToken: () => this.sessionToken,
570
619
  };
571
620
  }
572
621
  // Opens the hosted document picker in a modal iframe and resolves with
@@ -634,11 +683,6 @@ export class BrassClient {
634
683
  else
635
684
  this.session._clearLocalDelete(docId);
636
685
  },
637
- // Hand our fallback platform-session pointer to the embed so it can
638
- // authenticate the dashboard when its own framed cookie is blocked
639
- // (incognito / Safari ITP). Null in a normal browser, where the
640
- // embed's cross-site cookie carries the session on its own.
641
- getSessionToken: () => this.sessionToken,
642
686
  // Name the user this app is acting as, so the embed can refuse to
643
687
  // act as anyone else. The embed authenticates from the dashboard
644
688
  // origin's platform-session cookie, which moves when a second
@@ -679,14 +723,21 @@ export class BrassClient {
679
723
  // "live editing unavailable" in the UI.
680
724
  async openDocumentById(docId, options) {
681
725
  const opened = await this.openDocumentInternal(docId, options);
682
- this.maybeWriteDocIdToUrl(opened.id);
726
+ // The URL names the document the reader is looking at, and the session
727
+ // owns that whenever it holds one: this open is detached, so claiming the
728
+ // URL would leave the address bar naming a document the session is not
729
+ // showing, and a reload would swap the reader onto it. An app with no
730
+ // session document still gets the sync, which is the read-mostly caller
731
+ // this method exists for.
732
+ if (this.session.document === null)
733
+ this.maybeWriteDocIdToUrl(opened.id);
683
734
  return opened;
684
735
  }
685
736
  // Open a document READ-ONLY from a public-link token (the "anyone with
686
737
  // the link" anonymous path). No sign-in: the token is the credential and
687
738
  // the server serves a read-only, member-stripped snapshot with no live
688
739
  // subscription. `Brass.session` drives this from the URL on boot when the
689
- // page carries `?brass_link=`; there is no app-facing entry point.
740
+ // page carries `?brass_link=`.
690
741
  async openByLinkInternal(token, options) {
691
742
  const factory = this.getDocumentFactory();
692
743
  const opened = await factory.openByLink(token, options);
@@ -850,12 +901,12 @@ export class BrassClient {
850
901
  this.importRunner.setImporter(importer);
851
902
  if (this.guestLinkTokenValue !== null)
852
903
  return;
853
- for (const doc of this.openDocuments.values()) {
904
+ for (const doc of this.liveOpenDocuments()) {
854
905
  void this.importRunner.attempt(doc);
855
906
  }
856
907
  }
857
908
  // Track an opened document so an export request can resolve its id to
858
- // the live Document. Drop it when the doc goes; a re-open overwrites.
909
+ // the live Document. A re-open overwrites.
859
910
  trackOpenDocument(doc) {
860
911
  this.openDocuments.set(doc.id, doc);
861
912
  doc.onGone(() => {
@@ -870,22 +921,55 @@ export class BrassClient {
870
921
  void this.importRunner.attempt(doc);
871
922
  }
872
923
  }
924
+ // The tracked documents still open, dropping the rest as it goes.
925
+ // `onGone` covers only a server-side eviction, and two ordinary paths
926
+ // retire a Document without one: an app's `close()` (which parks it, so
927
+ // the public surface throws while the instance lives on in the park
928
+ // cache) and that cache's LRU eviction (a full teardown, no listener
929
+ // left to fire). Reading through this is what keeps a retired wrapper
930
+ // out of an answer, and what keeps the set from growing for the life of
931
+ // the page.
932
+ liveOpenDocuments() {
933
+ const live = [];
934
+ for (const [docId, doc] of this.openDocuments) {
935
+ if (doc._isLive)
936
+ live.push(doc);
937
+ else
938
+ this.openDocuments.delete(docId);
939
+ }
940
+ return live;
941
+ }
942
+ liveOpenDocument(docId) {
943
+ const doc = this.openDocuments.get(docId);
944
+ if (!doc)
945
+ return null;
946
+ if (doc._isLive)
947
+ return doc;
948
+ this.openDocuments.delete(docId);
949
+ return null;
950
+ }
873
951
  // Run the registered serializer for an export request from a hosted
874
952
  // surface. Resolves the doc from the open set (the active session doc is
875
953
  // the fallback for an app that drives it through the session), then hands
876
954
  // it to the serializer. A missing serializer is the ordinary "this app
877
- // does not export" outcome, not an error.
955
+ // does not export" outcome, not an error. A document the app has closed
956
+ // reports `no_document`: running the serializer against it would throw
957
+ // out of the app's own record reads and report `serializer_failed`, which
958
+ // names the app's code for a state the platform is holding.
878
959
  async serializeForExport(docId) {
879
960
  if (!this.serializer) {
880
961
  return { ok: false, code: 'no_serializer', message: 'No serializer registered.' };
881
962
  }
882
963
  const sessionDoc = this.documentSession?.document ?? null;
883
- const doc = this.openDocuments.get(docId) ??
884
- (sessionDoc && sessionDoc.id === docId ? sessionDoc : null);
964
+ const doc = this.liveOpenDocument(docId) ??
965
+ (sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
885
966
  if (!doc) {
886
967
  return { ok: false, code: 'no_document', message: 'Document is not open.' };
887
968
  }
888
969
  try {
970
+ // A serializer runs detached, with no subscription to have been fed by,
971
+ // and it needs the document once, so it is handed the containers whole
972
+ // (`StoredDocument`) rather than the fed surface an app holds.
889
973
  const bytes = await this.serializer(doc);
890
974
  return { ok: true, bytes };
891
975
  }
@@ -932,6 +1016,7 @@ export class BrassClient {
932
1016
  apiBaseUrl: this.apiBaseUrl,
933
1017
  getAccessToken: () => this.getAccessToken(),
934
1018
  getAppAssertion: () => this.getAppAssertion(),
1019
+ identity: () => this.user?.sub ?? null,
935
1020
  ...(wsClient ? { wsClient } : {}),
936
1021
  branchProvider: {
937
1022
  list: (docId) => docsInternal.listBranches(docId),
@@ -1019,28 +1104,53 @@ export class BrassClient {
1019
1104
  });
1020
1105
  return this.docWsClient;
1021
1106
  }
1107
+ // Resolves null only when the server answered that this app has no live
1108
+ // session, and rejects with a 503 `BrassError` when a `/refresh` found no
1109
+ // answer at all. The two outcomes drive opposite handling: null is the
1110
+ // signed-out state a caller renders a sign-in CTA for, where a rejection
1111
+ // is a momentary outage the caller retries. Collapsing them into null
1112
+ // reports a 401-shaped "Not signed in" to a signed-in user whose network
1113
+ // blipped, and hides the transient from every layer that self-heals on it.
1022
1114
  async getAccessToken() {
1023
1115
  this.requireInit();
1116
+ // On a `?brass_link=` page the link token is the credential and `init`
1117
+ // runs no auth bootstrap, so answer null rather than probing /refresh.
1118
+ // A visitor with no session would draw a 401 the browser logs; one who
1119
+ // holds a session for this app would get a real token, and a caller
1120
+ // that prefers a token over the link token (the socket does) would then
1121
+ // present membership where the page's whole authority is the link.
1122
+ if (this.guestLinkTokenValue !== null)
1123
+ return null;
1024
1124
  const current = this.tokens;
1025
1125
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1026
1126
  return current.accessToken;
1027
1127
  }
1028
- const refreshed = await this.refresh();
1029
- return refreshed?.accessToken ?? null;
1128
+ const settled = await this.refresh();
1129
+ if (settled.status === 'unavailable')
1130
+ throw refreshUnavailableError();
1131
+ return settled.status === 'ok' ? settled.tokens.accessToken : null;
1030
1132
  }
1031
1133
  // The acting-app assertion paired with the current access token, or
1032
1134
  // null when the proxy issued none. Mirrors `getAccessToken`'s
1033
1135
  // freshness check so the two stay in lockstep: a request builder that
1034
1136
  // calls `getAccessToken()` then this reads the same (possibly just
1035
- // refreshed) token set without forcing a second refresh.
1137
+ // refreshed) token set without forcing a second refresh. Rejects on a
1138
+ // transient outage for the same reason `getAccessToken` does.
1036
1139
  async getAppAssertion() {
1037
1140
  this.requireInit();
1141
+ // Paired with `getAccessToken`: a link guest holds no token to assert
1142
+ // an acting app against, and reaching /refresh here would authenticate
1143
+ // the page the same way.
1144
+ if (this.guestLinkTokenValue !== null)
1145
+ return null;
1038
1146
  const current = this.tokens;
1039
1147
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1040
1148
  return current.appAssertion ?? null;
1041
1149
  }
1042
- const refreshed = await this.refresh();
1043
- return refreshed?.appAssertion ?? null;
1150
+ const settled = await this.refresh();
1151
+ if (settled.status === 'unavailable')
1152
+ throw refreshUnavailableError();
1153
+ return settled.status === 'ok' ? (settled.tokens.appAssertion ?? null) : null;
1044
1154
  }
1045
1155
  async bootstrap() {
1046
1156
  if (typeof window === 'undefined')
@@ -1048,7 +1158,7 @@ export class BrassClient {
1048
1158
  const appId = this.requireAppId();
1049
1159
  // Every successful sign-in 302s back to return_to with the
1050
1160
  // platform-session cookie set AND a one-time `brass_code` query
1051
- // parameter (see infra/lambda/auth/token-redirect.ts). The cookie
1161
+ // parameter. The cookie
1052
1162
  // is the primary channel; the code is the incognito / Safari ITP /
1053
1163
  // post-third-party-cookie fallback for when the browser drops the
1054
1164
  // cross-site cookie on our /refresh XHR.
@@ -1061,8 +1171,7 @@ export class BrassClient {
1061
1171
  //
1062
1172
  // No app-controlled silent-mint path: the SDK never auto-fires
1063
1173
  // Brass.signIn() based on a URL hint. New app approvals always
1064
- // require an explicit click on the auth-hosted picker (see the
1065
- // adaptive /sign-in branch in infra/lambda/auth/sign-in.ts).
1174
+ // require an explicit click on the auth-hosted picker.
1066
1175
  // Same treatment for the launching organization: read it off the URL
1067
1176
  // and strip it here, so a client-side navigation later in the session
1068
1177
  // cannot lose it and the address bar stays the app's own.
@@ -1120,16 +1229,26 @@ export class BrassClient {
1120
1229
  if (this.inflightRefresh)
1121
1230
  return this.inflightRefresh;
1122
1231
  const appId = this.requireAppId();
1232
+ const epoch = this.authEpoch;
1123
1233
  this.inflightRefresh = (async () => {
1124
- const result = await this.runRefresh(appId);
1234
+ const outcome = await this.runRefresh(appId);
1235
+ // No answer means no change: the token set, the login hint, and any
1236
+ // fallback session token stay as they are, and a later attempt settles
1237
+ // the session.
1238
+ if (outcome.status === 'unavailable')
1239
+ return { status: 'unavailable' };
1240
+ // A sign-out landed while this round-trip was in flight, so whatever
1241
+ // it resolved belongs to a session the person has left. Report it as
1242
+ // no session and write nothing: the caller waiting on this token gets
1243
+ // the signed-out answer the sign-out established.
1244
+ if (this.authEpoch !== epoch)
1245
+ return { status: 'no_session' };
1246
+ const result = outcome.status === 'ok' ? outcome.result : null;
1125
1247
  this.setTokens(result?.tokens ?? null);
1126
1248
  // Remember whether a live session exists for this app so the next
1127
1249
  // cold load knows whether to probe /refresh (see bootstrap). A
1128
- // success proves a session; an empty result means none (or it's
1129
- // gone), so drop the hint and the next signed-out load stays
1130
- // silent. A transient network failure also clears it; the cost is
1131
- // one extra sign-in click on the next load, self-healing on the
1132
- // next success.
1250
+ // success proves a session; `no_session` is the server saying there
1251
+ // is none, so drop the hint and the next signed-out load stays silent.
1133
1252
  if (result) {
1134
1253
  storeLoginHint(appId);
1135
1254
  }
@@ -1141,12 +1260,11 @@ export class BrassClient {
1141
1260
  // Mirror into sessionStorage so a reload on this tab can
1142
1261
  // hydrate it (the cookie channel is unavailable in this
1143
1262
  // browser by construction; we only get here via the code or
1144
- // sid channels). The server-side `approved_apps` gate stays
1145
- // the security boundary; the stored sid is no more sensitive
1146
- // than the cookie it mirrors.
1263
+ // sid channels). The pointer is bound to this app id, so what
1264
+ // is stored authenticates this app and nothing else.
1147
1265
  storeSessionToken(appId, result.sessionToken);
1148
1266
  }
1149
- return result?.tokens ?? null;
1267
+ return result ? { status: 'ok', tokens: result.tokens } : { status: 'no_session' };
1150
1268
  })().finally(() => {
1151
1269
  this.inflightRefresh = null;
1152
1270
  });
@@ -1155,16 +1273,20 @@ export class BrassClient {
1155
1273
  // Picks the credential channel for a single /refresh round-trip. The
1156
1274
  // cookie path is primary and untouched for browsers that send the
1157
1275
  // third-party cookie; the one-time code is consumed only when that
1158
- // cookie path genuinely came back empty. Once a fallback session
1276
+ // cookie path resolved no session. Once a fallback session
1159
1277
  // token is in hand (the cross-site cookie is proven unavailable for
1160
1278
  // this page's life), every later refresh uses it directly.
1279
+ //
1280
+ // A rung falls through to the next only on `no_session`. Each one discards
1281
+ // a credential as it passes (the stale sid, the one-time code), and an
1282
+ // `unavailable` says nothing about the credential it just tried.
1161
1283
  async runRefresh(appId) {
1162
1284
  if (this.sessionToken) {
1163
1285
  const viaSession = await refreshTokens(this.authBaseUrl, appId, {
1164
1286
  kind: 'sessionToken',
1165
1287
  sessionToken: this.sessionToken,
1166
1288
  });
1167
- if (viaSession)
1289
+ if (viaSession.status !== 'no_session')
1168
1290
  return viaSession;
1169
1291
  // Stored sid is stale (server-side revoke, row expiry). Drop both
1170
1292
  // the in-memory copy and the sessionStorage mirror, then fall
@@ -1176,20 +1298,31 @@ export class BrassClient {
1176
1298
  clearSessionToken(appId);
1177
1299
  }
1178
1300
  const viaCookie = await refreshTokens(this.authBaseUrl, appId);
1179
- if (viaCookie)
1301
+ if (viaCookie.status !== 'no_session')
1180
1302
  return viaCookie;
1181
- // Cookie channel produced nothing. Fall back to the one-time code if
1182
- // we landed with one. Clear it first: it is single-use server-side,
1183
- // so a failed exchange must not be retried.
1303
+ // Cookie channel resolved no session. Fall back to the one-time code if
1304
+ // we landed with one. Clear it first: it is single-use server-side, so a
1305
+ // failed exchange must not be retried.
1184
1306
  const code = this.pendingAuthCode;
1185
- this.pendingAuthCode = null;
1186
1307
  if (!code)
1187
- return null;
1308
+ return viaCookie;
1309
+ this.pendingAuthCode = null;
1188
1310
  return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
1189
1311
  }
1190
1312
  setTokens(next) {
1191
- this.tokens = next;
1192
1313
  const nextUser = next ? decodeIdToken(next.idToken) : null;
1314
+ // A different person on this page. `/refresh` resolves whichever platform
1315
+ // session the browser holds, so the identity behind a page that never
1316
+ // navigated can become someone else's (another tab signs out and a second
1317
+ // person signs in). Run the retirement first, as its own transition, so
1318
+ // what the previous identity left open is retired before the new one is
1319
+ // announced rather than inherited by it.
1320
+ if (this.user !== null &&
1321
+ nextUser !== null &&
1322
+ this.user.sub !== nextUser.sub) {
1323
+ this.setTokens(null);
1324
+ }
1325
+ this.tokens = next;
1193
1326
  if (sameUser(this.user, nextUser))
1194
1327
  return;
1195
1328
  this.user = nextUser;
@@ -1200,9 +1333,18 @@ export class BrassClient {
1200
1333
  // (the same user), double-emitting on every signed-in app load.
1201
1334
  if (!this.bootstrapped)
1202
1335
  return;
1203
- for (const listener of this.listeners) {
1336
+ // Copied before the walk: a listener that detaches another would otherwise
1337
+ // remove it before its turn, and a `Set` never visits such an entry.
1338
+ for (const listener of Array.from(this.listeners)) {
1204
1339
  listener(nextUser);
1205
1340
  }
1341
+ // After the listeners, because the session closes the active document as
1342
+ // it hears the sign-out and a close PARKS the Document. A parked Document
1343
+ // answers the next open of its key out of memory, with no token and no
1344
+ // request, so one left behind here is the previous person's document
1345
+ // handed to whoever signs in next.
1346
+ if (nextUser === null)
1347
+ clearParkedDocuments();
1206
1348
  }
1207
1349
  async hydrateProfile() {
1208
1350
  if (!this.user)