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