@brass-build/client 0.1.0 → 0.2.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 +86 -0
- package/README.md +317 -231
- 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 +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +217 -75
- package/dist/client.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-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/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +19 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +43 -14
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +871 -223
- 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 +253 -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 +1 -1
- package/dist/internal.d.ts.map +1 -1
- 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 +15 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1851 -321
- 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 +336 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +591 -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 +226 -75
- 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 +140 -19
- 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/ws-client.d.ts +23 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +339 -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 +19 -12
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +17 -20
- 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 +225 -78
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +7 -7
- package/src/document-streams.ts +95 -9
- package/src/documents-namespace.ts +18 -3
- package/src/documents.ts +983 -277
- 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 +3406 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +261 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +1 -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 +2355 -515
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +687 -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 +249 -80
- 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 +163 -25
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/ws-client.ts +392 -92
- package/src/ws-frame-limit.ts +19 -12
package/src/client.ts
CHANGED
|
@@ -2,12 +2,15 @@ import {
|
|
|
2
2
|
buildSignInUrl,
|
|
3
3
|
buildSignedOutOfAppUrl,
|
|
4
4
|
refreshTokens,
|
|
5
|
-
|
|
5
|
+
revokeAppApproval,
|
|
6
|
+
type RefreshCredential,
|
|
7
|
+
type RefreshOutcome,
|
|
6
8
|
type TokenSet,
|
|
7
9
|
} from './auth-flow.js';
|
|
8
10
|
import { decodeIdToken } from './jwt.js';
|
|
9
11
|
import { createEmitter } from './emitter.js';
|
|
10
12
|
import {
|
|
13
|
+
clearParkedDocuments,
|
|
11
14
|
createOpenDocument,
|
|
12
15
|
type CreateDocumentArgs,
|
|
13
16
|
type CreateDocumentOptions,
|
|
@@ -28,7 +31,7 @@ import {
|
|
|
28
31
|
type SelectDocumentResult,
|
|
29
32
|
} from './hosted-ui.js';
|
|
30
33
|
import type { Document } from './documents.js';
|
|
31
|
-
import type { DocumentSerializer } from './serializer.js';
|
|
34
|
+
import type { DocumentSerializer, StoredDocument } from './serializer.js';
|
|
32
35
|
import {
|
|
33
36
|
createImportRunner,
|
|
34
37
|
type DocumentImporter,
|
|
@@ -111,6 +114,10 @@ import type {
|
|
|
111
114
|
} from './types.js';
|
|
112
115
|
|
|
113
116
|
const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
|
|
117
|
+
// How long `signOut` waits for the revocation before it navigates. The page
|
|
118
|
+
// it lands on finishes an unrevoked sign-out itself, so waiting longer than
|
|
119
|
+
// this only delays the sign-out.
|
|
120
|
+
const SIGN_OUT_REVOKE_DEADLINE_MS = 4000;
|
|
114
121
|
// The data API has its own host, NOT the dashboard origin: the
|
|
115
122
|
// dashboard (app.brass.build) is a static SPA, the data API is a
|
|
116
123
|
// separate gateway behind api.brass.build.
|
|
@@ -146,11 +153,22 @@ export interface ResolvedBaseUrls {
|
|
|
146
153
|
|
|
147
154
|
// Resolve the four base URLs from init options. Zero config applies all
|
|
148
155
|
// four production origins (including `wsBaseUrl`, so documents and
|
|
149
|
-
// presence work with nothing passed).
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
156
|
+
// presence work with nothing passed).
|
|
157
|
+
//
|
|
158
|
+
// Naming ANY `*BaseUrl` withholds the production `wsBaseUrl`: an app that
|
|
159
|
+
// overrides `apiBaseUrl` and inherited a production socket would connect to
|
|
160
|
+
// the wrong stack and silently drop events, where an unfilled `wsBaseUrl`
|
|
161
|
+
// fails loudly on subscribe.
|
|
162
|
+
//
|
|
163
|
+
// `authBaseUrl`, `apiBaseUrl` and `dashboardBaseUrl` still fall back to their
|
|
164
|
+
// production defaults, so a caller who names some but not all of them reaches
|
|
165
|
+
// production for the rest. `dashboardBaseUrl` is the one that goes wrong
|
|
166
|
+
// quietly: every hosted surface (the picker, share, the version bar) is framed
|
|
167
|
+
// from it, so an app on another stack frames production's, which either
|
|
168
|
+
// refuses to render or hands back ids the configured API cannot open. Name it
|
|
169
|
+
// alongside the others.
|
|
170
|
+
//
|
|
171
|
+
// Pure so the precedence is unit-testable.
|
|
154
172
|
export function resolveBaseUrls(options: BrassInitOptions): ResolvedBaseUrls {
|
|
155
173
|
const anyUrlOverride = Boolean(
|
|
156
174
|
options.authBaseUrl || options.apiBaseUrl || options.wsBaseUrl || options.dashboardBaseUrl,
|
|
@@ -179,6 +197,23 @@ const REFRESH_LEEWAY_MS = 60_000;
|
|
|
179
197
|
// server-side format bumps from stranding SDK consumers.
|
|
180
198
|
const APP_ID_PATTERN = /^brass_app_(?:(?:test|live|internal)_)?[A-Za-z0-9_-]+$/;
|
|
181
199
|
|
|
200
|
+
// What one `/refresh` settled, applied to the client's own state. Carries the
|
|
201
|
+
// `no_session` / `unavailable` split `RefreshOutcome` draws, because the two
|
|
202
|
+
// are opposite answers for a caller: `no_session` is the server saying this
|
|
203
|
+
// app has none, `unavailable` is the absence of an answer.
|
|
204
|
+
type RefreshSettlement =
|
|
205
|
+
| { status: 'ok'; tokens: TokenSet }
|
|
206
|
+
| { status: 'no_session' }
|
|
207
|
+
| { status: 'unavailable' };
|
|
208
|
+
|
|
209
|
+
// The rejection for `unavailable`. A 503 status is what makes the outcome
|
|
210
|
+
// legible to the layers that branch on one: an app's own retry, and the
|
|
211
|
+
// selection self-heal that clears a persisted doc id on a definitive 403 /
|
|
212
|
+
// 404 and leaves a transient alone.
|
|
213
|
+
function refreshUnavailableError(): BrassError {
|
|
214
|
+
return new BrassError('The sign-in service is unavailable', { status: 503 });
|
|
215
|
+
}
|
|
216
|
+
|
|
182
217
|
export class BrassClient {
|
|
183
218
|
private appId: string | null = null;
|
|
184
219
|
private authBaseUrl = DEFAULT_AUTH_BASE_URL;
|
|
@@ -230,7 +265,12 @@ export class BrassClient {
|
|
|
230
265
|
// Init-failure listeners. Swallow a listener's throw so one bad handler
|
|
231
266
|
// cannot block the others or mask the original error (already logged).
|
|
232
267
|
private readonly errorListeners = createEmitter<Error>({ onError: () => {} });
|
|
233
|
-
private inflightRefresh: Promise<
|
|
268
|
+
private inflightRefresh: Promise<RefreshSettlement> | null = null;
|
|
269
|
+
// Bumped by every `signOut`, and captured by a `/refresh` when it starts.
|
|
270
|
+
// A refresh that settles against a moved epoch drops its answer: the
|
|
271
|
+
// credentials it would write were cleared by a sign-out while it was in
|
|
272
|
+
// flight, and re-writing them signs the person back in.
|
|
273
|
+
private authEpoch = 0;
|
|
234
274
|
private initialized = false;
|
|
235
275
|
// Incognito / third-party-cookie fallback state. Both null in a normal
|
|
236
276
|
// browser, where the cross-site `brass_auth` cookie carries the session
|
|
@@ -238,7 +278,7 @@ export class BrassClient {
|
|
|
238
278
|
// - `pendingAuthCode`: the one-time `brass_code` pulled off the
|
|
239
279
|
// landing URL by bootstrap(), consumed at most once by refresh() if
|
|
240
280
|
// the cookie channel is unavailable.
|
|
241
|
-
// - `sessionToken`: the
|
|
281
|
+
// - `sessionToken`: the app-bound session pointer from a code
|
|
242
282
|
// exchange. Once set, every refresh() uses it instead of the
|
|
243
283
|
// cookie. Mirrored into sessionStorage so a reload in a
|
|
244
284
|
// cookie-blocked browser hydrates it; see session-persistence.ts.
|
|
@@ -280,8 +320,9 @@ export class BrassClient {
|
|
|
280
320
|
completeImport: (docId) => this.docsInternal().completeImport(docId),
|
|
281
321
|
reportError: (error) => this.fireError(error),
|
|
282
322
|
});
|
|
283
|
-
//
|
|
284
|
-
//
|
|
323
|
+
// One socket carrying every document topic this client subscribes,
|
|
324
|
+
// built on first open so an app that never opens a document never
|
|
325
|
+
// connects.
|
|
285
326
|
private docWsClient: BrassWsClient | null = null;
|
|
286
327
|
|
|
287
328
|
// init ALWAYS resolves: failures (bad/missing app_id) fire onError +
|
|
@@ -439,7 +480,7 @@ export class BrassClient {
|
|
|
439
480
|
// renders a signed-out UI or calls `signIn()`, which navigates the
|
|
440
481
|
// frame away from the export it was loaded to run.
|
|
441
482
|
if (this.exportGuest === null) {
|
|
442
|
-
for (const listener of this.listeners) {
|
|
483
|
+
for (const listener of Array.from(this.listeners)) {
|
|
443
484
|
listener(this.user);
|
|
444
485
|
}
|
|
445
486
|
}
|
|
@@ -458,23 +499,34 @@ export class BrassClient {
|
|
|
458
499
|
window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
|
|
459
500
|
}
|
|
460
501
|
|
|
461
|
-
// Per-app sign-out.
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
// and offers a one-click "Sign out of Brass too" link.
|
|
502
|
+
// Per-app sign-out. Revokes this app's server-side approval (subsequent
|
|
503
|
+
// /refresh calls for this app return 401 even on cookie reuse), then makes
|
|
504
|
+
// a top-level navigation to a Brass-hosted page that surfaces the email
|
|
505
|
+
// the user is still signed in to Brass as, and offers a one-click "Sign
|
|
506
|
+
// out of Brass too" link.
|
|
467
507
|
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
508
|
+
// The navigation is what closes the trust gap of "did sign-out actually
|
|
509
|
+
// happen?"; the revocation rides the credentialed route because the page
|
|
510
|
+
// answers whoever arrives at it and so writes nothing on arrival. The
|
|
511
|
+
// decision to ALSO sign out of the platform belongs to the user, made on
|
|
512
|
+
// the Brass-hosted page; the calling app cannot trigger platform-level
|
|
513
|
+
// sign-out from its own UI.
|
|
473
514
|
signOut(): void {
|
|
474
515
|
const appId = this.requireAppId();
|
|
475
516
|
if (typeof window === 'undefined') {
|
|
476
517
|
throw new Error('Brass.signOut must be called in a browser');
|
|
477
518
|
}
|
|
519
|
+
// Read before the clears below drop it: where the cross-site cookie is
|
|
520
|
+
// blocked, this pointer is the revocation's only credential.
|
|
521
|
+
const credential: RefreshCredential = this.sessionToken
|
|
522
|
+
? { kind: 'sessionToken', sessionToken: this.sessionToken }
|
|
523
|
+
: { kind: 'cookie' };
|
|
524
|
+
// Fence any `/refresh` already on the wire: landing after the clears
|
|
525
|
+
// below, it writes back the token set, the login hint and the session
|
|
526
|
+
// pointer, and re-emits the signed-in user. The hint and the pointer
|
|
527
|
+
// outlive the page, so that resurrects the session on the next load
|
|
528
|
+
// too, and the navigation below can be delayed or cancelled.
|
|
529
|
+
this.authEpoch += 1;
|
|
478
530
|
this.setTokens(null);
|
|
479
531
|
// Drop any fallback-mode credential too, so a stale session token
|
|
480
532
|
// can't resurrect the session on the next getAccessToken(). Clear
|
|
@@ -496,12 +548,22 @@ export class BrassClient {
|
|
|
496
548
|
// on the way to the confirmation page. Harmless when the app is not
|
|
497
549
|
// gated (the cookie simply isn't there). Off a hosted origin there is
|
|
498
550
|
// no such cookie, so go straight to the confirmation page.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
551
|
+
const target = isBrassHostedHost(window.location.hostname)
|
|
552
|
+
? `${window.location.origin}/__brass/leave?return_to=${encodeURIComponent(signedOutUrl)}`
|
|
553
|
+
: signedOutUrl;
|
|
554
|
+
// Leave once the revocation settles, so the page reports a sign-out that
|
|
555
|
+
// already happened rather than offering the button that finishes it.
|
|
556
|
+
let left = false;
|
|
557
|
+
const leave = (): void => {
|
|
558
|
+
if (left) return;
|
|
559
|
+
left = true;
|
|
560
|
+
window.location.assign(target);
|
|
561
|
+
};
|
|
562
|
+
const deadline = setTimeout(leave, SIGN_OUT_REVOKE_DEADLINE_MS);
|
|
563
|
+
void revokeAppApproval(this.authBaseUrl, appId, credential).then(() => {
|
|
564
|
+
clearTimeout(deadline);
|
|
565
|
+
leave();
|
|
566
|
+
});
|
|
505
567
|
}
|
|
506
568
|
|
|
507
569
|
get currentUser(): BrassUser | null {
|
|
@@ -563,10 +625,11 @@ export class BrassClient {
|
|
|
563
625
|
this.errorListeners.emit(error);
|
|
564
626
|
}
|
|
565
627
|
|
|
566
|
-
// The
|
|
567
|
-
//
|
|
568
|
-
// `Brass.openDocumentById` /
|
|
569
|
-
// Brass.init(appId) first" before
|
|
628
|
+
// The metadata + thumbnail namespace for a document the app already
|
|
629
|
+
// holds an id for (`get` / `setThumbnail` / `getThumbnail`); the
|
|
630
|
+
// editable surface stays on `Brass.openDocumentById` /
|
|
631
|
+
// `Brass.createDocument`. Throws "Call Brass.init(appId) first" before
|
|
632
|
+
// init.
|
|
570
633
|
get documents(): DocumentsNamespace {
|
|
571
634
|
return this.docsInternal();
|
|
572
635
|
}
|
|
@@ -713,9 +776,8 @@ export class BrassClient {
|
|
|
713
776
|
dashboardBaseUrl: this._dashboardBaseUrl,
|
|
714
777
|
getDocument: (docId) => this.documents.get(docId),
|
|
715
778
|
// The version-preview bar is a hosted embed; it takes the same theme
|
|
716
|
-
//
|
|
779
|
+
// as the other hosted surfaces.
|
|
717
780
|
hostedUiTheme: this.hostedUiTheme,
|
|
718
|
-
getSessionToken: () => this.sessionToken,
|
|
719
781
|
};
|
|
720
782
|
}
|
|
721
783
|
|
|
@@ -788,11 +850,6 @@ export class BrassClient {
|
|
|
788
850
|
if (active) this.session._noteLocalDelete(docId);
|
|
789
851
|
else this.session._clearLocalDelete(docId);
|
|
790
852
|
},
|
|
791
|
-
// Hand our fallback platform-session pointer to the embed so it can
|
|
792
|
-
// authenticate the dashboard when its own framed cookie is blocked
|
|
793
|
-
// (incognito / Safari ITP). Null in a normal browser, where the
|
|
794
|
-
// embed's cross-site cookie carries the session on its own.
|
|
795
|
-
getSessionToken: () => this.sessionToken,
|
|
796
853
|
// Name the user this app is acting as, so the embed can refuse to
|
|
797
854
|
// act as anyone else. The embed authenticates from the dashboard
|
|
798
855
|
// origin's platform-session cookie, which moves when a second
|
|
@@ -837,7 +894,13 @@ export class BrassClient {
|
|
|
837
894
|
options?: OpenDocumentByIdOptions,
|
|
838
895
|
): Promise<OpenedDocument> {
|
|
839
896
|
const opened = await this.openDocumentInternal(docId, options);
|
|
840
|
-
|
|
897
|
+
// The URL names the document the reader is looking at, and the session
|
|
898
|
+
// owns that whenever it holds one: this open is detached, so claiming the
|
|
899
|
+
// URL would leave the address bar naming a document the session is not
|
|
900
|
+
// showing, and a reload would swap the reader onto it. An app with no
|
|
901
|
+
// session document still gets the sync, which is the read-mostly caller
|
|
902
|
+
// this method exists for.
|
|
903
|
+
if (this.session.document === null) this.maybeWriteDocIdToUrl(opened.id);
|
|
841
904
|
return opened;
|
|
842
905
|
}
|
|
843
906
|
|
|
@@ -845,7 +908,7 @@ export class BrassClient {
|
|
|
845
908
|
// the link" anonymous path). No sign-in: the token is the credential and
|
|
846
909
|
// the server serves a read-only, member-stripped snapshot with no live
|
|
847
910
|
// subscription. `Brass.session` drives this from the URL on boot when the
|
|
848
|
-
// page carries `?brass_link
|
|
911
|
+
// page carries `?brass_link=`.
|
|
849
912
|
private async openByLinkInternal(
|
|
850
913
|
token: string,
|
|
851
914
|
options?: OpenDocumentByIdOptions,
|
|
@@ -1023,13 +1086,13 @@ export class BrassClient {
|
|
|
1023
1086
|
registerImporter(importer: DocumentImporter): void {
|
|
1024
1087
|
this.importRunner.setImporter(importer);
|
|
1025
1088
|
if (this.guestLinkTokenValue !== null) return;
|
|
1026
|
-
for (const doc of this.
|
|
1089
|
+
for (const doc of this.liveOpenDocuments()) {
|
|
1027
1090
|
void this.importRunner.attempt(doc);
|
|
1028
1091
|
}
|
|
1029
1092
|
}
|
|
1030
1093
|
|
|
1031
1094
|
// Track an opened document so an export request can resolve its id to
|
|
1032
|
-
// the live Document.
|
|
1095
|
+
// the live Document. A re-open overwrites.
|
|
1033
1096
|
private trackOpenDocument(doc: OpenedDocument): void {
|
|
1034
1097
|
this.openDocuments.set(doc.id, doc);
|
|
1035
1098
|
doc.onGone(() => {
|
|
@@ -1045,24 +1108,55 @@ export class BrassClient {
|
|
|
1045
1108
|
}
|
|
1046
1109
|
}
|
|
1047
1110
|
|
|
1111
|
+
// The tracked documents still open, dropping the rest as it goes.
|
|
1112
|
+
// `onGone` covers only a server-side eviction, and two ordinary paths
|
|
1113
|
+
// retire a Document without one: an app's `close()` (which parks it, so
|
|
1114
|
+
// the public surface throws while the instance lives on in the park
|
|
1115
|
+
// cache) and that cache's LRU eviction (a full teardown, no listener
|
|
1116
|
+
// left to fire). Reading through this is what keeps a retired wrapper
|
|
1117
|
+
// out of an answer, and what keeps the set from growing for the life of
|
|
1118
|
+
// the page.
|
|
1119
|
+
private liveOpenDocuments(): Document[] {
|
|
1120
|
+
const live: Document[] = [];
|
|
1121
|
+
for (const [docId, doc] of this.openDocuments) {
|
|
1122
|
+
if (doc._isLive) live.push(doc);
|
|
1123
|
+
else this.openDocuments.delete(docId);
|
|
1124
|
+
}
|
|
1125
|
+
return live;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private liveOpenDocument(docId: string): Document | null {
|
|
1129
|
+
const doc = this.openDocuments.get(docId);
|
|
1130
|
+
if (!doc) return null;
|
|
1131
|
+
if (doc._isLive) return doc;
|
|
1132
|
+
this.openDocuments.delete(docId);
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1048
1136
|
// Run the registered serializer for an export request from a hosted
|
|
1049
1137
|
// surface. Resolves the doc from the open set (the active session doc is
|
|
1050
1138
|
// the fallback for an app that drives it through the session), then hands
|
|
1051
1139
|
// it to the serializer. A missing serializer is the ordinary "this app
|
|
1052
|
-
// does not export" outcome, not an error.
|
|
1140
|
+
// does not export" outcome, not an error. A document the app has closed
|
|
1141
|
+
// reports `no_document`: running the serializer against it would throw
|
|
1142
|
+
// out of the app's own record reads and report `serializer_failed`, which
|
|
1143
|
+
// names the app's code for a state the platform is holding.
|
|
1053
1144
|
private async serializeForExport(docId: string): Promise<ExportSerializeOutcome> {
|
|
1054
1145
|
if (!this.serializer) {
|
|
1055
1146
|
return { ok: false, code: 'no_serializer', message: 'No serializer registered.' };
|
|
1056
1147
|
}
|
|
1057
1148
|
const sessionDoc = this.documentSession?.document ?? null;
|
|
1058
1149
|
const doc =
|
|
1059
|
-
this.
|
|
1060
|
-
(sessionDoc && sessionDoc.id === docId ? sessionDoc : null);
|
|
1150
|
+
this.liveOpenDocument(docId) ??
|
|
1151
|
+
(sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
|
|
1061
1152
|
if (!doc) {
|
|
1062
1153
|
return { ok: false, code: 'no_document', message: 'Document is not open.' };
|
|
1063
1154
|
}
|
|
1064
1155
|
try {
|
|
1065
|
-
|
|
1156
|
+
// A serializer runs detached, with no subscription to have been fed by,
|
|
1157
|
+
// and it needs the document once, so it is handed the containers whole
|
|
1158
|
+
// (`StoredDocument`) rather than the fed surface an app holds.
|
|
1159
|
+
const bytes = await this.serializer(doc as StoredDocument & Document);
|
|
1066
1160
|
return { ok: true, bytes };
|
|
1067
1161
|
} catch (err) {
|
|
1068
1162
|
return {
|
|
@@ -1107,6 +1201,7 @@ export class BrassClient {
|
|
|
1107
1201
|
apiBaseUrl: this.apiBaseUrl,
|
|
1108
1202
|
getAccessToken: () => this.getAccessToken(),
|
|
1109
1203
|
getAppAssertion: () => this.getAppAssertion(),
|
|
1204
|
+
identity: () => this.user?.sub ?? null,
|
|
1110
1205
|
...(wsClient ? { wsClient } : {}),
|
|
1111
1206
|
branchProvider: {
|
|
1112
1207
|
list: (docId) => docsInternal.listBranches(docId),
|
|
@@ -1200,29 +1295,50 @@ export class BrassClient {
|
|
|
1200
1295
|
return this.docWsClient;
|
|
1201
1296
|
}
|
|
1202
1297
|
|
|
1298
|
+
// Resolves null only when the server answered that this app has no live
|
|
1299
|
+
// session, and rejects with a 503 `BrassError` when a `/refresh` found no
|
|
1300
|
+
// answer at all. The two outcomes drive opposite handling: null is the
|
|
1301
|
+
// signed-out state a caller renders a sign-in CTA for, where a rejection
|
|
1302
|
+
// is a momentary outage the caller retries. Collapsing them into null
|
|
1303
|
+
// reports a 401-shaped "Not signed in" to a signed-in user whose network
|
|
1304
|
+
// blipped, and hides the transient from every layer that self-heals on it.
|
|
1203
1305
|
async getAccessToken(): Promise<string | null> {
|
|
1204
1306
|
this.requireInit();
|
|
1307
|
+
// On a `?brass_link=` page the link token is the credential and `init`
|
|
1308
|
+
// runs no auth bootstrap, so answer null rather than probing /refresh.
|
|
1309
|
+
// A visitor with no session would draw a 401 the browser logs; one who
|
|
1310
|
+
// holds a session for this app would get a real token, and a caller
|
|
1311
|
+
// that prefers a token over the link token (the socket does) would then
|
|
1312
|
+
// present membership where the page's whole authority is the link.
|
|
1313
|
+
if (this.guestLinkTokenValue !== null) return null;
|
|
1205
1314
|
const current = this.tokens;
|
|
1206
1315
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
1207
1316
|
return current.accessToken;
|
|
1208
1317
|
}
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1318
|
+
const settled = await this.refresh();
|
|
1319
|
+
if (settled.status === 'unavailable') throw refreshUnavailableError();
|
|
1320
|
+
return settled.status === 'ok' ? settled.tokens.accessToken : null;
|
|
1211
1321
|
}
|
|
1212
1322
|
|
|
1213
1323
|
// The acting-app assertion paired with the current access token, or
|
|
1214
1324
|
// null when the proxy issued none. Mirrors `getAccessToken`'s
|
|
1215
1325
|
// freshness check so the two stay in lockstep: a request builder that
|
|
1216
1326
|
// calls `getAccessToken()` then this reads the same (possibly just
|
|
1217
|
-
// refreshed) token set without forcing a second refresh.
|
|
1327
|
+
// refreshed) token set without forcing a second refresh. Rejects on a
|
|
1328
|
+
// transient outage for the same reason `getAccessToken` does.
|
|
1218
1329
|
async getAppAssertion(): Promise<string | null> {
|
|
1219
1330
|
this.requireInit();
|
|
1331
|
+
// Paired with `getAccessToken`: a link guest holds no token to assert
|
|
1332
|
+
// an acting app against, and reaching /refresh here would authenticate
|
|
1333
|
+
// the page the same way.
|
|
1334
|
+
if (this.guestLinkTokenValue !== null) return null;
|
|
1220
1335
|
const current = this.tokens;
|
|
1221
1336
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
1222
1337
|
return current.appAssertion ?? null;
|
|
1223
1338
|
}
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1339
|
+
const settled = await this.refresh();
|
|
1340
|
+
if (settled.status === 'unavailable') throw refreshUnavailableError();
|
|
1341
|
+
return settled.status === 'ok' ? (settled.tokens.appAssertion ?? null) : null;
|
|
1226
1342
|
}
|
|
1227
1343
|
|
|
1228
1344
|
private async bootstrap(): Promise<void> {
|
|
@@ -1230,7 +1346,7 @@ export class BrassClient {
|
|
|
1230
1346
|
const appId = this.requireAppId();
|
|
1231
1347
|
// Every successful sign-in 302s back to return_to with the
|
|
1232
1348
|
// platform-session cookie set AND a one-time `brass_code` query
|
|
1233
|
-
// parameter
|
|
1349
|
+
// parameter. The cookie
|
|
1234
1350
|
// is the primary channel; the code is the incognito / Safari ITP /
|
|
1235
1351
|
// post-third-party-cookie fallback for when the browser drops the
|
|
1236
1352
|
// cross-site cookie on our /refresh XHR.
|
|
@@ -1243,8 +1359,7 @@ export class BrassClient {
|
|
|
1243
1359
|
//
|
|
1244
1360
|
// No app-controlled silent-mint path: the SDK never auto-fires
|
|
1245
1361
|
// Brass.signIn() based on a URL hint. New app approvals always
|
|
1246
|
-
// require an explicit click on the auth-hosted picker
|
|
1247
|
-
// adaptive /sign-in branch in infra/lambda/auth/sign-in.ts).
|
|
1362
|
+
// require an explicit click on the auth-hosted picker.
|
|
1248
1363
|
// Same treatment for the launching organization: read it off the URL
|
|
1249
1364
|
// and strip it here, so a client-side navigation later in the session
|
|
1250
1365
|
// cannot lose it and the address bar stays the app's own.
|
|
@@ -1300,19 +1415,27 @@ export class BrassClient {
|
|
|
1300
1415
|
this.pendingAuthCode = null;
|
|
1301
1416
|
}
|
|
1302
1417
|
|
|
1303
|
-
private async refresh(): Promise<
|
|
1418
|
+
private async refresh(): Promise<RefreshSettlement> {
|
|
1304
1419
|
if (this.inflightRefresh) return this.inflightRefresh;
|
|
1305
1420
|
const appId = this.requireAppId();
|
|
1306
|
-
|
|
1307
|
-
|
|
1421
|
+
const epoch = this.authEpoch;
|
|
1422
|
+
this.inflightRefresh = (async (): Promise<RefreshSettlement> => {
|
|
1423
|
+
const outcome = await this.runRefresh(appId);
|
|
1424
|
+
// No answer means no change: the token set, the login hint, and any
|
|
1425
|
+
// fallback session token stay as they are, and a later attempt settles
|
|
1426
|
+
// the session.
|
|
1427
|
+
if (outcome.status === 'unavailable') return { status: 'unavailable' };
|
|
1428
|
+
// A sign-out landed while this round-trip was in flight, so whatever
|
|
1429
|
+
// it resolved belongs to a session the person has left. Report it as
|
|
1430
|
+
// no session and write nothing: the caller waiting on this token gets
|
|
1431
|
+
// the signed-out answer the sign-out established.
|
|
1432
|
+
if (this.authEpoch !== epoch) return { status: 'no_session' };
|
|
1433
|
+
const result = outcome.status === 'ok' ? outcome.result : null;
|
|
1308
1434
|
this.setTokens(result?.tokens ?? null);
|
|
1309
1435
|
// Remember whether a live session exists for this app so the next
|
|
1310
1436
|
// cold load knows whether to probe /refresh (see bootstrap). A
|
|
1311
|
-
// success proves a session;
|
|
1312
|
-
//
|
|
1313
|
-
// silent. A transient network failure also clears it; the cost is
|
|
1314
|
-
// one extra sign-in click on the next load, self-healing on the
|
|
1315
|
-
// next success.
|
|
1437
|
+
// success proves a session; `no_session` is the server saying there
|
|
1438
|
+
// is none, so drop the hint and the next signed-out load stays silent.
|
|
1316
1439
|
if (result) {
|
|
1317
1440
|
storeLoginHint(appId);
|
|
1318
1441
|
} else {
|
|
@@ -1323,12 +1446,11 @@ export class BrassClient {
|
|
|
1323
1446
|
// Mirror into sessionStorage so a reload on this tab can
|
|
1324
1447
|
// hydrate it (the cookie channel is unavailable in this
|
|
1325
1448
|
// browser by construction; we only get here via the code or
|
|
1326
|
-
// sid channels). The
|
|
1327
|
-
//
|
|
1328
|
-
// than the cookie it mirrors.
|
|
1449
|
+
// sid channels). The pointer is bound to this app id, so what
|
|
1450
|
+
// is stored authenticates this app and nothing else.
|
|
1329
1451
|
storeSessionToken(appId, result.sessionToken);
|
|
1330
1452
|
}
|
|
1331
|
-
return result
|
|
1453
|
+
return result ? { status: 'ok', tokens: result.tokens } : { status: 'no_session' };
|
|
1332
1454
|
})().finally(() => {
|
|
1333
1455
|
this.inflightRefresh = null;
|
|
1334
1456
|
});
|
|
@@ -1338,16 +1460,20 @@ export class BrassClient {
|
|
|
1338
1460
|
// Picks the credential channel for a single /refresh round-trip. The
|
|
1339
1461
|
// cookie path is primary and untouched for browsers that send the
|
|
1340
1462
|
// third-party cookie; the one-time code is consumed only when that
|
|
1341
|
-
// cookie path
|
|
1463
|
+
// cookie path resolved no session. Once a fallback session
|
|
1342
1464
|
// token is in hand (the cross-site cookie is proven unavailable for
|
|
1343
1465
|
// this page's life), every later refresh uses it directly.
|
|
1344
|
-
|
|
1466
|
+
//
|
|
1467
|
+
// A rung falls through to the next only on `no_session`. Each one discards
|
|
1468
|
+
// a credential as it passes (the stale sid, the one-time code), and an
|
|
1469
|
+
// `unavailable` says nothing about the credential it just tried.
|
|
1470
|
+
private async runRefresh(appId: string): Promise<RefreshOutcome> {
|
|
1345
1471
|
if (this.sessionToken) {
|
|
1346
1472
|
const viaSession = await refreshTokens(this.authBaseUrl, appId, {
|
|
1347
1473
|
kind: 'sessionToken',
|
|
1348
1474
|
sessionToken: this.sessionToken,
|
|
1349
1475
|
});
|
|
1350
|
-
if (viaSession) return viaSession;
|
|
1476
|
+
if (viaSession.status !== 'no_session') return viaSession;
|
|
1351
1477
|
// Stored sid is stale (server-side revoke, row expiry). Drop both
|
|
1352
1478
|
// the in-memory copy and the sessionStorage mirror, then fall
|
|
1353
1479
|
// through to the cookie / code paths. Without this, a hydrated-
|
|
@@ -1358,19 +1484,32 @@ export class BrassClient {
|
|
|
1358
1484
|
clearSessionToken(appId);
|
|
1359
1485
|
}
|
|
1360
1486
|
const viaCookie = await refreshTokens(this.authBaseUrl, appId);
|
|
1361
|
-
if (viaCookie) return viaCookie;
|
|
1362
|
-
// Cookie channel
|
|
1363
|
-
// we landed with one. Clear it first: it is single-use server-side,
|
|
1364
|
-
//
|
|
1487
|
+
if (viaCookie.status !== 'no_session') return viaCookie;
|
|
1488
|
+
// Cookie channel resolved no session. Fall back to the one-time code if
|
|
1489
|
+
// we landed with one. Clear it first: it is single-use server-side, so a
|
|
1490
|
+
// failed exchange must not be retried.
|
|
1365
1491
|
const code = this.pendingAuthCode;
|
|
1492
|
+
if (!code) return viaCookie;
|
|
1366
1493
|
this.pendingAuthCode = null;
|
|
1367
|
-
if (!code) return null;
|
|
1368
1494
|
return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
|
|
1369
1495
|
}
|
|
1370
1496
|
|
|
1371
1497
|
private setTokens(next: TokenSet | null): void {
|
|
1372
|
-
this.tokens = next;
|
|
1373
1498
|
const nextUser = next ? decodeIdToken(next.idToken) : null;
|
|
1499
|
+
// A different person on this page. `/refresh` resolves whichever platform
|
|
1500
|
+
// session the browser holds, so the identity behind a page that never
|
|
1501
|
+
// navigated can become someone else's (another tab signs out and a second
|
|
1502
|
+
// person signs in). Run the retirement first, as its own transition, so
|
|
1503
|
+
// what the previous identity left open is retired before the new one is
|
|
1504
|
+
// announced rather than inherited by it.
|
|
1505
|
+
if (
|
|
1506
|
+
this.user !== null &&
|
|
1507
|
+
nextUser !== null &&
|
|
1508
|
+
this.user.sub !== nextUser.sub
|
|
1509
|
+
) {
|
|
1510
|
+
this.setTokens(null);
|
|
1511
|
+
}
|
|
1512
|
+
this.tokens = next;
|
|
1374
1513
|
if (sameUser(this.user, nextUser)) return;
|
|
1375
1514
|
this.user = nextUser;
|
|
1376
1515
|
// Suppress emits while init is still in flight: init's finally
|
|
@@ -1379,9 +1518,17 @@ export class BrassClient {
|
|
|
1379
1518
|
// fire here (null -> user) AND fire again from init's finally
|
|
1380
1519
|
// (the same user), double-emitting on every signed-in app load.
|
|
1381
1520
|
if (!this.bootstrapped) return;
|
|
1382
|
-
|
|
1521
|
+
// Copied before the walk: a listener that detaches another would otherwise
|
|
1522
|
+
// remove it before its turn, and a `Set` never visits such an entry.
|
|
1523
|
+
for (const listener of Array.from(this.listeners)) {
|
|
1383
1524
|
listener(nextUser);
|
|
1384
1525
|
}
|
|
1526
|
+
// After the listeners, because the session closes the active document as
|
|
1527
|
+
// it hears the sign-out and a close PARKS the Document. A parked Document
|
|
1528
|
+
// answers the next open of its key out of memory, with no token and no
|
|
1529
|
+
// request, so one left behind here is the previous person's document
|
|
1530
|
+
// handed to whoever signs in next.
|
|
1531
|
+
if (nextUser === null) clearParkedDocuments();
|
|
1385
1532
|
}
|
|
1386
1533
|
|
|
1387
1534
|
private async hydrateProfile(): Promise<void> {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The typed way for in-repo tooling (the benches, the fuzz harness) to wrap a
|
|
2
|
+
// LoroDoc it already holds. It sits in a module of its own, outside what the
|
|
3
|
+
// package entry reaches, so it can name Loro's types while the SDK's public
|
|
4
|
+
// surface stays engine-neutral.
|
|
5
|
+
|
|
6
|
+
import type { LoroDoc } from 'loro-crdt';
|
|
7
|
+
|
|
8
|
+
import { Document, type DocumentOptions } from './documents.js';
|
|
9
|
+
|
|
10
|
+
export function documentFromEngine(
|
|
11
|
+
engine: LoroDoc,
|
|
12
|
+
options?: DocumentOptions,
|
|
13
|
+
): Document {
|
|
14
|
+
return Document._fromEngine(engine, options);
|
|
15
|
+
}
|
package/src/document-signals.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { createEmitter } from './emitter.js';
|
|
10
10
|
|
|
11
11
|
// Reason a Document's underlying doc was evicted. Mirrors the `doc_gone`
|
|
12
|
-
// frame's `reason
|
|
12
|
+
// frame's `reason`. New literals land
|
|
13
13
|
// here as the platform grows transitions that evict open subscribers.
|
|
14
14
|
// `moved`: the doc changed organization or project, so its access gate
|
|
15
15
|
// changed and open subscribers re-establish under the new location.
|
|
@@ -19,7 +19,7 @@ export type DocumentGoneListener = (reason: DocumentGoneReason) => void;
|
|
|
19
19
|
export type DocumentGoneUnsubscribe = () => void;
|
|
20
20
|
|
|
21
21
|
// Opaque status of slow server-side work (hydration, extraction, push).
|
|
22
|
-
// Mirrors the wire shape
|
|
22
|
+
// Mirrors the wire shape of the server's status frame. The app
|
|
23
23
|
// renders `message` verbatim, draws a bar from `progress`, and waits while
|
|
24
24
|
// `pending`. No kind / phase / error code: the server owns all wording and can
|
|
25
25
|
// change phases without any app change.
|
|
@@ -39,7 +39,7 @@ export type DocumentStatusListener = (status: DocumentWorkStatus) => void;
|
|
|
39
39
|
export type DocumentStatusUnsubscribe = () => void;
|
|
40
40
|
|
|
41
41
|
// Coarse divergence state of a drive-linked doc vs its source. Mirrors the
|
|
42
|
-
// server's `tracking_state
|
|
42
|
+
// server's `tracking_state`. The
|
|
43
43
|
// app reads `state` to decide which affordance to show, never to pick wording
|
|
44
44
|
// (that is server-authored `label` / `detail`).
|
|
45
45
|
export type DocumentDriveTrackingState =
|
|
@@ -54,11 +54,11 @@ export type DocumentDriveTrackingState =
|
|
|
54
54
|
// reads this to decide whether to show a Publish affordance at all.
|
|
55
55
|
export type DriveMirrorDirection = 'two_way' | 'import_only';
|
|
56
56
|
|
|
57
|
-
// The drive sync state of an open document
|
|
58
|
-
// `
|
|
59
|
-
//
|
|
57
|
+
// The drive sync state of an open document. Present only on drive-linked
|
|
58
|
+
// docs; `null` otherwise. Lets an app show "you have unpublished edits"
|
|
59
|
+
// without polling, while the hosted toolbar owns publishing itself. Words are
|
|
60
60
|
// server-authored (`label` / `detail`, rendered verbatim); the app branches
|
|
61
|
-
// only on `state` / `canPush
|
|
61
|
+
// only on `state` / `canPush`.
|
|
62
62
|
export interface DocumentDriveStatus {
|
|
63
63
|
// Adapter type token; an app may key an icon off it. The human label is
|
|
64
64
|
// `driveName`, so an app never maps the type to a vendor name itself.
|