@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
@@ -0,0 +1,1020 @@
1
+ // The hosted-UI launcher core.
2
+ //
3
+ // "Hosted UI" surfaces are Brass-owned pages (document picker, share dialog) a
4
+ // third-party app opens without building the UI. The app calls e.g.
5
+ // `Brass.selectDocument()`; this loads `${dashboardBaseUrl}/embed/<surface>`
6
+ // in a modal iframe, runs a postMessage handshake with the embed page's
7
+ // bridge, and resolves with whatever the user did.
8
+ //
9
+ // Iframe-first. When the iframe can't complete the handshake (CSP
10
+ // `frame-ancestors`, a partitioned third-party auth cookie, ...) the launcher
11
+ // falls back per surface:
12
+ // - `reject`: throw `BrassError` `code: 'iframe_unavailable'`. Used by
13
+ // value-returning launchers with nowhere else to send the user.
14
+ // - `redirect`: navigate the top window to the standalone
15
+ // `/embed/<surface>?...&return_to=<here>` page. Used by fire-and-forget
16
+ // launchers; the returned promise never settles (the page is gone).
17
+ // - `redirect-with-value`: the same navigation for a surface that still
18
+ // owes the caller a value, which comes home on one-shot URL params (the
19
+ // document picker).
20
+ //
21
+ // Bridge protocol (envelope `__brass_embed: 1`):
22
+ // iframe -> host: ready | resize{height} | result{payload} | cancel |
23
+ // error{code,message}
24
+ // host -> iframe: init{surface,params}
25
+ // The host validates `event.origin === dashboardOrigin` and
26
+ // `event.source === iframe.contentWindow` on every inbound message; the embed
27
+ // page learns the host origin from `init` and targets outbound at it (never
28
+ // `*`).
29
+
30
+ // Both sides ignore any `message` whose `data[ENVELOPE]` is not this exact
31
+ // value, so unrelated postMessage traffic never reaches the handshake.
32
+ import { BrassError } from './errors.js';
33
+
34
+ const ENVELOPE = '__brass_embed';
35
+ const PROTOCOL_VERSION = 1;
36
+
37
+ // No `ready` within this window means the iframe is unusable (CSP, origin
38
+ // unreachable, bridge threw). What the window is worth is set by what the
39
+ // fallback costs the user, not by which fallback runs.
40
+ //
41
+ // Only a plain `redirect` can afford the short window: the surface returns
42
+ // nothing, the standalone page finishes the same job, and the user keeps
43
+ // their place, so eating ten seconds of blank modal is the bigger cost.
44
+ //
45
+ // `reject` and `redirect-with-value` both end with the caller worse off (a
46
+ // thrown error, or the user navigated out of the host app with the value
47
+ // routed home through a URL round trip), so they wait long enough that only
48
+ // a genuinely unusable iframe times out. The embed page is a full
49
+ // application boot behind a cross-origin request, so a merely slow link
50
+ // clears the short window while the iframe is perfectly capable.
51
+ const READY_TIMEOUT_LONG_MS = 10_000;
52
+ const READY_TIMEOUT_SHORT_MS = 4_000;
53
+
54
+ // What a launcher does when the handshake never completes. See the timeout
55
+ // constants above for the window each one gets.
56
+ type UnavailableMode = 'reject' | 'redirect' | 'redirect-with-value';
57
+
58
+ // The embed page posts `resize` with its measured height; the host clamps to
59
+ // this range so a malformed or hostile height can't collapse the dialog or
60
+ // paint the whole viewport.
61
+ const INITIAL_IFRAME_HEIGHT = 480;
62
+ const MIN_IFRAME_HEIGHT = 160;
63
+ const MAX_IFRAME_HEIGHT = 760;
64
+
65
+ // Light/dark mode for the modal + embed page. Defaults to `'light'`: the
66
+ // dominant host is light-only (an app whose CSS ignores
67
+ // `prefers-color-scheme`), so the modal must render light regardless of OS
68
+ // preference. `'auto'` is the explicit opt-in for OS-following apps. Host apps
69
+ // that force a theme pass the matching value so the modal doesn't mismatch.
70
+ export type HostedUiTheme = 'light' | 'dark' | 'auto';
71
+
72
+ // Which aspect the hosted settings surface opens focused on. Intent, not
73
+ // layout: the hosted UI decides presentation (so dropping the tab bar needs
74
+ // no SDK change). `'details'` is name + location; `'share'` is the multi-tier
75
+ // share surface and the default (matches `shareDocument`); `'delete'` is
76
+ // soft-delete.
77
+ export type DocumentSettingsFocus = 'details' | 'share' | 'delete';
78
+
79
+ // Result of `Brass.selectDocument()`. `null` when dismissed. `project_id` is
80
+ // `string | null`: a "loose document" lives directly under an organization,
81
+ // so apps branching on project context must handle `null`. `role` is the
82
+ // caller's effective role, resolved via the scope-loop (an org-admin not
83
+ // granted the doc directly still surfaces as `owner`).
84
+ export interface SelectDocumentResult {
85
+ doc_id: string;
86
+ project_id: string | null;
87
+ organization_id: string;
88
+ role: 'owner' | 'editor' | 'viewer';
89
+ name: string;
90
+ created_at: string;
91
+ updated_at: string;
92
+ branch_id?: string;
93
+ }
94
+
95
+ // Result of the hosted create-destination dialog: where a new document goes,
96
+ // plus the chosen name. The dialog resolves a destination only; the SDK does
97
+ // the create itself so the app's credential path stamps `creating_app_id` and
98
+ // carries `app_metadata` / `documentOptions`. `project_id` is null for a loose
99
+ // document; `organization_id` is always the destination org.
100
+ export interface CreateDestinationResult {
101
+ organization_id: string;
102
+ project_id: string | null;
103
+ name: string;
104
+ }
105
+
106
+ // Result of the hosted acquire-organization surface: the organization a
107
+ // zero-org caller ended up with, whether they created a private space,
108
+ // created an organization, or accepted a pending invite.
109
+ export interface AcquireOrganizationResult {
110
+ organization_id: string;
111
+ }
112
+
113
+ // Result of the internal document-history launcher (opened by the
114
+ // session's `history` file-menu command). Three outcomes:
115
+ // - `{ branch_id }`: forked a NEW branch; open editable with
116
+ // `openDocumentById(docId, { branch: branch_id })`.
117
+ // - `{ branch_id, version }`: opened an EXISTING point; open read-only with
118
+ // `openDocumentById(docId, { branch: branch_id, version })`.
119
+ // - `null`: reviewed and closed without forking or opening (a pin persists
120
+ // server-side regardless, so pinning alone still resolves `null`).
121
+ export interface DocumentHistoryResult {
122
+ // The branch: the new branch for a fork, the version's branch for a version
123
+ // open. Pass it as `branch` to `openDocumentById`.
124
+ branch_id: string;
125
+ // Present only on a version open (not a fork). Pass alongside `branch_id`
126
+ // for a read-only historical open.
127
+ version?: number;
128
+ // The reader asked to browse versions in the application itself rather
129
+ // than pick one in the dialog, so the host shows its version bar over the
130
+ // live document. Carries no `version`: browsing starts where the reader
131
+ // already is.
132
+ browse?: boolean;
133
+ }
134
+
135
+ // Result of the internal install-consent launcher (`offerInstallConsent`
136
+ // opens it when a create / open hits an `app_not_installed` denial): the
137
+ // install row the hosted consent UI minted. The row's `scopes` is the
138
+ // granted set the admin actually approved, which may differ from the
139
+ // calling app's `requested_scopes` (e.g. the admin downgraded one entry
140
+ // before confirming).
141
+ export interface InstallAppResult {
142
+ organization_id: string;
143
+ app_id: string;
144
+ installed_by: string;
145
+ installed_at: string;
146
+ scopes: string[];
147
+ app_name: string | null;
148
+ }
149
+
150
+ // Result of the `drive-resolve` surface (from `Document.resolveDrive()`):
151
+ // how a `behind` / `diverged` drive-linked doc was resolved. `null` when
152
+ // dismissed.
153
+ export interface DriveResolveResult {
154
+ // The fresh mirror doc that took over the link (take-source / keep-both
155
+ // re-import the remote into a new doc); `null` when publish-over resolved
156
+ // in place.
157
+ newDocId: string | null;
158
+ }
159
+
160
+ // Result of the `document-settings` surface. Present only when the user
161
+ // duplicated the document and chose to open the copy; the copy is its own
162
+ // document, so the dialog reports its id and the caller opens it.
163
+ export interface DocumentSettingsResult {
164
+ newDocId: string;
165
+ }
166
+
167
+ // Result of running the app's serializer for an export. The host answers an
168
+ // embed page's `export-request` with the bytes or a failure reason.
169
+ // `no_serializer` is the ordinary "this app does not export" outcome; the
170
+ // rest are genuine failures.
171
+ export type ExportSerializeOutcome =
172
+ | { ok: true; bytes: Uint8Array }
173
+ | {
174
+ ok: false;
175
+ code: 'no_serializer' | 'no_document' | 'serializer_failed';
176
+ message: string;
177
+ };
178
+
179
+ export interface HostedUiLauncher {
180
+ // Hosted document picker (single-selection, with an inline new-document
181
+ // branch). Resolves with the chosen / created document or `null`.
182
+ // Iframe-only with a `BrassError` reject (a returned value can't survive a
183
+ // redirect fallback).
184
+ selectDocument(): Promise<SelectDocumentResult | null>;
185
+ // The document picker opened straight on its New panel to ask where a new
186
+ // document goes. Used by `Brass.session.create()` when a cold create is
187
+ // ambiguous across multiple orgs, so the user picks rather than the SDK
188
+ // guessing. Resolves with the destination (the dialog does NOT create the
189
+ // document) or `null`. Iframe-only, same rationale as `selectDocument`.
190
+ selectCreateDestination(options?: {
191
+ name?: string;
192
+ }): Promise<CreateDestinationResult | null>;
193
+ // Org-acquisition surface for a zero-org caller: create a private space,
194
+ // create an org, or accept a pending invite. Used by `createDocument` when
195
+ // implicit-org resolution finds no org. Resolves with the org or `null`.
196
+ // Iframe-only with a `BrassError` reject (the caller needs the id back).
197
+ acquireOrganization(): Promise<AcquireOrganizationResult | null>;
198
+ // Hosted multi-tier share dialog: "This document" (direct grants), "This
199
+ // project" (when present), "This organization", each with member list +
200
+ // add-by-email + role; effective access shown read-only on top.
201
+ // Fire-and-forget; iframe-first with redirect fallback.
202
+ shareDocument(docId: string): Promise<void>;
203
+ // Hosted document settings, absorbing Details / Share / Duplicate /
204
+ // Delete for one document. Pass `focus` to open on an aspect; defaults to
205
+ // `'share'` so `documentSettings(docId)` and `shareDocument(docId)`
206
+ // resolve to the same surface. Resolves with a `DocumentSettingsResult`
207
+ // when the user duplicated the document and asked to open the copy,
208
+ // `null` otherwise. Same iframe-first, redirect-fallback lifecycle as
209
+ // `shareDocument`, and the redirect arm resolves `null` (a navigated-away
210
+ // page reports nothing).
211
+ documentSettings(
212
+ docId: string,
213
+ options?: { focus?: DocumentSettingsFocus },
214
+ ): Promise<DocumentSettingsResult | null>;
215
+ // Hosted version-timeline modal (scrub, named pins, branch-from-a-version)
216
+ // for one document. Pass `branch` to scope to a branch; omit for the
217
+ // default. Resolves with a `DocumentHistoryResult` (`{ branch_id }` for a
218
+ // fork, `{ branch_id, version }` for a version open,
219
+ // `{ branch_id, browse: true }` to browse in the app, `null` otherwise).
220
+ // Iframe-only with a `BrassError` reject, same rationale as
221
+ // `selectDocument`.
222
+ documentHistory(
223
+ docId: string,
224
+ options?: { branch?: string },
225
+ ): Promise<DocumentHistoryResult | null>;
226
+ selectBranch(
227
+ docId: string,
228
+ options?: { branch?: string },
229
+ ): Promise<{ branch_id: string } | null>;
230
+ // Hosted install-consent UI: the org admin reviews the requested scopes in
231
+ // plain language and confirms / rejects; on confirm the backend writes the
232
+ // `ORGANIZATION#/INSTALL#` row. Resolves with the row or `null` on cancel.
233
+ // Iframe-only with a `BrassError` reject (the caller needs the row back).
234
+ // `organizationId` optional: omit to let the user pick inside the dialog
235
+ // (default selection is the only org, or the first by membership time).
236
+ installApp(organizationId?: string): Promise<InstallAppResult | null>;
237
+ // Hosted drive-divergence resolution (from `Document.resolveDrive()`):
238
+ // publish over the source, take the source, or keep both. Take-source /
239
+ // keep-both mint a fresh mirror doc (its id rides the result for the app to
240
+ // switch onto); publish-over resolves in place (`newDocId: null`). Resolves
241
+ // with the outcome or `null`. Iframe-only with a `BrassError` reject.
242
+ driveResolve(docId: string): Promise<DriveResolveResult | null>;
243
+ }
244
+
245
+ export interface HostedUiLauncherOptions {
246
+ // Origin the embed pages are served from. `Brass.init`'s `dashboardBaseUrl`,
247
+ // defaulted by the client.
248
+ dashboardBaseUrl: string;
249
+ // Threaded into param payloads for surfaces that need it (`installApp`).
250
+ // Every Brass app shares one sign-in client, so the JWT can't
251
+ // disambiguate the asking app; the embed page reads `appId` off the bridge.
252
+ // Optional so non-install fixtures can omit it.
253
+ callingAppId?: string;
254
+ // Pins the modal + embed page to a theme. Defaults to `'light'`; pass
255
+ // `'auto'` to follow OS preference. Host apps that force a theme pass it so
256
+ // the modal doesn't mismatch (the close button sits over the iframe and
257
+ // goes invisible when chrome and content land on opposite sides).
258
+ theme?: HostedUiTheme;
259
+ // The export backend: a hosted Download asks the running app to serialise
260
+ // its open doc; this runs the app's serializer for `docId`. Omitted by
261
+ // fixtures that never export.
262
+ serializeForExport?: (docId: string) => Promise<ExportSerializeOutcome>;
263
+ // The hosted delete surface signals over the bridge that the local user is
264
+ // deleting `docId` (`active: true`), or that the delete failed
265
+ // (`active: false`, a retraction). The client routes this to the session so
266
+ // the incoming `doc_gone` for the user's own delete returns to the empty
267
+ // state instead of parking the remote-eviction error surface. Signalled
268
+ // pre-flight, so the note beats the server's `doc_gone` frame.
269
+ onLocalDelete?: (docId: string, active: boolean) => void;
270
+ // The SDK's fallback platform-session pointer (`sid`), non-null only when
271
+ // this browser blocked the cross-site `brass_auth` cookie and the SDK is
272
+ // running in code / session-token mode. Read at dialog-open time and posted
273
+ // in the bridge `init` (to the pinned dashboard origin only) so the embed
274
+ // page can refresh the dashboard's own tokens when its framed cookie is
275
+ // likewise blocked. Null in a normal browser, where the embed's cookie
276
+ // carries its session and no hand-off is needed.
277
+ getSessionToken?: () => string | null;
278
+ // The `sub` of the user this app is currently acting as, read at
279
+ // dialog-open time and posted in the bridge `init`. The embed page
280
+ // authenticates from the dashboard origin's own platform-session cookie,
281
+ // which can name a different user than the token this app holds (a second
282
+ // account signed in on another tab replaces the browser's session, and
283
+ // this app keeps serving its cached token until it expires). Naming the
284
+ // user here is what lets the embed refuse to act as anyone else.
285
+ getActingSub?: () => string | null;
286
+ // Called when the user takes the refusal surface's sign-in action. The
287
+ // embed cannot run it: signing in inside the iframe authenticates that
288
+ // frame, not the app around it, so the host navigates its own page to the
289
+ // Brass sign-in for its app. A user gesture on the pinned dashboard origin
290
+ // is the only thing that reaches this, so it does not open a path for an
291
+ // app to trigger a sign-in on its own.
292
+ onSignInRequested?: () => void;
293
+ // The one-shot query params the picker's redirect fallback returns the pick
294
+ // on. When set, the document picker degrades to a full-page redirect
295
+ // instead of rejecting where the iframe can't be hosted: the standalone
296
+ // picker navigates home with these params and the SDK consumes them on
297
+ // boot. The SDK always sets this in production; a fixture that omits it
298
+ // keeps the picker iframe-only (reject).
299
+ pickReturn?: { docParam: string; branchParam: string };
300
+ }
301
+
302
+ interface EmbedOutcome {
303
+ payload: unknown;
304
+ }
305
+
306
+ // Full-page fallback when the iframe can't be hosted: navigate the top
307
+ // window to the standalone embed page, threading the surface params plus a
308
+ // `return_to` so the standalone page can send the user home when done.
309
+ function redirectToStandalone(
310
+ baseUrl: string,
311
+ surface: string,
312
+ params: Record<string, unknown>,
313
+ theme: HostedUiTheme,
314
+ ): void {
315
+ const url = new URL(`${baseUrl}/embed/${surface}`);
316
+ for (const [key, value] of Object.entries(params)) {
317
+ // `sessionToken` is an in-iframe-only credential hand-off (posted over the
318
+ // bridge to the pinned dashboard origin); never let it reach a URL. On the
319
+ // redirect fallback the standalone page is top-level and first-party, so
320
+ // its own cookie carries the session and no hand-off is needed.
321
+ if (key === 'sessionToken') continue;
322
+ if (value !== undefined && value !== null) {
323
+ url.searchParams.set(key, String(value));
324
+ }
325
+ }
326
+ url.searchParams.set('return_to', window.location.href);
327
+ // Thread the theme only when it overrides the `'light'` default, keeping the
328
+ // URL stable for the no-config path (mirrors the iframe-src construction).
329
+ if (theme !== 'light') {
330
+ url.searchParams.set('theme', theme);
331
+ }
332
+ window.location.assign(url.toString());
333
+ }
334
+
335
+ // Resolved light/dark for the modal chrome. `'auto'` reads the host's
336
+ // `prefers-color-scheme` once on open; the embed page still receives the
337
+ // unresolved `'auto'` so its own rule keeps tracking OS preference.
338
+ function resolveChromeTheme(theme: HostedUiTheme): 'light' | 'dark' {
339
+ if (theme === 'light' || theme === 'dark') return theme;
340
+ const mql = window.matchMedia?.('(prefers-color-scheme: dark)');
341
+ return mql?.matches ? 'dark' : 'light';
342
+ }
343
+
344
+ interface ChromeTokens {
345
+ backdrop: string;
346
+ cardBackground: string;
347
+ cardShadow: string;
348
+ closeButtonColor: string;
349
+ // Spinner colours for the loader that sits over the iframe until
350
+ // the embed page's bridge handshake completes.
351
+ spinnerTrack: string;
352
+ spinnerAccent: string;
353
+ }
354
+
355
+ function chromeTokensFor(resolved: 'light' | 'dark'): ChromeTokens {
356
+ if (resolved === 'dark') {
357
+ return {
358
+ backdrop: 'rgba(0, 0, 0, 0.65)',
359
+ cardBackground: '#11141b',
360
+ cardShadow: '0 20px 60px rgba(0, 0, 0, 0.55)',
361
+ closeButtonColor: '#e7eaf0',
362
+ spinnerTrack: 'rgba(231, 234, 240, 0.18)',
363
+ spinnerAccent: '#e7eaf0',
364
+ };
365
+ }
366
+ return {
367
+ backdrop: 'rgba(15, 23, 42, 0.55)',
368
+ cardBackground: '#fff',
369
+ cardShadow: '0 20px 60px rgba(15, 23, 42, 0.35)',
370
+ closeButtonColor: '#0f172a',
371
+ spinnerTrack: 'rgba(15, 23, 42, 0.12)',
372
+ spinnerAccent: '#0f172a',
373
+ };
374
+ }
375
+
376
+ const CHROME_STYLE_ID = 'brass-hosted-ui-chrome';
377
+
378
+ // The chrome dims the page with the dialog element's own themed background, so
379
+ // the UA's `::backdrop` would stack a second, unthemed dim beneath it. A
380
+ // pseudo-element cannot be styled inline, which is why the chrome injects a
381
+ // stylesheet at all; everything else it paints is an inline style.
382
+ const CHROME_CSS = `dialog[data-brass-hosted-ui]::backdrop { background: transparent; }`;
383
+
384
+ function injectChromeStyles(doc: Document): void {
385
+ if (doc.getElementById(CHROME_STYLE_ID)) return;
386
+ const style = doc.createElement('style');
387
+ style.id = CHROME_STYLE_ID;
388
+ style.textContent = CHROME_CSS;
389
+ doc.head.appendChild(style);
390
+ }
391
+
392
+ // The control the reader last acted on, which is who a dismissed surface hands
393
+ // focus back to. `document.activeElement` when the launcher is called looks
394
+ // like the answer and is often `<body>` by then: a host that disables its
395
+ // trigger for the life of the dialog does so before calling, and disabling a
396
+ // focused control blurs it. Tracking the activation instead survives that.
397
+ let lastActivated: HTMLElement | null = null;
398
+ let trackingActivations = false;
399
+
400
+ const ACTIVATABLE =
401
+ 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
402
+
403
+ function trackActivation(event: Event): void {
404
+ // Enter and Space are the keys that press a control; every other keystroke
405
+ // is someone typing, and typing is not what a dialog returns focus to.
406
+ if (event instanceof KeyboardEvent && event.key !== 'Enter' && event.key !== ' ') {
407
+ return;
408
+ }
409
+ const target = event.target;
410
+ if (!(target instanceof HTMLElement)) return;
411
+ lastActivated = target.closest<HTMLElement>(ACTIVATABLE) ?? target;
412
+ }
413
+
414
+ /**
415
+ * Start recording which control the reader last pressed, so a dismissed
416
+ * surface can hand the keyboard back to it. Idempotent.
417
+ *
418
+ * @internal
419
+ */
420
+ export function trackHostedUiActivations(): void {
421
+ if (trackingActivations || typeof document === 'undefined') return;
422
+ trackingActivations = true;
423
+ document.addEventListener('pointerdown', trackActivation, true);
424
+ document.addEventListener('keydown', trackActivation, true);
425
+ }
426
+
427
+ // Who a dismissal should put the keyboard back on: whatever holds focus when
428
+ // the surface opens, or the last control the reader pressed when nothing does.
429
+ function resolveOpener(): HTMLElement | null {
430
+ const active = document.activeElement;
431
+ if (active instanceof HTMLElement && active !== document.body) return active;
432
+ return lastActivated;
433
+ }
434
+
435
+ // Opens `${baseUrl}/embed/<surface>` in a modal iframe and drives the bridge
436
+ // handshake. Resolves with the `result` payload or `null` on cancel; rejects
437
+ // on not-in-browser / embed error. On the ready timeout it rejects
438
+ // (`unavailable: 'reject'`) or redirects and never settles (the two
439
+ // `redirect` modes).
440
+ function openEmbed(
441
+ baseUrl: string,
442
+ dashboardOrigin: string,
443
+ surface: string,
444
+ params: Record<string, unknown>,
445
+ unavailable: UnavailableMode,
446
+ theme: HostedUiTheme,
447
+ // Handles an `export-request`: serialise the surface's `docId` and post the
448
+ // bytes back. Only the document surfaces (with a Download button) pass it.
449
+ onExportRequest?: (docId: string) => Promise<ExportSerializeOutcome>,
450
+ // Notified when the delete surface reports a local delete (or its
451
+ // retraction) for a doc id, so the session can classify the matching
452
+ // `doc_gone`. Only the document-settings / share surface emits it.
453
+ onLocalDelete?: (docId: string, active: boolean) => void,
454
+ // Notified when the user takes the refusal surface's sign-in action.
455
+ onSignInRequested?: () => void,
456
+ ): Promise<EmbedOutcome | null> {
457
+ return new Promise<EmbedOutcome | null>((resolve, reject) => {
458
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
459
+ reject(
460
+ new BrassError('Hosted UI requires a browser environment.', {
461
+ code: 'not_in_browser',
462
+ }),
463
+ );
464
+ return;
465
+ }
466
+
467
+ const resolvedChrome = resolveChromeTheme(theme);
468
+ const tokens = chromeTokensFor(resolvedChrome);
469
+
470
+ injectChromeStyles(document);
471
+
472
+ // A `<dialog>` opened with `showModal()` (below), which is what makes the
473
+ // host page inert while the surface is up: the reader tabs around the
474
+ // dialog instead of through the app behind it, and the browser enforces
475
+ // that rather than a key handler here. The UA box styles for a dialog (a
476
+ // content-sized, centered, bordered, padded box with a max-width) are all
477
+ // wrong for a full-viewport backdrop, so each is zeroed.
478
+ const overlay = document.createElement('dialog');
479
+ overlay.setAttribute('data-brass-hosted-ui', surface);
480
+ overlay.setAttribute('data-brass-theme', theme);
481
+ Object.assign(overlay.style, {
482
+ position: 'fixed',
483
+ inset: '0',
484
+ margin: '0',
485
+ padding: '0',
486
+ border: '0',
487
+ width: 'auto',
488
+ height: 'auto',
489
+ maxWidth: 'none',
490
+ maxHeight: 'none',
491
+ // Above effectively everything; this is a foreground modal. The top
492
+ // layer already outranks any z-index, so this is what keeps the chrome
493
+ // in front on the fallback path where `showModal` is unavailable.
494
+ zIndex: '2147483647',
495
+ display: 'flex',
496
+ alignItems: 'center',
497
+ justifyContent: 'center',
498
+ background: tokens.backdrop,
499
+ } satisfies Partial<CSSStyleDeclaration>);
500
+
501
+ const card = document.createElement('div');
502
+ // Expose the frame as a labelled modal dialog: the overlay behind it is
503
+ // the backdrop, and the embed page inside the iframe carries its own
504
+ // heading. The close button / Escape dismiss it (below); a backdrop
505
+ // click does not.
506
+ card.setAttribute('role', 'dialog');
507
+ card.setAttribute('aria-modal', 'true');
508
+ card.setAttribute('aria-label', 'Brass');
509
+ Object.assign(card.style, {
510
+ position: 'relative',
511
+ width: 'min(420px, calc(100vw - 32px))',
512
+ background: tokens.cardBackground,
513
+ borderRadius: '12px',
514
+ overflow: 'hidden',
515
+ boxShadow: tokens.cardShadow,
516
+ } satisfies Partial<CSSStyleDeclaration>);
517
+
518
+ const iframe = document.createElement('iframe');
519
+ // Thread the theme on the iframe URL (not the bridge `init` payload) so
520
+ // the embed page pins its `color-scheme` before it paints, avoiding a
521
+ // wrong-theme flash while the handshake is in flight. Omit the `'light'`
522
+ // default to keep the URL stable for the no-config path.
523
+ if (theme === 'light') {
524
+ iframe.src = `${baseUrl}/embed/${surface}`;
525
+ } else {
526
+ const iframeUrl = new URL(`${baseUrl}/embed/${surface}`);
527
+ iframeUrl.searchParams.set('theme', theme);
528
+ iframe.src = iframeUrl.toString();
529
+ }
530
+ iframe.title = 'Brass';
531
+ // The dialogs offer copy-to-clipboard actions (the share surface's
532
+ // document link); a cross-origin iframe only gets the Clipboard API
533
+ // when its embedder delegates it, so writeText throws NotAllowedError
534
+ // without this.
535
+ iframe.allow = 'clipboard-write';
536
+ // `colorScheme` + `background` on the iframe ELEMENT defend against a
537
+ // flash through the OS color while the embed HTML + JS loads: until the
538
+ // embed page's `applyEmbedTheme()` pins `color-scheme` on its own
539
+ // `<html>`, the browser paints the iframe's default background from the OS
540
+ // `prefers-color-scheme` (a black flash on a dark-OS phone opening a
541
+ // light modal). Pinning here paints the resolved color before any request
542
+ // leaves the wire. `'auto'` follows OS preference, matching the embed page
543
+ // once loaded.
544
+ Object.assign(iframe.style, {
545
+ display: 'block',
546
+ border: '0',
547
+ width: '100%',
548
+ height: `${INITIAL_IFRAME_HEIGHT}px`,
549
+ colorScheme: theme === 'auto' ? 'light dark' : resolvedChrome,
550
+ background: tokens.cardBackground,
551
+ } satisfies Partial<CSSStyleDeclaration>);
552
+
553
+ const closeButton = document.createElement('button');
554
+ closeButton.type = 'button';
555
+ closeButton.setAttribute('aria-label', 'Close');
556
+ closeButton.textContent = '×';
557
+ Object.assign(closeButton.style, {
558
+ position: 'absolute',
559
+ top: '6px',
560
+ right: '8px',
561
+ width: '28px',
562
+ height: '28px',
563
+ border: '0',
564
+ borderRadius: '6px',
565
+ background: 'transparent',
566
+ color: tokens.closeButtonColor,
567
+ fontSize: '20px',
568
+ lineHeight: '1',
569
+ cursor: 'pointer',
570
+ } satisfies Partial<CSSStyleDeclaration>);
571
+
572
+ card.appendChild(closeButton);
573
+ card.appendChild(iframe);
574
+
575
+ // Loading spinner over the iframe until the `ready` handshake. Without it
576
+ // the user sees a blank panel for ~300-1000ms while the iframe boots,
577
+ // reading as "the click did nothing". It can't sit BEHIND the iframe
578
+ // because the iframe paints its own background synchronously (the
579
+ // OS-color-flash defense above), so it goes on top and hides on `ready`.
580
+ const loader = document.createElement('div');
581
+ Object.assign(loader.style, {
582
+ position: 'absolute',
583
+ inset: '0',
584
+ display: 'flex',
585
+ alignItems: 'center',
586
+ justifyContent: 'center',
587
+ background: tokens.cardBackground,
588
+ zIndex: '1',
589
+ // Brief fade so the spinner-to-content transition isn't abrupt.
590
+ transition: 'opacity 120ms ease-out',
591
+ } satisfies Partial<CSSStyleDeclaration>);
592
+ const spinner = document.createElement('div');
593
+ Object.assign(spinner.style, {
594
+ width: '32px',
595
+ height: '32px',
596
+ borderRadius: '50%',
597
+ border: `3px solid ${tokens.spinnerTrack}`,
598
+ borderTopColor: tokens.spinnerAccent,
599
+ boxSizing: 'border-box',
600
+ } satisfies Partial<CSSStyleDeclaration>);
601
+ // Web Animations API rather than a `<style>` @keyframes, so no CSS is
602
+ // injected into the host page (which would need scoping to avoid leaking).
603
+ if (typeof spinner.animate === 'function') {
604
+ spinner.animate(
605
+ [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
606
+ { duration: 800, iterations: Infinity, easing: 'linear' },
607
+ );
608
+ }
609
+ loader.appendChild(spinner);
610
+ card.appendChild(loader);
611
+
612
+ overlay.appendChild(card);
613
+
614
+ let settled = false;
615
+ let readyTimer: ReturnType<typeof setTimeout> | undefined;
616
+
617
+ // Where to put the reader back. Dismissal detaches the dialog rather than
618
+ // closing it in place, so this is the launcher's job; a reader left on
619
+ // `<body>` resumes tabbing from the top of the page instead of from the
620
+ // control that opened the surface.
621
+ const opener = resolveOpener();
622
+
623
+ function restoreFocus(): void {
624
+ if (!opener?.isConnected) return;
625
+ opener.focus();
626
+ if (document.activeElement === opener) return;
627
+ // A host that disabled its trigger for the life of the dialog re-enables
628
+ // it when the launcher settles, which happens after this runs, so the
629
+ // press above landed on a disabled control and did nothing. Try again
630
+ // past the caller's own reaction, and only while nothing has taken focus
631
+ // since, so a host that moves focus itself on the result keeps it.
632
+ setTimeout(() => {
633
+ const active = document.activeElement;
634
+ if (active !== null && active !== document.body) return;
635
+ if (opener.isConnected) opener.focus();
636
+ }, 0);
637
+ }
638
+
639
+ function teardown(): void {
640
+ window.removeEventListener('message', onMessage);
641
+ window.removeEventListener('keydown', onKeydown, true);
642
+ overlay.removeEventListener('cancel', onCancel);
643
+ if (readyTimer !== undefined) clearTimeout(readyTimer);
644
+ if (overlay.open) overlay.close();
645
+ overlay.remove();
646
+ restoreFocus();
647
+ }
648
+
649
+ function settleResolve(value: EmbedOutcome | null): void {
650
+ if (settled) return;
651
+ settled = true;
652
+ teardown();
653
+ resolve(value);
654
+ }
655
+
656
+ function settleReject(error: BrassError): void {
657
+ if (settled) return;
658
+ settled = true;
659
+ teardown();
660
+ reject(error);
661
+ }
662
+
663
+ function onMessage(event: MessageEvent): void {
664
+ // Origin + source pinning: only the embed page we opened, in the
665
+ // dashboard origin, can drive this handshake.
666
+ if (event.origin !== dashboardOrigin) return;
667
+ if (event.source !== iframe.contentWindow) return;
668
+ const data = event.data as Record<string, unknown> | null;
669
+ if (!data || data[ENVELOPE] !== PROTOCOL_VERSION) return;
670
+
671
+ switch (data['type']) {
672
+ case 'ready': {
673
+ if (readyTimer !== undefined) clearTimeout(readyTimer);
674
+ iframe.contentWindow?.postMessage(
675
+ { [ENVELOPE]: PROTOCOL_VERSION, type: 'init', surface, params },
676
+ dashboardOrigin,
677
+ );
678
+ // Embed page mounted; fade the loader out (delayed removal so the
679
+ // last 120ms is a fade, not a snap).
680
+ loader.style.opacity = '0';
681
+ window.setTimeout(() => {
682
+ loader.style.display = 'none';
683
+ }, 150);
684
+ // Hand the keyboard to the embed now that there is something inside
685
+ // to receive it: opening the dialog leaves focus on the close
686
+ // button, which is the right place to wait while the frame boots and
687
+ // the wrong place to stay once it has. Only when the reader has not
688
+ // moved in the meantime, since taking focus back off a control they
689
+ // tabbed to during the load is worse than leaving it where it is.
690
+ if (document.activeElement === closeButton) iframe.focus();
691
+ break;
692
+ }
693
+ case 'resize': {
694
+ const height = data['height'];
695
+ if (typeof height === 'number' && Number.isFinite(height)) {
696
+ const clamped = Math.min(
697
+ MAX_IFRAME_HEIGHT,
698
+ Math.max(MIN_IFRAME_HEIGHT, Math.round(height)),
699
+ );
700
+ iframe.style.height = `${clamped}px`;
701
+ }
702
+ break;
703
+ }
704
+ case 'export-request': {
705
+ // The Download button asks this host to serialise its open doc. The
706
+ // docId is the surface's own param, not the child's to choose, so
707
+ // the app never serialises a doc the page wasn't opened for. The
708
+ // dialog stays open; do not settle the promise.
709
+ const requestId = data['requestId'];
710
+ const docId = typeof params['docId'] === 'string' ? params['docId'] : '';
711
+ const reply = (outcome: ExportSerializeOutcome): void => {
712
+ iframe.contentWindow?.postMessage(
713
+ { [ENVELOPE]: PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome },
714
+ dashboardOrigin,
715
+ );
716
+ };
717
+ if (!onExportRequest) {
718
+ reply({ ok: false, code: 'no_serializer', message: 'Export is not supported.' });
719
+ break;
720
+ }
721
+ void onExportRequest(docId).then(reply, (err: unknown) => {
722
+ reply({
723
+ ok: false,
724
+ code: 'serializer_failed',
725
+ message: err instanceof Error ? err.message : 'Serializer failed.',
726
+ });
727
+ });
728
+ break;
729
+ }
730
+ case 'request-sign-in': {
731
+ // The user clicked the refusal surface's sign-in action. Settle the
732
+ // launcher first (this dialog is over; the host is about to
733
+ // navigate) so the caller's promise does not dangle on a page that
734
+ // is going away, then run the host's sign-in.
735
+ settleResolve(null);
736
+ onSignInRequested?.();
737
+ break;
738
+ }
739
+ case 'document-deleted':
740
+ case 'document-delete-failed': {
741
+ // The delete surface reports that the local user is deleting a doc
742
+ // (or that the attempt failed). Fire-and-forget: the dialog settles
743
+ // separately through its own `result` / `cancel`, so do not settle
744
+ // the promise here.
745
+ const docId = data['docId'];
746
+ if (onLocalDelete && typeof docId === 'string' && docId) {
747
+ onLocalDelete(docId, data['type'] === 'document-deleted');
748
+ }
749
+ break;
750
+ }
751
+ case 'result': {
752
+ settleResolve({ payload: data['payload'] });
753
+ break;
754
+ }
755
+ case 'cancel': {
756
+ settleResolve(null);
757
+ break;
758
+ }
759
+ case 'error': {
760
+ const message =
761
+ typeof data['message'] === 'string' && data['message']
762
+ ? (data['message'] as string)
763
+ : 'The hosted UI could not be opened.';
764
+ settleReject(new BrassError(message, { code: 'embed_error' }));
765
+ break;
766
+ }
767
+ default:
768
+ break;
769
+ }
770
+ }
771
+
772
+ // The close button dismisses. A backdrop click does NOT: these surfaces
773
+ // hold in-progress work (connecting a drive, sharing, picking a
774
+ // destination), so a stray click outside the card must not throw it away.
775
+ // Dismissal is the explicit close button or Escape only.
776
+ closeButton.addEventListener('click', () => settleResolve(null));
777
+
778
+ // Escape dismisses too, matching the close button. This host-side
779
+ // listener fires for an Escape landing on the modal chrome (the close
780
+ // button) or the host page behind it; an Escape pressed while focus is
781
+ // inside the cross-origin iframe never reaches the host, so the embed
782
+ // page relays it as a `cancel` over the bridge. Capture phase so the
783
+ // foreground modal wins over any Escape handler the host app registered.
784
+ function onKeydown(event: KeyboardEvent): void {
785
+ if (event.key === 'Escape' && !settled) {
786
+ event.stopPropagation();
787
+ settleResolve(null);
788
+ }
789
+ }
790
+ window.addEventListener('keydown', onKeydown, true);
791
+
792
+ // An open dialog answers Escape itself, and closing it that way would
793
+ // leave the launcher's promise unsettled. Route it into the one dismissal
794
+ // path instead; `onKeydown` above normally gets there first, so this is
795
+ // the backstop for an Escape it does not see.
796
+ function onCancel(event: Event): void {
797
+ event.preventDefault();
798
+ settleResolve(null);
799
+ }
800
+ overlay.addEventListener('cancel', onCancel);
801
+
802
+ window.addEventListener('message', onMessage);
803
+ const readyTimeoutMs =
804
+ unavailable === 'redirect'
805
+ ? READY_TIMEOUT_SHORT_MS
806
+ : READY_TIMEOUT_LONG_MS;
807
+ readyTimer = setTimeout(() => {
808
+ if (unavailable !== 'reject') {
809
+ // Graceful fallback: tear down the never-loaded modal and navigate to
810
+ // the standalone page. The promise never settles (this page is about
811
+ // to be replaced).
812
+ if (settled) return;
813
+ settled = true;
814
+ teardown();
815
+ redirectToStandalone(baseUrl, surface, params, theme);
816
+ return;
817
+ }
818
+ settleReject(
819
+ new BrassError(
820
+ 'The Brass hosted UI did not load. This browser or app context ' +
821
+ 'may block third-party iframes.',
822
+ { code: 'iframe_unavailable' },
823
+ ),
824
+ );
825
+ }, readyTimeoutMs);
826
+
827
+ document.body.appendChild(overlay);
828
+ // The call that makes the dialog modal: without it the chrome paints
829
+ // identically and Tab keeps walking the host app behind it. Guarded so a
830
+ // browser without the top layer still gets the chrome, non-modal, rather
831
+ // than no dialog at all.
832
+ if (typeof overlay.showModal === 'function') overlay.showModal();
833
+ });
834
+ }
835
+
836
+ export function createHostedUiLauncher(
837
+ opts: HostedUiLauncherOptions,
838
+ ): HostedUiLauncher {
839
+ const baseUrl = opts.dashboardBaseUrl.replace(/\/$/, '');
840
+ // Parsed once for the postMessage origin checks; a malformed
841
+ // `dashboardBaseUrl` throws here, at construction, not mid-handshake.
842
+ const dashboardOrigin = new URL(baseUrl).origin;
843
+ // Watch for activations from here on, so a surface opened later knows which
844
+ // control the reader pressed to get there.
845
+ trackHostedUiActivations();
846
+ // Default `'light'`: most hosts are light-only, and an `'auto'` default
847
+ // would put a dark embed inside a light host on a dark-OS user.
848
+ const theme: HostedUiTheme = opts.theme ?? 'light';
849
+
850
+ // Merge the SDK's fallback session pointer into a surface's bridge params
851
+ // when this browser blocked the cross-site cookie (so the embed page can
852
+ // refresh the dashboard's tokens without its own framed cookie). Read at
853
+ // open time, not launcher-construction time, since the sid is populated by
854
+ // the first fallback refresh, which may land after the launcher is built.
855
+ // Every embed surface authenticates the same way, so this is cross-cutting
856
+ // rather than per-surface like `appId`.
857
+ const withSession = (
858
+ params: Record<string, unknown>,
859
+ ): Record<string, unknown> => {
860
+ const sid = opts.getSessionToken?.();
861
+ const actingSub = opts.getActingSub?.();
862
+ return {
863
+ ...params,
864
+ ...(sid ? { sessionToken: sid } : {}),
865
+ ...(actingSub ? { actingSub } : {}),
866
+ };
867
+ };
868
+
869
+ // Open a hosted surface and unwrap its bridge outcome: `null` is a user
870
+ // dismissal, otherwise the payload. `exportDoc` threads the export
871
+ // serializer for the document surfaces whose page offers a Download
872
+ // action.
873
+ const launch = async <T>(
874
+ surface: string,
875
+ params: Record<string, unknown>,
876
+ unavailable: UnavailableMode,
877
+ exportDoc?: boolean,
878
+ ): Promise<T | null> => {
879
+ const outcome = await openEmbed(
880
+ baseUrl,
881
+ dashboardOrigin,
882
+ surface,
883
+ withSession(params),
884
+ unavailable,
885
+ theme,
886
+ exportDoc ? opts.serializeForExport : undefined,
887
+ // Only the document-settings / share surface deletes; harmless to pass
888
+ // for all, since the message arrives only from that surface.
889
+ opts.onLocalDelete,
890
+ opts.onSignInRequested,
891
+ );
892
+ if (!outcome) return null;
893
+ return outcome.payload as T;
894
+ };
895
+
896
+ // Every Brass app shares one sign-in client, so a request's JWT
897
+ // does not say which app made it. Surfaces that must name the caller
898
+ // (its scopes on the install screen, its documents in the picker, the app
899
+ // a share link should open in) read this off the bridge instead.
900
+ const callerAppParam = (): Record<string, string> =>
901
+ opts.callingAppId !== undefined ? { appId: opts.callingAppId } : {};
902
+
903
+ return {
904
+ async selectDocument(): Promise<SelectDocumentResult | null> {
905
+ // Thread the calling app id over the bridge (every Brass app shares one
906
+ // sign-in client, so the origin can't otherwise tell which
907
+ // app opened the picker). The embed page uses it for a "only documents
908
+ // this app can open" filter.
909
+ const params: Record<string, unknown> = {};
910
+ if (opts.callingAppId !== undefined) params['appId'] = opts.callingAppId;
911
+ // Where the iframe can't be hosted (cookie-partitioning browsers), the
912
+ // picker falls back to a full-page redirect: the standalone page returns
913
+ // the pick on these one-shot params, which the SDK consumes on boot to
914
+ // open the document, first-party and top-level, the context the iframe
915
+ // fails in. A returned value can't survive that navigation, so the raw
916
+ // promise resolves only on the in-iframe path; the pick otherwise
917
+ // arrives through the session.
918
+ //
919
+ // That fallback ejects the user from the host app, so the picker takes
920
+ // the long handshake window (`redirect-with-value`): degrading early on
921
+ // a slow-but-working iframe costs the user their place for nothing.
922
+ const pickReturn = opts.pickReturn;
923
+ if (pickReturn) {
924
+ params['docParam'] = pickReturn.docParam;
925
+ params['branchParam'] = pickReturn.branchParam;
926
+ }
927
+ return launch<SelectDocumentResult>(
928
+ 'select-document',
929
+ params,
930
+ pickReturn ? 'redirect-with-value' : 'reject',
931
+ );
932
+ },
933
+ async selectCreateDestination(options?: {
934
+ name?: string;
935
+ }): Promise<CreateDestinationResult | null> {
936
+ // The `select-document` surface opened on its New panel via
937
+ // `intent: 'create'`: the embed page resolves a destination and sends
938
+ // it back instead of creating the doc, so the create stays on the SDK's
939
+ // credential path. `name` pre-fills the dialog.
940
+ const params: Record<string, unknown> = { intent: 'create' };
941
+ if (opts.callingAppId !== undefined) params['appId'] = opts.callingAppId;
942
+ if (options?.name !== undefined) params['name'] = options.name;
943
+ return launch<CreateDestinationResult>('select-document', params, 'reject');
944
+ },
945
+ async acquireOrganization(): Promise<AcquireOrganizationResult | null> {
946
+ const params: Record<string, unknown> = {};
947
+ if (opts.callingAppId !== undefined) params['appId'] = opts.callingAppId;
948
+ return launch<AcquireOrganizationResult>(
949
+ 'acquire-organization',
950
+ params,
951
+ 'reject',
952
+ );
953
+ },
954
+ async shareDocument(docId: string): Promise<void> {
955
+ // The share-focused dialog, on the same surface `documentSettings`
956
+ // reaches with `focus: 'share'`. It discards the outcome, so it passes
957
+ // no `canOpenCopy`: that flag says the caller will route a duplicate's
958
+ // `newDocId` into `openSiblingDoc`, and this one cannot.
959
+ await launch(
960
+ 'share-document',
961
+ { ...callerAppParam(), docId, focus: 'share' },
962
+ 'redirect',
963
+ true,
964
+ );
965
+ },
966
+ async documentSettings(
967
+ docId: string,
968
+ options?: { focus?: DocumentSettingsFocus },
969
+ ): Promise<DocumentSettingsResult | null> {
970
+ // The embed page reads `focus` from `init.params`. Defaults to
971
+ // `'share'` so passing only docId matches the `shareDocument(docId)`
972
+ // surface.
973
+ const focus = options?.focus ?? 'share';
974
+ return launch<DocumentSettingsResult>(
975
+ // The surface names one dialog with two entry points, and it is what
976
+ // the overlay carries as `data-brass-hosted-ui`, so it follows what
977
+ // is on screen rather than which method was called. A host that
978
+ // selects the share dialog matches it whether the app opened it or
979
+ // the toolbar's Share did.
980
+ focus === 'share' ? 'share-document' : 'document-settings',
981
+ // `canOpenCopy` tells the surface this caller routes a duplicate's
982
+ // `newDocId` into `openSiblingDoc`. The hosted page is served
983
+ // fresh against pinned SDKs, so it offers the Open only to a host
984
+ // that says it will honour it.
985
+ { ...callerAppParam(), docId, focus, canOpenCopy: true },
986
+ 'redirect',
987
+ true,
988
+ );
989
+ },
990
+ async documentHistory(
991
+ docId: string,
992
+ options?: { branch?: string },
993
+ ): Promise<DocumentHistoryResult | null> {
994
+ const params: Record<string, unknown> = { docId };
995
+ if (options?.branch !== undefined) params['branch'] = options.branch;
996
+ return launch<DocumentHistoryResult>('document-history', params, 'reject');
997
+ },
998
+ async selectBranch(
999
+ docId: string,
1000
+ options?: { branch?: string },
1001
+ ): Promise<{ branch_id: string } | null> {
1002
+ const params: Record<string, unknown> = { docId };
1003
+ if (options?.branch !== undefined) params['branch'] = options.branch;
1004
+ return launch<{ branch_id: string }>('select-branch', params, 'reject');
1005
+ },
1006
+ async installApp(organizationId?: string): Promise<InstallAppResult | null> {
1007
+ // Thread the caller's `appId` over the bridge so the embed page renders
1008
+ // the right app's name + scopes (every Brass app shares one sign-in
1009
+ // user-pool client, so the JWT can't disambiguate). On confirm the
1010
+ // embed page POSTs the install server-side and forwards the response.
1011
+ const params: Record<string, unknown> = {};
1012
+ if (opts.callingAppId !== undefined) params['appId'] = opts.callingAppId;
1013
+ if (organizationId !== undefined) params['organizationId'] = organizationId;
1014
+ return launch<InstallAppResult>('install-app', params, 'reject');
1015
+ },
1016
+ async driveResolve(docId: string): Promise<DriveResolveResult | null> {
1017
+ return launch<DriveResolveResult>('drive-resolve', { docId }, 'reject');
1018
+ },
1019
+ };
1020
+ }