@brass-build/client 0.1.0 → 0.3.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 +72 -63
- package/CHANGELOG.md +97 -0
- package/README.md +325 -235
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +245 -76
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +22 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +49 -16
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +981 -237
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +204 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +30 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1905 -322
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +335 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +590 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +356 -83
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +162 -20
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +27 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +408 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +23 -17
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +21 -22
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +262 -80
- package/src/document-checkpoints.ts +30 -0
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +120 -8
- package/src/document-streams.ts +95 -9
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +38 -13
- package/src/documents.ts +1103 -290
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3407 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +216 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +7 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2441 -502
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +686 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +387 -89
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +183 -26
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +461 -92
- package/src/ws-frame-limit.ts +23 -17
package/dist/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { buildSignInUrl, buildSignedOutOfAppUrl, refreshTokens, } from './auth-flow.js';
|
|
1
|
+
import { buildSignInUrl, buildSignedOutOfAppUrl, refreshTokens, revokeAppApproval, } from './auth-flow.js';
|
|
2
2
|
import { decodeIdToken } from './jwt.js';
|
|
3
3
|
import { createEmitter } from './emitter.js';
|
|
4
|
-
import { createOpenDocument, } from './open-document.js';
|
|
4
|
+
import { clearParkedDocuments, createOpenDocument, } from './open-document.js';
|
|
5
5
|
import { askSchemaConflict } from './schema-conflict-dialog.js';
|
|
6
6
|
import { BrassError } from './errors.js';
|
|
7
7
|
import { createHostedUiLauncher, trackHostedUiActivations, } from './hosted-ui.js';
|
|
@@ -20,6 +20,10 @@ import { createSessionToolbar, createSessionHeader, createFileMenu, createDocume
|
|
|
20
20
|
import { BrassWsClient } from './ws-client.js';
|
|
21
21
|
import { fetchHostedRuntime, readInjectedRuntime, isBrassHostedHost, HOSTED_RUNTIME_PATH, } from './hosted-runtime.js';
|
|
22
22
|
const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
|
|
23
|
+
// How long `signOut` waits for the revocation before it navigates. The page
|
|
24
|
+
// it lands on finishes an unrevoked sign-out itself, so waiting longer than
|
|
25
|
+
// this only delays the sign-out.
|
|
26
|
+
const SIGN_OUT_REVOKE_DEADLINE_MS = 4000;
|
|
23
27
|
// The data API has its own host, NOT the dashboard origin: the
|
|
24
28
|
// dashboard (app.brass.build) is a static SPA, the data API is a
|
|
25
29
|
// separate gateway behind api.brass.build.
|
|
@@ -41,11 +45,22 @@ const PROD_ORIGINS = {
|
|
|
41
45
|
};
|
|
42
46
|
// Resolve the four base URLs from init options. Zero config applies all
|
|
43
47
|
// four production origins (including `wsBaseUrl`, so documents and
|
|
44
|
-
// presence work with nothing passed).
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
48
|
+
// presence work with nothing passed).
|
|
49
|
+
//
|
|
50
|
+
// Naming ANY `*BaseUrl` withholds the production `wsBaseUrl`: an app that
|
|
51
|
+
// overrides `apiBaseUrl` and inherited a production socket would connect to
|
|
52
|
+
// the wrong stack and silently drop events, where an unfilled `wsBaseUrl`
|
|
53
|
+
// fails loudly on subscribe.
|
|
54
|
+
//
|
|
55
|
+
// `authBaseUrl`, `apiBaseUrl` and `dashboardBaseUrl` still fall back to their
|
|
56
|
+
// production defaults, so a caller who names some but not all of them reaches
|
|
57
|
+
// production for the rest. `dashboardBaseUrl` is the one that goes wrong
|
|
58
|
+
// quietly: every hosted surface (the picker, share, the version bar) is framed
|
|
59
|
+
// from it, so an app on another stack frames production's, which either
|
|
60
|
+
// refuses to render or hands back ids the configured API cannot open. Name it
|
|
61
|
+
// alongside the others.
|
|
62
|
+
//
|
|
63
|
+
// Pure so the precedence is unit-testable.
|
|
49
64
|
export function resolveBaseUrls(options) {
|
|
50
65
|
const anyUrlOverride = Boolean(options.authBaseUrl || options.apiBaseUrl || options.wsBaseUrl || options.dashboardBaseUrl);
|
|
51
66
|
const base = anyUrlOverride ? null : PROD_ORIGINS;
|
|
@@ -71,6 +86,13 @@ const REFRESH_LEEWAY_MS = 60_000;
|
|
|
71
86
|
// group accepts both shapes, and the permissive tail keeps minor
|
|
72
87
|
// server-side format bumps from stranding SDK consumers.
|
|
73
88
|
const APP_ID_PATTERN = /^brass_app_(?:(?:test|live|internal)_)?[A-Za-z0-9_-]+$/;
|
|
89
|
+
// The rejection for `unavailable`. A 503 status is what makes the outcome
|
|
90
|
+
// legible to the layers that branch on one: an app's own retry, and the
|
|
91
|
+
// selection self-heal that clears a persisted doc id on a definitive 403 /
|
|
92
|
+
// 404 and leaves a transient alone.
|
|
93
|
+
function refreshUnavailableError() {
|
|
94
|
+
return new BrassError('The sign-in service is unavailable', { status: 503 });
|
|
95
|
+
}
|
|
74
96
|
export class BrassClient {
|
|
75
97
|
appId = null;
|
|
76
98
|
authBaseUrl = DEFAULT_AUTH_BASE_URL;
|
|
@@ -123,6 +145,11 @@ export class BrassClient {
|
|
|
123
145
|
// cannot block the others or mask the original error (already logged).
|
|
124
146
|
errorListeners = createEmitter({ onError: () => { } });
|
|
125
147
|
inflightRefresh = null;
|
|
148
|
+
// Bumped by every `signOut`, and captured by a `/refresh` when it starts.
|
|
149
|
+
// A refresh that settles against a moved epoch drops its answer: the
|
|
150
|
+
// credentials it would write were cleared by a sign-out while it was in
|
|
151
|
+
// flight, and re-writing them signs the person back in.
|
|
152
|
+
authEpoch = 0;
|
|
126
153
|
initialized = false;
|
|
127
154
|
// Incognito / third-party-cookie fallback state. Both null in a normal
|
|
128
155
|
// browser, where the cross-site `brass_auth` cookie carries the session
|
|
@@ -130,7 +157,7 @@ export class BrassClient {
|
|
|
130
157
|
// - `pendingAuthCode`: the one-time `brass_code` pulled off the
|
|
131
158
|
// landing URL by bootstrap(), consumed at most once by refresh() if
|
|
132
159
|
// the cookie channel is unavailable.
|
|
133
|
-
// - `sessionToken`: the
|
|
160
|
+
// - `sessionToken`: the app-bound session pointer from a code
|
|
134
161
|
// exchange. Once set, every refresh() uses it instead of the
|
|
135
162
|
// cookie. Mirrored into sessionStorage so a reload in a
|
|
136
163
|
// cookie-blocked browser hydrates it; see session-persistence.ts.
|
|
@@ -170,8 +197,9 @@ export class BrassClient {
|
|
|
170
197
|
completeImport: (docId) => this.docsInternal().completeImport(docId),
|
|
171
198
|
reportError: (error) => this.fireError(error),
|
|
172
199
|
});
|
|
173
|
-
//
|
|
174
|
-
//
|
|
200
|
+
// One socket carrying every document topic this client subscribes,
|
|
201
|
+
// built on first open so an app that never opens a document never
|
|
202
|
+
// connects.
|
|
175
203
|
docWsClient = null;
|
|
176
204
|
// init ALWAYS resolves: failures (bad/missing app_id) fire onError +
|
|
177
205
|
// console.error rather than rejecting. Otherwise
|
|
@@ -320,7 +348,7 @@ export class BrassClient {
|
|
|
320
348
|
// renders a signed-out UI or calls `signIn()`, which navigates the
|
|
321
349
|
// frame away from the export it was loaded to run.
|
|
322
350
|
if (this.exportGuest === null) {
|
|
323
|
-
for (const listener of this.listeners) {
|
|
351
|
+
for (const listener of Array.from(this.listeners)) {
|
|
324
352
|
listener(this.user);
|
|
325
353
|
}
|
|
326
354
|
}
|
|
@@ -338,23 +366,34 @@ export class BrassClient {
|
|
|
338
366
|
return;
|
|
339
367
|
window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
|
|
340
368
|
}
|
|
341
|
-
// Per-app sign-out.
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
// and offers a one-click "Sign out of Brass too" link.
|
|
369
|
+
// Per-app sign-out. Revokes this app's server-side approval (subsequent
|
|
370
|
+
// /refresh calls for this app return 401 even on cookie reuse), then makes
|
|
371
|
+
// a top-level navigation to a Brass-hosted page that surfaces the email
|
|
372
|
+
// the user is still signed in to Brass as, and offers a one-click "Sign
|
|
373
|
+
// out of Brass too" link.
|
|
347
374
|
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
375
|
+
// The navigation is what closes the trust gap of "did sign-out actually
|
|
376
|
+
// happen?"; the revocation rides the credentialed route because the page
|
|
377
|
+
// answers whoever arrives at it and so writes nothing on arrival. The
|
|
378
|
+
// decision to ALSO sign out of the platform belongs to the user, made on
|
|
379
|
+
// the Brass-hosted page; the calling app cannot trigger platform-level
|
|
380
|
+
// sign-out from its own UI.
|
|
353
381
|
signOut() {
|
|
354
382
|
const appId = this.requireAppId();
|
|
355
383
|
if (typeof window === 'undefined') {
|
|
356
384
|
throw new Error('Brass.signOut must be called in a browser');
|
|
357
385
|
}
|
|
386
|
+
// Read before the clears below drop it: where the cross-site cookie is
|
|
387
|
+
// blocked, this pointer is the revocation's only credential.
|
|
388
|
+
const credential = this.sessionToken
|
|
389
|
+
? { kind: 'sessionToken', sessionToken: this.sessionToken }
|
|
390
|
+
: { kind: 'cookie' };
|
|
391
|
+
// Fence any `/refresh` already on the wire: landing after the clears
|
|
392
|
+
// below, it writes back the token set, the login hint and the session
|
|
393
|
+
// pointer, and re-emits the signed-in user. The hint and the pointer
|
|
394
|
+
// outlive the page, so that resurrects the session on the next load
|
|
395
|
+
// too, and the navigation below can be delayed or cancelled.
|
|
396
|
+
this.authEpoch += 1;
|
|
358
397
|
this.setTokens(null);
|
|
359
398
|
// Drop any fallback-mode credential too, so a stale session token
|
|
360
399
|
// can't resurrect the session on the next getAccessToken(). Clear
|
|
@@ -376,13 +415,23 @@ export class BrassClient {
|
|
|
376
415
|
// on the way to the confirmation page. Harmless when the app is not
|
|
377
416
|
// gated (the cookie simply isn't there). Off a hosted origin there is
|
|
378
417
|
// no such cookie, so go straight to the confirmation page.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
418
|
+
const target = isBrassHostedHost(window.location.hostname)
|
|
419
|
+
? `${window.location.origin}/__brass/leave?return_to=${encodeURIComponent(signedOutUrl)}`
|
|
420
|
+
: signedOutUrl;
|
|
421
|
+
// Leave once the revocation settles, so the page reports a sign-out that
|
|
422
|
+
// already happened rather than offering the button that finishes it.
|
|
423
|
+
let left = false;
|
|
424
|
+
const leave = () => {
|
|
425
|
+
if (left)
|
|
426
|
+
return;
|
|
427
|
+
left = true;
|
|
428
|
+
window.location.assign(target);
|
|
429
|
+
};
|
|
430
|
+
const deadline = setTimeout(leave, SIGN_OUT_REVOKE_DEADLINE_MS);
|
|
431
|
+
void revokeAppApproval(this.authBaseUrl, appId, credential).then(() => {
|
|
432
|
+
clearTimeout(deadline);
|
|
433
|
+
leave();
|
|
434
|
+
});
|
|
386
435
|
}
|
|
387
436
|
get currentUser() {
|
|
388
437
|
return this.user;
|
|
@@ -438,10 +487,11 @@ export class BrassClient {
|
|
|
438
487
|
fireError(error) {
|
|
439
488
|
this.errorListeners.emit(error);
|
|
440
489
|
}
|
|
441
|
-
// The
|
|
442
|
-
//
|
|
443
|
-
// `Brass.openDocumentById` /
|
|
444
|
-
// Brass.init(appId) first" before
|
|
490
|
+
// The metadata + thumbnail namespace for a document the app already
|
|
491
|
+
// holds an id for (`get` / `setThumbnail` / `getThumbnail`); the
|
|
492
|
+
// editable surface stays on `Brass.openDocumentById` /
|
|
493
|
+
// `Brass.createDocument`. Throws "Call Brass.init(appId) first" before
|
|
494
|
+
// init.
|
|
445
495
|
get documents() {
|
|
446
496
|
return this.docsInternal();
|
|
447
497
|
}
|
|
@@ -564,9 +614,8 @@ export class BrassClient {
|
|
|
564
614
|
dashboardBaseUrl: this._dashboardBaseUrl,
|
|
565
615
|
getDocument: (docId) => this.documents.get(docId),
|
|
566
616
|
// The version-preview bar is a hosted embed; it takes the same theme
|
|
567
|
-
//
|
|
617
|
+
// as the other hosted surfaces.
|
|
568
618
|
hostedUiTheme: this.hostedUiTheme,
|
|
569
|
-
getSessionToken: () => this.sessionToken,
|
|
570
619
|
};
|
|
571
620
|
}
|
|
572
621
|
// Opens the hosted document picker in a modal iframe and resolves with
|
|
@@ -634,11 +683,6 @@ export class BrassClient {
|
|
|
634
683
|
else
|
|
635
684
|
this.session._clearLocalDelete(docId);
|
|
636
685
|
},
|
|
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
686
|
// Name the user this app is acting as, so the embed can refuse to
|
|
643
687
|
// act as anyone else. The embed authenticates from the dashboard
|
|
644
688
|
// origin's platform-session cookie, which moves when a second
|
|
@@ -679,14 +723,21 @@ export class BrassClient {
|
|
|
679
723
|
// "live editing unavailable" in the UI.
|
|
680
724
|
async openDocumentById(docId, options) {
|
|
681
725
|
const opened = await this.openDocumentInternal(docId, options);
|
|
682
|
-
|
|
726
|
+
// The URL names the document the reader is looking at, and the session
|
|
727
|
+
// owns that whenever it holds one: this open is detached, so claiming the
|
|
728
|
+
// URL would leave the address bar naming a document the session is not
|
|
729
|
+
// showing, and a reload would swap the reader onto it. An app with no
|
|
730
|
+
// session document still gets the sync, which is the read-mostly caller
|
|
731
|
+
// this method exists for.
|
|
732
|
+
if (this.session.document === null)
|
|
733
|
+
this.maybeWriteDocIdToUrl(opened.id);
|
|
683
734
|
return opened;
|
|
684
735
|
}
|
|
685
736
|
// Open a document READ-ONLY from a public-link token (the "anyone with
|
|
686
737
|
// the link" anonymous path). No sign-in: the token is the credential and
|
|
687
738
|
// the server serves a read-only, member-stripped snapshot with no live
|
|
688
739
|
// subscription. `Brass.session` drives this from the URL on boot when the
|
|
689
|
-
// page carries `?brass_link
|
|
740
|
+
// page carries `?brass_link=`.
|
|
690
741
|
async openByLinkInternal(token, options) {
|
|
691
742
|
const factory = this.getDocumentFactory();
|
|
692
743
|
const opened = await factory.openByLink(token, options);
|
|
@@ -850,12 +901,12 @@ export class BrassClient {
|
|
|
850
901
|
this.importRunner.setImporter(importer);
|
|
851
902
|
if (this.guestLinkTokenValue !== null)
|
|
852
903
|
return;
|
|
853
|
-
for (const doc of this.
|
|
904
|
+
for (const doc of this.liveOpenDocuments()) {
|
|
854
905
|
void this.importRunner.attempt(doc);
|
|
855
906
|
}
|
|
856
907
|
}
|
|
857
908
|
// Track an opened document so an export request can resolve its id to
|
|
858
|
-
// the live Document.
|
|
909
|
+
// the live Document. A re-open overwrites.
|
|
859
910
|
trackOpenDocument(doc) {
|
|
860
911
|
this.openDocuments.set(doc.id, doc);
|
|
861
912
|
doc.onGone(() => {
|
|
@@ -870,22 +921,82 @@ export class BrassClient {
|
|
|
870
921
|
void this.importRunner.attempt(doc);
|
|
871
922
|
}
|
|
872
923
|
}
|
|
924
|
+
// The tracked documents still open, dropping the rest as it goes.
|
|
925
|
+
// `onGone` covers only a server-side eviction, and two ordinary paths
|
|
926
|
+
// retire a Document without one: an app's `close()` (which parks it, so
|
|
927
|
+
// the public surface throws while the instance lives on in the park
|
|
928
|
+
// cache) and that cache's LRU eviction (a full teardown, no listener
|
|
929
|
+
// left to fire). Reading through this is what keeps a retired wrapper
|
|
930
|
+
// out of an answer, and what keeps the set from growing for the life of
|
|
931
|
+
// the page.
|
|
932
|
+
liveOpenDocuments() {
|
|
933
|
+
const live = [];
|
|
934
|
+
for (const [docId, doc] of this.openDocuments) {
|
|
935
|
+
if (doc._isLive)
|
|
936
|
+
live.push(doc);
|
|
937
|
+
else
|
|
938
|
+
this.openDocuments.delete(docId);
|
|
939
|
+
}
|
|
940
|
+
return live;
|
|
941
|
+
}
|
|
942
|
+
liveOpenDocument(docId) {
|
|
943
|
+
const doc = this.openDocuments.get(docId);
|
|
944
|
+
if (!doc)
|
|
945
|
+
return null;
|
|
946
|
+
if (doc._isLive)
|
|
947
|
+
return doc;
|
|
948
|
+
this.openDocuments.delete(docId);
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
// Publish a document to a drive, supplying the bytes when the platform has
|
|
952
|
+
// no writer for it.
|
|
953
|
+
//
|
|
954
|
+
// Where the export of this document runs in THIS app, the SDK runs the
|
|
955
|
+
// registered serializer in process and the push carries its bytes, rather
|
|
956
|
+
// than sending the reader through the hosted export frame. That is what
|
|
957
|
+
// makes a publish from inside an app one click, and it is the same on the
|
|
958
|
+
// publish that creates the drive file and on every push after it: the
|
|
959
|
+
// caller's own app either writes this document or it does not.
|
|
960
|
+
//
|
|
961
|
+
// Everything else is the route's, including refusing bytes from an app the
|
|
962
|
+
// exporter setting does not name, which it does by name rather than the
|
|
963
|
+
// client predicting it.
|
|
964
|
+
async pushDocumentToDrive(doc, input) {
|
|
965
|
+
const docId = doc.id;
|
|
966
|
+
if (docId === null || !doc._exportRunsHere) {
|
|
967
|
+
return await this.docsInternal().pushToDrive(doc, input);
|
|
968
|
+
}
|
|
969
|
+
const serialized = await this.serializeForExport(docId);
|
|
970
|
+
if (!serialized.ok) {
|
|
971
|
+
throw new BrassError(`This app could not write the document out to publish it: ${serialized.message}`, { status: 400 });
|
|
972
|
+
}
|
|
973
|
+
return await this.docsInternal().pushToDrive(doc, {
|
|
974
|
+
...input,
|
|
975
|
+
bytes: serialized.bytes,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
873
978
|
// Run the registered serializer for an export request from a hosted
|
|
874
979
|
// surface. Resolves the doc from the open set (the active session doc is
|
|
875
980
|
// the fallback for an app that drives it through the session), then hands
|
|
876
981
|
// it to the serializer. A missing serializer is the ordinary "this app
|
|
877
|
-
// does not export" outcome, not an error.
|
|
982
|
+
// does not export" outcome, not an error. A document the app has closed
|
|
983
|
+
// reports `no_document`: running the serializer against it would throw
|
|
984
|
+
// out of the app's own record reads and report `serializer_failed`, which
|
|
985
|
+
// names the app's code for a state the platform is holding.
|
|
878
986
|
async serializeForExport(docId) {
|
|
879
987
|
if (!this.serializer) {
|
|
880
988
|
return { ok: false, code: 'no_serializer', message: 'No serializer registered.' };
|
|
881
989
|
}
|
|
882
990
|
const sessionDoc = this.documentSession?.document ?? null;
|
|
883
|
-
const doc = this.
|
|
884
|
-
(sessionDoc && sessionDoc.id === docId ? sessionDoc : null);
|
|
991
|
+
const doc = this.liveOpenDocument(docId) ??
|
|
992
|
+
(sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
|
|
885
993
|
if (!doc) {
|
|
886
994
|
return { ok: false, code: 'no_document', message: 'Document is not open.' };
|
|
887
995
|
}
|
|
888
996
|
try {
|
|
997
|
+
// A serializer runs detached, with no subscription to have been fed by,
|
|
998
|
+
// and it needs the document once, so it is handed the containers whole
|
|
999
|
+
// (`StoredDocument`) rather than the fed surface an app holds.
|
|
889
1000
|
const bytes = await this.serializer(doc);
|
|
890
1001
|
return { ok: true, bytes };
|
|
891
1002
|
}
|
|
@@ -932,6 +1043,7 @@ export class BrassClient {
|
|
|
932
1043
|
apiBaseUrl: this.apiBaseUrl,
|
|
933
1044
|
getAccessToken: () => this.getAccessToken(),
|
|
934
1045
|
getAppAssertion: () => this.getAppAssertion(),
|
|
1046
|
+
identity: () => this.user?.sub ?? null,
|
|
935
1047
|
...(wsClient ? { wsClient } : {}),
|
|
936
1048
|
branchProvider: {
|
|
937
1049
|
list: (docId) => docsInternal.listBranches(docId),
|
|
@@ -946,7 +1058,7 @@ export class BrassClient {
|
|
|
946
1058
|
},
|
|
947
1059
|
thumbnailUploader: (doc, image, width, height) => docsNs.setThumbnail(doc, { image, width, height }),
|
|
948
1060
|
checkpointCreator: (doc) => docsInternal.createCheckpoint(doc),
|
|
949
|
-
drivePusher: (doc, input) =>
|
|
1061
|
+
drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
|
|
950
1062
|
sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
|
|
951
1063
|
hostedUiProvider: {
|
|
952
1064
|
documentHistory: (docId, options) => this.documentHistory(docId, options),
|
|
@@ -1019,28 +1131,53 @@ export class BrassClient {
|
|
|
1019
1131
|
});
|
|
1020
1132
|
return this.docWsClient;
|
|
1021
1133
|
}
|
|
1134
|
+
// Resolves null only when the server answered that this app has no live
|
|
1135
|
+
// session, and rejects with a 503 `BrassError` when a `/refresh` found no
|
|
1136
|
+
// answer at all. The two outcomes drive opposite handling: null is the
|
|
1137
|
+
// signed-out state a caller renders a sign-in CTA for, where a rejection
|
|
1138
|
+
// is a momentary outage the caller retries. Collapsing them into null
|
|
1139
|
+
// reports a 401-shaped "Not signed in" to a signed-in user whose network
|
|
1140
|
+
// blipped, and hides the transient from every layer that self-heals on it.
|
|
1022
1141
|
async getAccessToken() {
|
|
1023
1142
|
this.requireInit();
|
|
1143
|
+
// On a `?brass_link=` page the link token is the credential and `init`
|
|
1144
|
+
// runs no auth bootstrap, so answer null rather than probing /refresh.
|
|
1145
|
+
// A visitor with no session would draw a 401 the browser logs; one who
|
|
1146
|
+
// holds a session for this app would get a real token, and a caller
|
|
1147
|
+
// that prefers a token over the link token (the socket does) would then
|
|
1148
|
+
// present membership where the page's whole authority is the link.
|
|
1149
|
+
if (this.guestLinkTokenValue !== null)
|
|
1150
|
+
return null;
|
|
1024
1151
|
const current = this.tokens;
|
|
1025
1152
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
1026
1153
|
return current.accessToken;
|
|
1027
1154
|
}
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1155
|
+
const settled = await this.refresh();
|
|
1156
|
+
if (settled.status === 'unavailable')
|
|
1157
|
+
throw refreshUnavailableError();
|
|
1158
|
+
return settled.status === 'ok' ? settled.tokens.accessToken : null;
|
|
1030
1159
|
}
|
|
1031
1160
|
// The acting-app assertion paired with the current access token, or
|
|
1032
1161
|
// null when the proxy issued none. Mirrors `getAccessToken`'s
|
|
1033
1162
|
// freshness check so the two stay in lockstep: a request builder that
|
|
1034
1163
|
// calls `getAccessToken()` then this reads the same (possibly just
|
|
1035
|
-
// refreshed) token set without forcing a second refresh.
|
|
1164
|
+
// refreshed) token set without forcing a second refresh. Rejects on a
|
|
1165
|
+
// transient outage for the same reason `getAccessToken` does.
|
|
1036
1166
|
async getAppAssertion() {
|
|
1037
1167
|
this.requireInit();
|
|
1168
|
+
// Paired with `getAccessToken`: a link guest holds no token to assert
|
|
1169
|
+
// an acting app against, and reaching /refresh here would authenticate
|
|
1170
|
+
// the page the same way.
|
|
1171
|
+
if (this.guestLinkTokenValue !== null)
|
|
1172
|
+
return null;
|
|
1038
1173
|
const current = this.tokens;
|
|
1039
1174
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
1040
1175
|
return current.appAssertion ?? null;
|
|
1041
1176
|
}
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1177
|
+
const settled = await this.refresh();
|
|
1178
|
+
if (settled.status === 'unavailable')
|
|
1179
|
+
throw refreshUnavailableError();
|
|
1180
|
+
return settled.status === 'ok' ? (settled.tokens.appAssertion ?? null) : null;
|
|
1044
1181
|
}
|
|
1045
1182
|
async bootstrap() {
|
|
1046
1183
|
if (typeof window === 'undefined')
|
|
@@ -1048,7 +1185,7 @@ export class BrassClient {
|
|
|
1048
1185
|
const appId = this.requireAppId();
|
|
1049
1186
|
// Every successful sign-in 302s back to return_to with the
|
|
1050
1187
|
// platform-session cookie set AND a one-time `brass_code` query
|
|
1051
|
-
// parameter
|
|
1188
|
+
// parameter. The cookie
|
|
1052
1189
|
// is the primary channel; the code is the incognito / Safari ITP /
|
|
1053
1190
|
// post-third-party-cookie fallback for when the browser drops the
|
|
1054
1191
|
// cross-site cookie on our /refresh XHR.
|
|
@@ -1061,8 +1198,7 @@ export class BrassClient {
|
|
|
1061
1198
|
//
|
|
1062
1199
|
// No app-controlled silent-mint path: the SDK never auto-fires
|
|
1063
1200
|
// Brass.signIn() based on a URL hint. New app approvals always
|
|
1064
|
-
// require an explicit click on the auth-hosted picker
|
|
1065
|
-
// adaptive /sign-in branch in infra/lambda/auth/sign-in.ts).
|
|
1201
|
+
// require an explicit click on the auth-hosted picker.
|
|
1066
1202
|
// Same treatment for the launching organization: read it off the URL
|
|
1067
1203
|
// and strip it here, so a client-side navigation later in the session
|
|
1068
1204
|
// cannot lose it and the address bar stays the app's own.
|
|
@@ -1120,16 +1256,26 @@ export class BrassClient {
|
|
|
1120
1256
|
if (this.inflightRefresh)
|
|
1121
1257
|
return this.inflightRefresh;
|
|
1122
1258
|
const appId = this.requireAppId();
|
|
1259
|
+
const epoch = this.authEpoch;
|
|
1123
1260
|
this.inflightRefresh = (async () => {
|
|
1124
|
-
const
|
|
1261
|
+
const outcome = await this.runRefresh(appId);
|
|
1262
|
+
// No answer means no change: the token set, the login hint, and any
|
|
1263
|
+
// fallback session token stay as they are, and a later attempt settles
|
|
1264
|
+
// the session.
|
|
1265
|
+
if (outcome.status === 'unavailable')
|
|
1266
|
+
return { status: 'unavailable' };
|
|
1267
|
+
// A sign-out landed while this round-trip was in flight, so whatever
|
|
1268
|
+
// it resolved belongs to a session the person has left. Report it as
|
|
1269
|
+
// no session and write nothing: the caller waiting on this token gets
|
|
1270
|
+
// the signed-out answer the sign-out established.
|
|
1271
|
+
if (this.authEpoch !== epoch)
|
|
1272
|
+
return { status: 'no_session' };
|
|
1273
|
+
const result = outcome.status === 'ok' ? outcome.result : null;
|
|
1125
1274
|
this.setTokens(result?.tokens ?? null);
|
|
1126
1275
|
// Remember whether a live session exists for this app so the next
|
|
1127
1276
|
// cold load knows whether to probe /refresh (see bootstrap). A
|
|
1128
|
-
// success proves a session;
|
|
1129
|
-
//
|
|
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.
|
|
1277
|
+
// success proves a session; `no_session` is the server saying there
|
|
1278
|
+
// is none, so drop the hint and the next signed-out load stays silent.
|
|
1133
1279
|
if (result) {
|
|
1134
1280
|
storeLoginHint(appId);
|
|
1135
1281
|
}
|
|
@@ -1141,12 +1287,11 @@ export class BrassClient {
|
|
|
1141
1287
|
// Mirror into sessionStorage so a reload on this tab can
|
|
1142
1288
|
// hydrate it (the cookie channel is unavailable in this
|
|
1143
1289
|
// browser by construction; we only get here via the code or
|
|
1144
|
-
// sid channels). The
|
|
1145
|
-
//
|
|
1146
|
-
// than the cookie it mirrors.
|
|
1290
|
+
// sid channels). The pointer is bound to this app id, so what
|
|
1291
|
+
// is stored authenticates this app and nothing else.
|
|
1147
1292
|
storeSessionToken(appId, result.sessionToken);
|
|
1148
1293
|
}
|
|
1149
|
-
return result
|
|
1294
|
+
return result ? { status: 'ok', tokens: result.tokens } : { status: 'no_session' };
|
|
1150
1295
|
})().finally(() => {
|
|
1151
1296
|
this.inflightRefresh = null;
|
|
1152
1297
|
});
|
|
@@ -1155,16 +1300,20 @@ export class BrassClient {
|
|
|
1155
1300
|
// Picks the credential channel for a single /refresh round-trip. The
|
|
1156
1301
|
// cookie path is primary and untouched for browsers that send the
|
|
1157
1302
|
// third-party cookie; the one-time code is consumed only when that
|
|
1158
|
-
// cookie path
|
|
1303
|
+
// cookie path resolved no session. Once a fallback session
|
|
1159
1304
|
// token is in hand (the cross-site cookie is proven unavailable for
|
|
1160
1305
|
// this page's life), every later refresh uses it directly.
|
|
1306
|
+
//
|
|
1307
|
+
// A rung falls through to the next only on `no_session`. Each one discards
|
|
1308
|
+
// a credential as it passes (the stale sid, the one-time code), and an
|
|
1309
|
+
// `unavailable` says nothing about the credential it just tried.
|
|
1161
1310
|
async runRefresh(appId) {
|
|
1162
1311
|
if (this.sessionToken) {
|
|
1163
1312
|
const viaSession = await refreshTokens(this.authBaseUrl, appId, {
|
|
1164
1313
|
kind: 'sessionToken',
|
|
1165
1314
|
sessionToken: this.sessionToken,
|
|
1166
1315
|
});
|
|
1167
|
-
if (viaSession)
|
|
1316
|
+
if (viaSession.status !== 'no_session')
|
|
1168
1317
|
return viaSession;
|
|
1169
1318
|
// Stored sid is stale (server-side revoke, row expiry). Drop both
|
|
1170
1319
|
// the in-memory copy and the sessionStorage mirror, then fall
|
|
@@ -1176,20 +1325,31 @@ export class BrassClient {
|
|
|
1176
1325
|
clearSessionToken(appId);
|
|
1177
1326
|
}
|
|
1178
1327
|
const viaCookie = await refreshTokens(this.authBaseUrl, appId);
|
|
1179
|
-
if (viaCookie)
|
|
1328
|
+
if (viaCookie.status !== 'no_session')
|
|
1180
1329
|
return viaCookie;
|
|
1181
|
-
// Cookie channel
|
|
1182
|
-
// we landed with one. Clear it first: it is single-use server-side,
|
|
1183
|
-
//
|
|
1330
|
+
// Cookie channel resolved no session. Fall back to the one-time code if
|
|
1331
|
+
// we landed with one. Clear it first: it is single-use server-side, so a
|
|
1332
|
+
// failed exchange must not be retried.
|
|
1184
1333
|
const code = this.pendingAuthCode;
|
|
1185
|
-
this.pendingAuthCode = null;
|
|
1186
1334
|
if (!code)
|
|
1187
|
-
return
|
|
1335
|
+
return viaCookie;
|
|
1336
|
+
this.pendingAuthCode = null;
|
|
1188
1337
|
return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
|
|
1189
1338
|
}
|
|
1190
1339
|
setTokens(next) {
|
|
1191
|
-
this.tokens = next;
|
|
1192
1340
|
const nextUser = next ? decodeIdToken(next.idToken) : null;
|
|
1341
|
+
// A different person on this page. `/refresh` resolves whichever platform
|
|
1342
|
+
// session the browser holds, so the identity behind a page that never
|
|
1343
|
+
// navigated can become someone else's (another tab signs out and a second
|
|
1344
|
+
// person signs in). Run the retirement first, as its own transition, so
|
|
1345
|
+
// what the previous identity left open is retired before the new one is
|
|
1346
|
+
// announced rather than inherited by it.
|
|
1347
|
+
if (this.user !== null &&
|
|
1348
|
+
nextUser !== null &&
|
|
1349
|
+
this.user.sub !== nextUser.sub) {
|
|
1350
|
+
this.setTokens(null);
|
|
1351
|
+
}
|
|
1352
|
+
this.tokens = next;
|
|
1193
1353
|
if (sameUser(this.user, nextUser))
|
|
1194
1354
|
return;
|
|
1195
1355
|
this.user = nextUser;
|
|
@@ -1200,9 +1360,18 @@ export class BrassClient {
|
|
|
1200
1360
|
// (the same user), double-emitting on every signed-in app load.
|
|
1201
1361
|
if (!this.bootstrapped)
|
|
1202
1362
|
return;
|
|
1203
|
-
|
|
1363
|
+
// Copied before the walk: a listener that detaches another would otherwise
|
|
1364
|
+
// remove it before its turn, and a `Set` never visits such an entry.
|
|
1365
|
+
for (const listener of Array.from(this.listeners)) {
|
|
1204
1366
|
listener(nextUser);
|
|
1205
1367
|
}
|
|
1368
|
+
// After the listeners, because the session closes the active document as
|
|
1369
|
+
// it hears the sign-out and a close PARKS the Document. A parked Document
|
|
1370
|
+
// answers the next open of its key out of memory, with no token and no
|
|
1371
|
+
// request, so one left behind here is the previous person's document
|
|
1372
|
+
// handed to whoever signs in next.
|
|
1373
|
+
if (nextUser === null)
|
|
1374
|
+
clearParkedDocuments();
|
|
1206
1375
|
}
|
|
1207
1376
|
async hydrateProfile() {
|
|
1208
1377
|
if (!this.user)
|