@brass-build/client 0.4.0 → 0.5.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 +142 -41
- package/CHANGELOG.md +93 -4
- package/README.md +666 -179
- package/dist/acting-organization.d.ts.map +1 -1
- package/dist/acting-organization.js +6 -11
- package/dist/acting-organization.js.map +1 -1
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +51 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +982 -124
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/acting-organization.ts +6 -12
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1092 -121
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/dist/client.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { buildSignInUrl, buildSignedOutOfAppUrl, refreshTokens, revokeAppApproval, } from './auth-flow.js';
|
|
2
|
+
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
2
3
|
import { ensureWasmReady } from './wasm-runtime.js';
|
|
3
4
|
import { decodeIdToken } from './jwt.js';
|
|
4
5
|
import { createEmitter } from './emitter.js';
|
|
6
|
+
import { adoptSealedCacheOwner } from './sealed-cache.js';
|
|
5
7
|
import { clearParkedDocuments, createOpenDocument, } from './open-document.js';
|
|
6
8
|
import { askSchemaConflict } from './schema-conflict-dialog.js';
|
|
9
|
+
import { askReplaceTrial } from './trial-replace-dialog.js';
|
|
7
10
|
import { BrassError } from './errors.js';
|
|
11
|
+
import { inDevelopment } from './in-development.js';
|
|
12
|
+
import { importerWaitRejections } from './importer-wait.js';
|
|
13
|
+
import { checkManifestDrift } from './manifest-drift.js';
|
|
14
|
+
import { createApiFetch } from './api-fetch.js';
|
|
15
|
+
import { chooseFile } from './file-choice.js';
|
|
8
16
|
import { createHostedUiLauncher, trackHostedUiActivations, } from './hosted-ui.js';
|
|
9
17
|
import { createImportRunner, } from './importer.js';
|
|
10
18
|
import { isExportMode, startExportGuest, } from './export-guest.js';
|
|
11
|
-
import {
|
|
19
|
+
import { isImportMode, startImportGuest, } from './import-guest.js';
|
|
20
|
+
import { clearClaimSecret, readClaimedLink, storeClaimedLink, clearGuestLinkToken, clearUnsavedWork, markClaimIntent, readClaimSecret, readGuestLinkToken, readUnsavedWork, storeUnsavedWork, takeClaimIntent, markTrialEdited, trialWasEdited, writeGuestLinkToken, } from './guest-link.js';
|
|
12
21
|
import { consumeLaunchOrganizationId } from './launch-context.js';
|
|
22
|
+
import { onPageRestored, restoredPageIsStale } from './page-restore.js';
|
|
23
|
+
import { placeThroughHostedSurfaces } from './placement.js';
|
|
13
24
|
import { callService, normalizeServiceHost, listAppServices, } from './call-service.js';
|
|
14
25
|
import { clearAll as clearLastSelections, readLastDocument, storeLastDocument, } from './selection-persistence.js';
|
|
15
26
|
import { clearActingOrganization, fetchActingOrganization, readActingOrganization, resolveActingOrganization, switchActingOrganization, storeActingOrganization, visibleOrganization, } from './acting-organization.js';
|
|
@@ -23,6 +34,20 @@ import { createSessionToolbar, createSessionHeader, createFileMenu, createDocume
|
|
|
23
34
|
import { BrassWsClient } from './ws-client.js';
|
|
24
35
|
import { fetchHostedRuntime, readInjectedRuntime, isBrassHostedHost, HOSTED_RUNTIME_PATH, } from './hosted-runtime.js';
|
|
25
36
|
const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
|
|
37
|
+
const NOT_IMPORTED_MESSAGE = 'Not imported';
|
|
38
|
+
const IMPORT_FAILED_MESSAGE = 'This file could not be imported';
|
|
39
|
+
// The rejection of an open whose import did not land, naming why when this
|
|
40
|
+
// client stopped waiting for it.
|
|
41
|
+
function notImported(doc) {
|
|
42
|
+
return new BrassError(doc._importAbandoned ?? NOT_IMPORTED_MESSAGE, {
|
|
43
|
+
status: 0,
|
|
44
|
+
code: 'not_imported',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// How long an open waits for `registerImporter` before it rejects
|
|
48
|
+
// `not_imported`, and when a development build first reports the wait.
|
|
49
|
+
const IMPORTER_WAIT_CEILING_MS = 60_000;
|
|
50
|
+
const IMPORTER_WAIT_WARN_MS = 5_000;
|
|
26
51
|
// How long `signOut` waits for the revocation before it navigates. The page
|
|
27
52
|
// it lands on finishes an unrevoked sign-out itself, so waiting longer than
|
|
28
53
|
// this only delays the sign-out.
|
|
@@ -98,6 +123,7 @@ function refreshUnavailableError() {
|
|
|
98
123
|
}
|
|
99
124
|
export class BrassClient {
|
|
100
125
|
appId = null;
|
|
126
|
+
initFailure = null;
|
|
101
127
|
authBaseUrl = DEFAULT_AUTH_BASE_URL;
|
|
102
128
|
apiBaseUrl = DEFAULT_API_BASE_URL;
|
|
103
129
|
apiBaseUrlConfigured = false;
|
|
@@ -113,9 +139,8 @@ export class BrassClient {
|
|
|
113
139
|
// (no `prefers-color-scheme: dark` in its CSS). `'auto'` follows the
|
|
114
140
|
// OS preference; `'dark'` pins dark.
|
|
115
141
|
hostedUiTheme = 'light';
|
|
116
|
-
// See `BrassInitOptions.
|
|
117
|
-
|
|
118
|
-
openIncompatibleDocuments = false;
|
|
142
|
+
// See `BrassInitOptions.open`: the mode the session opens documents in.
|
|
143
|
+
openReadOnly = false;
|
|
119
144
|
// Doc-selection-in-URL sync. On by default; off via
|
|
120
145
|
// `syncDocToUrl: false`. Param names configurable via `docUrlParam` /
|
|
121
146
|
// `branchUrlParam`.
|
|
@@ -144,6 +169,10 @@ export class BrassClient {
|
|
|
144
169
|
// on `authStatus`.
|
|
145
170
|
bootstrapped = false;
|
|
146
171
|
listeners = new Set();
|
|
172
|
+
// The session's own listener. It reacts to sign-in and sign-out, and is
|
|
173
|
+
// left out of a guest-mode change: the session starts that change itself,
|
|
174
|
+
// mid-open, and would otherwise begin a second open of the same document.
|
|
175
|
+
sessionUserListeners = new WeakSet();
|
|
147
176
|
// Init-failure listeners. Swallow a listener's throw so one bad handler
|
|
148
177
|
// cannot block the others or mask the original error (already logged).
|
|
149
178
|
errorListeners = createEmitter({ onError: () => { } });
|
|
@@ -155,7 +184,7 @@ export class BrassClient {
|
|
|
155
184
|
authEpoch = 0;
|
|
156
185
|
initialized = false;
|
|
157
186
|
// Incognito / third-party-cookie fallback state. Both null in a normal
|
|
158
|
-
// browser, where the cross-site `brass_auth` cookie carries the session
|
|
187
|
+
// browser, where the cross-site `__Host-brass_auth` cookie carries the session
|
|
159
188
|
// and `/refresh` works without a body.
|
|
160
189
|
// - `pendingAuthCode`: the one-time `brass_code` pulled off the
|
|
161
190
|
// landing URL by bootstrap(), consumed at most once by refresh() if
|
|
@@ -174,13 +203,19 @@ export class BrassClient {
|
|
|
174
203
|
documentSession = null;
|
|
175
204
|
hostedUiLauncher = null;
|
|
176
205
|
documentFactory = null;
|
|
177
|
-
// The `?brass_link=` token
|
|
178
|
-
//
|
|
206
|
+
// The `?brass_link=` token the page's document opens through, whoever is
|
|
207
|
+
// signed in; null when the page is not on a link. Set in `init`.
|
|
179
208
|
guestLinkTokenValue = null;
|
|
209
|
+
watchingPageRestore = false;
|
|
180
210
|
// The organization this page was launched from (`?brass_org=`), or null
|
|
181
211
|
// when the app was reached some other way. A create that names no
|
|
182
212
|
// destination of its own lands here; see `launch-context.ts`.
|
|
183
213
|
launchOrganizationId = null;
|
|
214
|
+
// The install-consent dialog in flight per organization, so concurrent
|
|
215
|
+
// denials share one. Keyed by organization because that is what an install
|
|
216
|
+
// is granted on, and cleared when the dialog settles so a later denial
|
|
217
|
+
// (a declined install, a second organization) raises its own.
|
|
218
|
+
installConsentByOrganization = new Map();
|
|
184
219
|
// The organization this page has resolved to act in, and the name the
|
|
185
220
|
// server gave it. Held for the life of the page load so repeated acts
|
|
186
221
|
// resolve once; dropped on sign-out, and replaced by a later switch.
|
|
@@ -201,6 +236,11 @@ export class BrassClient {
|
|
|
201
236
|
// id, which is as durable an answer as a name.
|
|
202
237
|
organizationNames = new Map();
|
|
203
238
|
actingOrganizationListeners = new Set();
|
|
239
|
+
// The resolve in flight, so concurrent acts share one answer and the
|
|
240
|
+
// chooser opens once. Keyed by whether the caller may be asked, because a
|
|
241
|
+
// read that answers null where the chooser would open must not settle an
|
|
242
|
+
// act that is waiting for the person, and an act must not take that null.
|
|
243
|
+
actingOrganizationResolve = null;
|
|
204
244
|
// One document's organization, by document id. A document's placement
|
|
205
245
|
// does not move under an open app, so the read behind it runs once per
|
|
206
246
|
// id; the in-flight promise is shared so concurrent acts make one call.
|
|
@@ -213,21 +253,66 @@ export class BrassClient {
|
|
|
213
253
|
// iframe (`isExportMode`): serializes a handed-in snapshot and never
|
|
214
254
|
// authenticates. `registerSerializer` forwards to it.
|
|
215
255
|
exportGuest = null;
|
|
256
|
+
// Set only when Brass loaded this bundle to run its importer headless
|
|
257
|
+
// (`isImportMode`). `registerImporter` forwards to it.
|
|
258
|
+
importGuest = null;
|
|
259
|
+
importer = null;
|
|
260
|
+
// Opens of a file this app imports, waiting for `registerImporter`.
|
|
261
|
+
importerWaiters = [];
|
|
262
|
+
// An open gave up waiting for `registerImporter`, so a registration after
|
|
263
|
+
// it is late and says so once.
|
|
264
|
+
importerWaitExpired = false;
|
|
216
265
|
// Open documents by id, so an export request can hand the serializer
|
|
217
266
|
// the right Document. Dropped when a doc goes; a re-open overwrites.
|
|
218
267
|
openDocuments = new Map();
|
|
219
268
|
// The app's import backend (`Brass.registerImporter`) plus the claim
|
|
220
269
|
// protocol run per opened document. The deps read the documents
|
|
221
270
|
// namespace lazily, so attempts (always post-open) build it on demand.
|
|
271
|
+
// A guest imports only the trial document it started from a file, through
|
|
272
|
+
// that document's edit link, which is the page's credential.
|
|
222
273
|
importRunner = createImportRunner({
|
|
223
|
-
claimImport: (docId) =>
|
|
224
|
-
|
|
274
|
+
claimImport: (docId) => {
|
|
275
|
+
const guest = this.guestImports(docId);
|
|
276
|
+
return guest ? guest.claimImport() : this.docsInternal().claimImport(docId);
|
|
277
|
+
},
|
|
278
|
+
stageImport: (docId, bundle) => {
|
|
279
|
+
const guest = this.guestImports(docId);
|
|
280
|
+
return guest ? guest.stageImport(bundle) : this.docsInternal().stageImport(docId, bundle);
|
|
281
|
+
},
|
|
282
|
+
completeImport: (docId, report, uploadKey) => {
|
|
283
|
+
const guest = this.guestImports(docId);
|
|
284
|
+
return guest
|
|
285
|
+
? guest.completeImport(report, uploadKey)
|
|
286
|
+
: this.docsInternal().completeImport(docId, report, uploadKey);
|
|
287
|
+
},
|
|
288
|
+
failImport: (docId, message) => {
|
|
289
|
+
const guest = this.guestImports(docId);
|
|
290
|
+
return guest ? guest.failImport(message) : this.docsInternal().failImport(docId, message);
|
|
291
|
+
},
|
|
225
292
|
reportError: (error) => this.fireError(error),
|
|
226
293
|
});
|
|
294
|
+
guestImports(docId) {
|
|
295
|
+
const token = this.guestLinkTokenValue;
|
|
296
|
+
return token === null ? null : this.getDocumentFactory().guestImports(token, docId);
|
|
297
|
+
}
|
|
298
|
+
// Whether this page runs a registered importer for an open document: one
|
|
299
|
+
// whose join offers this app the import (`awaits_import`), or whose file is
|
|
300
|
+
// still being stored, which the runner waits out before it claims. Any other
|
|
301
|
+
// document has nothing to import, and the platform refuses a claim on it.
|
|
302
|
+
// A guest imports only into a trial document it started from a file (a trial
|
|
303
|
+
// carries an expiry).
|
|
304
|
+
importsInto(doc) {
|
|
305
|
+
const offered = doc._awaitsImport || doc._workStatusNow?.pending === true;
|
|
306
|
+
if (this.guestLinkTokenValue === null)
|
|
307
|
+
return offered;
|
|
308
|
+
return doc.expiresAt !== null && offered;
|
|
309
|
+
}
|
|
227
310
|
// One socket carrying every document topic this client subscribes,
|
|
228
311
|
// built on first open so an app that never opens a document never
|
|
229
312
|
// connects.
|
|
230
313
|
docWsClient = null;
|
|
314
|
+
// One socket per link a document on this page opened through, by token.
|
|
315
|
+
linkWsClients = new Map();
|
|
231
316
|
// init ALWAYS resolves: failures (bad/missing app_id) fire onError +
|
|
232
317
|
// console.error rather than rejecting. Otherwise
|
|
233
318
|
// `void Brass.init(typo).then(setup)` would never run .then() (init
|
|
@@ -240,6 +325,7 @@ export class BrassClient {
|
|
|
240
325
|
// the click handler that opens a surface, so it would miss the press that
|
|
241
326
|
// opened it and have nothing to hand focus back to on dismissal.
|
|
242
327
|
trackHostedUiActivations();
|
|
328
|
+
this.initFailure = null;
|
|
243
329
|
// The WASM this package runs on is fetched from here, so the download
|
|
244
330
|
// overlaps the auth round-trip below and is usually ready before anything
|
|
245
331
|
// asks for it. It is deliberately NOT awaited here: an app's sign-in
|
|
@@ -310,9 +396,7 @@ export class BrassClient {
|
|
|
310
396
|
this.apiBaseUrlConfigured = urls.apiBaseUrlConfigured;
|
|
311
397
|
this.wsBaseUrl = urls.wsBaseUrl;
|
|
312
398
|
this._dashboardBaseUrl = urls.dashboardBaseUrl;
|
|
313
|
-
|
|
314
|
-
this.openIncompatibleDocuments = true;
|
|
315
|
-
}
|
|
399
|
+
this.openReadOnly = options.open?.readOnly === true;
|
|
316
400
|
if (options.hostedUiTheme) {
|
|
317
401
|
this.hostedUiTheme = options.hostedUiTheme;
|
|
318
402
|
}
|
|
@@ -340,16 +424,65 @@ export class BrassClient {
|
|
|
340
424
|
this.exportGuest.setSerializer(this.serializer);
|
|
341
425
|
return;
|
|
342
426
|
}
|
|
427
|
+
if (typeof window !== 'undefined' && isImportMode(window.location.search)) {
|
|
428
|
+
this.importGuest = startImportGuest();
|
|
429
|
+
if (this.importer)
|
|
430
|
+
this.importGuest.setImporter(this.importer);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
343
433
|
const guestToken = typeof window !== 'undefined'
|
|
344
434
|
? readGuestLinkToken(window.location.search)
|
|
345
435
|
: null;
|
|
346
436
|
if (guestToken) {
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
437
|
+
// Only a sign-in this tab started from this token claims a document
|
|
438
|
+
// created without an account, and only in the browser holding the
|
|
439
|
+
// mint's claim secret. `brass_code` on the URL is not enough: the
|
|
440
|
+
// cookie session resolves whether or not the code is real, so a
|
|
441
|
+
// crafted link carrying one would claim into whoever opens it. A
|
|
442
|
+
// stored hint or session pointer says nothing about whether the
|
|
443
|
+
// person meant to keep THIS document either, so a signed-in browser
|
|
444
|
+
// opening the link cold stays signed in and leaves the document
|
|
445
|
+
// temporary; the strip's Keep is the deliberate act that claims it.
|
|
446
|
+
//
|
|
447
|
+
// Who the person is comes from the session either way. The link is
|
|
448
|
+
// the credential for this document alone: its reads, writes and
|
|
449
|
+
// socket use the link, and a link open carries the person's own
|
|
450
|
+
// credential beside it (`currentLinkCredential`).
|
|
451
|
+
if (signInReturned()) {
|
|
452
|
+
const appId = this.requireAppId();
|
|
453
|
+
const claimSecret = takeClaimIntent(appId) === guestToken
|
|
454
|
+
? readClaimSecret(appId, guestToken)
|
|
455
|
+
: null;
|
|
456
|
+
await this.bootstrap();
|
|
457
|
+
if (this.user !== null) {
|
|
458
|
+
const anonymous = await this.probeAnonymousLink(guestToken);
|
|
459
|
+
const claim = anonymous !== null && claimSecret !== null
|
|
460
|
+
? { ...anonymous, claimSecret }
|
|
461
|
+
: null;
|
|
462
|
+
if (anonymous !== null && claim === null) {
|
|
463
|
+
this.reportUncatchable(new BrassError('Only the browser that started this work can keep it. It stays open here until it expires.'));
|
|
464
|
+
}
|
|
465
|
+
if (claim !== null) {
|
|
466
|
+
if (await this.claimAnonymousLink(claim)) {
|
|
467
|
+
await this.hydrateProfile();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
// Refused, or the person declined to place it: `claimAnonymousLink`
|
|
471
|
+
// has entered guest mode on the token.
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
await this.bootstrap();
|
|
478
|
+
}
|
|
479
|
+
// The token is the document's credential: the session opens the
|
|
480
|
+
// document through it. `authStatus` reports the person (`'guest'`
|
|
481
|
+
// with nobody signed in) and the finally block's initial
|
|
482
|
+
// `onUserChange` emit drives the boot (the session's guest-aware
|
|
483
|
+
// handler calls start()).
|
|
352
484
|
this.guestLinkTokenValue = guestToken;
|
|
485
|
+
await this.hydrateProfile();
|
|
353
486
|
return;
|
|
354
487
|
}
|
|
355
488
|
await this.bootstrap();
|
|
@@ -357,6 +490,11 @@ export class BrassClient {
|
|
|
357
490
|
}
|
|
358
491
|
catch (err) {
|
|
359
492
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
493
|
+
// A failure before `initialized` is the app's configuration (a missing
|
|
494
|
+
// or malformed app id, a hosted bundle without its runtime config), so
|
|
495
|
+
// it holds for the page's life and the session reports it as its error.
|
|
496
|
+
if (!this.initialized)
|
|
497
|
+
this.initFailure = new BrassError(error.message);
|
|
360
498
|
this.reportUncatchable(error);
|
|
361
499
|
// Deliberately do not re-throw: callers using
|
|
362
500
|
// `void Brass.init(...).then(setup)` should see .then() fire so the
|
|
@@ -380,10 +518,28 @@ export class BrassClient {
|
|
|
380
518
|
// mean the person is signed out, and an app that reads it as that
|
|
381
519
|
// renders a signed-out UI or calls `signIn()`, which navigates the
|
|
382
520
|
// frame away from the export it was loaded to run.
|
|
383
|
-
if (this.
|
|
521
|
+
if (!this.isHeadlessGuest()) {
|
|
522
|
+
// Before anything opens: bytes kept on this device for whoever used
|
|
523
|
+
// it last are dropped unless that was this same identity.
|
|
524
|
+
adoptSealedCacheOwner(this.user?.sub ?? null);
|
|
384
525
|
for (const listener of Array.from(this.listeners)) {
|
|
385
526
|
listener(this.user);
|
|
386
527
|
}
|
|
528
|
+
if (this.initialized && !this.watchingPageRestore) {
|
|
529
|
+
this.watchingPageRestore = true;
|
|
530
|
+
onPageRestored(() => void this.reloadIfRestoredStale());
|
|
531
|
+
}
|
|
532
|
+
if (this.user !== null && this.appId && typeof window !== 'undefined') {
|
|
533
|
+
checkManifestDrift({
|
|
534
|
+
appId: this.appId,
|
|
535
|
+
origin: window.location.origin,
|
|
536
|
+
apiFetch: createApiFetch({
|
|
537
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
538
|
+
getAccessToken: () => this.getAccessToken(),
|
|
539
|
+
getAppAssertion: () => this.getAppAssertion(),
|
|
540
|
+
}),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
387
543
|
}
|
|
388
544
|
}
|
|
389
545
|
}
|
|
@@ -397,9 +553,11 @@ export class BrassClient {
|
|
|
397
553
|
// raised over a decision the app did not get wrong. The error channel
|
|
398
554
|
// carries it to the same places, and to the listener the app registered.
|
|
399
555
|
if (!this.initialized || !this.appId) {
|
|
400
|
-
this.reportUncatchable(new Error(this.
|
|
401
|
-
?
|
|
402
|
-
:
|
|
556
|
+
this.reportUncatchable(new Error(this.initFailure
|
|
557
|
+
? this.initFailure.message
|
|
558
|
+
: this.initialized
|
|
559
|
+
? 'Brass.init was called without an app ID'
|
|
560
|
+
: 'Call Brass.init(appId) first'));
|
|
403
561
|
return;
|
|
404
562
|
}
|
|
405
563
|
const appId = this.appId;
|
|
@@ -409,8 +567,10 @@ export class BrassClient {
|
|
|
409
567
|
// An export frame stays where it is. It runs a serializer over bytes
|
|
410
568
|
// Brass handed it and never calls the API, so navigating to sign-in
|
|
411
569
|
// would abandon the export mid-flight for a session it has no use for.
|
|
412
|
-
if (this.
|
|
570
|
+
if (this.isHeadlessGuest())
|
|
413
571
|
return;
|
|
572
|
+
if (this.guestLinkTokenValue !== null)
|
|
573
|
+
markClaimIntent(appId, this.guestLinkTokenValue);
|
|
414
574
|
window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
|
|
415
575
|
}
|
|
416
576
|
// Per-app sign-out. Revokes this app's server-side approval (subsequent
|
|
@@ -460,9 +620,11 @@ export class BrassClient {
|
|
|
460
620
|
// memberships are their own, so both the stored choice and this page
|
|
461
621
|
// load's resolution go with the session.
|
|
462
622
|
clearActingOrganization(appId);
|
|
463
|
-
this.
|
|
464
|
-
this
|
|
465
|
-
|
|
623
|
+
this.retireActingOrganizationState();
|
|
624
|
+
// And the guest documents this browser made, with the secrets that claim
|
|
625
|
+
// them, so the next person here is neither offered one nor able to keep it.
|
|
626
|
+
clearUnsavedWork(appId);
|
|
627
|
+
clearClaimSecret(appId);
|
|
466
628
|
const signedOutUrl = buildSignedOutOfAppUrl(this.authBaseUrl, appId, window.location.href);
|
|
467
629
|
// On a Brass-hosted origin, route the sign-out through the edge's
|
|
468
630
|
// /__brass/leave so the first-party brass_site gate cookie is cleared
|
|
@@ -499,9 +661,9 @@ export class BrassClient {
|
|
|
499
661
|
get authStatus() {
|
|
500
662
|
if (!this.bootstrapped)
|
|
501
663
|
return 'unknown';
|
|
502
|
-
if (this.
|
|
503
|
-
return '
|
|
504
|
-
return this.
|
|
664
|
+
if (this.user !== null)
|
|
665
|
+
return 'signed-in';
|
|
666
|
+
return this.guestLinkTokenValue !== null ? 'guest' : 'signed-out';
|
|
505
667
|
}
|
|
506
668
|
// The organization this app is acting in, as far as this page load already
|
|
507
669
|
// knows. Synchronous, so nothing is resolved to answer it: the open
|
|
@@ -513,7 +675,7 @@ export class BrassClient {
|
|
|
513
675
|
this.launchOrganizationId);
|
|
514
676
|
}
|
|
515
677
|
get isGuest() {
|
|
516
|
-
return this.
|
|
678
|
+
return this.authStatus === 'guest';
|
|
517
679
|
}
|
|
518
680
|
// The resolved dashboard origin after `init` (the option's value if
|
|
519
681
|
// provided; the SDK default otherwise), the origin the Hosted UI iframes
|
|
@@ -530,7 +692,7 @@ export class BrassClient {
|
|
|
530
692
|
// a flicker through the initial `null`. Post-init subscribers get an
|
|
531
693
|
// immediate emit with the current value, the usual "register and
|
|
532
694
|
// read current state" idiom.
|
|
533
|
-
if (this.bootstrapped && this.
|
|
695
|
+
if (this.bootstrapped && !this.isHeadlessGuest()) {
|
|
534
696
|
listener(this.user);
|
|
535
697
|
}
|
|
536
698
|
return () => {
|
|
@@ -564,6 +726,7 @@ export class BrassClient {
|
|
|
564
726
|
apiBaseUrl: this.apiBaseUrl,
|
|
565
727
|
getAccessToken: () => this.getAccessToken(),
|
|
566
728
|
getAppAssertion: () => this.getAppAssertion(),
|
|
729
|
+
getSocket: () => this.appId === null ? undefined : this.getDocWsClient(this.appId),
|
|
567
730
|
});
|
|
568
731
|
}
|
|
569
732
|
return this.documentsNamespace;
|
|
@@ -577,10 +740,33 @@ export class BrassClient {
|
|
|
577
740
|
this.documentSession = createDocumentSession({
|
|
578
741
|
isSignedIn: () => this.user !== null,
|
|
579
742
|
authStatus: () => this.authStatus,
|
|
580
|
-
|
|
581
|
-
|
|
743
|
+
initFailure: () => this.initFailure,
|
|
744
|
+
onUserChange: (listener) => {
|
|
745
|
+
const wrapped = (user) => listener(user !== null);
|
|
746
|
+
this.sessionUserListeners.add(wrapped);
|
|
747
|
+
return this.onUserChange(wrapped);
|
|
748
|
+
},
|
|
749
|
+
openDocumentById: (docId, options, consent) => this.asOwnAccess(() => this.openDocumentInternal(docId, options, consent)),
|
|
582
750
|
guestLinkToken: () => this.guestLinkTokenValue,
|
|
751
|
+
openReadOnly: () => this.openReadOnly,
|
|
583
752
|
openDocumentByLink: (token, options) => this.openByLinkInternal(token, options),
|
|
753
|
+
mintAnonymousDocument: (args) => this.getDocumentFactory().mintAnonymousDocument(args),
|
|
754
|
+
enterGuestMode: (token) => this.enterGuestMode(token),
|
|
755
|
+
chooseAnonymousFile: () => this.chooseAnonymousFile(),
|
|
756
|
+
signedOutUseRefusal: () => this.signedOutUseRefusal(),
|
|
757
|
+
appOffersNew: () => this.appOffersNew(),
|
|
758
|
+
mintAnonymousDocumentFromFile: (args) => this.getDocumentFactory().mintAnonymousDocumentFromFile(args),
|
|
759
|
+
unsavedWork: () => this.readLiveUnsavedWork(),
|
|
760
|
+
clearUnsavedWork: () => {
|
|
761
|
+
if (this.appId !== null)
|
|
762
|
+
clearUnsavedWork(this.appId);
|
|
763
|
+
},
|
|
764
|
+
leaveGuestMode: () => this.leaveGuestMode(),
|
|
765
|
+
followClaimedLink: (token) => this.followClaimedLink(token),
|
|
766
|
+
markTrialEdited: (token, expiresAt) => markTrialEdited(token, expiresAt),
|
|
767
|
+
trialEdited: (token) => trialWasEdited(token),
|
|
768
|
+
confirmReplaceTrial: (expiresAt) => askReplaceTrial(expiresAt),
|
|
769
|
+
signIn: () => this.signIn(),
|
|
584
770
|
createDocumentId: (args) => this.createDocumentIdWithConsent(args),
|
|
585
771
|
selectDocument: () => this.selectDocumentInternal(),
|
|
586
772
|
selectCreateDestination: (options) => this.getHostedUiLauncher().selectCreateDestination(options),
|
|
@@ -671,7 +857,15 @@ export class BrassClient {
|
|
|
671
857
|
onUserChange: (listener) => this.onUserChange(listener),
|
|
672
858
|
signIn: () => this.signIn(),
|
|
673
859
|
signOut: () => this.signOut(),
|
|
674
|
-
|
|
860
|
+
canKeep: () => this.canKeepTemporaryWork(),
|
|
861
|
+
keep: () => {
|
|
862
|
+
void this.keepTemporaryWork();
|
|
863
|
+
},
|
|
864
|
+
// Both environment-dependent values are read at use rather than copied
|
|
865
|
+
// in here: a piece can be mounted before `init()` resolves, and until it
|
|
866
|
+
// does these hold the production defaults. See `AccountMenuDeps`.
|
|
867
|
+
dashboardBaseUrl: () => this._dashboardBaseUrl,
|
|
868
|
+
appId: () => this.appId ?? undefined,
|
|
675
869
|
getActingOrganization: () => this.visibleActingOrganization(),
|
|
676
870
|
subscribeActingOrganization: (listener) => {
|
|
677
871
|
this.actingOrganizationListeners.add(listener);
|
|
@@ -688,9 +882,10 @@ export class BrassClient {
|
|
|
688
882
|
void this.switchActingOrganization();
|
|
689
883
|
},
|
|
690
884
|
getDocument: (docId) => this.documents.get(docId),
|
|
885
|
+
onLink: () => this.guestLinkTokenValue !== null,
|
|
691
886
|
// The version-preview bar is a hosted embed; it takes the same theme
|
|
692
887
|
// as the other hosted surfaces.
|
|
693
|
-
hostedUiTheme: this.hostedUiTheme,
|
|
888
|
+
hostedUiTheme: () => this.hostedUiTheme,
|
|
694
889
|
};
|
|
695
890
|
}
|
|
696
891
|
// Opens the hosted document picker in a modal iframe and resolves with
|
|
@@ -717,6 +912,25 @@ export class BrassClient {
|
|
|
717
912
|
storeLastDocument(appId, result);
|
|
718
913
|
return result;
|
|
719
914
|
}
|
|
915
|
+
// Opens the hosted picker for a SET of documents and resolves with every
|
|
916
|
+
// one the person chose: `[]` when they took out everything `selected`
|
|
917
|
+
// held, `null` when they dismissed it. The set is confined
|
|
918
|
+
// to one organization. `options.accept` narrows what the picker offers to
|
|
919
|
+
// this request alone, which is how an app that has read a file names the
|
|
920
|
+
// siblings it is missing; `options.prompt` says why it is asking; and
|
|
921
|
+
// `options.selected` names what the app already holds, so a later call
|
|
922
|
+
// edits that set and resolves with the whole of it.
|
|
923
|
+
//
|
|
924
|
+
// Opening is unchanged: pass each ref's `doc_id` to `openDocumentById`. The
|
|
925
|
+
// URL carries one document, so a set is the app's to hold; nothing here
|
|
926
|
+
// writes to it. Throws before init.
|
|
927
|
+
async chooseInputs(families, options) {
|
|
928
|
+
return this.getHostedUiLauncher().chooseInputs(families, options);
|
|
929
|
+
}
|
|
930
|
+
// One file as a reference, from the ordinary Open list. Throws before init.
|
|
931
|
+
async chooseInput(families, options) {
|
|
932
|
+
return this.getHostedUiLauncher().chooseInput(families, options);
|
|
933
|
+
}
|
|
720
934
|
// The value the most recent `selectDocument()` resolved with
|
|
721
935
|
// (per-app sessionStorage), or `null` if nothing was picked this
|
|
722
936
|
// tab. The session's boot restore reads it as the bare-URL
|
|
@@ -800,7 +1014,12 @@ export class BrassClient {
|
|
|
800
1014
|
// observable via `Brass.onError` if the caller wants to surface
|
|
801
1015
|
// "live editing unavailable" in the UI.
|
|
802
1016
|
async openDocumentById(docId, options) {
|
|
803
|
-
|
|
1017
|
+
// A frozen copy is read beside whatever the page shows, usually as one of
|
|
1018
|
+
// many, so it claims no URL and raises no consent dialog of its own.
|
|
1019
|
+
if (options?.frozen === true) {
|
|
1020
|
+
return this.reopensBeside(await this.openDocumentInternal(docId, options, 'skip', true), options);
|
|
1021
|
+
}
|
|
1022
|
+
const opened = this.reopensBeside(await this.openDocumentInternal(docId, options), options);
|
|
804
1023
|
// The URL names the document the reader is looking at, and the session
|
|
805
1024
|
// owns that whenever it holds one: this open is detached, so claiming the
|
|
806
1025
|
// URL would leave the address bar naming a document the session is not
|
|
@@ -811,36 +1030,203 @@ export class BrassClient {
|
|
|
811
1030
|
this.maybeWriteDocIdToUrl(opened.id);
|
|
812
1031
|
return opened;
|
|
813
1032
|
}
|
|
1033
|
+
// `doc.reopen` for a copy this method opened: the same open in the asked
|
|
1034
|
+
// mode, on the branch the copy is on, after which this copy closes.
|
|
1035
|
+
reopensBeside(doc, options) {
|
|
1036
|
+
doc._setReopener(async (mode) => {
|
|
1037
|
+
const { readOnly: _readOnly, frozen: _frozen, version: _version, ...rest } = options ?? {};
|
|
1038
|
+
const { readOnly: _alias, readOnlyReason: _reason, ...documentOptions } = rest.documentOptions ?? {};
|
|
1039
|
+
// A copy opened at a stream carries the stream's line as `branchId`,
|
|
1040
|
+
// which the join refuses as a branch, so it reopens on the branch the
|
|
1041
|
+
// open named.
|
|
1042
|
+
const branch = rest.stream !== undefined ? rest.branch : (doc.branchId ?? undefined);
|
|
1043
|
+
const next = await this.openDocumentById(doc.id, {
|
|
1044
|
+
...rest,
|
|
1045
|
+
documentOptions,
|
|
1046
|
+
...(branch !== undefined ? { branch } : {}),
|
|
1047
|
+
...(mode === 'frozen' ? { frozen: true } : { readOnly: mode === 'read' }),
|
|
1048
|
+
});
|
|
1049
|
+
doc.close();
|
|
1050
|
+
return next;
|
|
1051
|
+
});
|
|
1052
|
+
return doc;
|
|
1053
|
+
}
|
|
814
1054
|
// Open a document READ-ONLY from a public-link token (the "anyone with
|
|
815
1055
|
// the link" anonymous path). No sign-in: the token is the credential and
|
|
816
1056
|
// the server serves a read-only, member-stripped snapshot with no live
|
|
817
1057
|
// subscription. `Brass.session` drives this from the URL on boot when the
|
|
818
1058
|
// page carries `?brass_link=`.
|
|
819
1059
|
async openByLinkInternal(token, options) {
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
this.
|
|
1060
|
+
const opened = await this.importedByLink(await this.joinByLink(token, options), token, options);
|
|
1061
|
+
// A document the platform will remove was created without an account,
|
|
1062
|
+
// and its token is the only way back to it once this tab is gone. Every
|
|
1063
|
+
// guest open of one lands here (a create while signed out, the boot of
|
|
1064
|
+
// its URL, a shared link to one), so this is where the app's signed-out
|
|
1065
|
+
// gate learns of it (`session.unsavedWork`).
|
|
1066
|
+
// A token this browser has claimed is no longer unsaved work, whatever
|
|
1067
|
+
// the open answered: a page the browser kept from before the claim can
|
|
1068
|
+
// still open through it.
|
|
1069
|
+
const appId = this.requireAppId();
|
|
1070
|
+
if (opened.expiresAt !== null && readClaimedLink(appId, token) === null) {
|
|
1071
|
+
storeUnsavedWork(appId, token, opened.expiresAt);
|
|
1072
|
+
}
|
|
1073
|
+
return opened;
|
|
1074
|
+
}
|
|
1075
|
+
async joinByLink(token, options) {
|
|
1076
|
+
const opened = await this.getDocumentFactory().openByLink(token, options);
|
|
1077
|
+
// A link the person opened as themselves was tracked by that open.
|
|
1078
|
+
if (this.openDocuments.get(opened.id) !== opened)
|
|
1079
|
+
this.trackOpenDocument(opened);
|
|
823
1080
|
return opened;
|
|
824
1081
|
}
|
|
1082
|
+
// A trial document started from a file is imported by this page before the
|
|
1083
|
+
// open resolves, as `openDocumentInternal` imports a member's: an open whose
|
|
1084
|
+
// import does not land rejects rather than resolving the empty document.
|
|
1085
|
+
async importedByLink(opened, token, options) {
|
|
1086
|
+
if (!opened._awaitsImport || !this.importsInto(opened))
|
|
1087
|
+
return opened;
|
|
1088
|
+
if (await this.importBeforeResolving(opened, options?.frozen === true, options?.onSnapshotProgress)) {
|
|
1089
|
+
return opened;
|
|
1090
|
+
}
|
|
1091
|
+
const imported = await this.joinByLink(token, options);
|
|
1092
|
+
if (!imported._awaitsImport)
|
|
1093
|
+
return imported;
|
|
1094
|
+
imported._reopen();
|
|
1095
|
+
imported.close();
|
|
1096
|
+
throw notImported(opened);
|
|
1097
|
+
}
|
|
825
1098
|
// The open path shared by the public `openDocumentById` and the
|
|
826
1099
|
// session. It does NOT write the URL: the public method pins the id
|
|
827
1100
|
// (`replaceState`) for direct multi-doc callers, while the session
|
|
828
1101
|
// owns its own push / replace history writes so a single-doc open
|
|
829
1102
|
// becomes a back-navigable entry without the two layers double-writing.
|
|
830
|
-
|
|
1103
|
+
//
|
|
1104
|
+
// A file this app imports and nobody has imported yet is imported before the
|
|
1105
|
+
// open resolves, in every mode. The platform refuses the open of a file only
|
|
1106
|
+
// another app imports (`not_imported`), so a join that still awaits an
|
|
1107
|
+
// import names this app as the importer.
|
|
1108
|
+
async openDocumentInternal(docId, options, consent = 'offer',
|
|
1109
|
+
// A copy read beside whatever the page shows (one of a set a pass reads,
|
|
1110
|
+
// or a frozen copy) is not the session's copy of its id, so it is not
|
|
1111
|
+
// tracked as the open copy of that id.
|
|
1112
|
+
beside = false) {
|
|
1113
|
+
const opened = await this.joinDocument(docId, options, consent, beside);
|
|
1114
|
+
if (!opened._awaitsImport || !this.importsInto(opened))
|
|
1115
|
+
return opened;
|
|
1116
|
+
if (await this.importBeforeResolving(opened, options?.frozen === true, options?.onSnapshotProgress)) {
|
|
1117
|
+
return opened;
|
|
1118
|
+
}
|
|
1119
|
+
const imported = await this.joinDocument(docId, options, 'skip', beside);
|
|
1120
|
+
if (!imported._awaitsImport)
|
|
1121
|
+
return imported;
|
|
1122
|
+
// The import ended without landing (the claim was refused, or this client
|
|
1123
|
+
// stopped waiting on another window's), which the app's error handler has
|
|
1124
|
+
// already been told.
|
|
1125
|
+
imported._reopen();
|
|
1126
|
+
imported.close();
|
|
1127
|
+
throw notImported(opened);
|
|
1128
|
+
}
|
|
1129
|
+
// Runs this app's importer on a document opened before its import, and
|
|
1130
|
+
// resolves once the import has ended: true when the copy now holds the
|
|
1131
|
+
// import (a frozen copy, reloaded in place), false when the open joins again
|
|
1132
|
+
// for it. Rejects with the import's error when it failed.
|
|
1133
|
+
async importBeforeResolving(doc, frozen, progress) {
|
|
1134
|
+
// An open that ran before the app registered its importer waits for it.
|
|
1135
|
+
if (this.importer === null && !(await this.waitForImporter(doc))) {
|
|
1136
|
+
if (doc._isLive) {
|
|
1137
|
+
doc._reopen();
|
|
1138
|
+
doc.close();
|
|
1139
|
+
}
|
|
1140
|
+
// The message reaches the app's users, so the cause goes to the console.
|
|
1141
|
+
console.warn(`[brass] An open failed with not_imported: this app's manifest declares an importer for the file and Brass.registerImporter was not called within ${IMPORTER_WAIT_CEILING_MS / 1000}s.`);
|
|
1142
|
+
const refused = new BrassError(NOT_IMPORTED_MESSAGE, { status: 0, code: 'not_imported' });
|
|
1143
|
+
importerWaitRejections.add(refused);
|
|
1144
|
+
throw refused;
|
|
1145
|
+
}
|
|
1146
|
+
// A tracked copy had its attempt when it was tracked or when the importer
|
|
1147
|
+
// was registered, so a call for the same open is a no-op. A frozen copy
|
|
1148
|
+
// takes the import under the same handles.
|
|
1149
|
+
void this.importRunner.attempt(doc, frozen ? { reloadInPlace: true } : undefined);
|
|
1150
|
+
await new Promise((resolve) => {
|
|
1151
|
+
const stops = [];
|
|
1152
|
+
let settled = false;
|
|
1153
|
+
const done = () => {
|
|
1154
|
+
if (settled)
|
|
1155
|
+
return;
|
|
1156
|
+
settled = true;
|
|
1157
|
+
queueMicrotask(() => stops.forEach((stop) => stop()));
|
|
1158
|
+
resolve();
|
|
1159
|
+
};
|
|
1160
|
+
stops.push(doc.onGone(done));
|
|
1161
|
+
stops.push(doc.onStatus((status) => {
|
|
1162
|
+
// A settled status from the work that stored the file starts the
|
|
1163
|
+
// import, whose hold is set before this listener hears that status.
|
|
1164
|
+
if (!status.pending && doc._importHold === null)
|
|
1165
|
+
done();
|
|
1166
|
+
// The import's own progress is what the opener shows while it waits.
|
|
1167
|
+
else
|
|
1168
|
+
progress?.(0, null, 'importing', status.message);
|
|
1169
|
+
}));
|
|
1170
|
+
});
|
|
1171
|
+
if (doc._isLive && doc._contentLoadFailed) {
|
|
1172
|
+
const message = doc._workStatusNow?.message;
|
|
1173
|
+
doc._reopen();
|
|
1174
|
+
doc.close();
|
|
1175
|
+
throw new BrassError(message !== undefined && message !== '' ? message : IMPORT_FAILED_MESSAGE, {
|
|
1176
|
+
status: 0,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
if (frozen && doc._isLive && doc._goneReason === null) {
|
|
1180
|
+
// A reload the importer started is shared.
|
|
1181
|
+
await doc.reload();
|
|
1182
|
+
if (doc._isLive)
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
// Retired as moved rather than parked, so the join that follows builds a
|
|
1186
|
+
// copy from the imported content instead of taking this empty one back.
|
|
1187
|
+
if (doc._isLive) {
|
|
1188
|
+
doc._reopen();
|
|
1189
|
+
doc.close();
|
|
1190
|
+
}
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
// Resolves true when `registerImporter` runs, false at the ceiling.
|
|
1194
|
+
waitForImporter(doc) {
|
|
1195
|
+
return new Promise((resolve) => {
|
|
1196
|
+
const warning = inDevelopment()
|
|
1197
|
+
? setTimeout(() => {
|
|
1198
|
+
const dot = doc.name?.lastIndexOf('.') ?? -1;
|
|
1199
|
+
const type = dot > 0 ? `${doc.name.slice(dot)} files` : 'this file';
|
|
1200
|
+
console.warn(`[brass] An open is waiting for an importer: this app's manifest declares one for ${type}, and Brass.registerImporter has not been called. The open fails with not_imported after ${IMPORTER_WAIT_CEILING_MS / 1000}s.`);
|
|
1201
|
+
}, IMPORTER_WAIT_WARN_MS)
|
|
1202
|
+
: undefined;
|
|
1203
|
+
const settle = (registered) => {
|
|
1204
|
+
clearTimeout(warning);
|
|
1205
|
+
clearTimeout(ceiling);
|
|
1206
|
+
const at = this.importerWaiters.indexOf(waiter);
|
|
1207
|
+
if (at !== -1)
|
|
1208
|
+
this.importerWaiters.splice(at, 1);
|
|
1209
|
+
resolve(registered);
|
|
1210
|
+
};
|
|
1211
|
+
const waiter = () => settle(true);
|
|
1212
|
+
const ceiling = setTimeout(() => {
|
|
1213
|
+
this.importerWaitExpired = true;
|
|
1214
|
+
settle(false);
|
|
1215
|
+
}, IMPORTER_WAIT_CEILING_MS);
|
|
1216
|
+
this.importerWaiters.push(waiter);
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
async joinDocument(docId, options, consent, beside) {
|
|
831
1220
|
const factory = this.getDocumentFactory();
|
|
832
1221
|
// After getDocumentFactory() resolves, requireAppId() has run and
|
|
833
1222
|
// this.appId is a string.
|
|
834
1223
|
const appId = this.appId;
|
|
835
|
-
// An app that declared it opens these carries the answer on every open,
|
|
836
|
-
// so nothing is asked and nothing is refused.
|
|
837
|
-
const withConsent = this
|
|
838
|
-
.openIncompatibleDocuments
|
|
839
|
-
? { ...options, acceptSchemaConflict: true }
|
|
840
|
-
: options;
|
|
841
1224
|
try {
|
|
842
|
-
const opened = await factory.open(docId,
|
|
843
|
-
|
|
1225
|
+
const opened = await factory.open(docId, options);
|
|
1226
|
+
// The tracked set is the live copy of each document, keyed by id; a
|
|
1227
|
+
// copy read beside it must not replace it there.
|
|
1228
|
+
if (!beside)
|
|
1229
|
+
this.trackOpenDocument(opened);
|
|
844
1230
|
return opened;
|
|
845
1231
|
}
|
|
846
1232
|
catch (err) {
|
|
@@ -854,10 +1240,11 @@ export class BrassClient {
|
|
|
854
1240
|
if (!openAnyway)
|
|
855
1241
|
throw err;
|
|
856
1242
|
const opened = await factory.open(docId, {
|
|
857
|
-
...
|
|
1243
|
+
...options,
|
|
858
1244
|
acceptSchemaConflict: true,
|
|
859
1245
|
});
|
|
860
|
-
|
|
1246
|
+
if (!beside)
|
|
1247
|
+
this.trackOpenDocument(opened);
|
|
861
1248
|
return opened;
|
|
862
1249
|
}
|
|
863
1250
|
// A denial because the document's org has no install of this app
|
|
@@ -870,7 +1257,8 @@ export class BrassClient {
|
|
|
870
1257
|
// before they touch anything.
|
|
871
1258
|
if (consent === 'offer' && (await this.offerInstallConsent(err))) {
|
|
872
1259
|
const opened = await factory.open(docId, options);
|
|
873
|
-
|
|
1260
|
+
if (!beside)
|
|
1261
|
+
this.trackOpenDocument(opened);
|
|
874
1262
|
return opened;
|
|
875
1263
|
}
|
|
876
1264
|
if (isAppNotInstalled(err))
|
|
@@ -900,26 +1288,40 @@ export class BrassClient {
|
|
|
900
1288
|
}
|
|
901
1289
|
async createDocument(options) {
|
|
902
1290
|
const opened = await this.createDocumentInternal(options);
|
|
903
|
-
|
|
1291
|
+
// A document minted without an account is reached through the link
|
|
1292
|
+
// token the URL already carries, never through its id.
|
|
1293
|
+
if (this.guestLinkTokenValue === null)
|
|
1294
|
+
this.maybeWriteDocIdToUrl(opened.id);
|
|
904
1295
|
return opened;
|
|
905
1296
|
}
|
|
906
1297
|
// See `openDocumentInternal`: the create path the session uses, which
|
|
907
1298
|
// leaves the URL to the session's own history writes.
|
|
908
1299
|
async createDocumentInternal(options) {
|
|
909
1300
|
const factory = this.getDocumentFactory();
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1301
|
+
// No account: mint the document without one and open it through its
|
|
1302
|
+
// link token, which becomes this page's credential.
|
|
1303
|
+
if (this.authStatus === 'signed-out') {
|
|
1304
|
+
const minted = await factory.mintAnonymousDocument(options?.name !== undefined ? { name: options.name } : {});
|
|
1305
|
+
this.enterGuestMode(minted.linkToken);
|
|
1306
|
+
return this.openByLinkInternal(minted.linkToken, options?.documentOptions
|
|
1307
|
+
? { documentOptions: options.documentOptions }
|
|
1308
|
+
: undefined);
|
|
914
1309
|
}
|
|
915
|
-
|
|
916
|
-
|
|
1310
|
+
return this.asOwnAccess(async () => {
|
|
1311
|
+
try {
|
|
917
1312
|
const opened = await factory.create(options);
|
|
918
1313
|
this.trackOpenDocument(opened);
|
|
919
1314
|
return opened;
|
|
920
1315
|
}
|
|
921
|
-
|
|
922
|
-
|
|
1316
|
+
catch (err) {
|
|
1317
|
+
if (await this.offerInstallConsent(err)) {
|
|
1318
|
+
const opened = await factory.create(options);
|
|
1319
|
+
this.trackOpenDocument(opened);
|
|
1320
|
+
return opened;
|
|
1321
|
+
}
|
|
1322
|
+
throw err;
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
923
1325
|
}
|
|
924
1326
|
// Create-only for the session: classify the outcome (created / needs a
|
|
925
1327
|
// destination / needs an organization) so the session drives the picker /
|
|
@@ -938,16 +1340,370 @@ export class BrassClient {
|
|
|
938
1340
|
throw err;
|
|
939
1341
|
}
|
|
940
1342
|
}
|
|
1343
|
+
// The claim's install-consent retry, the same reactive install
|
|
1344
|
+
// `createDocumentIdWithConsent` gives a create.
|
|
1345
|
+
async claimAnonymousDocumentWithConsent(docId, args) {
|
|
1346
|
+
const factory = this.getDocumentFactory();
|
|
1347
|
+
try {
|
|
1348
|
+
return await factory.claimAnonymousDocument(docId, args);
|
|
1349
|
+
}
|
|
1350
|
+
catch (err) {
|
|
1351
|
+
if (await this.offerInstallConsent(err)) {
|
|
1352
|
+
return factory.claimAnonymousDocument(docId, args);
|
|
1353
|
+
}
|
|
1354
|
+
throw err;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// The app's upload-types read, once per page. A failed read is dropped so
|
|
1358
|
+
// the next asks again.
|
|
1359
|
+
anonymousUploadTypes() {
|
|
1360
|
+
if (this.anonymousUploadTypesRead === null) {
|
|
1361
|
+
const read = this.getDocumentFactory().anonymousUploadTypes();
|
|
1362
|
+
this.anonymousUploadTypesRead = read;
|
|
1363
|
+
read.catch(() => {
|
|
1364
|
+
if (this.anonymousUploadTypesRead === read)
|
|
1365
|
+
this.anonymousUploadTypesRead = null;
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
return this.anonymousUploadTypesRead;
|
|
1369
|
+
}
|
|
1370
|
+
anonymousUploadTypesRead = null;
|
|
1371
|
+
// Whether this app offers New, from its row on the platform. A failed read
|
|
1372
|
+
// offers New, and the create reports at the press.
|
|
1373
|
+
async appOffersNew() {
|
|
1374
|
+
try {
|
|
1375
|
+
return (await this.anonymousUploadTypes()).offersNew;
|
|
1376
|
+
}
|
|
1377
|
+
catch {
|
|
1378
|
+
return true;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
// The file types a visitor can start a document of this app from.
|
|
1382
|
+
async anonymousUploadAccept() {
|
|
1383
|
+
const read = await this.anonymousUploadTypes();
|
|
1384
|
+
if ('refusal' in read)
|
|
1385
|
+
throw read.refusal;
|
|
1386
|
+
return read.accept;
|
|
1387
|
+
}
|
|
1388
|
+
// The refusal this app meets without an account, read off the same
|
|
1389
|
+
// upload-types read a signed-out pick makes, so a later Upload reuses it.
|
|
1390
|
+
// Only the `sign_in_required` answer withdraws New and Open; any
|
|
1391
|
+
// other failure leaves them to report at the press.
|
|
1392
|
+
async signedOutUseRefusal() {
|
|
1393
|
+
try {
|
|
1394
|
+
await this.anonymousUploadAccept();
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
return err instanceof BrassError && err.code === 'sign_in_required' ? err : null;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// The file a signed-out pick starts a document from. An app that can start
|
|
1402
|
+
// from no file type refuses rather than opening a dialog every file in
|
|
1403
|
+
// which would be refused. The types are read at the press, inside the
|
|
1404
|
+
// several seconds a browser lets that press open a file dialog.
|
|
1405
|
+
async chooseAnonymousFile() {
|
|
1406
|
+
const { accept, maxBytes } = await this.anonymousUploadAccept();
|
|
1407
|
+
if (accept === '') {
|
|
1408
|
+
throw new BrassError('This app cannot open a file without an account. Sign in to open one.', {
|
|
1409
|
+
status: 400,
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
const file = await chooseFile(accept);
|
|
1413
|
+
if (file !== null && file.size > maxBytes) {
|
|
1414
|
+
const mb = Math.floor(maxBytes / (1024 * 1024));
|
|
1415
|
+
throw new BrassError(`That file is too large to open without an account (limit ${mb} MB). Sign in to import it.`, { status: 400 });
|
|
1416
|
+
}
|
|
1417
|
+
return file;
|
|
1418
|
+
}
|
|
1419
|
+
// Make a link token the open document's credential, with the URL carrying
|
|
1420
|
+
// it so a reload and a sign-in both return to the document. With nobody
|
|
1421
|
+
// signed in `authStatus` reads `'guest'`; a signed-in person who did not
|
|
1422
|
+
// keep the document stays signed in.
|
|
1423
|
+
enterGuestMode(token) {
|
|
1424
|
+
const credentialChanged = this.guestLinkTokenValue !== token;
|
|
1425
|
+
this.guestLinkTokenValue = token;
|
|
1426
|
+
writeGuestLinkToken(token);
|
|
1427
|
+
// A guest socket is admitted to the one document its link names, so a
|
|
1428
|
+
// socket opened under another credential cannot carry this one.
|
|
1429
|
+
if (credentialChanged)
|
|
1430
|
+
this.docWsClient?.reconnect();
|
|
1431
|
+
this.emitGuestModeChange();
|
|
1432
|
+
}
|
|
1433
|
+
// `authStatus` moved between 'signed-out' (or 'signed-in') and 'guest'
|
|
1434
|
+
// without a sign-in or sign-out, which is still an auth change to an app
|
|
1435
|
+
// that renders its shells off `onUserChange`.
|
|
1436
|
+
emitGuestModeChange() {
|
|
1437
|
+
// With someone signed in, a link coming or going leaves `authStatus` as it
|
|
1438
|
+
// was, so there is nothing to announce.
|
|
1439
|
+
if (!this.bootstrapped || this.isHeadlessGuest() || this.user !== null)
|
|
1440
|
+
return;
|
|
1441
|
+
for (const listener of Array.from(this.listeners)) {
|
|
1442
|
+
if (this.sessionUserListeners.has(listener))
|
|
1443
|
+
continue;
|
|
1444
|
+
listener(this.user);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
// Take a token back out of the page's credential and URL after it failed
|
|
1448
|
+
// to open, so `authStatus` reads `'signed-out'` again. Only the session's
|
|
1449
|
+
// continue path reaches this, from a page that held no session.
|
|
1450
|
+
leaveGuestMode() {
|
|
1451
|
+
this.guestLinkTokenValue = null;
|
|
1452
|
+
clearGuestLinkToken();
|
|
1453
|
+
this.emitGuestModeChange();
|
|
1454
|
+
}
|
|
1455
|
+
// Where a refused link leads this person, when their own claim revoked it.
|
|
1456
|
+
// For the claimant the page leaves the link signed in, with the document's
|
|
1457
|
+
// id on the URL. A page with no session, in the browser that made the
|
|
1458
|
+
// claim, leaves the link signed out with the id on the URL, so the sign-in
|
|
1459
|
+
// the app offers returns to the document. Anyone else keeps the refusal.
|
|
1460
|
+
async followClaimedLink(token) {
|
|
1461
|
+
if (this.guestLinkTokenValue !== token)
|
|
1462
|
+
return null;
|
|
1463
|
+
const tokens = this.tokens;
|
|
1464
|
+
if (this.user === null || tokens === null) {
|
|
1465
|
+
const claimedHere = this.appId === null ? null : readClaimedLink(this.appId, token);
|
|
1466
|
+
if (claimedHere === null)
|
|
1467
|
+
return null;
|
|
1468
|
+
this.forgetUnsavedWorkFor(token);
|
|
1469
|
+
this.leaveClaimedLink(claimedHere);
|
|
1470
|
+
this.emitGuestModeChange();
|
|
1471
|
+
return 'sign-in';
|
|
1472
|
+
}
|
|
1473
|
+
const docId = await this.readClaimedLink(token, tokens).catch(() => null);
|
|
1474
|
+
if (docId === null || this.guestLinkTokenValue !== token)
|
|
1475
|
+
return null;
|
|
1476
|
+
if (this.appId !== null)
|
|
1477
|
+
storeClaimedLink(this.appId, token, docId);
|
|
1478
|
+
this.forgetUnsavedWorkFor(token);
|
|
1479
|
+
this.leaveClaimedLink(docId);
|
|
1480
|
+
await this.hydrateProfile();
|
|
1481
|
+
this.docWsClient?.reconnect();
|
|
1482
|
+
this.emitGuestModeChange();
|
|
1483
|
+
return { docId };
|
|
1484
|
+
}
|
|
1485
|
+
// The way back to unsaved work, unless its token is one this browser has
|
|
1486
|
+
// claimed: that work has an owner and a document id, and a page that opened
|
|
1487
|
+
// the token from before the claim may have written it back.
|
|
1488
|
+
readLiveUnsavedWork() {
|
|
1489
|
+
if (this.appId === null)
|
|
1490
|
+
return null;
|
|
1491
|
+
const entry = readUnsavedWork(this.appId);
|
|
1492
|
+
if (entry === null || readClaimedLink(this.appId, entry.token) === null)
|
|
1493
|
+
return entry;
|
|
1494
|
+
clearUnsavedWork(this.appId);
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
forgetUnsavedWorkFor(token) {
|
|
1498
|
+
if (this.appId !== null && readUnsavedWork(this.appId)?.token === token) {
|
|
1499
|
+
clearUnsavedWork(this.appId);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
leaveClaimedLink(docId) {
|
|
1503
|
+
this.guestLinkTokenValue = null;
|
|
1504
|
+
clearGuestLinkToken();
|
|
1505
|
+
this.maybeWriteDocIdToUrl(docId);
|
|
1506
|
+
}
|
|
1507
|
+
async readClaimedLink(token, tokens) {
|
|
1508
|
+
const headers = {
|
|
1509
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
1510
|
+
};
|
|
1511
|
+
applyClientVersionHeader(headers);
|
|
1512
|
+
if (tokens.appAssertion)
|
|
1513
|
+
headers['x-brass-app-assertion'] = tokens.appAssertion;
|
|
1514
|
+
const response = await fetch(`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/claimed`, { method: 'GET', headers });
|
|
1515
|
+
if (!response.ok)
|
|
1516
|
+
return null;
|
|
1517
|
+
const body = (await response.json());
|
|
1518
|
+
return typeof body.doc_id === 'string' && body.doc_id !== '' ? body.doc_id : null;
|
|
1519
|
+
}
|
|
1520
|
+
// A page back from the back-forward cache, checked against what may have
|
|
1521
|
+
// moved while it was frozen; see `page-restore.ts`.
|
|
1522
|
+
async reloadIfRestoredStale() {
|
|
1523
|
+
const appId = this.appId;
|
|
1524
|
+
if (appId === null)
|
|
1525
|
+
return;
|
|
1526
|
+
const stale = await restoredPageIsStale({
|
|
1527
|
+
guestLinkToken: this.guestLinkTokenValue,
|
|
1528
|
+
signedIn: this.user !== null,
|
|
1529
|
+
sessionHinted: readLoginHint(appId),
|
|
1530
|
+
}, (token) => this.linkStillOpen(token));
|
|
1531
|
+
if (stale)
|
|
1532
|
+
window.location.reload();
|
|
1533
|
+
}
|
|
1534
|
+
async linkStillOpen(token) {
|
|
1535
|
+
try {
|
|
1536
|
+
const response = await fetch(`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/view`, { method: 'GET', cache: 'no-store' });
|
|
1537
|
+
if (response.ok)
|
|
1538
|
+
return true;
|
|
1539
|
+
return response.status === 403 || response.status === 404 || response.status === 410
|
|
1540
|
+
? false
|
|
1541
|
+
: null;
|
|
1542
|
+
}
|
|
1543
|
+
catch {
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// Whether the signed-in person can keep the temporary document this page is
|
|
1548
|
+
// on: only the browser that made it holds the secret its claim takes.
|
|
1549
|
+
canKeepTemporaryWork() {
|
|
1550
|
+
const token = this.guestLinkTokenValue;
|
|
1551
|
+
if (token === null || this.user === null || this.appId === null)
|
|
1552
|
+
return false;
|
|
1553
|
+
if (this.session.document?.expiresAt == null)
|
|
1554
|
+
return false;
|
|
1555
|
+
return readClaimSecret(this.appId, token) !== null;
|
|
1556
|
+
}
|
|
1557
|
+
// Keep the temporary document this page is on, signed in, without leaving
|
|
1558
|
+
// the page: the claim a sign-in from the page would make, then the document
|
|
1559
|
+
// opened by id. The claim evicts the link's own connections, so the page
|
|
1560
|
+
// leaves the link before it reopens rather than following the eviction.
|
|
1561
|
+
async keepTemporaryWork() {
|
|
1562
|
+
const token = this.guestLinkTokenValue;
|
|
1563
|
+
if (token === null || this.user === null || !this.canKeepTemporaryWork())
|
|
1564
|
+
return false;
|
|
1565
|
+
const appId = this.requireAppId();
|
|
1566
|
+
const claimSecret = readClaimSecret(appId, token);
|
|
1567
|
+
const anonymous = await this.probeAnonymousLink(token);
|
|
1568
|
+
if (claimSecret === null || anonymous === null)
|
|
1569
|
+
return false;
|
|
1570
|
+
if (!(await this.claimAnonymousLink({ ...anonymous, claimSecret })))
|
|
1571
|
+
return false;
|
|
1572
|
+
this.leaveLinkForOwnAccess();
|
|
1573
|
+
try {
|
|
1574
|
+
await this.session.open(anonymous.docId);
|
|
1575
|
+
}
|
|
1576
|
+
catch (err) {
|
|
1577
|
+
this.reportUncatchable(err instanceof Error ? err : new Error(String(err)));
|
|
1578
|
+
}
|
|
1579
|
+
return true;
|
|
1580
|
+
}
|
|
1581
|
+
// A signed-in person on a link page who opens or creates another document
|
|
1582
|
+
// does so as themselves, so the page stops being on the link: the URL drops
|
|
1583
|
+
// it and the socket reconnects with their own credential.
|
|
1584
|
+
leaveLinkForOwnAccess() {
|
|
1585
|
+
if (this.guestLinkTokenValue === null || this.user === null)
|
|
1586
|
+
return;
|
|
1587
|
+
this.guestLinkTokenValue = null;
|
|
1588
|
+
clearGuestLinkToken();
|
|
1589
|
+
}
|
|
1590
|
+
// Run an open or create the person makes as themselves, off the link. One
|
|
1591
|
+
// that fails leaves the page where it was, still on the link: the document
|
|
1592
|
+
// it shows is the link's, and the URL is what brings a reload back to it.
|
|
1593
|
+
async asOwnAccess(run) {
|
|
1594
|
+
const token = this.user !== null ? this.guestLinkTokenValue : null;
|
|
1595
|
+
this.leaveLinkForOwnAccess();
|
|
1596
|
+
try {
|
|
1597
|
+
return await run();
|
|
1598
|
+
}
|
|
1599
|
+
catch (err) {
|
|
1600
|
+
if (token !== null && this.guestLinkTokenValue === null && this.user !== null) {
|
|
1601
|
+
this.guestLinkTokenValue = token;
|
|
1602
|
+
writeGuestLinkToken(token);
|
|
1603
|
+
}
|
|
1604
|
+
throw err;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
// The signed-in person's credential, carried beside a link on its open: an
|
|
1608
|
+
// app that opens links only for people signed in to it asks for it. Read
|
|
1609
|
+
// through `getAccessToken`, which renews a token set near its expiry.
|
|
1610
|
+
async currentLinkCredential() {
|
|
1611
|
+
if (this.user === null || (await this.getAccessToken()) === null)
|
|
1612
|
+
return null;
|
|
1613
|
+
const tokens = this.tokens;
|
|
1614
|
+
if (this.user === null || tokens === null || tokens.appAssertion === undefined)
|
|
1615
|
+
return null;
|
|
1616
|
+
return { accessToken: tokens.accessToken, appAssertion: tokens.appAssertion };
|
|
1617
|
+
}
|
|
1618
|
+
// Whether a link token names a document created without an account, read
|
|
1619
|
+
// off `GET /links/{token}/view` (no snapshot, no credential). Null for an
|
|
1620
|
+
// ordinary share link, and for a token the server no longer answers.
|
|
1621
|
+
async probeAnonymousLink(token) {
|
|
1622
|
+
try {
|
|
1623
|
+
const response = await fetch(`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/view`, { method: 'GET' });
|
|
1624
|
+
if (!response.ok)
|
|
1625
|
+
return null;
|
|
1626
|
+
const body = (await response.json());
|
|
1627
|
+
if (body.anonymous !== true || typeof body.doc_id !== 'string') {
|
|
1628
|
+
return null;
|
|
1629
|
+
}
|
|
1630
|
+
return {
|
|
1631
|
+
token,
|
|
1632
|
+
docId: body.doc_id,
|
|
1633
|
+
...(typeof body.name === 'string' ? { name: body.name } : {}),
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
catch {
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
// Claim a document created without an account into the signed-in person's
|
|
1641
|
+
// organization, through the placement loop a create runs. On success the
|
|
1642
|
+
// claimed id replaces the token in the URL and is stashed as the last
|
|
1643
|
+
// selection, so the session's restore and an app reading `?doc=` both
|
|
1644
|
+
// find it. A refused or dismissed claim reports through `onError` and
|
|
1645
|
+
// enters guest mode on the token, editable until it expires; a reload
|
|
1646
|
+
// carries no code, so it does not claim again.
|
|
1647
|
+
async claimAnonymousLink(claim) {
|
|
1648
|
+
const appId = this.requireAppId();
|
|
1649
|
+
let chosenName;
|
|
1650
|
+
let result = null;
|
|
1651
|
+
try {
|
|
1652
|
+
result = await placeThroughHostedSurfaces(this.getHostedUiLauncher(), (destination) => this.claimAnonymousDocumentWithConsent(claim.docId, {
|
|
1653
|
+
linkToken: claim.token,
|
|
1654
|
+
claimSecret: claim.claimSecret,
|
|
1655
|
+
...(destination !== undefined ? { destination } : {}),
|
|
1656
|
+
...(chosenName !== undefined ? { name: chosenName } : {}),
|
|
1657
|
+
}), claim.name, (chosen) => {
|
|
1658
|
+
chosenName = chosen.name;
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
catch (err) {
|
|
1662
|
+
this.reportUncatchable(err instanceof Error ? err : new Error(String(err)));
|
|
1663
|
+
}
|
|
1664
|
+
if (result === null) {
|
|
1665
|
+
this.enterGuestMode(claim.token);
|
|
1666
|
+
return false;
|
|
1667
|
+
}
|
|
1668
|
+
clearGuestLinkToken();
|
|
1669
|
+
// Claimed, the document is reached by id like any other, so there is
|
|
1670
|
+
// no unsaved work to come back to.
|
|
1671
|
+
clearUnsavedWork(appId);
|
|
1672
|
+
clearClaimSecret(appId, claim.token);
|
|
1673
|
+
storeClaimedLink(appId, claim.token, claim.docId);
|
|
1674
|
+
this.maybeWriteDocIdToUrl(claim.docId);
|
|
1675
|
+
if (result.record)
|
|
1676
|
+
storeLastDocument(appId, result.record);
|
|
1677
|
+
return true;
|
|
1678
|
+
}
|
|
941
1679
|
// The `app_not_installed` denial names the org; open the hosted
|
|
942
1680
|
// install-consent dialog for it so the person decides: an admin (or a
|
|
943
1681
|
// member of an `open`-policy org) installs, a member under `request`
|
|
944
1682
|
// sends the admin request from the same dialog. Nothing is installed
|
|
945
1683
|
// or requested implicitly. Resolves true only when an install landed,
|
|
946
1684
|
// in which case the caller retries the denied call once.
|
|
947
|
-
|
|
1685
|
+
offerInstallConsent(err) {
|
|
948
1686
|
if (!isAppNotInstalled(err) || !err.organizationId)
|
|
949
|
-
return false;
|
|
1687
|
+
return Promise.resolve(false);
|
|
950
1688
|
const organizationId = err.organizationId;
|
|
1689
|
+
// One dialog per organization, however many calls were refused. An app
|
|
1690
|
+
// that opens a SET hits the same denial once per document, and each raise
|
|
1691
|
+
// is a modal: the reader answers the one on top and finds another behind
|
|
1692
|
+
// it, for work they already consented to. The install a dialog lands is
|
|
1693
|
+
// the whole answer for every caller waiting on it, and each still retries
|
|
1694
|
+
// its own denied call afterwards.
|
|
1695
|
+
const inFlight = this.installConsentByOrganization.get(organizationId);
|
|
1696
|
+
if (inFlight)
|
|
1697
|
+
return inFlight;
|
|
1698
|
+
const raised = this.raiseInstallConsent(organizationId).finally(() => {
|
|
1699
|
+
this.installConsentByOrganization.delete(organizationId);
|
|
1700
|
+
});
|
|
1701
|
+
this.installConsentByOrganization.set(organizationId, raised);
|
|
1702
|
+
return raised;
|
|
1703
|
+
}
|
|
1704
|
+
// The dialog itself, held apart so the memo above is the only thing that
|
|
1705
|
+
// decides whether one opens.
|
|
1706
|
+
async raiseInstallConsent(organizationId) {
|
|
951
1707
|
try {
|
|
952
1708
|
const result = await this.getHostedUiLauncher().installApp(organizationId);
|
|
953
1709
|
if (result === null) {
|
|
@@ -969,6 +1725,22 @@ export class BrassClient {
|
|
|
969
1725
|
return false;
|
|
970
1726
|
}
|
|
971
1727
|
}
|
|
1728
|
+
// Everything this page load holds about which organization it acts in, and
|
|
1729
|
+
// what it is called. Enumerated here rather than at each transition,
|
|
1730
|
+
// because the memberships behind every one of these belong to the person
|
|
1731
|
+
// who was signed in: a name, a launch, an open document's organization and
|
|
1732
|
+
// a resolution are all answers about them. A caller that clears a subset
|
|
1733
|
+
// hands the next person the rest, which is what the account menu reads.
|
|
1734
|
+
retireActingOrganizationState() {
|
|
1735
|
+
this.actingOrganization = null;
|
|
1736
|
+
this.launchOrganizationId = null;
|
|
1737
|
+
this.chromeDocumentOrganization = null;
|
|
1738
|
+
this.chromeOrganizationToken += 1;
|
|
1739
|
+
this.organizationNames.clear();
|
|
1740
|
+
this.documentOrganizationIds.clear();
|
|
1741
|
+
this.actingOrganizationResolve = null;
|
|
1742
|
+
this.emitActingOrganization();
|
|
1743
|
+
}
|
|
972
1744
|
// Drop a resolution that never became one the app can act in. Scoped to the
|
|
973
1745
|
// organization named, so a resolution the app has moved on from is left
|
|
974
1746
|
// alone.
|
|
@@ -976,13 +1748,20 @@ export class BrassClient {
|
|
|
976
1748
|
if (this.actingOrganization?.id !== organizationId)
|
|
977
1749
|
return;
|
|
978
1750
|
this.actingOrganization = null;
|
|
979
|
-
|
|
1751
|
+
// The tab's stored choice is a separate answer from this page load's
|
|
1752
|
+
// resolution: the memo may hold an organization the launch or an open
|
|
1753
|
+
// document supplied while the slot holds a deliberate choice made
|
|
1754
|
+
// earlier, so it is cleared only when it names the same organization.
|
|
1755
|
+
const appId = this.requireAppId();
|
|
1756
|
+
if (readActingOrganization(appId) === organizationId) {
|
|
1757
|
+
clearActingOrganization(appId);
|
|
1758
|
+
}
|
|
980
1759
|
this.emitActingOrganization();
|
|
981
1760
|
}
|
|
982
1761
|
// Register the app's export backend. The platform calls it when a
|
|
983
1762
|
// hosted surface downloads an app-owned (blob) document: it receives
|
|
984
1763
|
// the open Document and returns the source file's bytes, the inverse of
|
|
985
|
-
// the
|
|
1764
|
+
// the app's importer. The app never invokes it
|
|
986
1765
|
// and never delivers the bytes itself; the platform stages + downloads
|
|
987
1766
|
// what it returns. One serializer per app.
|
|
988
1767
|
registerSerializer(serializer) {
|
|
@@ -991,20 +1770,37 @@ export class BrassClient {
|
|
|
991
1770
|
// this registration to run it.
|
|
992
1771
|
this.exportGuest?.setSerializer(serializer);
|
|
993
1772
|
}
|
|
994
|
-
// Register the app's import backend
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
//
|
|
1773
|
+
// Register the app's import backend, `(file) => { streams, report? }`.
|
|
1774
|
+
// The SDK claims the import with the platform on each open, so exactly one
|
|
1775
|
+
// client runs it, and the platform seals what it returns as the document's
|
|
1776
|
+
// first content. One importer per app; a later registration
|
|
998
1777
|
// replaces the earlier. Documents already open (registration raced the
|
|
999
|
-
// first open) get their attempt here
|
|
1778
|
+
// first open) get their attempt here, and so do inputs whose open stopped
|
|
1779
|
+
// waiting for it.
|
|
1000
1780
|
registerImporter(importer) {
|
|
1781
|
+
this.importer = importer;
|
|
1001
1782
|
this.importRunner.setImporter(importer);
|
|
1002
|
-
|
|
1783
|
+
for (const registered of this.importerWaiters.splice(0))
|
|
1784
|
+
registered();
|
|
1785
|
+
if (this.importGuest !== null) {
|
|
1786
|
+
this.importGuest.setImporter(importer);
|
|
1003
1787
|
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (this.importerWaitExpired) {
|
|
1790
|
+
this.importerWaitExpired = false;
|
|
1791
|
+
console.warn(`[brass] Brass.registerImporter was called after an open had waited ${IMPORTER_WAIT_CEILING_MS / 1000}s for it and failed with not_imported. Call it before opening documents.`);
|
|
1792
|
+
}
|
|
1004
1793
|
for (const doc of this.liveOpenDocuments()) {
|
|
1005
|
-
|
|
1794
|
+
if (this.importsInto(doc))
|
|
1795
|
+
void this.importRunner.attempt(doc);
|
|
1796
|
+
doc._importerRegistered();
|
|
1006
1797
|
}
|
|
1007
1798
|
}
|
|
1799
|
+
// A bundle Brass loaded to run its serializer or importer headless holds
|
|
1800
|
+
// no session and emits none.
|
|
1801
|
+
isHeadlessGuest() {
|
|
1802
|
+
return this.exportGuest !== null || this.importGuest !== null;
|
|
1803
|
+
}
|
|
1008
1804
|
// Track an opened document so an export request can resolve its id to
|
|
1009
1805
|
// the live Document. A re-open overwrites.
|
|
1010
1806
|
trackOpenDocument(doc) {
|
|
@@ -1015,11 +1811,9 @@ export class BrassClient {
|
|
|
1015
1811
|
}
|
|
1016
1812
|
});
|
|
1017
1813
|
// Every open path funnels through here, so this is where a registered
|
|
1018
|
-
// importer gets its per-document attempt.
|
|
1019
|
-
|
|
1020
|
-
if (this.guestLinkTokenValue === null) {
|
|
1814
|
+
// importer gets its per-document attempt.
|
|
1815
|
+
if (this.importsInto(doc))
|
|
1021
1816
|
void this.importRunner.attempt(doc);
|
|
1022
|
-
}
|
|
1023
1817
|
}
|
|
1024
1818
|
// The tracked documents still open, dropping the rest as it goes.
|
|
1025
1819
|
// `onGone` covers only a server-side eviction, and two ordinary paths
|
|
@@ -1068,7 +1862,7 @@ export class BrassClient {
|
|
|
1068
1862
|
}
|
|
1069
1863
|
const serialized = await this.serializeForExport(docId);
|
|
1070
1864
|
if (!serialized.ok) {
|
|
1071
|
-
throw new BrassError(`This app could not write
|
|
1865
|
+
throw new BrassError(`This app could not write this out as a file to publish it: ${serialized.message}`, { status: 400 });
|
|
1072
1866
|
}
|
|
1073
1867
|
return await this.docsInternal().pushToDrive(doc, {
|
|
1074
1868
|
...input,
|
|
@@ -1091,7 +1885,7 @@ export class BrassClient {
|
|
|
1091
1885
|
const doc = this.liveOpenDocument(docId) ??
|
|
1092
1886
|
(sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
|
|
1093
1887
|
if (!doc) {
|
|
1094
|
-
return { ok: false, code: 'no_document', message: '
|
|
1888
|
+
return { ok: false, code: 'no_document', message: 'This is not open in the app.' };
|
|
1095
1889
|
}
|
|
1096
1890
|
try {
|
|
1097
1891
|
// A serializer runs detached, with no subscription to have been fed by,
|
|
@@ -1143,8 +1937,11 @@ export class BrassClient {
|
|
|
1143
1937
|
apiBaseUrl: this.apiBaseUrl,
|
|
1144
1938
|
getAccessToken: () => this.getAccessToken(),
|
|
1145
1939
|
getAppAssertion: () => this.getAppAssertion(),
|
|
1940
|
+
getLinkCredential: () => this.currentLinkCredential(),
|
|
1941
|
+
openAsMember: (docId, options) => this.openDocumentInternal(docId, options, 'skip'),
|
|
1146
1942
|
identity: () => this.user?.sub ?? null,
|
|
1147
1943
|
...(wsClient ? { wsClient } : {}),
|
|
1944
|
+
linkWsClient: (token) => this.getLinkWsClient(appId, token),
|
|
1148
1945
|
branchProvider: {
|
|
1149
1946
|
list: (docId) => docsInternal.listBranches(docId),
|
|
1150
1947
|
create: (docId, input) => docsInternal.createBranch(docId, input),
|
|
@@ -1156,10 +1953,17 @@ export class BrassClient {
|
|
|
1156
1953
|
// wire unstamped while each layer's own tests stay green.
|
|
1157
1954
|
create: (docId, branchId, name) => docsInternal.createStream(docId, branchId, name),
|
|
1158
1955
|
},
|
|
1159
|
-
thumbnailUploader: (doc, image, width, height) =>
|
|
1160
|
-
|
|
1956
|
+
thumbnailUploader: (doc, image, width, height, version) => version === undefined
|
|
1957
|
+
? docsNs.setThumbnail(doc, { image, width, height })
|
|
1958
|
+
: docsInternal.setThumbnailAt(doc, { image, width, height }, version),
|
|
1959
|
+
checkpointCreator: (doc, anchor) => docsInternal.createCheckpoint(doc, anchor),
|
|
1161
1960
|
drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
|
|
1162
1961
|
sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
|
|
1962
|
+
provisionalInputsPoster: (docId, body, opts) => docsInternal.postProvisionalInputs(docId, body, opts),
|
|
1963
|
+
inputsRuntime: (docId) => ({
|
|
1964
|
+
readKey: (key, query) => docsInternal.readInputKeyStatus(docId, key, query),
|
|
1965
|
+
open: (inputId, options) => this.openDocumentById(inputId, options),
|
|
1966
|
+
}),
|
|
1163
1967
|
hostedUiProvider: {
|
|
1164
1968
|
documentHistory: (docId, options) => this.documentHistory(docId, options),
|
|
1165
1969
|
documentSettings: (docId, options) => this.getHostedUiLauncher().documentSettings(docId, options),
|
|
@@ -1192,7 +1996,7 @@ export class BrassClient {
|
|
|
1192
1996
|
// the active-doc swap consistent (URL sync, command list
|
|
1193
1997
|
// re-render, branch-list refresh on the new doc).
|
|
1194
1998
|
openSiblingDoc: async (docId, options) => {
|
|
1195
|
-
await this.session.
|
|
1999
|
+
await this.session._openSibling(docId, options);
|
|
1196
2000
|
},
|
|
1197
2001
|
selectBranch: (docId, options) => this.getHostedUiLauncher().selectBranch(docId, options),
|
|
1198
2002
|
// `doc.resolveDrive()` opens the hosted resolution surface;
|
|
@@ -1220,17 +2024,32 @@ export class BrassClient {
|
|
|
1220
2024
|
wsBaseUrl: this.wsBaseUrl,
|
|
1221
2025
|
appId,
|
|
1222
2026
|
getAccessToken: () => this.getAccessToken(),
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
// the
|
|
1228
|
-
// resolves null here and connects un-gated.
|
|
2027
|
+
// With nobody signed in the page socket connects with the link token;
|
|
2028
|
+
// a signed-in person's carries their own credential, and a document
|
|
2029
|
+
// opened through a link uses that link's socket (`getLinkWsClient`).
|
|
2030
|
+
getGuestLinkToken: () => (this.user === null ? this.guestLinkTokenValue : null),
|
|
2031
|
+
// Attests the socket with the same assertion the HTTP calls carry.
|
|
1229
2032
|
getAppAssertion: () => this.getAppAssertion(),
|
|
1230
2033
|
fireError: (error) => this.fireError(error),
|
|
1231
2034
|
});
|
|
1232
2035
|
return this.docWsClient;
|
|
1233
2036
|
}
|
|
2037
|
+
getLinkWsClient(appId, token) {
|
|
2038
|
+
if (!this.wsBaseUrl)
|
|
2039
|
+
return undefined;
|
|
2040
|
+
let client = this.linkWsClients.get(token);
|
|
2041
|
+
if (client === undefined) {
|
|
2042
|
+
client = new BrassWsClient({
|
|
2043
|
+
wsBaseUrl: this.wsBaseUrl,
|
|
2044
|
+
appId,
|
|
2045
|
+
getAccessToken: () => Promise.resolve(null),
|
|
2046
|
+
getGuestLinkToken: () => token,
|
|
2047
|
+
fireError: (error) => this.fireError(error),
|
|
2048
|
+
});
|
|
2049
|
+
this.linkWsClients.set(token, client);
|
|
2050
|
+
}
|
|
2051
|
+
return client;
|
|
2052
|
+
}
|
|
1234
2053
|
// Resolves null only when the server answered that this app has no live
|
|
1235
2054
|
// session, and rejects with a 503 `BrassError` when a `/refresh` found no
|
|
1236
2055
|
// answer at all. The two outcomes drive opposite handling: null is the
|
|
@@ -1240,13 +2059,11 @@ export class BrassClient {
|
|
|
1240
2059
|
// blipped, and hides the transient from every layer that self-heals on it.
|
|
1241
2060
|
async getAccessToken() {
|
|
1242
2061
|
this.requireInit();
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
|
|
1248
|
-
// present membership where the page's whole authority is the link.
|
|
1249
|
-
if (this.guestLinkTokenValue !== null)
|
|
2062
|
+
// A `?brass_link=` page with nobody signed in answers null rather than
|
|
2063
|
+
// probing /refresh, which would draw a 401 the browser logs. A signed-in
|
|
2064
|
+
// person's token serves what they do as themselves; the link document's
|
|
2065
|
+
// own requests and the socket carry the link instead.
|
|
2066
|
+
if (this.guestLinkTokenValue !== null && this.user === null)
|
|
1250
2067
|
return null;
|
|
1251
2068
|
const current = this.tokens;
|
|
1252
2069
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
@@ -1270,7 +2087,25 @@ export class BrassClient {
|
|
|
1270
2087
|
// names one, so this is the only place the answer comes from.
|
|
1271
2088
|
// `ask: false` answers null where the chooser would open, for a read an app
|
|
1272
2089
|
// makes before the person has pressed anything.
|
|
2090
|
+
//
|
|
2091
|
+
// One resolve at a time per mode: concurrent acts share the in-flight one,
|
|
2092
|
+
// so the chooser opens once and every caller hears the same answer. The two
|
|
2093
|
+
// modes stay apart, since a read must not wait on a dialog and an act must
|
|
2094
|
+
// not take a read's null as its answer.
|
|
1273
2095
|
resolveActingOrganizationId(options) {
|
|
2096
|
+
const ask = options === undefined;
|
|
2097
|
+
const inFlight = this.actingOrganizationResolve;
|
|
2098
|
+
if (inFlight !== null && inFlight.ask === ask)
|
|
2099
|
+
return inFlight.promise;
|
|
2100
|
+
const promise = this.runActingOrganizationResolve(ask).finally(() => {
|
|
2101
|
+
if (this.actingOrganizationResolve?.promise === promise) {
|
|
2102
|
+
this.actingOrganizationResolve = null;
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
this.actingOrganizationResolve = { ask, promise };
|
|
2106
|
+
return promise;
|
|
2107
|
+
}
|
|
2108
|
+
runActingOrganizationResolve(ask) {
|
|
1274
2109
|
const appId = this.requireAppId();
|
|
1275
2110
|
return resolveActingOrganization({
|
|
1276
2111
|
activeDocumentOrganizationId: () => this.activeDocumentOrganizationId(),
|
|
@@ -1285,7 +2120,7 @@ export class BrassClient {
|
|
|
1285
2120
|
remember: (organizationId, organizationName, persist) => {
|
|
1286
2121
|
this.setActingOrganization(organizationId, organizationName, persist);
|
|
1287
2122
|
},
|
|
1288
|
-
},
|
|
2123
|
+
}, ask ? {} : { ask: false });
|
|
1289
2124
|
}
|
|
1290
2125
|
// The same resolve for a caller that has nowhere to put a null: the ladder
|
|
1291
2126
|
// asks, and a person who dismisses the chooser is a BrassError rather than
|
|
@@ -1369,7 +2204,8 @@ export class BrassClient {
|
|
|
1369
2204
|
// document picked through the hosted picker already named its organization
|
|
1370
2205
|
// on the way in, which answers with no request at all.
|
|
1371
2206
|
activeDocumentOrganizationId() {
|
|
1372
|
-
// A
|
|
2207
|
+
// A document opened through a link is read through the link alone, so its
|
|
2208
|
+
// record is not the person's to read.
|
|
1373
2209
|
if (this.guestLinkTokenValue !== null)
|
|
1374
2210
|
return Promise.resolve(null);
|
|
1375
2211
|
const docId = this.session.document?.id;
|
|
@@ -1671,10 +2507,9 @@ export class BrassClient {
|
|
|
1671
2507
|
}
|
|
1672
2508
|
async getAppAssertion() {
|
|
1673
2509
|
this.requireInit();
|
|
1674
|
-
// Paired with `getAccessToken`: a link
|
|
1675
|
-
// an acting app against
|
|
1676
|
-
|
|
1677
|
-
if (this.guestLinkTokenValue !== null)
|
|
2510
|
+
// Paired with `getAccessToken`: a link page with nobody signed in holds
|
|
2511
|
+
// no token to assert an acting app against.
|
|
2512
|
+
if (this.guestLinkTokenValue !== null && this.user === null)
|
|
1678
2513
|
return null;
|
|
1679
2514
|
const current = this.tokens;
|
|
1680
2515
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
@@ -1691,16 +2526,15 @@ export class BrassClient {
|
|
|
1691
2526
|
const appId = this.requireAppId();
|
|
1692
2527
|
// Every successful sign-in 302s back to return_to with the
|
|
1693
2528
|
// platform-session cookie set AND a one-time `brass_code` query
|
|
1694
|
-
// parameter. The cookie
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
2529
|
+
// parameter. The first refresh exchanges the code with the cookie
|
|
2530
|
+
// alongside, and the server answers in cookie mode when the cookie came
|
|
2531
|
+
// with it, or hands back the fallback pointer when the browser dropped
|
|
2532
|
+
// it (incognito / Safari ITP / post-third-party-cookie browsers).
|
|
1698
2533
|
//
|
|
1699
2534
|
// Pull the code off the URL immediately (before any /refresh)
|
|
1700
2535
|
// and strip it via history.replaceState so it never lingers in the
|
|
1701
2536
|
// address bar, browser history, or a referrer header. It is held in
|
|
1702
|
-
// memory and consumed at most once
|
|
1703
|
-
// turns out to fail (see refresh()).
|
|
2537
|
+
// memory and consumed at most once (see runRefresh()).
|
|
1704
2538
|
//
|
|
1705
2539
|
// No app-controlled silent-mint path: the SDK never auto-fires
|
|
1706
2540
|
// Brass.signIn() based on a URL hint. New app approvals always
|
|
@@ -1809,12 +2643,12 @@ export class BrassClient {
|
|
|
1809
2643
|
});
|
|
1810
2644
|
return this.inflightRefresh;
|
|
1811
2645
|
}
|
|
1812
|
-
// Picks the credential channel for a single /refresh round-trip.
|
|
1813
|
-
//
|
|
1814
|
-
//
|
|
1815
|
-
// cookie
|
|
1816
|
-
// token is in hand (the cross-site cookie is proven
|
|
1817
|
-
// this page's life), every later refresh uses it directly.
|
|
2646
|
+
// Picks the credential channel for a single /refresh round-trip. A landed
|
|
2647
|
+
// one-time code goes first, on the same credentialed request as the cookie,
|
|
2648
|
+
// so a browser that drops the cross-site cookie signs in on one request
|
|
2649
|
+
// rather than after a cookie refresh it was always going to refuse. Once a
|
|
2650
|
+
// fallback session token is in hand (the cross-site cookie is proven
|
|
2651
|
+
// unavailable for this page's life), every later refresh uses it directly.
|
|
1818
2652
|
//
|
|
1819
2653
|
// A rung falls through to the next only on `no_session`. Each one discards
|
|
1820
2654
|
// a credential as it passes (the stale sid, the one-time code), and an
|
|
@@ -1836,17 +2670,16 @@ export class BrassClient {
|
|
|
1836
2670
|
this.sessionToken = null;
|
|
1837
2671
|
clearSessionToken(appId);
|
|
1838
2672
|
}
|
|
1839
|
-
|
|
1840
|
-
if (viaCookie.status !== 'no_session')
|
|
1841
|
-
return viaCookie;
|
|
1842
|
-
// Cookie channel resolved no session. Fall back to the one-time code if
|
|
1843
|
-
// we landed with one. Clear it first: it is single-use server-side, so a
|
|
2673
|
+
// Clear the code before spending it: it is single-use server-side, so a
|
|
1844
2674
|
// failed exchange must not be retried.
|
|
1845
2675
|
const code = this.pendingAuthCode;
|
|
1846
|
-
if (
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2676
|
+
if (code) {
|
|
2677
|
+
this.pendingAuthCode = null;
|
|
2678
|
+
const viaCode = await refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
|
|
2679
|
+
if (viaCode.status !== 'no_session')
|
|
2680
|
+
return viaCode;
|
|
2681
|
+
}
|
|
2682
|
+
return refreshTokens(this.authBaseUrl, appId);
|
|
1850
2683
|
}
|
|
1851
2684
|
setTokens(next) {
|
|
1852
2685
|
const nextUser = next ? decodeIdToken(next.idToken) : null;
|
|
@@ -1860,9 +2693,7 @@ export class BrassClient {
|
|
|
1860
2693
|
nextUser !== null &&
|
|
1861
2694
|
this.user.sub !== nextUser.sub) {
|
|
1862
2695
|
this.setTokens(null);
|
|
1863
|
-
this.
|
|
1864
|
-
this.emitActingOrganization();
|
|
1865
|
-
this.documentOrganizationIds.clear();
|
|
2696
|
+
this.retireActingOrganizationState();
|
|
1866
2697
|
}
|
|
1867
2698
|
// A session ENDING, which is where the per-app stashes are dropped.
|
|
1868
2699
|
//
|
|
@@ -1902,9 +2733,11 @@ export class BrassClient {
|
|
|
1902
2733
|
// it hears the sign-out and a close PARKS the Document. A parked Document
|
|
1903
2734
|
// answers the next open of its key out of memory, with no token and no
|
|
1904
2735
|
// request, so one left behind here is the previous person's document
|
|
1905
|
-
// handed to whoever signs in next.
|
|
2736
|
+
// handed to whoever signs in next. Frozen bytes kept on the device go with
|
|
2737
|
+
// it for the same reason.
|
|
1906
2738
|
if (nextUser === null)
|
|
1907
2739
|
clearParkedDocuments();
|
|
2740
|
+
adoptSealedCacheOwner(nextUser?.sub ?? null);
|
|
1908
2741
|
}
|
|
1909
2742
|
async hydrateProfile() {
|
|
1910
2743
|
if (!this.user)
|
|
@@ -1914,8 +2747,20 @@ export class BrassClient {
|
|
|
1914
2747
|
const token = this.tokens?.accessToken;
|
|
1915
2748
|
if (!token)
|
|
1916
2749
|
return;
|
|
2750
|
+
// The same headers every other data-API call carries. The bearer names
|
|
2751
|
+
// the person and never the app, so a platform that requires the acting
|
|
2752
|
+
// app refuses a request holding only it, and this one degrades silently
|
|
2753
|
+
// (see the catch below), which would leave a reader looking at a raw sub
|
|
2754
|
+
// where their name belongs with nothing reporting why.
|
|
2755
|
+
//
|
|
2756
|
+
// Read straight off the held token set rather than through
|
|
2757
|
+
// `getAppAssertion()`, mirroring the access token above: this is
|
|
2758
|
+
// best-effort hydration, so it must not drive a refresh of its own.
|
|
1917
2759
|
try {
|
|
1918
|
-
const
|
|
2760
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
2761
|
+
applyClientVersionHeader(headers);
|
|
2762
|
+
await applyAppAssertionHeader(headers, () => Promise.resolve(this.tokens?.appAssertion ?? null));
|
|
2763
|
+
const res = await fetch(`${this.apiBaseUrl}/users/${encodeURIComponent(this.user.sub)}`, { headers });
|
|
1919
2764
|
if (!res.ok)
|
|
1920
2765
|
return;
|
|
1921
2766
|
const profile = (await res.json());
|
|
@@ -1958,6 +2803,19 @@ export class BrassClient {
|
|
|
1958
2803
|
// works and the proxy's code went unused). Best-effort: any failure
|
|
1959
2804
|
// (exotic URL, history API unavailable) just yields null and the SDK
|
|
1960
2805
|
// falls back to the cookie-only path.
|
|
2806
|
+
// Whether a sign-in this page started has just returned: the auth proxy
|
|
2807
|
+
// appends `brass_code` to `return_to` on every successful sign-in. Read
|
|
2808
|
+
// without consuming, since `bootstrap` spends it.
|
|
2809
|
+
function signInReturned() {
|
|
2810
|
+
if (typeof window === 'undefined')
|
|
2811
|
+
return false;
|
|
2812
|
+
try {
|
|
2813
|
+
return new URL(window.location.href).searchParams.has('brass_code');
|
|
2814
|
+
}
|
|
2815
|
+
catch {
|
|
2816
|
+
return false;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
1961
2819
|
function consumeAuthCodeParam() {
|
|
1962
2820
|
try {
|
|
1963
2821
|
const url = new URL(window.location.href);
|