@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.
- package/AGENTS.md +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
package/src/hosted-ui.ts
ADDED
|
@@ -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
|
+
}
|