@brass-build/client 0.1.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 (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
package/dist/client.js ADDED
@@ -0,0 +1,1278 @@
1
+ import { buildSignInUrl, buildSignedOutOfAppUrl, refreshTokens, } from './auth-flow.js';
2
+ import { decodeIdToken } from './jwt.js';
3
+ import { createEmitter } from './emitter.js';
4
+ import { createOpenDocument, } from './open-document.js';
5
+ import { askSchemaConflict } from './schema-conflict-dialog.js';
6
+ import { BrassError } from './errors.js';
7
+ import { createHostedUiLauncher, trackHostedUiActivations, } from './hosted-ui.js';
8
+ import { createImportRunner, } from './importer.js';
9
+ import { isExportMode, startExportGuest, } from './export-guest.js';
10
+ import { readGuestLinkToken } from './guest-link.js';
11
+ import { consumeLaunchOrganizationId } from './launch-context.js';
12
+ import { clearAll as clearLastSelections, readLastDocument, storeLastDocument, } from './selection-persistence.js';
13
+ import { DEFAULT_BRANCH_URL_PARAM, DEFAULT_DOC_URL_PARAM, PICK_BRANCH_PARAM, PICK_DOC_PARAM, clearUrlSelection, readUrlSelection, subscribePopState, takePickFromUrl, writeDocIdToUrl, writeUrlSelection, } from './doc-url-sync.js';
14
+ import { composePageTitle, setPageTitle } from './page-title.js';
15
+ import { clearSessionToken, readSessionToken, storeSessionToken, } from './session-persistence.js';
16
+ import { clearLoginHint, readLoginHint, storeLoginHint, } from './login-hint.js';
17
+ import { createDocumentsNamespace, } from './documents-namespace.js';
18
+ import { createDocumentSession, } from './session.js';
19
+ import { createSessionToolbar, createSessionHeader, createFileMenu, createDocumentTitle, createUndoRedo, createPresenceRoster, createDriveSync, createAccountMenu, createVersionPreview, } from './session-toolbar.js';
20
+ import { BrassWsClient } from './ws-client.js';
21
+ import { fetchHostedRuntime, readInjectedRuntime, isBrassHostedHost, HOSTED_RUNTIME_PATH, } from './hosted-runtime.js';
22
+ const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
23
+ // The data API has its own host, NOT the dashboard origin: the
24
+ // dashboard (app.brass.build) is a static SPA, the data API is a
25
+ // separate gateway behind api.brass.build.
26
+ const DEFAULT_API_BASE_URL = 'https://api.brass.build';
27
+ // Origin serving the `/embed/*` hosted-UI pages. An app on another
28
+ // stack overrides it via `Brass.init`'s `dashboardBaseUrl`.
29
+ const DEFAULT_DASHBOARD_BASE_URL = 'https://app.brass.build';
30
+ // The WS gateway has its own hostname, so it is not derivable from the
31
+ // api host and zero config has to supply it alongside the other three.
32
+ const DEFAULT_WS_BASE_URL = 'wss://ws.brass.build';
33
+ // Production's four origins, which are the only ones the package
34
+ // carries: every other stack is addressed by passing its own, so no
35
+ // deployment's hostname scheme ships to an app that installs this.
36
+ const PROD_ORIGINS = {
37
+ authBaseUrl: DEFAULT_AUTH_BASE_URL,
38
+ apiBaseUrl: DEFAULT_API_BASE_URL,
39
+ wsBaseUrl: DEFAULT_WS_BASE_URL,
40
+ dashboardBaseUrl: DEFAULT_DASHBOARD_BASE_URL,
41
+ };
42
+ // Resolve the four base URLs from init options. Zero config applies all
43
+ // 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.
49
+ export function resolveBaseUrls(options) {
50
+ const anyUrlOverride = Boolean(options.authBaseUrl || options.apiBaseUrl || options.wsBaseUrl || options.dashboardBaseUrl);
51
+ const base = anyUrlOverride ? null : PROD_ORIGINS;
52
+ const strip = (url) => url.replace(/\/$/, '');
53
+ return {
54
+ authBaseUrl: options.authBaseUrl
55
+ ? strip(options.authBaseUrl)
56
+ : (base?.authBaseUrl ?? DEFAULT_AUTH_BASE_URL),
57
+ apiBaseUrl: options.apiBaseUrl
58
+ ? strip(options.apiBaseUrl)
59
+ : (base?.apiBaseUrl ?? DEFAULT_API_BASE_URL),
60
+ apiBaseUrlConfigured: Boolean(options.apiBaseUrl) || base !== null,
61
+ wsBaseUrl: options.wsBaseUrl ? strip(options.wsBaseUrl) : (base?.wsBaseUrl ?? null),
62
+ dashboardBaseUrl: options.dashboardBaseUrl
63
+ ? strip(options.dashboardBaseUrl)
64
+ : (base?.dashboardBaseUrl ?? DEFAULT_DASHBOARD_BASE_URL),
65
+ };
66
+ }
67
+ // Refresh slightly before expiry so callers never see a stale access token.
68
+ const REFRESH_LEEWAY_MS = 60_000;
69
+ // App id format. The server mints `brass_app_` + random chars; some
70
+ // real ids carry a `test`/`live`/`internal` infix. The optional infix
71
+ // group accepts both shapes, and the permissive tail keeps minor
72
+ // server-side format bumps from stranding SDK consumers.
73
+ const APP_ID_PATTERN = /^brass_app_(?:(?:test|live|internal)_)?[A-Za-z0-9_-]+$/;
74
+ export class BrassClient {
75
+ appId = null;
76
+ authBaseUrl = DEFAULT_AUTH_BASE_URL;
77
+ apiBaseUrl = DEFAULT_API_BASE_URL;
78
+ apiBaseUrlConfigured = false;
79
+ // Null only when the caller named some other `*BaseUrl` and not this
80
+ // one; subscribing then fails loudly rather than connecting to a
81
+ // different stack than the override points at.
82
+ wsBaseUrl = null;
83
+ // Origin the `/embed/*` hosted-UI pages load from. Unlike `wsBaseUrl`
84
+ // this always has a value (the production default), so the launchers
85
+ // work with zero config in production.
86
+ _dashboardBaseUrl = DEFAULT_DASHBOARD_BASE_URL;
87
+ // Defaults to `'light'`: the dominant hosted-UI host is light-only
88
+ // (no `prefers-color-scheme: dark` in its CSS). `'auto'` follows the
89
+ // OS preference; `'dark'` pins dark.
90
+ hostedUiTheme = 'light';
91
+ // See `BrassInitOptions.openIncompatibleDocuments`. When set, every open
92
+ // carries the answer the dialog would otherwise ask for.
93
+ openIncompatibleDocuments = false;
94
+ // Doc-selection-in-URL sync. On by default; off via
95
+ // `syncDocToUrl: false`. Param names configurable via `docUrlParam` /
96
+ // `branchUrlParam`.
97
+ syncDocToUrl = true;
98
+ docUrlParam = DEFAULT_DOC_URL_PARAM;
99
+ branchUrlParam = DEFAULT_BRANCH_URL_PARAM;
100
+ // Page-title sync. On by default; off via `syncDocToTitle: false`. The
101
+ // title at the first sync is captured so it can be restored when no
102
+ // document is open.
103
+ syncDocToTitle = true;
104
+ originalPageTitle = null;
105
+ tokens = null;
106
+ user = null;
107
+ // Flips to true exactly once, after `init()` settles (session
108
+ // resolved, none found, or validation failed). Before it flips the
109
+ // user is "unresolved": `this.user` is null only because we haven't
110
+ // checked. Drives two things:
111
+ // 1. `authStatus` (and `session.status`) report `'unknown'` while
112
+ // false, so a framework app can render a neutral state instead of
113
+ // its sign-in CTA during bootstrap.
114
+ // 2. Listener firing is gated: pre-init subscribers never see the
115
+ // misleading initial-`null` emit, only the resolved value;
116
+ // post-init subscribers get an immediate fire with the current
117
+ // value.
118
+ // `Brass.user` stays binary `BrassUser | null`; the third state lives
119
+ // on `authStatus`.
120
+ bootstrapped = false;
121
+ listeners = new Set();
122
+ // Init-failure listeners. Swallow a listener's throw so one bad handler
123
+ // cannot block the others or mask the original error (already logged).
124
+ errorListeners = createEmitter({ onError: () => { } });
125
+ inflightRefresh = null;
126
+ initialized = false;
127
+ // Incognito / third-party-cookie fallback state. Both null in a normal
128
+ // browser, where the cross-site `brass_auth` cookie carries the session
129
+ // and `/refresh` works without a body.
130
+ // - `pendingAuthCode`: the one-time `brass_code` pulled off the
131
+ // landing URL by bootstrap(), consumed at most once by refresh() if
132
+ // the cookie channel is unavailable.
133
+ // - `sessionToken`: the platform-session pointer from a code
134
+ // exchange. Once set, every refresh() uses it instead of the
135
+ // cookie. Mirrored into sessionStorage so a reload in a
136
+ // cookie-blocked browser hydrates it; see session-persistence.ts.
137
+ pendingAuthCode = null;
138
+ sessionToken = null;
139
+ // Namespaces are lazily built on first access and memoised so
140
+ // repeated reads return the same object.
141
+ documentsNamespace = null;
142
+ // Unlike the other namespaces the session needs no appId, so its
143
+ // getter does not gate on `init`.
144
+ documentSession = null;
145
+ hostedUiLauncher = null;
146
+ documentFactory = null;
147
+ // The `?brass_link=` token when this page was opened as an anonymous
148
+ // link guest; null on the ordinary signed-in path. Set in `init`.
149
+ guestLinkTokenValue = null;
150
+ // The organization this page was launched from (`?brass_org=`), or null
151
+ // when the app was reached some other way. A create that names no
152
+ // destination of its own lands here; see `launch-context.ts`.
153
+ launchOrganizationId = null;
154
+ // The app's export backend (`Brass.registerSerializer`), invoked by the
155
+ // platform when a hosted surface downloads an app-owned blob doc. One
156
+ // per app; a later registration replaces the earlier.
157
+ serializer = null;
158
+ // Set only when this bundle was loaded by Brass in a hidden export-mode
159
+ // iframe (`isExportMode`): serializes a handed-in snapshot and never
160
+ // authenticates. `registerSerializer` forwards to it.
161
+ exportGuest = null;
162
+ // Open documents by id, so an export request can hand the serializer
163
+ // the right Document. Dropped when a doc goes; a re-open overwrites.
164
+ openDocuments = new Map();
165
+ // The app's import backend (`Brass.registerImporter`) plus the claim
166
+ // protocol run per opened document. The deps read the documents
167
+ // namespace lazily, so attempts (always post-open) build it on demand.
168
+ importRunner = createImportRunner({
169
+ claimImport: (docId) => this.docsInternal().claimImport(docId),
170
+ completeImport: (docId) => this.docsInternal().completeImport(docId),
171
+ reportError: (error) => this.fireError(error),
172
+ });
173
+ // Separate from the projects namespace's WS client: one connection
174
+ // per surface.
175
+ docWsClient = null;
176
+ // init ALWAYS resolves: failures (bad/missing app_id) fire onError +
177
+ // console.error rather than rejecting. Otherwise
178
+ // `void Brass.init(typo).then(setup)` would never run .then() (init
179
+ // rejected), hanging the page at its loading state with no visible
180
+ // error. Resolving means .then() fires with `Brass.user === null` and
181
+ // the page transitions to signed-out.
182
+ async init(appId, options = {}) {
183
+ // Start watching for activations here rather than where the hosted-UI
184
+ // launcher is built: the launcher is built on first use, which is inside
185
+ // the click handler that opens a surface, so it would miss the press that
186
+ // opened it and have nothing to hand focus back to on dismissal.
187
+ trackHostedUiActivations();
188
+ try {
189
+ // A Brass-hosted bundle resolves its app id and all four origins from
190
+ // the serving slot's runtime descriptor, which is authoritative: the
191
+ // slot's environment is fixed by the origin, so init args pointing
192
+ // elsewhere (incl. a baked app id, which hosted demo bundles carry and
193
+ // expect the slot to replace) are a cross-environment mismatch. Whether
194
+ // an origin IS a Brass slot is the HOST's call, not the SDK's: the SDK
195
+ // cannot tell a slot from a developer's own hosting by hostname (a
196
+ // custom serving domain / vanity URL is the customer's own name).
197
+ //
198
+ // The serving slot therefore STAMPS the descriptor into the page it
199
+ // serves (`window.__brassRuntime`, injected into `index.html`). The SDK
200
+ // reads that first, synchronously and fetch-free, on any host: its
201
+ // presence means this origin is a Brass slot. A developer self-hosting
202
+ // stamps nothing, so their own config stands with no request made. The
203
+ // same artifact is thus portable unmodified across an external host and
204
+ // any Brass slot (onbrass or a vanity URL). The `.well-known` fetch is a
205
+ // fallback for a served page that predates the injection, kept behind
206
+ // the same heuristic so a self-hosting developer who passes their own
207
+ // origins never triggers it either.
208
+ let hosted = typeof window !== 'undefined' ? readInjectedRuntime(window) : null;
209
+ const reliesOnSlotEnvironment = !options.authBaseUrl &&
210
+ !options.apiBaseUrl &&
211
+ !options.wsBaseUrl &&
212
+ !options.dashboardBaseUrl;
213
+ if (typeof window !== 'undefined') {
214
+ if (!hosted &&
215
+ (isBrassHostedHost(window.location.hostname) || reliesOnSlotEnvironment)) {
216
+ hosted = await fetchHostedRuntime();
217
+ }
218
+ if (!hosted && !appId && isBrassHostedHost(window.location.hostname)) {
219
+ throw new Error('Brass.init could not load this hosted bundle\'s runtime config ' +
220
+ `(${HOSTED_RUNTIME_PATH}). Deploy the bundle again from your app detail ` +
221
+ 'page in the Brass dashboard, or pass an app ID to Brass.init.');
222
+ }
223
+ }
224
+ const effectiveAppId = hosted ? hosted.appId : appId;
225
+ if (!effectiveAppId) {
226
+ throw new Error('Brass.init requires an app ID. Get one from your app detail page in the Brass dashboard.');
227
+ }
228
+ if (!APP_ID_PATTERN.test(effectiveAppId)) {
229
+ // Catches pasting the README placeholder ('brass_app_...')
230
+ // verbatim, or a prefix typo ('bras_app_...').
231
+ throw new Error(`Brass.init received an app ID that does not look right (got: ${truncate(effectiveAppId)}). ` +
232
+ `Expected 'brass_app_<id>' as shown on your app detail page in the dashboard.`);
233
+ }
234
+ this.appId = effectiveAppId;
235
+ const urls = hosted
236
+ ? {
237
+ authBaseUrl: hosted.authBaseUrl,
238
+ apiBaseUrl: hosted.apiBaseUrl,
239
+ apiBaseUrlConfigured: true,
240
+ wsBaseUrl: hosted.wsBaseUrl,
241
+ dashboardBaseUrl: hosted.dashboardBaseUrl,
242
+ }
243
+ : resolveBaseUrls(options);
244
+ this.authBaseUrl = urls.authBaseUrl;
245
+ this.apiBaseUrl = urls.apiBaseUrl;
246
+ this.apiBaseUrlConfigured = urls.apiBaseUrlConfigured;
247
+ this.wsBaseUrl = urls.wsBaseUrl;
248
+ this._dashboardBaseUrl = urls.dashboardBaseUrl;
249
+ if (options.openIncompatibleDocuments === true) {
250
+ this.openIncompatibleDocuments = true;
251
+ }
252
+ if (options.hostedUiTheme) {
253
+ this.hostedUiTheme = options.hostedUiTheme;
254
+ }
255
+ if (options.syncDocToUrl === false) {
256
+ this.syncDocToUrl = false;
257
+ }
258
+ if (options.docUrlParam) {
259
+ this.docUrlParam = options.docUrlParam;
260
+ }
261
+ if (options.branchUrlParam) {
262
+ this.branchUrlParam = options.branchUrlParam;
263
+ }
264
+ if (options.syncDocToTitle === false) {
265
+ this.syncDocToTitle = false;
266
+ }
267
+ this.initialized = true;
268
+ if (typeof window !== 'undefined' && isExportMode(window.location.search)) {
269
+ // Loaded by Brass purely to serialize a handed-in snapshot. Skip
270
+ // the auth bootstrap entirely: the guest never calls the API, and
271
+ // the cookie is third-party-partitioned in this framed context
272
+ // anyway. The app's own `registerSerializer` (which it calls after
273
+ // this init resolves) is forwarded to the guest.
274
+ this.exportGuest = startExportGuest();
275
+ if (this.serializer)
276
+ this.exportGuest.setSerializer(this.serializer);
277
+ return;
278
+ }
279
+ const guestToken = typeof window !== 'undefined'
280
+ ? readGuestLinkToken(window.location.search)
281
+ : null;
282
+ if (guestToken) {
283
+ // Anonymous "anyone with the link" open. The token IS the
284
+ // credential, so there is no auth bootstrap: the session opens the
285
+ // document read-only through it. `authStatus` reports `'guest'` and
286
+ // the finally block's initial `onUserChange` emit drives the
287
+ // read-only boot (the session's guest-aware handler calls start()).
288
+ this.guestLinkTokenValue = guestToken;
289
+ return;
290
+ }
291
+ await this.bootstrap();
292
+ await this.hydrateProfile();
293
+ }
294
+ catch (err) {
295
+ const error = err instanceof Error ? err : new Error(String(err));
296
+ // console.error so the failure is visible in DevTools even when no
297
+ // onError listener was registered. Prefixed for grep-ability.
298
+ console.error('[Brass]', error.message);
299
+ this.fireError(error);
300
+ // Deliberately do not re-throw: callers using
301
+ // `void Brass.init(...).then(setup)` should see .then() fire so the
302
+ // page transitions out of its loading state. signIn() etc. continue
303
+ // to throw "Call Brass.init(appId) first" because this.initialized
304
+ // stays false on a validation failure.
305
+ }
306
+ finally {
307
+ // Single source of truth for the first `onUserChange` emit.
308
+ // `setTokens` inside bootstrap intentionally does not fire while
309
+ // `bootstrapped` is false, so we get exactly one notification per
310
+ // listener once init settles, whether validation rejected the
311
+ // app_id, the refresh found no session, or the refresh resolved a
312
+ // real user. An app that gates `#sign-in` on a single onUserChange
313
+ // callback therefore sees one emit with the actual signed-in
314
+ // state, not a transient `null` (a sign-in-button flicker) while
315
+ // bootstrap is in flight.
316
+ this.bootstrapped = true;
317
+ // An export frame gets no emit. It holds no session and needs none
318
+ // (the guest serializes a handed-in snapshot), so `null` here does not
319
+ // mean the person is signed out, and an app that reads it as that
320
+ // renders a signed-out UI or calls `signIn()`, which navigates the
321
+ // frame away from the export it was loaded to run.
322
+ if (this.exportGuest === null) {
323
+ for (const listener of this.listeners) {
324
+ listener(this.user);
325
+ }
326
+ }
327
+ }
328
+ }
329
+ signIn() {
330
+ const appId = this.requireAppId();
331
+ if (typeof window === 'undefined') {
332
+ throw new Error('Brass.signIn must be called in a browser');
333
+ }
334
+ // An export frame stays where it is. It runs a serializer over bytes
335
+ // Brass handed it and never calls the API, so navigating to sign-in
336
+ // would abandon the export mid-flight for a session it has no use for.
337
+ if (this.exportGuest !== null)
338
+ return;
339
+ window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
340
+ }
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.
347
+ //
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.
353
+ signOut() {
354
+ const appId = this.requireAppId();
355
+ if (typeof window === 'undefined') {
356
+ throw new Error('Brass.signOut must be called in a browser');
357
+ }
358
+ this.setTokens(null);
359
+ // Drop any fallback-mode credential too, so a stale session token
360
+ // can't resurrect the session on the next getAccessToken(). Clear
361
+ // both the in-memory copy and the sessionStorage mirror; without
362
+ // the storage clear, a reload after sign-out would hydrate the
363
+ // dead sid and the next refresh would 401 once before recovering.
364
+ this.sessionToken = null;
365
+ this.pendingAuthCode = null;
366
+ clearSessionToken(appId);
367
+ // Forget the login hint so the next cold load after sign-out renders
368
+ // signed-out without probing /refresh.
369
+ clearLoginHint(appId);
370
+ // Clear the per-app selection-persistence layer so the next user
371
+ // on this tab doesn't see the previous user's last selection.
372
+ clearLastSelections(appId);
373
+ const signedOutUrl = buildSignedOutOfAppUrl(this.authBaseUrl, appId, window.location.href);
374
+ // On a Brass-hosted origin, route the sign-out through the edge's
375
+ // /__brass/leave so the first-party brass_site gate cookie is cleared
376
+ // on the way to the confirmation page. Harmless when the app is not
377
+ // gated (the cookie simply isn't there). Off a hosted origin there is
378
+ // 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
+ }
386
+ }
387
+ get currentUser() {
388
+ return this.user;
389
+ }
390
+ // The tri-state auth lifecycle. `'unknown'` until `init` settles, then
391
+ // `'signed-in'` / `'signed-out'`, tracking every later sign-in / out.
392
+ // The explicit `'unknown'` is what lets an app render a neutral state
393
+ // during bootstrap rather than defaulting a falsy `user` to the
394
+ // sign-in CTA. Synchronous so a framework app can seed its initial
395
+ // render from it.
396
+ get authStatus() {
397
+ if (!this.bootstrapped)
398
+ return 'unknown';
399
+ if (this.guestLinkTokenValue !== null)
400
+ return 'guest';
401
+ return this.user !== null ? 'signed-in' : 'signed-out';
402
+ }
403
+ // The anonymous public-link token this page was opened with
404
+ // (`?brass_link=`), or null on the ordinary signed-in path. Set during
405
+ // `init` and read by the session to boot the read-only guest document.
406
+ get isGuest() {
407
+ return this.guestLinkTokenValue !== null;
408
+ }
409
+ // The resolved dashboard origin after `init` (the option's value if
410
+ // provided; the SDK default otherwise), the origin the Hosted UI iframes
411
+ // load from. Returns the default before `init` resolves; never null.
412
+ // @internal
413
+ get dashboardBaseUrl() {
414
+ return this._dashboardBaseUrl;
415
+ }
416
+ onUserChange(listener) {
417
+ this.listeners.add(listener);
418
+ // Only emit on subscribe once init has settled. Pre-init subscribers
419
+ // (the canonical README pattern: register, then call init) get their
420
+ // first emit from init's finally block with the resolved state, not
421
+ // a flicker through the initial `null`. Post-init subscribers get an
422
+ // immediate emit with the current value, the usual "register and
423
+ // read current state" idiom.
424
+ if (this.bootstrapped && this.exportGuest === null) {
425
+ listener(this.user);
426
+ }
427
+ return () => {
428
+ this.listeners.delete(listener);
429
+ };
430
+ }
431
+ // Subscribe to init failures. Fires synchronously inside init's catch
432
+ // block when validation rejects an app_id, so a page can render an
433
+ // error state instead of a silent loading spinner. Register BEFORE
434
+ // calling init() to catch the first error.
435
+ onError(listener) {
436
+ return this.errorListeners.on(listener);
437
+ }
438
+ fireError(error) {
439
+ this.errorListeners.emit(error);
440
+ }
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.
445
+ get documents() {
446
+ return this.docsInternal();
447
+ }
448
+ // Builds (once) and returns the namespace with its internal methods,
449
+ // for SDK plumbing that reaches past the public `documents` surface.
450
+ docsInternal() {
451
+ this.requireAppId();
452
+ if (!this.documentsNamespace) {
453
+ this.documentsNamespace = createDocumentsNamespace({
454
+ apiBaseUrl: this.apiBaseUrl,
455
+ getAccessToken: () => this.getAccessToken(),
456
+ getAppAssertion: () => this.getAppAssertion(),
457
+ });
458
+ }
459
+ return this.documentsNamespace;
460
+ }
461
+ // Unlike the other namespace getters this does NOT require `init`
462
+ // first: `Brass.session.subscribe(render)` is meant to run BEFORE
463
+ // `Brass.init(...)`, the same ordering `Brass.onUserChange`
464
+ // supports. The session's methods keep their own init guards.
465
+ get session() {
466
+ if (!this.documentSession) {
467
+ this.documentSession = createDocumentSession({
468
+ isSignedIn: () => this.user !== null,
469
+ authStatus: () => this.authStatus,
470
+ onUserChange: (listener) => this.onUserChange((user) => listener(user !== null)),
471
+ openDocumentById: (docId, options, consent) => this.openDocumentInternal(docId, options, consent),
472
+ guestLinkToken: () => this.guestLinkTokenValue,
473
+ openDocumentByLink: (token, options) => this.openByLinkInternal(token, options),
474
+ createDocumentId: (args) => this.createDocumentIdWithConsent(args),
475
+ selectDocument: () => this.selectDocumentInternal(),
476
+ selectCreateDestination: (options) => this.getHostedUiLauncher().selectCreateDestination(options),
477
+ acquireOrganization: () => this.getHostedUiLauncher().acquireOrganization(),
478
+ documentHistory: (docId, options) => this.documentHistory(docId, options),
479
+ documentSettings: (docId, options) => this.getHostedUiLauncher().documentSettings(docId, options),
480
+ readUrlSelection: () => this.syncDocToUrl
481
+ ? readUrlSelection(this.docUrlParam, this.branchUrlParam)
482
+ : null,
483
+ // The redirect picker's one-shot return channel: consumed on boot
484
+ // regardless of `syncDocToUrl` so a pick opens even when the app runs
485
+ // its own selection.
486
+ takePickSelection: () => takePickFromUrl(),
487
+ writeUrlSelection: (selection, options) => {
488
+ if (this.syncDocToUrl) {
489
+ writeUrlSelection(selection, this.docUrlParam, this.branchUrlParam, options);
490
+ }
491
+ },
492
+ clearUrlSelection: () => {
493
+ if (this.syncDocToUrl) {
494
+ clearUrlSelection(this.docUrlParam, this.branchUrlParam);
495
+ }
496
+ },
497
+ onUrlPopState: (cb) => this.syncDocToUrl ? subscribePopState(cb) : () => { },
498
+ setPageTitle: (name) => this.applyPageTitle(name),
499
+ lastSelectedDocument: () => this.lastSelectedDocument(),
500
+ // `this.documents` is read inside the closures, not here, so
501
+ // its `requireAppId` guard runs only when the session
502
+ // actually calls one of these (always post-init).
503
+ documents: {
504
+ get: (docId) => this.documents.get(docId),
505
+ setThumbnail: (docId, input) => this.documents.setThumbnail(docId, input),
506
+ },
507
+ reportError: (error) => this.fireError(error),
508
+ });
509
+ }
510
+ return this.documentSession;
511
+ }
512
+ // SDK-rendered document toolbar. Renders the file menu, document
513
+ // title, location, account menu, and presence roster into
514
+ // `container`; the app fills the returned `tools` slot with its own
515
+ // buttons.
516
+ mountToolbar(container, options) {
517
+ return createSessionToolbar(container, this.toolbarDeps(), options);
518
+ }
519
+ // The bare header shell for an app that builds its own header but wants
520
+ // the proven responsive layout: mount pieces into the returned
521
+ // `start` / `tools` / `end` slots and the account avatar stays pinned
522
+ // top-right on mobile, never wrapping when the tools wrap.
523
+ mountHeader(container) {
524
+ return createSessionHeader(container, this.toolbarDeps());
525
+ }
526
+ // Composable header pieces. An app that builds its own shell drops
527
+ // the standard account menu, presence roster, title, file menu, or
528
+ // undo/redo into it without adopting the whole `mountToolbar` bar.
529
+ // Each renders one cluster and returns `{ destroy(): void }`.
530
+ mountFileMenu(container) {
531
+ return createFileMenu(container, this.toolbarDeps());
532
+ }
533
+ mountDocumentTitle(container) {
534
+ return createDocumentTitle(container, this.toolbarDeps());
535
+ }
536
+ mountUndoRedo(container) {
537
+ return createUndoRedo(container, this.toolbarDeps());
538
+ }
539
+ mountPresenceRoster(container) {
540
+ return createPresenceRoster(container, this.toolbarDeps());
541
+ }
542
+ mountDriveSync(container) {
543
+ return createDriveSync(container, this.toolbarDeps());
544
+ }
545
+ mountAccountMenu(container) {
546
+ return createAccountMenu(container, this.toolbarDeps());
547
+ }
548
+ // The historical-version preview banner (return-to-live during a hosted
549
+ // `history` preview). The full toolbar and the header shell already include
550
+ // it; this standalone mount is only for an app that builds fully custom
551
+ // chrome and still wants the correct preview affordance.
552
+ mountVersionPreview(container) {
553
+ return createVersionPreview(container, this.toolbarDeps());
554
+ }
555
+ // The capabilities every toolbar piece reads. The full bar and each
556
+ // standalone piece take a subset of this one object.
557
+ toolbarDeps() {
558
+ return {
559
+ session: this.session,
560
+ getUser: () => this.currentUser,
561
+ onUserChange: (listener) => this.onUserChange(listener),
562
+ signIn: () => this.signIn(),
563
+ signOut: () => this.signOut(),
564
+ dashboardBaseUrl: this._dashboardBaseUrl,
565
+ getDocument: (docId) => this.documents.get(docId),
566
+ // The version-preview bar is a hosted embed; it takes the same theme
567
+ // and fallback session pointer as the other hosted surfaces.
568
+ hostedUiTheme: this.hostedUiTheme,
569
+ getSessionToken: () => this.sessionToken,
570
+ };
571
+ }
572
+ // Opens the hosted document picker in a modal iframe and resolves with
573
+ // the document the user picked or created (`null` if they dismissed it).
574
+ // Where the iframe can't be hosted the picker redirects instead: the
575
+ // page navigates away, so this call never resolves on that path, and the
576
+ // pick opens into `Brass.session` on reload. So render the active
577
+ // document from the session (as an app does anyway); the return value is
578
+ // the modal path's inline convenience, correct whenever you receive it.
579
+ // Throws before init.
580
+ async selectDocument() {
581
+ const result = await this.selectDocumentInternal();
582
+ if (result)
583
+ this.maybeWriteDocIdToUrl(result.doc_id);
584
+ return result;
585
+ }
586
+ // See `openDocumentInternal`: the picker path the session uses. It
587
+ // persists the last selection but leaves the URL to the session,
588
+ // which pushes a history entry once it opens the picked document.
589
+ async selectDocumentInternal() {
590
+ const appId = this.requireAppId();
591
+ const result = await this.getHostedUiLauncher().selectDocument();
592
+ if (result)
593
+ storeLastDocument(appId, result);
594
+ return result;
595
+ }
596
+ // The value the most recent `selectDocument()` resolved with
597
+ // (per-app sessionStorage), or `null` if nothing was picked this
598
+ // tab. The session's boot restore reads it as the bare-URL
599
+ // fallback. Cleared on `signOut()`.
600
+ lastSelectedDocument() {
601
+ return readLastDocument(this.requireAppId());
602
+ }
603
+ // Opens the hosted multi-tier share dialog (document / project /
604
+ // organization sections in one UI) focused on the given document.
605
+ // Fire-and-forget, iframe-first with redirect fallback. Throws "Call
606
+ // Brass.init(appId) first" before init.
607
+ shareDocument(docId) {
608
+ return this.getHostedUiLauncher().shareDocument(docId);
609
+ }
610
+ // The hosted document history modal for one document. Pass `branch`
611
+ // to scope the timeline to a branch; omit for the default. Resolves
612
+ // with a `DocumentHistoryResult` (`{ branch_id }` for a forked
613
+ // branch, `{ branch_id, version }` for a read-only past version) or
614
+ // `null` if the user closed without doing either. Rejects with
615
+ // `BrassError` when the iframe cannot be hosted. Throws "Call
616
+ // Brass.init(appId) first" before init.
617
+ documentHistory(docId, options) {
618
+ return this.getHostedUiLauncher().documentHistory(docId, options);
619
+ }
620
+ getHostedUiLauncher() {
621
+ const appId = this.requireAppId();
622
+ if (!this.hostedUiLauncher) {
623
+ this.hostedUiLauncher = createHostedUiLauncher({
624
+ dashboardBaseUrl: this._dashboardBaseUrl,
625
+ callingAppId: appId,
626
+ theme: this.hostedUiTheme,
627
+ serializeForExport: (docId) => this.serializeForExport(docId),
628
+ // The hosted delete surface reports a local delete over the bridge so
629
+ // the session returns to the empty state on the user's own delete,
630
+ // rather than the remote-eviction error surface that keeps the URL.
631
+ onLocalDelete: (docId, active) => {
632
+ if (active)
633
+ this.session._noteLocalDelete(docId);
634
+ else
635
+ this.session._clearLocalDelete(docId);
636
+ },
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
+ // Name the user this app is acting as, so the embed can refuse to
643
+ // act as anyone else. The embed authenticates from the dashboard
644
+ // origin's platform-session cookie, which moves when a second
645
+ // account signs in on another tab, while this app keeps serving the
646
+ // token it already holds.
647
+ getActingSub: () => this.currentUser?.sub ?? null,
648
+ // The embed resolved a different user, which means this app's token
649
+ // is the stale half. Re-authenticate so the SDK reflects the session
650
+ // the browser actually has (signed out, when this app is not
651
+ // approved on it) instead of rendering a user who has been replaced.
652
+ // The user asked, from the refusal surface, to put this app on the
653
+ // account the browser holds. `signIn()` navigates to the hosted
654
+ // sign-in for this app, which lands on the consent screen for that
655
+ // account.
656
+ onSignInRequested: () => {
657
+ this.signIn();
658
+ },
659
+ // The picker falls back to a full-page redirect where the iframe
660
+ // can't be hosted, returning the pick on these one-shot params, which
661
+ // the session consumes on boot. Independent of `syncDocToUrl`: the
662
+ // hand-off params are the SDK's own, not the app's selection sync, so
663
+ // the fallback works even for an app that manages its own URL.
664
+ pickReturn: { docParam: PICK_DOC_PARAM, branchParam: PICK_BRANCH_PARAM },
665
+ });
666
+ }
667
+ return this.hostedUiLauncher;
668
+ }
669
+ // Returns a fully editable `Document`. Throws "Call Brass.init(appId)
670
+ // first" before init.
671
+ //
672
+ // Editing requires `wsBaseUrl` in `Brass.init` options. When it's
673
+ // absent, the returned Document still materialises against the
674
+ // snapshot (HTTP path works) and `mutate` still updates local state,
675
+ // but the local op never ships to the server. We do not throw in
676
+ // that case because the HTTP join path stays useful for read-mostly
677
+ // tools (admin viewers, migration scripts); the on-error signal is
678
+ // observable via `Brass.onError` if the caller wants to surface
679
+ // "live editing unavailable" in the UI.
680
+ async openDocumentById(docId, options) {
681
+ const opened = await this.openDocumentInternal(docId, options);
682
+ this.maybeWriteDocIdToUrl(opened.id);
683
+ return opened;
684
+ }
685
+ // Open a document READ-ONLY from a public-link token (the "anyone with
686
+ // the link" anonymous path). No sign-in: the token is the credential and
687
+ // the server serves a read-only, member-stripped snapshot with no live
688
+ // subscription. `Brass.session` drives this from the URL on boot when the
689
+ // page carries `?brass_link=`; there is no app-facing entry point.
690
+ async openByLinkInternal(token, options) {
691
+ const factory = this.getDocumentFactory();
692
+ const opened = await factory.openByLink(token, options);
693
+ this.trackOpenDocument(opened);
694
+ return opened;
695
+ }
696
+ // The open path shared by the public `openDocumentById` and the
697
+ // session. It does NOT write the URL: the public method pins the id
698
+ // (`replaceState`) for direct multi-doc callers, while the session
699
+ // owns its own push / replace history writes so a single-doc open
700
+ // becomes a back-navigable entry without the two layers double-writing.
701
+ async openDocumentInternal(docId, options, consent = 'offer') {
702
+ const factory = this.getDocumentFactory();
703
+ // After getDocumentFactory() resolves, requireAppId() has run and
704
+ // this.appId is a string.
705
+ const appId = this.appId;
706
+ // An app that declared it opens these carries the answer on every open,
707
+ // so nothing is asked and nothing is refused.
708
+ const withConsent = this
709
+ .openIncompatibleDocuments
710
+ ? { ...options, acceptSchemaConflict: true }
711
+ : options;
712
+ try {
713
+ const opened = await factory.open(docId, withConsent);
714
+ this.trackOpenDocument(opened);
715
+ return opened;
716
+ }
717
+ catch (err) {
718
+ // The platform refuses to join a document this app's schema cannot
719
+ // read. Unlike the install consent below, this one is offered on a
720
+ // BACKGROUND open too: a bookmark, a reload and a restored session are
721
+ // exactly the paths that reach an open having asked nobody, and they
722
+ // are what the refusal exists for.
723
+ if (isSchemaConflict(err)) {
724
+ const { openAnyway } = await askSchemaConflict(err.message, err.title);
725
+ if (!openAnyway)
726
+ throw err;
727
+ const opened = await factory.open(docId, {
728
+ ...withConsent,
729
+ acceptSchemaConflict: true,
730
+ });
731
+ this.trackOpenDocument(opened);
732
+ return opened;
733
+ }
734
+ // A denial because the document's org has no install of this app
735
+ // is a consent moment, not a stale selection: surface the hosted
736
+ // install dialog so the person decides (install, or send the
737
+ // admin request), then retry once if an install landed. Only for
738
+ // an open the person initiated: the session's background opens
739
+ // (boot restore, history navigation) pass `consent: 'skip'`
740
+ // because a consent dialog nobody asked for pops over the app
741
+ // before they touch anything.
742
+ if (consent === 'offer' && (await this.offerInstallConsent(err))) {
743
+ const opened = await factory.open(docId, options);
744
+ this.trackOpenDocument(opened);
745
+ return opened;
746
+ }
747
+ if (isAppNotInstalled(err))
748
+ throw err;
749
+ // Self-heal the selection-persistence layer when the persisted
750
+ // doc has gone stale (deleted, access revoked, or never owned by
751
+ // this user). Without this an app that restores from
752
+ // `lastSelectedDocument()` on boot would loop on the same
753
+ // failure across every subsequent reload until the user picks a
754
+ // new doc by hand. 403 / 404 are the unambiguously-stale cases;
755
+ // 401 (not signed in) and 5xx (transient) are left alone. The URL's
756
+ // `?doc=` id is deliberately NOT cleared here: the session parks in
757
+ // its 'error' state on a failed open, and that error surface belongs
758
+ // to the id the URL names, so stripping it would strand the failure
759
+ // (a reload would forget the doc) and lose the id a user needs to
760
+ // copy when requesting access. `lastSelectedDocument` is a private
761
+ // convenience with no such visible anchor, so clearing it is safe.
762
+ if (err instanceof BrassError &&
763
+ (err.status === 403 || err.status === 404)) {
764
+ const last = readLastDocument(appId);
765
+ if (last && last.doc_id === docId) {
766
+ clearLastSelections(appId);
767
+ }
768
+ }
769
+ throw err;
770
+ }
771
+ }
772
+ async createDocument(options) {
773
+ const opened = await this.createDocumentInternal(options);
774
+ this.maybeWriteDocIdToUrl(opened.id);
775
+ return opened;
776
+ }
777
+ // See `openDocumentInternal`: the create path the session uses, which
778
+ // leaves the URL to the session's own history writes.
779
+ async createDocumentInternal(options) {
780
+ const factory = this.getDocumentFactory();
781
+ try {
782
+ const opened = await factory.create(options);
783
+ this.trackOpenDocument(opened);
784
+ return opened;
785
+ }
786
+ catch (err) {
787
+ if (await this.offerInstallConsent(err)) {
788
+ const opened = await factory.create(options);
789
+ this.trackOpenDocument(opened);
790
+ return opened;
791
+ }
792
+ throw err;
793
+ }
794
+ }
795
+ // Create-only for the session: classify the outcome (created / needs a
796
+ // destination / needs an organization) so the session drives the picker /
797
+ // acquire surface itself. An `app_not_installed` denial on the resolved
798
+ // org opens the install-consent dialog and retries once, the same reactive
799
+ // install the open / low-level-create paths get.
800
+ async createDocumentIdWithConsent(args) {
801
+ const factory = this.getDocumentFactory();
802
+ try {
803
+ return await factory.createDocumentId(args);
804
+ }
805
+ catch (err) {
806
+ if (await this.offerInstallConsent(err)) {
807
+ return factory.createDocumentId(args);
808
+ }
809
+ throw err;
810
+ }
811
+ }
812
+ // The `app_not_installed` denial names the org; open the hosted
813
+ // install-consent dialog for it so the person decides: an admin (or a
814
+ // member of an `open`-policy org) installs, a member under `request`
815
+ // sends the admin request from the same dialog. Nothing is installed
816
+ // or requested implicitly. Resolves true only when an install landed,
817
+ // in which case the caller retries the denied call once.
818
+ async offerInstallConsent(err) {
819
+ if (!isAppNotInstalled(err) || !err.organizationId)
820
+ return false;
821
+ try {
822
+ const result = await this.getHostedUiLauncher().installApp(err.organizationId);
823
+ return result !== null;
824
+ }
825
+ catch {
826
+ // Hosted UI unavailable here (no DOM, or the dialog failed); the
827
+ // original denial stands.
828
+ return false;
829
+ }
830
+ }
831
+ // Register the app's export backend. The platform calls it when a
832
+ // hosted surface downloads an app-owned (blob) document: it receives
833
+ // the open Document and returns the source file's bytes, the inverse of
834
+ // the parse the app ran on `doc.sourceBlob()`. The app never invokes it
835
+ // and never delivers the bytes itself; the platform stages + downloads
836
+ // what it returns. One serializer per app.
837
+ registerSerializer(serializer) {
838
+ this.serializer = serializer;
839
+ // In export-mode the guest may already hold the snapshot, waiting on
840
+ // this registration to run it.
841
+ this.exportGuest?.setSerializer(serializer);
842
+ }
843
+ // Register the app's import backend: parses a stored source file into
844
+ // the document's records. The SDK claims the import with the platform
845
+ // on each open, so exactly one client runs it; the other open clients
846
+ // receive the records live. One importer per app; a later registration
847
+ // replaces the earlier. Documents already open (registration raced the
848
+ // first open) get their attempt here.
849
+ registerImporter(importer) {
850
+ this.importRunner.setImporter(importer);
851
+ if (this.guestLinkTokenValue !== null)
852
+ return;
853
+ for (const doc of this.openDocuments.values()) {
854
+ void this.importRunner.attempt(doc);
855
+ }
856
+ }
857
+ // 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.
859
+ trackOpenDocument(doc) {
860
+ this.openDocuments.set(doc.id, doc);
861
+ doc.onGone(() => {
862
+ if (this.openDocuments.get(doc.id) === doc) {
863
+ this.openDocuments.delete(doc.id);
864
+ }
865
+ });
866
+ // Every open path funnels through here, so this is where a registered
867
+ // importer gets its per-document attempt. A guest open is read-only
868
+ // with no API credential, so no import runs for it.
869
+ if (this.guestLinkTokenValue === null) {
870
+ void this.importRunner.attempt(doc);
871
+ }
872
+ }
873
+ // Run the registered serializer for an export request from a hosted
874
+ // surface. Resolves the doc from the open set (the active session doc is
875
+ // the fallback for an app that drives it through the session), then hands
876
+ // it to the serializer. A missing serializer is the ordinary "this app
877
+ // does not export" outcome, not an error.
878
+ async serializeForExport(docId) {
879
+ if (!this.serializer) {
880
+ return { ok: false, code: 'no_serializer', message: 'No serializer registered.' };
881
+ }
882
+ const sessionDoc = this.documentSession?.document ?? null;
883
+ const doc = this.openDocuments.get(docId) ??
884
+ (sessionDoc && sessionDoc.id === docId ? sessionDoc : null);
885
+ if (!doc) {
886
+ return { ok: false, code: 'no_document', message: 'Document is not open.' };
887
+ }
888
+ try {
889
+ const bytes = await this.serializer(doc);
890
+ return { ok: true, bytes };
891
+ }
892
+ catch (err) {
893
+ return {
894
+ ok: false,
895
+ code: 'serializer_failed',
896
+ message: err instanceof Error ? err.message : 'Serializer failed.',
897
+ };
898
+ }
899
+ }
900
+ maybeWriteDocIdToUrl(docId) {
901
+ if (!this.syncDocToUrl)
902
+ return;
903
+ writeDocIdToUrl(docId, this.docUrlParam);
904
+ }
905
+ // Set the page title to the active document's name, suffixed with the app
906
+ // name (`Doc · App`), or restore the page's original title when no document
907
+ // is open. Gated by `syncDocToTitle`. The original title is captured on the
908
+ // first sync so close / sign-out can put it back rather than leaving a stale
909
+ // doc name in the tab, and it doubles as the app-name suffix (see
910
+ // `composePageTitle`).
911
+ applyPageTitle(name) {
912
+ if (!this.syncDocToTitle)
913
+ return;
914
+ if (typeof document === 'undefined')
915
+ return;
916
+ if (this.originalPageTitle === null) {
917
+ this.originalPageTitle = document.title;
918
+ }
919
+ setPageTitle(composePageTitle(name, this.originalPageTitle));
920
+ }
921
+ getDocumentFactory() {
922
+ const appId = this.requireAppId();
923
+ if (!this.documentFactory) {
924
+ const wsClient = this.getDocWsClient(appId);
925
+ const docsNs = this.documents;
926
+ // Internal-typed handle for the methods on the internal namespace
927
+ // (createCheckpoint, listBranches / createBranch, pushToDrive).
928
+ // The getter above initialized the field, so it is non-null here.
929
+ const docsInternal = this.documentsNamespace;
930
+ this.documentFactory = createOpenDocument({
931
+ appId,
932
+ apiBaseUrl: this.apiBaseUrl,
933
+ getAccessToken: () => this.getAccessToken(),
934
+ getAppAssertion: () => this.getAppAssertion(),
935
+ ...(wsClient ? { wsClient } : {}),
936
+ branchProvider: {
937
+ list: (docId) => docsInternal.listBranches(docId),
938
+ create: (docId, input) => docsInternal.createBranch(docId, input),
939
+ },
940
+ streamProvider: {
941
+ list: (docId, branchId) => docsInternal.listStreams(docId, branchId),
942
+ // Forward ALL create params. A narrower-arity lambda type-checks,
943
+ // so a wrapper that pinches off a param reaches the
944
+ // wire unstamped while each layer's own tests stay green.
945
+ create: (docId, branchId, name) => docsInternal.createStream(docId, branchId, name),
946
+ },
947
+ thumbnailUploader: (doc, image, width, height) => docsNs.setThumbnail(doc, { image, width, height }),
948
+ checkpointCreator: (doc) => docsInternal.createCheckpoint(doc),
949
+ drivePusher: (doc, input) => docsInternal.pushToDrive(doc, input),
950
+ sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
951
+ hostedUiProvider: {
952
+ documentHistory: (docId, options) => this.documentHistory(docId, options),
953
+ documentSettings: (docId, options) => this.getHostedUiLauncher().documentSettings(docId, options),
954
+ refreshName: async (docId) => {
955
+ try {
956
+ const record = await docsNs.get(docId);
957
+ return record.name;
958
+ }
959
+ catch {
960
+ return null;
961
+ }
962
+ },
963
+ // Whether the share command's label names a download. The
964
+ // server resolves it from the apps installed on the document's
965
+ // organization, so this is a read rather than anything the SDK
966
+ // can work out from the open document. A failed read answers
967
+ // `null` (could not tell) rather than `false`, which is what
968
+ // lets the label try again later.
969
+ documentDownloadable: async (docId) => {
970
+ try {
971
+ const record = await docsNs.get(docId);
972
+ return record.downloadable === true;
973
+ }
974
+ catch {
975
+ return null;
976
+ }
977
+ },
978
+ // The doc's `history` built-in delegates cross-branch /
979
+ // fork outcomes here. Routing through the session keeps
980
+ // the active-doc swap consistent (URL sync, command list
981
+ // re-render, branch-list refresh on the new doc).
982
+ openSiblingDoc: async (docId, options) => {
983
+ await this.session.open(docId, options);
984
+ },
985
+ selectBranch: (docId, options) => this.getHostedUiLauncher().selectBranch(docId, options),
986
+ // `doc.resolveDrive()` opens the hosted resolution surface;
987
+ // a take-source / keep-both outcome re-opens onto the new
988
+ // mirror doc through `openSiblingDoc` above.
989
+ driveResolve: (docId) => this.getHostedUiLauncher().driveResolve(docId),
990
+ // Zero-org fallback for a no-arg `createDocument`: open the
991
+ // hosted acquire-organization surface so an app-only caller
992
+ // picks how to get their first org, then the create retries
993
+ // against it.
994
+ acquireOrganization: () => this.getHostedUiLauncher().acquireOrganization(),
995
+ },
996
+ launchOrganizationId: () => this.launchOrganizationId,
997
+ reportError: (err) => this.fireError(err),
998
+ });
999
+ }
1000
+ return this.documentFactory;
1001
+ }
1002
+ getDocWsClient(appId) {
1003
+ if (!this.wsBaseUrl)
1004
+ return undefined;
1005
+ if (this.docWsClient)
1006
+ return this.docWsClient;
1007
+ this.docWsClient = new BrassWsClient({
1008
+ wsBaseUrl: this.wsBaseUrl,
1009
+ appId,
1010
+ getAccessToken: () => this.getAccessToken(),
1011
+ // Anonymous link guest: with no access token the socket connects with
1012
+ // this link token and the server admits a read-only doc subscription.
1013
+ getGuestLinkToken: () => this.guestLinkTokenValue,
1014
+ // Attests the socket with the same assertion the HTTP calls carry so
1015
+ // the server enforces Layer 2 on op-write; a first-party client
1016
+ // resolves null here and connects un-gated.
1017
+ getAppAssertion: () => this.getAppAssertion(),
1018
+ fireError: (error) => this.fireError(error),
1019
+ });
1020
+ return this.docWsClient;
1021
+ }
1022
+ async getAccessToken() {
1023
+ this.requireInit();
1024
+ const current = this.tokens;
1025
+ if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1026
+ return current.accessToken;
1027
+ }
1028
+ const refreshed = await this.refresh();
1029
+ return refreshed?.accessToken ?? null;
1030
+ }
1031
+ // The acting-app assertion paired with the current access token, or
1032
+ // null when the proxy issued none. Mirrors `getAccessToken`'s
1033
+ // freshness check so the two stay in lockstep: a request builder that
1034
+ // calls `getAccessToken()` then this reads the same (possibly just
1035
+ // refreshed) token set without forcing a second refresh.
1036
+ async getAppAssertion() {
1037
+ this.requireInit();
1038
+ const current = this.tokens;
1039
+ if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
1040
+ return current.appAssertion ?? null;
1041
+ }
1042
+ const refreshed = await this.refresh();
1043
+ return refreshed?.appAssertion ?? null;
1044
+ }
1045
+ async bootstrap() {
1046
+ if (typeof window === 'undefined')
1047
+ return;
1048
+ const appId = this.requireAppId();
1049
+ // Every successful sign-in 302s back to return_to with the
1050
+ // platform-session cookie set AND a one-time `brass_code` query
1051
+ // parameter (see infra/lambda/auth/token-redirect.ts). The cookie
1052
+ // is the primary channel; the code is the incognito / Safari ITP /
1053
+ // post-third-party-cookie fallback for when the browser drops the
1054
+ // cross-site cookie on our /refresh XHR.
1055
+ //
1056
+ // Pull the code off the URL immediately (before any /refresh)
1057
+ // and strip it via history.replaceState so it never lingers in the
1058
+ // address bar, browser history, or a referrer header. It is held in
1059
+ // memory and consumed at most once, only if the cookie refresh
1060
+ // turns out to fail (see refresh()).
1061
+ //
1062
+ // No app-controlled silent-mint path: the SDK never auto-fires
1063
+ // 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).
1066
+ // Same treatment for the launching organization: read it off the URL
1067
+ // and strip it here, so a client-side navigation later in the session
1068
+ // cannot lose it and the address bar stays the app's own.
1069
+ this.launchOrganizationId = consumeLaunchOrganizationId();
1070
+ this.pendingAuthCode = consumeAuthCodeParam();
1071
+ if (this.pendingAuthCode) {
1072
+ // A fresh sign-in just landed. Burn any previously-stored
1073
+ // session_token so a stale one (different account, server-side
1074
+ // revoke between tabs) cannot preempt the fresh code. The new
1075
+ // code's exchange in refresh() will overwrite this with its own.
1076
+ clearSessionToken(appId);
1077
+ // Record the login hint optimistically: a code on the URL means a
1078
+ // sign-in just happened, so a session exists. Writing it now (not
1079
+ // only after the refresh resolves) closes the window where an app
1080
+ // does a client-side navigation immediately after sign-in: the
1081
+ // re-bootstrapped page then still has a reason to probe. If the
1082
+ // code exchange ultimately fails, refresh() clears the hint again.
1083
+ storeLoginHint(appId);
1084
+ }
1085
+ else {
1086
+ // No fresh code on the URL. Hydrate the fallback session_token
1087
+ // from a prior run on this tab so a reload in a cookie-blocked
1088
+ // browser keeps the user signed in. Null in the common (cookie-
1089
+ // works) case where the fallback path never ran, in which case
1090
+ // refresh() still tries the cookie channel first.
1091
+ this.sessionToken = readSessionToken(appId);
1092
+ }
1093
+ // Probe /refresh only when there's a positive reason to believe a
1094
+ // session exists: a fresh sign-in code just landed, a hydrated
1095
+ // fallback session token, or a stored login hint from a prior
1096
+ // successful auth on this app. The platform-session cookie is
1097
+ // httpOnly + cross-site, so the SDK cannot read it to know, and
1098
+ // POSTing /refresh blindly for a signed-out or not-yet-approved
1099
+ // visitor returns a 401 the browser logs to the console even though
1100
+ // we handle it. With none of these signals, treat the user as
1101
+ // signed-out: no network call, no console error. A returning,
1102
+ // approved user still gets a silent restore (the hint persists in
1103
+ // localStorage), and a new-app visitor signs in with the same
1104
+ // explicit click as before (which lands a brass_code and probes).
1105
+ if (this.pendingAuthCode !== null ||
1106
+ this.sessionToken !== null ||
1107
+ readLoginHint(appId)) {
1108
+ await this.refresh();
1109
+ }
1110
+ // The code is a landing credential, so it expires with the landing. Past
1111
+ // bootstrap it is a pointer to the session that was live when this page
1112
+ // loaded, and the browser can since have moved to another one (a second
1113
+ // account signed in on another tab). A later refresh falling through to
1114
+ // it would re-authenticate this app as the account the browser has left,
1115
+ // and pin that account's `sid` for the tab, so the app would keep acting
1116
+ // as someone this browser can no longer reach.
1117
+ this.pendingAuthCode = null;
1118
+ }
1119
+ async refresh() {
1120
+ if (this.inflightRefresh)
1121
+ return this.inflightRefresh;
1122
+ const appId = this.requireAppId();
1123
+ this.inflightRefresh = (async () => {
1124
+ const result = await this.runRefresh(appId);
1125
+ this.setTokens(result?.tokens ?? null);
1126
+ // Remember whether a live session exists for this app so the next
1127
+ // 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.
1133
+ if (result) {
1134
+ storeLoginHint(appId);
1135
+ }
1136
+ else {
1137
+ clearLoginHint(appId);
1138
+ }
1139
+ if (result?.sessionToken) {
1140
+ this.sessionToken = result.sessionToken;
1141
+ // Mirror into sessionStorage so a reload on this tab can
1142
+ // hydrate it (the cookie channel is unavailable in this
1143
+ // 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.
1147
+ storeSessionToken(appId, result.sessionToken);
1148
+ }
1149
+ return result?.tokens ?? null;
1150
+ })().finally(() => {
1151
+ this.inflightRefresh = null;
1152
+ });
1153
+ return this.inflightRefresh;
1154
+ }
1155
+ // Picks the credential channel for a single /refresh round-trip. The
1156
+ // cookie path is primary and untouched for browsers that send the
1157
+ // third-party cookie; the one-time code is consumed only when that
1158
+ // cookie path genuinely came back empty. Once a fallback session
1159
+ // token is in hand (the cross-site cookie is proven unavailable for
1160
+ // this page's life), every later refresh uses it directly.
1161
+ async runRefresh(appId) {
1162
+ if (this.sessionToken) {
1163
+ const viaSession = await refreshTokens(this.authBaseUrl, appId, {
1164
+ kind: 'sessionToken',
1165
+ sessionToken: this.sessionToken,
1166
+ });
1167
+ if (viaSession)
1168
+ return viaSession;
1169
+ // Stored sid is stale (server-side revoke, row expiry). Drop both
1170
+ // the in-memory copy and the sessionStorage mirror, then fall
1171
+ // through to the cookie / code paths. Without this, a hydrated-
1172
+ // but-dead sid would block every refresh for the rest of the
1173
+ // tab's life even when the cookie or a landed `brass_code` could
1174
+ // recover the session.
1175
+ this.sessionToken = null;
1176
+ clearSessionToken(appId);
1177
+ }
1178
+ const viaCookie = await refreshTokens(this.authBaseUrl, appId);
1179
+ if (viaCookie)
1180
+ 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.
1184
+ const code = this.pendingAuthCode;
1185
+ this.pendingAuthCode = null;
1186
+ if (!code)
1187
+ return null;
1188
+ return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
1189
+ }
1190
+ setTokens(next) {
1191
+ this.tokens = next;
1192
+ const nextUser = next ? decodeIdToken(next.idToken) : null;
1193
+ if (sameUser(this.user, nextUser))
1194
+ return;
1195
+ this.user = nextUser;
1196
+ // Suppress emits while init is still in flight: init's finally
1197
+ // block fires the single canonical emit once bootstrap settles.
1198
+ // Without this guard, a bootstrap that resolves a real user would
1199
+ // fire here (null -> user) AND fire again from init's finally
1200
+ // (the same user), double-emitting on every signed-in app load.
1201
+ if (!this.bootstrapped)
1202
+ return;
1203
+ for (const listener of this.listeners) {
1204
+ listener(nextUser);
1205
+ }
1206
+ }
1207
+ async hydrateProfile() {
1208
+ if (!this.user)
1209
+ return;
1210
+ if (!this.apiBaseUrlConfigured)
1211
+ return;
1212
+ const token = this.tokens?.accessToken;
1213
+ if (!token)
1214
+ return;
1215
+ try {
1216
+ const res = await fetch(`${this.apiBaseUrl}/users/${encodeURIComponent(this.user.sub)}`, { headers: { Authorization: `Bearer ${token}` } });
1217
+ if (!res.ok)
1218
+ return;
1219
+ const profile = (await res.json());
1220
+ if (this.user) {
1221
+ if (profile.display_name)
1222
+ this.user.display_name = profile.display_name;
1223
+ if (profile.avatar_url)
1224
+ this.user.avatar_url = profile.avatar_url;
1225
+ }
1226
+ }
1227
+ catch {
1228
+ // Profile hydration is best-effort; the app works without it.
1229
+ }
1230
+ }
1231
+ requireInit() {
1232
+ if (!this.initialized)
1233
+ throw new Error('Call Brass.init(appId) first');
1234
+ }
1235
+ requireAppId() {
1236
+ this.requireInit();
1237
+ if (!this.appId)
1238
+ throw new Error('Brass.init was called without an app ID');
1239
+ return this.appId;
1240
+ }
1241
+ }
1242
+ // Reads the one-time `brass_code` the auth proxy appended to return_to
1243
+ // and strips it from the visible URL in the same tick. Returns null when
1244
+ // there is no code (a hard reload, or a browser where the cookie channel
1245
+ // works and the proxy's code went unused). Best-effort: any failure
1246
+ // (exotic URL, history API unavailable) just yields null and the SDK
1247
+ // falls back to the cookie-only path.
1248
+ function consumeAuthCodeParam() {
1249
+ try {
1250
+ const url = new URL(window.location.href);
1251
+ const code = url.searchParams.get('brass_code');
1252
+ if (!code)
1253
+ return null;
1254
+ url.searchParams.delete('brass_code');
1255
+ window.history.replaceState(window.history.state, '', url.toString());
1256
+ return code;
1257
+ }
1258
+ catch {
1259
+ return null;
1260
+ }
1261
+ }
1262
+ function sameUser(a, b) {
1263
+ if (a === b)
1264
+ return true;
1265
+ if (!a || !b)
1266
+ return false;
1267
+ return a.sub === b.sub && a.email === b.email;
1268
+ }
1269
+ function truncate(s) {
1270
+ return s.length > 40 ? `${s.slice(0, 40)}...` : s;
1271
+ }
1272
+ function isSchemaConflict(err) {
1273
+ return err instanceof BrassError && err.code === 'schema_conflict';
1274
+ }
1275
+ function isAppNotInstalled(err) {
1276
+ return err instanceof BrassError && err.code === 'app_not_installed';
1277
+ }
1278
+ //# sourceMappingURL=client.js.map