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