@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/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,
@@ -111,6 +114,10 @@ import type {
111
114
  } from './types.js';
112
115
 
113
116
  const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
117
+ // How long `signOut` waits for the revocation before it navigates. The page
118
+ // it lands on finishes an unrevoked sign-out itself, so waiting longer than
119
+ // this only delays the sign-out.
120
+ const SIGN_OUT_REVOKE_DEADLINE_MS = 4000;
114
121
  // The data API has its own host, NOT the dashboard origin: the
115
122
  // dashboard (app.brass.build) is a static SPA, the data API is a
116
123
  // separate gateway behind api.brass.build.
@@ -146,11 +153,22 @@ export interface ResolvedBaseUrls {
146
153
 
147
154
  // Resolve the four base URLs from init options. Zero config applies all
148
155
  // 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.
156
+ // presence work with nothing passed).
157
+ //
158
+ // Naming ANY `*BaseUrl` withholds the production `wsBaseUrl`: an app that
159
+ // overrides `apiBaseUrl` and inherited a production socket would connect to
160
+ // the wrong stack and silently drop events, where an unfilled `wsBaseUrl`
161
+ // fails loudly on subscribe.
162
+ //
163
+ // `authBaseUrl`, `apiBaseUrl` and `dashboardBaseUrl` still fall back to their
164
+ // production defaults, so a caller who names some but not all of them reaches
165
+ // production for the rest. `dashboardBaseUrl` is the one that goes wrong
166
+ // quietly: every hosted surface (the picker, share, the version bar) is framed
167
+ // from it, so an app on another stack frames production's, which either
168
+ // refuses to render or hands back ids the configured API cannot open. Name it
169
+ // alongside the others.
170
+ //
171
+ // Pure so the precedence is unit-testable.
154
172
  export function resolveBaseUrls(options: BrassInitOptions): ResolvedBaseUrls {
155
173
  const anyUrlOverride = Boolean(
156
174
  options.authBaseUrl || options.apiBaseUrl || options.wsBaseUrl || options.dashboardBaseUrl,
@@ -179,6 +197,23 @@ const REFRESH_LEEWAY_MS = 60_000;
179
197
  // server-side format bumps from stranding SDK consumers.
180
198
  const APP_ID_PATTERN = /^brass_app_(?:(?:test|live|internal)_)?[A-Za-z0-9_-]+$/;
181
199
 
200
+ // What one `/refresh` settled, applied to the client's own state. Carries the
201
+ // `no_session` / `unavailable` split `RefreshOutcome` draws, because the two
202
+ // are opposite answers for a caller: `no_session` is the server saying this
203
+ // app has none, `unavailable` is the absence of an answer.
204
+ type RefreshSettlement =
205
+ | { status: 'ok'; tokens: TokenSet }
206
+ | { status: 'no_session' }
207
+ | { status: 'unavailable' };
208
+
209
+ // The rejection for `unavailable`. A 503 status is what makes the outcome
210
+ // legible to the layers that branch on one: an app's own retry, and the
211
+ // selection self-heal that clears a persisted doc id on a definitive 403 /
212
+ // 404 and leaves a transient alone.
213
+ function refreshUnavailableError(): BrassError {
214
+ return new BrassError('The sign-in service is unavailable', { status: 503 });
215
+ }
216
+
182
217
  export class BrassClient {
183
218
  private appId: string | null = null;
184
219
  private authBaseUrl = DEFAULT_AUTH_BASE_URL;
@@ -230,7 +265,12 @@ export class BrassClient {
230
265
  // Init-failure listeners. Swallow a listener's throw so one bad handler
231
266
  // cannot block the others or mask the original error (already logged).
232
267
  private readonly errorListeners = createEmitter<Error>({ onError: () => {} });
233
- private inflightRefresh: Promise<TokenSet | null> | null = null;
268
+ private inflightRefresh: Promise<RefreshSettlement> | null = null;
269
+ // Bumped by every `signOut`, and captured by a `/refresh` when it starts.
270
+ // A refresh that settles against a moved epoch drops its answer: the
271
+ // credentials it would write were cleared by a sign-out while it was in
272
+ // flight, and re-writing them signs the person back in.
273
+ private authEpoch = 0;
234
274
  private initialized = false;
235
275
  // Incognito / third-party-cookie fallback state. Both null in a normal
236
276
  // browser, where the cross-site `brass_auth` cookie carries the session
@@ -238,7 +278,7 @@ export class BrassClient {
238
278
  // - `pendingAuthCode`: the one-time `brass_code` pulled off the
239
279
  // landing URL by bootstrap(), consumed at most once by refresh() if
240
280
  // the cookie channel is unavailable.
241
- // - `sessionToken`: the platform-session pointer from a code
281
+ // - `sessionToken`: the app-bound session pointer from a code
242
282
  // exchange. Once set, every refresh() uses it instead of the
243
283
  // cookie. Mirrored into sessionStorage so a reload in a
244
284
  // cookie-blocked browser hydrates it; see session-persistence.ts.
@@ -280,8 +320,9 @@ export class BrassClient {
280
320
  completeImport: (docId) => this.docsInternal().completeImport(docId),
281
321
  reportError: (error) => this.fireError(error),
282
322
  });
283
- // Separate from the projects namespace's WS client: one connection
284
- // per surface.
323
+ // One socket carrying every document topic this client subscribes,
324
+ // built on first open so an app that never opens a document never
325
+ // connects.
285
326
  private docWsClient: BrassWsClient | null = null;
286
327
 
287
328
  // init ALWAYS resolves: failures (bad/missing app_id) fire onError +
@@ -439,7 +480,7 @@ export class BrassClient {
439
480
  // renders a signed-out UI or calls `signIn()`, which navigates the
440
481
  // frame away from the export it was loaded to run.
441
482
  if (this.exportGuest === null) {
442
- for (const listener of this.listeners) {
483
+ for (const listener of Array.from(this.listeners)) {
443
484
  listener(this.user);
444
485
  }
445
486
  }
@@ -458,23 +499,34 @@ export class BrassClient {
458
499
  window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
459
500
  }
460
501
 
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.
502
+ // Per-app sign-out. Revokes this app's server-side approval (subsequent
503
+ // /refresh calls for this app return 401 even on cookie reuse), then makes
504
+ // a top-level navigation to a Brass-hosted page that surfaces the email
505
+ // the user is still signed in to Brass as, and offers a one-click "Sign
506
+ // out of Brass too" link.
467
507
  //
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.
508
+ // The navigation is what closes the trust gap of "did sign-out actually
509
+ // happen?"; the revocation rides the credentialed route because the page
510
+ // answers whoever arrives at it and so writes nothing on arrival. The
511
+ // decision to ALSO sign out of the platform belongs to the user, made on
512
+ // the Brass-hosted page; the calling app cannot trigger platform-level
513
+ // sign-out from its own UI.
473
514
  signOut(): void {
474
515
  const appId = this.requireAppId();
475
516
  if (typeof window === 'undefined') {
476
517
  throw new Error('Brass.signOut must be called in a browser');
477
518
  }
519
+ // Read before the clears below drop it: where the cross-site cookie is
520
+ // blocked, this pointer is the revocation's only credential.
521
+ const credential: RefreshCredential = this.sessionToken
522
+ ? { kind: 'sessionToken', sessionToken: this.sessionToken }
523
+ : { kind: 'cookie' };
524
+ // Fence any `/refresh` already on the wire: landing after the clears
525
+ // below, it writes back the token set, the login hint and the session
526
+ // pointer, and re-emits the signed-in user. The hint and the pointer
527
+ // outlive the page, so that resurrects the session on the next load
528
+ // too, and the navigation below can be delayed or cancelled.
529
+ this.authEpoch += 1;
478
530
  this.setTokens(null);
479
531
  // Drop any fallback-mode credential too, so a stale session token
480
532
  // can't resurrect the session on the next getAccessToken(). Clear
@@ -496,12 +548,22 @@ export class BrassClient {
496
548
  // on the way to the confirmation page. Harmless when the app is not
497
549
  // gated (the cookie simply isn't there). Off a hosted origin there is
498
550
  // 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
- }
551
+ const target = isBrassHostedHost(window.location.hostname)
552
+ ? `${window.location.origin}/__brass/leave?return_to=${encodeURIComponent(signedOutUrl)}`
553
+ : signedOutUrl;
554
+ // Leave once the revocation settles, so the page reports a sign-out that
555
+ // already happened rather than offering the button that finishes it.
556
+ let left = false;
557
+ const leave = (): void => {
558
+ if (left) return;
559
+ left = true;
560
+ window.location.assign(target);
561
+ };
562
+ const deadline = setTimeout(leave, SIGN_OUT_REVOKE_DEADLINE_MS);
563
+ void revokeAppApproval(this.authBaseUrl, appId, credential).then(() => {
564
+ clearTimeout(deadline);
565
+ leave();
566
+ });
505
567
  }
506
568
 
507
569
  get currentUser(): BrassUser | null {
@@ -563,10 +625,11 @@ export class BrassClient {
563
625
  this.errorListeners.emit(error);
564
626
  }
565
627
 
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.
628
+ // The metadata + thumbnail namespace for a document the app already
629
+ // holds an id for (`get` / `setThumbnail` / `getThumbnail`); the
630
+ // editable surface stays on `Brass.openDocumentById` /
631
+ // `Brass.createDocument`. Throws "Call Brass.init(appId) first" before
632
+ // init.
570
633
  get documents(): DocumentsNamespace {
571
634
  return this.docsInternal();
572
635
  }
@@ -713,9 +776,8 @@ export class BrassClient {
713
776
  dashboardBaseUrl: this._dashboardBaseUrl,
714
777
  getDocument: (docId) => this.documents.get(docId),
715
778
  // The version-preview bar is a hosted embed; it takes the same theme
716
- // and fallback session pointer as the other hosted surfaces.
779
+ // as the other hosted surfaces.
717
780
  hostedUiTheme: this.hostedUiTheme,
718
- getSessionToken: () => this.sessionToken,
719
781
  };
720
782
  }
721
783
 
@@ -788,11 +850,6 @@ export class BrassClient {
788
850
  if (active) this.session._noteLocalDelete(docId);
789
851
  else this.session._clearLocalDelete(docId);
790
852
  },
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
853
  // Name the user this app is acting as, so the embed can refuse to
797
854
  // act as anyone else. The embed authenticates from the dashboard
798
855
  // origin's platform-session cookie, which moves when a second
@@ -837,7 +894,13 @@ export class BrassClient {
837
894
  options?: OpenDocumentByIdOptions,
838
895
  ): Promise<OpenedDocument> {
839
896
  const opened = await this.openDocumentInternal(docId, options);
840
- this.maybeWriteDocIdToUrl(opened.id);
897
+ // The URL names the document the reader is looking at, and the session
898
+ // owns that whenever it holds one: this open is detached, so claiming the
899
+ // URL would leave the address bar naming a document the session is not
900
+ // showing, and a reload would swap the reader onto it. An app with no
901
+ // session document still gets the sync, which is the read-mostly caller
902
+ // this method exists for.
903
+ if (this.session.document === null) this.maybeWriteDocIdToUrl(opened.id);
841
904
  return opened;
842
905
  }
843
906
 
@@ -845,7 +908,7 @@ export class BrassClient {
845
908
  // the link" anonymous path). No sign-in: the token is the credential and
846
909
  // the server serves a read-only, member-stripped snapshot with no live
847
910
  // subscription. `Brass.session` drives this from the URL on boot when the
848
- // page carries `?brass_link=`; there is no app-facing entry point.
911
+ // page carries `?brass_link=`.
849
912
  private async openByLinkInternal(
850
913
  token: string,
851
914
  options?: OpenDocumentByIdOptions,
@@ -1023,13 +1086,13 @@ export class BrassClient {
1023
1086
  registerImporter(importer: DocumentImporter): void {
1024
1087
  this.importRunner.setImporter(importer);
1025
1088
  if (this.guestLinkTokenValue !== null) return;
1026
- for (const doc of this.openDocuments.values()) {
1089
+ for (const doc of this.liveOpenDocuments()) {
1027
1090
  void this.importRunner.attempt(doc);
1028
1091
  }
1029
1092
  }
1030
1093
 
1031
1094
  // 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.
1095
+ // the live Document. A re-open overwrites.
1033
1096
  private trackOpenDocument(doc: OpenedDocument): void {
1034
1097
  this.openDocuments.set(doc.id, doc);
1035
1098
  doc.onGone(() => {
@@ -1045,24 +1108,55 @@ export class BrassClient {
1045
1108
  }
1046
1109
  }
1047
1110
 
1111
+ // The tracked documents still open, dropping the rest as it goes.
1112
+ // `onGone` covers only a server-side eviction, and two ordinary paths
1113
+ // retire a Document without one: an app's `close()` (which parks it, so
1114
+ // the public surface throws while the instance lives on in the park
1115
+ // cache) and that cache's LRU eviction (a full teardown, no listener
1116
+ // left to fire). Reading through this is what keeps a retired wrapper
1117
+ // out of an answer, and what keeps the set from growing for the life of
1118
+ // the page.
1119
+ private liveOpenDocuments(): Document[] {
1120
+ const live: Document[] = [];
1121
+ for (const [docId, doc] of this.openDocuments) {
1122
+ if (doc._isLive) live.push(doc);
1123
+ else this.openDocuments.delete(docId);
1124
+ }
1125
+ return live;
1126
+ }
1127
+
1128
+ private liveOpenDocument(docId: string): Document | null {
1129
+ const doc = this.openDocuments.get(docId);
1130
+ if (!doc) return null;
1131
+ if (doc._isLive) return doc;
1132
+ this.openDocuments.delete(docId);
1133
+ return null;
1134
+ }
1135
+
1048
1136
  // Run the registered serializer for an export request from a hosted
1049
1137
  // surface. Resolves the doc from the open set (the active session doc is
1050
1138
  // the fallback for an app that drives it through the session), then hands
1051
1139
  // it to the serializer. A missing serializer is the ordinary "this app
1052
- // does not export" outcome, not an error.
1140
+ // does not export" outcome, not an error. A document the app has closed
1141
+ // reports `no_document`: running the serializer against it would throw
1142
+ // out of the app's own record reads and report `serializer_failed`, which
1143
+ // names the app's code for a state the platform is holding.
1053
1144
  private async serializeForExport(docId: string): Promise<ExportSerializeOutcome> {
1054
1145
  if (!this.serializer) {
1055
1146
  return { ok: false, code: 'no_serializer', message: 'No serializer registered.' };
1056
1147
  }
1057
1148
  const sessionDoc = this.documentSession?.document ?? null;
1058
1149
  const doc =
1059
- this.openDocuments.get(docId) ??
1060
- (sessionDoc && sessionDoc.id === docId ? sessionDoc : null);
1150
+ this.liveOpenDocument(docId) ??
1151
+ (sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
1061
1152
  if (!doc) {
1062
1153
  return { ok: false, code: 'no_document', message: 'Document is not open.' };
1063
1154
  }
1064
1155
  try {
1065
- const bytes = await this.serializer(doc);
1156
+ // A serializer runs detached, with no subscription to have been fed by,
1157
+ // and it needs the document once, so it is handed the containers whole
1158
+ // (`StoredDocument`) rather than the fed surface an app holds.
1159
+ const bytes = await this.serializer(doc as StoredDocument & Document);
1066
1160
  return { ok: true, bytes };
1067
1161
  } catch (err) {
1068
1162
  return {
@@ -1107,6 +1201,7 @@ export class BrassClient {
1107
1201
  apiBaseUrl: this.apiBaseUrl,
1108
1202
  getAccessToken: () => this.getAccessToken(),
1109
1203
  getAppAssertion: () => this.getAppAssertion(),
1204
+ identity: () => this.user?.sub ?? null,
1110
1205
  ...(wsClient ? { wsClient } : {}),
1111
1206
  branchProvider: {
1112
1207
  list: (docId) => docsInternal.listBranches(docId),
@@ -1200,29 +1295,50 @@ export class BrassClient {
1200
1295
  return this.docWsClient;
1201
1296
  }
1202
1297
 
1298
+ // Resolves null only when the server answered that this app has no live
1299
+ // session, and rejects with a 503 `BrassError` when a `/refresh` found no
1300
+ // answer at all. The two outcomes drive opposite handling: null is the
1301
+ // signed-out state a caller renders a sign-in CTA for, where a rejection
1302
+ // is a momentary outage the caller retries. Collapsing them into null
1303
+ // reports a 401-shaped "Not signed in" to a signed-in user whose network
1304
+ // blipped, and hides the transient from every layer that self-heals on it.
1203
1305
  async getAccessToken(): Promise<string | null> {
1204
1306
  this.requireInit();
1307
+ // On a `?brass_link=` page the link token is the credential and `init`
1308
+ // runs no auth bootstrap, so answer null rather than probing /refresh.
1309
+ // A visitor with no session would draw a 401 the browser logs; one who
1310
+ // holds a session for this app would get a real token, and a caller
1311
+ // that prefers a token over the link token (the socket does) would then
1312
+ // present membership where the page's whole authority is the link.
1313
+ if (this.guestLinkTokenValue !== null) return null;
1205
1314
  const current = this.tokens;
1206
1315
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1207
1316
  return current.accessToken;
1208
1317
  }
1209
- const refreshed = await this.refresh();
1210
- return refreshed?.accessToken ?? null;
1318
+ const settled = await this.refresh();
1319
+ if (settled.status === 'unavailable') throw refreshUnavailableError();
1320
+ return settled.status === 'ok' ? settled.tokens.accessToken : null;
1211
1321
  }
1212
1322
 
1213
1323
  // The acting-app assertion paired with the current access token, or
1214
1324
  // null when the proxy issued none. Mirrors `getAccessToken`'s
1215
1325
  // freshness check so the two stay in lockstep: a request builder that
1216
1326
  // calls `getAccessToken()` then this reads the same (possibly just
1217
- // refreshed) token set without forcing a second refresh.
1327
+ // refreshed) token set without forcing a second refresh. Rejects on a
1328
+ // transient outage for the same reason `getAccessToken` does.
1218
1329
  async getAppAssertion(): Promise<string | null> {
1219
1330
  this.requireInit();
1331
+ // Paired with `getAccessToken`: a link guest holds no token to assert
1332
+ // an acting app against, and reaching /refresh here would authenticate
1333
+ // the page the same way.
1334
+ if (this.guestLinkTokenValue !== null) return null;
1220
1335
  const current = this.tokens;
1221
1336
  if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1222
1337
  return current.appAssertion ?? null;
1223
1338
  }
1224
- const refreshed = await this.refresh();
1225
- return refreshed?.appAssertion ?? null;
1339
+ const settled = await this.refresh();
1340
+ if (settled.status === 'unavailable') throw refreshUnavailableError();
1341
+ return settled.status === 'ok' ? (settled.tokens.appAssertion ?? null) : null;
1226
1342
  }
1227
1343
 
1228
1344
  private async bootstrap(): Promise<void> {
@@ -1230,7 +1346,7 @@ export class BrassClient {
1230
1346
  const appId = this.requireAppId();
1231
1347
  // Every successful sign-in 302s back to return_to with the
1232
1348
  // platform-session cookie set AND a one-time `brass_code` query
1233
- // parameter (see infra/lambda/auth/token-redirect.ts). The cookie
1349
+ // parameter. The cookie
1234
1350
  // is the primary channel; the code is the incognito / Safari ITP /
1235
1351
  // post-third-party-cookie fallback for when the browser drops the
1236
1352
  // cross-site cookie on our /refresh XHR.
@@ -1243,8 +1359,7 @@ export class BrassClient {
1243
1359
  //
1244
1360
  // No app-controlled silent-mint path: the SDK never auto-fires
1245
1361
  // 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).
1362
+ // require an explicit click on the auth-hosted picker.
1248
1363
  // Same treatment for the launching organization: read it off the URL
1249
1364
  // and strip it here, so a client-side navigation later in the session
1250
1365
  // cannot lose it and the address bar stays the app's own.
@@ -1300,19 +1415,27 @@ export class BrassClient {
1300
1415
  this.pendingAuthCode = null;
1301
1416
  }
1302
1417
 
1303
- private async refresh(): Promise<TokenSet | null> {
1418
+ private async refresh(): Promise<RefreshSettlement> {
1304
1419
  if (this.inflightRefresh) return this.inflightRefresh;
1305
1420
  const appId = this.requireAppId();
1306
- this.inflightRefresh = (async () => {
1307
- const result = await this.runRefresh(appId);
1421
+ const epoch = this.authEpoch;
1422
+ this.inflightRefresh = (async (): Promise<RefreshSettlement> => {
1423
+ const outcome = await this.runRefresh(appId);
1424
+ // No answer means no change: the token set, the login hint, and any
1425
+ // fallback session token stay as they are, and a later attempt settles
1426
+ // the session.
1427
+ if (outcome.status === 'unavailable') return { status: 'unavailable' };
1428
+ // A sign-out landed while this round-trip was in flight, so whatever
1429
+ // it resolved belongs to a session the person has left. Report it as
1430
+ // no session and write nothing: the caller waiting on this token gets
1431
+ // the signed-out answer the sign-out established.
1432
+ if (this.authEpoch !== epoch) return { status: 'no_session' };
1433
+ const result = outcome.status === 'ok' ? outcome.result : null;
1308
1434
  this.setTokens(result?.tokens ?? null);
1309
1435
  // Remember whether a live session exists for this app so the next
1310
1436
  // 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.
1437
+ // success proves a session; `no_session` is the server saying there
1438
+ // is none, so drop the hint and the next signed-out load stays silent.
1316
1439
  if (result) {
1317
1440
  storeLoginHint(appId);
1318
1441
  } else {
@@ -1323,12 +1446,11 @@ export class BrassClient {
1323
1446
  // Mirror into sessionStorage so a reload on this tab can
1324
1447
  // hydrate it (the cookie channel is unavailable in this
1325
1448
  // 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.
1449
+ // sid channels). The pointer is bound to this app id, so what
1450
+ // is stored authenticates this app and nothing else.
1329
1451
  storeSessionToken(appId, result.sessionToken);
1330
1452
  }
1331
- return result?.tokens ?? null;
1453
+ return result ? { status: 'ok', tokens: result.tokens } : { status: 'no_session' };
1332
1454
  })().finally(() => {
1333
1455
  this.inflightRefresh = null;
1334
1456
  });
@@ -1338,16 +1460,20 @@ export class BrassClient {
1338
1460
  // Picks the credential channel for a single /refresh round-trip. The
1339
1461
  // cookie path is primary and untouched for browsers that send the
1340
1462
  // third-party cookie; the one-time code is consumed only when that
1341
- // cookie path genuinely came back empty. Once a fallback session
1463
+ // cookie path resolved no session. Once a fallback session
1342
1464
  // token is in hand (the cross-site cookie is proven unavailable for
1343
1465
  // this page's life), every later refresh uses it directly.
1344
- private async runRefresh(appId: string): Promise<RefreshResult | null> {
1466
+ //
1467
+ // A rung falls through to the next only on `no_session`. Each one discards
1468
+ // a credential as it passes (the stale sid, the one-time code), and an
1469
+ // `unavailable` says nothing about the credential it just tried.
1470
+ private async runRefresh(appId: string): Promise<RefreshOutcome> {
1345
1471
  if (this.sessionToken) {
1346
1472
  const viaSession = await refreshTokens(this.authBaseUrl, appId, {
1347
1473
  kind: 'sessionToken',
1348
1474
  sessionToken: this.sessionToken,
1349
1475
  });
1350
- if (viaSession) return viaSession;
1476
+ if (viaSession.status !== 'no_session') return viaSession;
1351
1477
  // Stored sid is stale (server-side revoke, row expiry). Drop both
1352
1478
  // the in-memory copy and the sessionStorage mirror, then fall
1353
1479
  // through to the cookie / code paths. Without this, a hydrated-
@@ -1358,19 +1484,32 @@ export class BrassClient {
1358
1484
  clearSessionToken(appId);
1359
1485
  }
1360
1486
  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.
1487
+ if (viaCookie.status !== 'no_session') return viaCookie;
1488
+ // Cookie channel resolved no session. Fall back to the one-time code if
1489
+ // we landed with one. Clear it first: it is single-use server-side, so a
1490
+ // failed exchange must not be retried.
1365
1491
  const code = this.pendingAuthCode;
1492
+ if (!code) return viaCookie;
1366
1493
  this.pendingAuthCode = null;
1367
- if (!code) return null;
1368
1494
  return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
1369
1495
  }
1370
1496
 
1371
1497
  private setTokens(next: TokenSet | null): void {
1372
- this.tokens = next;
1373
1498
  const nextUser = next ? decodeIdToken(next.idToken) : null;
1499
+ // A different person on this page. `/refresh` resolves whichever platform
1500
+ // session the browser holds, so the identity behind a page that never
1501
+ // navigated can become someone else's (another tab signs out and a second
1502
+ // person signs in). Run the retirement first, as its own transition, so
1503
+ // what the previous identity left open is retired before the new one is
1504
+ // announced rather than inherited by it.
1505
+ if (
1506
+ this.user !== null &&
1507
+ nextUser !== null &&
1508
+ this.user.sub !== nextUser.sub
1509
+ ) {
1510
+ this.setTokens(null);
1511
+ }
1512
+ this.tokens = next;
1374
1513
  if (sameUser(this.user, nextUser)) return;
1375
1514
  this.user = nextUser;
1376
1515
  // Suppress emits while init is still in flight: init's finally
@@ -1379,9 +1518,17 @@ export class BrassClient {
1379
1518
  // fire here (null -> user) AND fire again from init's finally
1380
1519
  // (the same user), double-emitting on every signed-in app load.
1381
1520
  if (!this.bootstrapped) return;
1382
- for (const listener of this.listeners) {
1521
+ // Copied before the walk: a listener that detaches another would otherwise
1522
+ // remove it before its turn, and a `Set` never visits such an entry.
1523
+ for (const listener of Array.from(this.listeners)) {
1383
1524
  listener(nextUser);
1384
1525
  }
1526
+ // After the listeners, because the session closes the active document as
1527
+ // it hears the sign-out and a close PARKS the Document. A parked Document
1528
+ // answers the next open of its key out of memory, with no token and no
1529
+ // request, so one left behind here is the previous person's document
1530
+ // handed to whoever signs in next.
1531
+ if (nextUser === null) clearParkedDocuments();
1385
1532
  }
1386
1533
 
1387
1534
  private async hydrateProfile(): Promise<void> {
@@ -0,0 +1,15 @@
1
+ // The typed way for in-repo tooling (the benches, the fuzz harness) to wrap a
2
+ // LoroDoc it already holds. It sits in a module of its own, outside what the
3
+ // package entry reaches, so it can name Loro's types while the SDK's public
4
+ // surface stays engine-neutral.
5
+
6
+ import type { LoroDoc } from 'loro-crdt';
7
+
8
+ import { Document, type DocumentOptions } from './documents.js';
9
+
10
+ export function documentFromEngine(
11
+ engine: LoroDoc,
12
+ options?: DocumentOptions,
13
+ ): Document {
14
+ return Document._fromEngine(engine, options);
15
+ }
@@ -9,7 +9,7 @@
9
9
  import { createEmitter } from './emitter.js';
10
10
 
11
11
  // Reason a Document's underlying doc was evicted. Mirrors the `doc_gone`
12
- // frame's `reason` (CONTRACT.md "Server-pushed `doc_gone`"). New literals land
12
+ // frame's `reason`. New literals land
13
13
  // here as the platform grows transitions that evict open subscribers.
14
14
  // `moved`: the doc changed organization or project, so its access gate
15
15
  // changed and open subscribers re-establish under the new location.
@@ -19,7 +19,7 @@ export type DocumentGoneListener = (reason: DocumentGoneReason) => void;
19
19
  export type DocumentGoneUnsubscribe = () => void;
20
20
 
21
21
  // Opaque status of slow server-side work (hydration, extraction, push).
22
- // Mirrors the wire shape (CONTRACT.md "Server-pushed `doc_status`"). The app
22
+ // Mirrors the wire shape of the server's status frame. The app
23
23
  // renders `message` verbatim, draws a bar from `progress`, and waits while
24
24
  // `pending`. No kind / phase / error code: the server owns all wording and can
25
25
  // change phases without any app change.
@@ -39,7 +39,7 @@ export type DocumentStatusListener = (status: DocumentWorkStatus) => void;
39
39
  export type DocumentStatusUnsubscribe = () => void;
40
40
 
41
41
  // Coarse divergence state of a drive-linked doc vs its source. Mirrors the
42
- // server's `tracking_state` (CONTRACT.md "Drive document tracking state"). The
42
+ // server's `tracking_state`. The
43
43
  // app reads `state` to decide which affordance to show, never to pick wording
44
44
  // (that is server-authored `label` / `detail`).
45
45
  export type DocumentDriveTrackingState =
@@ -54,11 +54,11 @@ export type DocumentDriveTrackingState =
54
54
  // reads this to decide whether to show a Publish affordance at all.
55
55
  export type DriveMirrorDirection = 'two_way' | 'import_only';
56
56
 
57
- // The drive sync state of an open document, the read-side companion to
58
- // `doc.pushToDrive(...)`. Present only on drive-linked docs; `null` otherwise.
59
- // Lets an app show "you have unpublished edits" without polling. Words are
57
+ // The drive sync state of an open document. Present only on drive-linked
58
+ // docs; `null` otherwise. Lets an app show "you have unpublished edits"
59
+ // without polling, while the hosted toolbar owns publishing itself. Words are
60
60
  // server-authored (`label` / `detail`, rendered verbatim); the app branches
61
- // only on `state` / `canPush` to decide whether to show an affordance.
61
+ // only on `state` / `canPush`.
62
62
  export interface DocumentDriveStatus {
63
63
  // Adapter type token; an app may key an icon off it. The human label is
64
64
  // `driveName`, so an app never maps the type to a vendor name itself.