@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/src/client.ts
CHANGED
|
@@ -7,25 +7,43 @@ import {
|
|
|
7
7
|
type RefreshOutcome,
|
|
8
8
|
type TokenSet,
|
|
9
9
|
} from './auth-flow.js';
|
|
10
|
+
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
10
11
|
import { ensureWasmReady } from './wasm-runtime.js';
|
|
11
12
|
import { decodeIdToken } from './jwt.js';
|
|
12
13
|
import { createEmitter } from './emitter.js';
|
|
14
|
+
import { adoptSealedCacheOwner } from './sealed-cache.js';
|
|
13
15
|
import {
|
|
14
16
|
clearParkedDocuments,
|
|
15
17
|
createOpenDocument,
|
|
18
|
+
type AnonymousUploadAccept,
|
|
19
|
+
type AnonymousUploadTypes,
|
|
20
|
+
type GuestImportCalls,
|
|
21
|
+
type ClaimAnonymousDocumentArgs,
|
|
22
|
+
type ClaimDocumentResult,
|
|
16
23
|
type CreateDocumentArgs,
|
|
17
24
|
type CreateDocumentOptions,
|
|
18
25
|
type CreateDocumentResult,
|
|
19
26
|
type DocumentFactory,
|
|
27
|
+
type LinkCredential,
|
|
20
28
|
type OpenDocumentByIdOptions,
|
|
21
29
|
type OpenedDocument,
|
|
22
30
|
} from './open-document.js';
|
|
23
31
|
import { askSchemaConflict } from './schema-conflict-dialog.js';
|
|
32
|
+
import { askReplaceTrial } from './trial-replace-dialog.js';
|
|
24
33
|
import { BrassError } from './errors.js';
|
|
34
|
+
import { inDevelopment } from './in-development.js';
|
|
35
|
+
import { importerWaitRejections } from './importer-wait.js';
|
|
36
|
+
import { checkManifestDrift } from './manifest-drift.js';
|
|
37
|
+
import { createApiFetch } from './api-fetch.js';
|
|
38
|
+
import { chooseFile } from './file-choice.js';
|
|
25
39
|
import {
|
|
26
40
|
createHostedUiLauncher,
|
|
27
41
|
trackHostedUiActivations,
|
|
28
42
|
type DocumentHistoryResult,
|
|
43
|
+
type ChooseInputOptions,
|
|
44
|
+
type ChooseInputsOptions,
|
|
45
|
+
type DocumentFamilies,
|
|
46
|
+
type InputReference,
|
|
29
47
|
type ExportSerializeOutcome,
|
|
30
48
|
type HostedUiLauncher,
|
|
31
49
|
type HostedUiTheme,
|
|
@@ -43,8 +61,31 @@ import {
|
|
|
43
61
|
startExportGuest,
|
|
44
62
|
type ExportGuestController,
|
|
45
63
|
} from './export-guest.js';
|
|
46
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
isImportMode,
|
|
66
|
+
startImportGuest,
|
|
67
|
+
type ImportGuestController,
|
|
68
|
+
} from './import-guest.js';
|
|
69
|
+
import {
|
|
70
|
+
clearClaimSecret,
|
|
71
|
+
readClaimedLink,
|
|
72
|
+
storeClaimedLink,
|
|
73
|
+
clearGuestLinkToken,
|
|
74
|
+
clearUnsavedWork,
|
|
75
|
+
markClaimIntent,
|
|
76
|
+
readClaimSecret,
|
|
77
|
+
readGuestLinkToken,
|
|
78
|
+
readUnsavedWork,
|
|
79
|
+
storeUnsavedWork,
|
|
80
|
+
takeClaimIntent,
|
|
81
|
+
markTrialEdited,
|
|
82
|
+
trialWasEdited,
|
|
83
|
+
writeGuestLinkToken,
|
|
84
|
+
type UnsavedWorkEntry,
|
|
85
|
+
} from './guest-link.js';
|
|
47
86
|
import { consumeLaunchOrganizationId } from './launch-context.js';
|
|
87
|
+
import { onPageRestored, restoredPageIsStale } from './page-restore.js';
|
|
88
|
+
import { placeThroughHostedSurfaces } from './placement.js';
|
|
48
89
|
import {
|
|
49
90
|
callService,
|
|
50
91
|
normalizeServiceHost,
|
|
@@ -99,6 +140,7 @@ import {
|
|
|
99
140
|
} from './documents-namespace.js';
|
|
100
141
|
import {
|
|
101
142
|
createDocumentSession,
|
|
143
|
+
type ClaimedLinkTarget,
|
|
102
144
|
type DocumentSession,
|
|
103
145
|
} from './session.js';
|
|
104
146
|
import {
|
|
@@ -134,6 +176,22 @@ import type {
|
|
|
134
176
|
} from './types.js';
|
|
135
177
|
|
|
136
178
|
const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
|
|
179
|
+
|
|
180
|
+
const NOT_IMPORTED_MESSAGE = 'Not imported';
|
|
181
|
+
const IMPORT_FAILED_MESSAGE = 'This file could not be imported';
|
|
182
|
+
|
|
183
|
+
// The rejection of an open whose import did not land, naming why when this
|
|
184
|
+
// client stopped waiting for it.
|
|
185
|
+
function notImported(doc: Document): BrassError {
|
|
186
|
+
return new BrassError(doc._importAbandoned ?? NOT_IMPORTED_MESSAGE, {
|
|
187
|
+
status: 0,
|
|
188
|
+
code: 'not_imported',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// How long an open waits for `registerImporter` before it rejects
|
|
192
|
+
// `not_imported`, and when a development build first reports the wait.
|
|
193
|
+
const IMPORTER_WAIT_CEILING_MS = 60_000;
|
|
194
|
+
const IMPORTER_WAIT_WARN_MS = 5_000;
|
|
137
195
|
// How long `signOut` waits for the revocation before it navigates. The page
|
|
138
196
|
// it lands on finishes an unrevoked sign-out itself, so waiting longer than
|
|
139
197
|
// this only delays the sign-out.
|
|
@@ -211,6 +269,7 @@ export function resolveBaseUrls(options: BrassInitOptions): ResolvedBaseUrls {
|
|
|
211
269
|
}
|
|
212
270
|
// Refresh slightly before expiry so callers never see a stale access token.
|
|
213
271
|
const REFRESH_LEEWAY_MS = 60_000;
|
|
272
|
+
|
|
214
273
|
// App id format. The server mints `brass_app_` + random chars; some
|
|
215
274
|
// real ids carry a `test`/`live`/`internal` infix. The optional infix
|
|
216
275
|
// group accepts both shapes, and the permissive tail keeps minor
|
|
@@ -234,8 +293,19 @@ function refreshUnavailableError(): BrassError {
|
|
|
234
293
|
return new BrassError('The sign-in service is unavailable', { status: 503 });
|
|
235
294
|
}
|
|
236
295
|
|
|
296
|
+
// A link token that names a document created without an account, as the
|
|
297
|
+
// probe read it: what the claim needs, plus the name the destination dialog
|
|
298
|
+
// is pre-filled with.
|
|
299
|
+
interface AnonymousLinkClaim {
|
|
300
|
+
token: string;
|
|
301
|
+
claimSecret: string;
|
|
302
|
+
docId: string;
|
|
303
|
+
name?: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
237
306
|
export class BrassClient {
|
|
238
307
|
private appId: string | null = null;
|
|
308
|
+
private initFailure: BrassError | null = null;
|
|
239
309
|
private authBaseUrl = DEFAULT_AUTH_BASE_URL;
|
|
240
310
|
private apiBaseUrl = DEFAULT_API_BASE_URL;
|
|
241
311
|
private apiBaseUrlConfigured = false;
|
|
@@ -251,9 +321,8 @@ export class BrassClient {
|
|
|
251
321
|
// (no `prefers-color-scheme: dark` in its CSS). `'auto'` follows the
|
|
252
322
|
// OS preference; `'dark'` pins dark.
|
|
253
323
|
private hostedUiTheme: HostedUiTheme = 'light';
|
|
254
|
-
// See `BrassInitOptions.
|
|
255
|
-
|
|
256
|
-
private openIncompatibleDocuments = false;
|
|
324
|
+
// See `BrassInitOptions.open`: the mode the session opens documents in.
|
|
325
|
+
private openReadOnly = false;
|
|
257
326
|
// Doc-selection-in-URL sync. On by default; off via
|
|
258
327
|
// `syncDocToUrl: false`. Param names configurable via `docUrlParam` /
|
|
259
328
|
// `branchUrlParam`.
|
|
@@ -282,6 +351,10 @@ export class BrassClient {
|
|
|
282
351
|
// on `authStatus`.
|
|
283
352
|
private bootstrapped = false;
|
|
284
353
|
private listeners = new Set<UserChangeListener>();
|
|
354
|
+
// The session's own listener. It reacts to sign-in and sign-out, and is
|
|
355
|
+
// left out of a guest-mode change: the session starts that change itself,
|
|
356
|
+
// mid-open, and would otherwise begin a second open of the same document.
|
|
357
|
+
private readonly sessionUserListeners = new WeakSet<UserChangeListener>();
|
|
285
358
|
// Init-failure listeners. Swallow a listener's throw so one bad handler
|
|
286
359
|
// cannot block the others or mask the original error (already logged).
|
|
287
360
|
private readonly errorListeners = createEmitter<Error>({ onError: () => {} });
|
|
@@ -293,7 +366,7 @@ export class BrassClient {
|
|
|
293
366
|
private authEpoch = 0;
|
|
294
367
|
private initialized = false;
|
|
295
368
|
// Incognito / third-party-cookie fallback state. Both null in a normal
|
|
296
|
-
// browser, where the cross-site `brass_auth` cookie carries the session
|
|
369
|
+
// browser, where the cross-site `__Host-brass_auth` cookie carries the session
|
|
297
370
|
// and `/refresh` works without a body.
|
|
298
371
|
// - `pendingAuthCode`: the one-time `brass_code` pulled off the
|
|
299
372
|
// landing URL by bootstrap(), consumed at most once by refresh() if
|
|
@@ -314,13 +387,22 @@ export class BrassClient {
|
|
|
314
387
|
private documentSession: DocumentSession | null = null;
|
|
315
388
|
private hostedUiLauncher: HostedUiLauncher | null = null;
|
|
316
389
|
private documentFactory: DocumentFactory | null = null;
|
|
317
|
-
// The `?brass_link=` token
|
|
318
|
-
//
|
|
390
|
+
// The `?brass_link=` token the page's document opens through, whoever is
|
|
391
|
+
// signed in; null when the page is not on a link. Set in `init`.
|
|
319
392
|
private guestLinkTokenValue: string | null = null;
|
|
393
|
+
private watchingPageRestore = false;
|
|
320
394
|
// The organization this page was launched from (`?brass_org=`), or null
|
|
321
395
|
// when the app was reached some other way. A create that names no
|
|
322
396
|
// destination of its own lands here; see `launch-context.ts`.
|
|
323
397
|
private launchOrganizationId: string | null = null;
|
|
398
|
+
// The install-consent dialog in flight per organization, so concurrent
|
|
399
|
+
// denials share one. Keyed by organization because that is what an install
|
|
400
|
+
// is granted on, and cleared when the dialog settles so a later denial
|
|
401
|
+
// (a declined install, a second organization) raises its own.
|
|
402
|
+
private readonly installConsentByOrganization = new Map<
|
|
403
|
+
string,
|
|
404
|
+
Promise<boolean>
|
|
405
|
+
>();
|
|
324
406
|
// The organization this page has resolved to act in, and the name the
|
|
325
407
|
// server gave it. Held for the life of the page load so repeated acts
|
|
326
408
|
// resolve once; dropped on sign-out, and replaced by a later switch.
|
|
@@ -352,6 +434,14 @@ export class BrassClient {
|
|
|
352
434
|
private actingOrganizationListeners = new Set<
|
|
353
435
|
(organization: { id: string; name: string } | null) => void
|
|
354
436
|
>();
|
|
437
|
+
// The resolve in flight, so concurrent acts share one answer and the
|
|
438
|
+
// chooser opens once. Keyed by whether the caller may be asked, because a
|
|
439
|
+
// read that answers null where the chooser would open must not settle an
|
|
440
|
+
// act that is waiting for the person, and an act must not take that null.
|
|
441
|
+
private actingOrganizationResolve: {
|
|
442
|
+
ask: boolean;
|
|
443
|
+
promise: Promise<string | null>;
|
|
444
|
+
} | null = null;
|
|
355
445
|
// One document's organization, by document id. A document's placement
|
|
356
446
|
// does not move under an open app, so the read behind it runs once per
|
|
357
447
|
// id; the in-flight promise is shared so concurrent acts make one call.
|
|
@@ -364,21 +454,67 @@ export class BrassClient {
|
|
|
364
454
|
// iframe (`isExportMode`): serializes a handed-in snapshot and never
|
|
365
455
|
// authenticates. `registerSerializer` forwards to it.
|
|
366
456
|
private exportGuest: ExportGuestController | null = null;
|
|
457
|
+
// Set only when Brass loaded this bundle to run its importer headless
|
|
458
|
+
// (`isImportMode`). `registerImporter` forwards to it.
|
|
459
|
+
private importGuest: ImportGuestController | null = null;
|
|
460
|
+
private importer: DocumentImporter | null = null;
|
|
461
|
+
// Opens of a file this app imports, waiting for `registerImporter`.
|
|
462
|
+
private readonly importerWaiters: Array<() => void> = [];
|
|
463
|
+
// An open gave up waiting for `registerImporter`, so a registration after
|
|
464
|
+
// it is late and says so once.
|
|
465
|
+
private importerWaitExpired = false;
|
|
367
466
|
// Open documents by id, so an export request can hand the serializer
|
|
368
467
|
// the right Document. Dropped when a doc goes; a re-open overwrites.
|
|
369
468
|
private openDocuments = new Map<string, Document>();
|
|
370
469
|
// The app's import backend (`Brass.registerImporter`) plus the claim
|
|
371
470
|
// protocol run per opened document. The deps read the documents
|
|
372
471
|
// namespace lazily, so attempts (always post-open) build it on demand.
|
|
472
|
+
// A guest imports only the trial document it started from a file, through
|
|
473
|
+
// that document's edit link, which is the page's credential.
|
|
373
474
|
private readonly importRunner: ImportRunner = createImportRunner({
|
|
374
|
-
claimImport: (docId) =>
|
|
375
|
-
|
|
475
|
+
claimImport: (docId) => {
|
|
476
|
+
const guest = this.guestImports(docId);
|
|
477
|
+
return guest ? guest.claimImport() : this.docsInternal().claimImport(docId);
|
|
478
|
+
},
|
|
479
|
+
stageImport: (docId, bundle) => {
|
|
480
|
+
const guest = this.guestImports(docId);
|
|
481
|
+
return guest ? guest.stageImport(bundle) : this.docsInternal().stageImport(docId, bundle);
|
|
482
|
+
},
|
|
483
|
+
completeImport: (docId, report, uploadKey) => {
|
|
484
|
+
const guest = this.guestImports(docId);
|
|
485
|
+
return guest
|
|
486
|
+
? guest.completeImport(report, uploadKey)
|
|
487
|
+
: this.docsInternal().completeImport(docId, report, uploadKey);
|
|
488
|
+
},
|
|
489
|
+
failImport: (docId, message) => {
|
|
490
|
+
const guest = this.guestImports(docId);
|
|
491
|
+
return guest ? guest.failImport(message) : this.docsInternal().failImport(docId, message);
|
|
492
|
+
},
|
|
376
493
|
reportError: (error) => this.fireError(error),
|
|
377
494
|
});
|
|
495
|
+
|
|
496
|
+
private guestImports(docId: string): GuestImportCalls | null {
|
|
497
|
+
const token = this.guestLinkTokenValue;
|
|
498
|
+
return token === null ? null : this.getDocumentFactory().guestImports(token, docId);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Whether this page runs a registered importer for an open document: one
|
|
502
|
+
// whose join offers this app the import (`awaits_import`), or whose file is
|
|
503
|
+
// still being stored, which the runner waits out before it claims. Any other
|
|
504
|
+
// document has nothing to import, and the platform refuses a claim on it.
|
|
505
|
+
// A guest imports only into a trial document it started from a file (a trial
|
|
506
|
+
// carries an expiry).
|
|
507
|
+
private importsInto(doc: Document): boolean {
|
|
508
|
+
const offered = doc._awaitsImport || doc._workStatusNow?.pending === true;
|
|
509
|
+
if (this.guestLinkTokenValue === null) return offered;
|
|
510
|
+
return doc.expiresAt !== null && offered;
|
|
511
|
+
}
|
|
378
512
|
// One socket carrying every document topic this client subscribes,
|
|
379
513
|
// built on first open so an app that never opens a document never
|
|
380
514
|
// connects.
|
|
381
515
|
private docWsClient: BrassWsClient | null = null;
|
|
516
|
+
// One socket per link a document on this page opened through, by token.
|
|
517
|
+
private readonly linkWsClients = new Map<string, BrassWsClient>();
|
|
382
518
|
|
|
383
519
|
// init ALWAYS resolves: failures (bad/missing app_id) fire onError +
|
|
384
520
|
// console.error rather than rejecting. Otherwise
|
|
@@ -392,6 +528,7 @@ export class BrassClient {
|
|
|
392
528
|
// the click handler that opens a surface, so it would miss the press that
|
|
393
529
|
// opened it and have nothing to hand focus back to on dismissal.
|
|
394
530
|
trackHostedUiActivations();
|
|
531
|
+
this.initFailure = null;
|
|
395
532
|
// The WASM this package runs on is fetched from here, so the download
|
|
396
533
|
// overlaps the auth round-trip below and is usually ready before anything
|
|
397
534
|
// asks for it. It is deliberately NOT awaited here: an app's sign-in
|
|
@@ -472,9 +609,7 @@ export class BrassClient {
|
|
|
472
609
|
this.apiBaseUrlConfigured = urls.apiBaseUrlConfigured;
|
|
473
610
|
this.wsBaseUrl = urls.wsBaseUrl;
|
|
474
611
|
this._dashboardBaseUrl = urls.dashboardBaseUrl;
|
|
475
|
-
|
|
476
|
-
this.openIncompatibleDocuments = true;
|
|
477
|
-
}
|
|
612
|
+
this.openReadOnly = options.open?.readOnly === true;
|
|
478
613
|
if (options.hostedUiTheme) {
|
|
479
614
|
this.hostedUiTheme = options.hostedUiTheme;
|
|
480
615
|
}
|
|
@@ -501,23 +636,80 @@ export class BrassClient {
|
|
|
501
636
|
if (this.serializer) this.exportGuest.setSerializer(this.serializer);
|
|
502
637
|
return;
|
|
503
638
|
}
|
|
639
|
+
if (typeof window !== 'undefined' && isImportMode(window.location.search)) {
|
|
640
|
+
this.importGuest = startImportGuest();
|
|
641
|
+
if (this.importer) this.importGuest.setImporter(this.importer);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
504
644
|
const guestToken =
|
|
505
645
|
typeof window !== 'undefined'
|
|
506
646
|
? readGuestLinkToken(window.location.search)
|
|
507
647
|
: null;
|
|
508
648
|
if (guestToken) {
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
649
|
+
// Only a sign-in this tab started from this token claims a document
|
|
650
|
+
// created without an account, and only in the browser holding the
|
|
651
|
+
// mint's claim secret. `brass_code` on the URL is not enough: the
|
|
652
|
+
// cookie session resolves whether or not the code is real, so a
|
|
653
|
+
// crafted link carrying one would claim into whoever opens it. A
|
|
654
|
+
// stored hint or session pointer says nothing about whether the
|
|
655
|
+
// person meant to keep THIS document either, so a signed-in browser
|
|
656
|
+
// opening the link cold stays signed in and leaves the document
|
|
657
|
+
// temporary; the strip's Keep is the deliberate act that claims it.
|
|
658
|
+
//
|
|
659
|
+
// Who the person is comes from the session either way. The link is
|
|
660
|
+
// the credential for this document alone: its reads, writes and
|
|
661
|
+
// socket use the link, and a link open carries the person's own
|
|
662
|
+
// credential beside it (`currentLinkCredential`).
|
|
663
|
+
if (signInReturned()) {
|
|
664
|
+
const appId = this.requireAppId();
|
|
665
|
+
const claimSecret =
|
|
666
|
+
takeClaimIntent(appId) === guestToken
|
|
667
|
+
? readClaimSecret(appId, guestToken)
|
|
668
|
+
: null;
|
|
669
|
+
await this.bootstrap();
|
|
670
|
+
if (this.user !== null) {
|
|
671
|
+
const anonymous = await this.probeAnonymousLink(guestToken);
|
|
672
|
+
const claim =
|
|
673
|
+
anonymous !== null && claimSecret !== null
|
|
674
|
+
? { ...anonymous, claimSecret }
|
|
675
|
+
: null;
|
|
676
|
+
if (anonymous !== null && claim === null) {
|
|
677
|
+
this.reportUncatchable(
|
|
678
|
+
new BrassError(
|
|
679
|
+
'Only the browser that started this work can keep it. It stays open here until it expires.',
|
|
680
|
+
),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
if (claim !== null) {
|
|
684
|
+
if (await this.claimAnonymousLink(claim)) {
|
|
685
|
+
await this.hydrateProfile();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Refused, or the person declined to place it: `claimAnonymousLink`
|
|
689
|
+
// has entered guest mode on the token.
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
} else {
|
|
694
|
+
await this.bootstrap();
|
|
695
|
+
}
|
|
696
|
+
// The token is the document's credential: the session opens the
|
|
697
|
+
// document through it. `authStatus` reports the person (`'guest'`
|
|
698
|
+
// with nobody signed in) and the finally block's initial
|
|
699
|
+
// `onUserChange` emit drives the boot (the session's guest-aware
|
|
700
|
+
// handler calls start()).
|
|
514
701
|
this.guestLinkTokenValue = guestToken;
|
|
702
|
+
await this.hydrateProfile();
|
|
515
703
|
return;
|
|
516
704
|
}
|
|
517
705
|
await this.bootstrap();
|
|
518
706
|
await this.hydrateProfile();
|
|
519
707
|
} catch (err) {
|
|
520
708
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
709
|
+
// A failure before `initialized` is the app's configuration (a missing
|
|
710
|
+
// or malformed app id, a hosted bundle without its runtime config), so
|
|
711
|
+
// it holds for the page's life and the session reports it as its error.
|
|
712
|
+
if (!this.initialized) this.initFailure = new BrassError(error.message);
|
|
521
713
|
this.reportUncatchable(error);
|
|
522
714
|
// Deliberately do not re-throw: callers using
|
|
523
715
|
// `void Brass.init(...).then(setup)` should see .then() fire so the
|
|
@@ -540,10 +732,28 @@ export class BrassClient {
|
|
|
540
732
|
// mean the person is signed out, and an app that reads it as that
|
|
541
733
|
// renders a signed-out UI or calls `signIn()`, which navigates the
|
|
542
734
|
// frame away from the export it was loaded to run.
|
|
543
|
-
if (this.
|
|
735
|
+
if (!this.isHeadlessGuest()) {
|
|
736
|
+
// Before anything opens: bytes kept on this device for whoever used
|
|
737
|
+
// it last are dropped unless that was this same identity.
|
|
738
|
+
adoptSealedCacheOwner(this.user?.sub ?? null);
|
|
544
739
|
for (const listener of Array.from(this.listeners)) {
|
|
545
740
|
listener(this.user);
|
|
546
741
|
}
|
|
742
|
+
if (this.initialized && !this.watchingPageRestore) {
|
|
743
|
+
this.watchingPageRestore = true;
|
|
744
|
+
onPageRestored(() => void this.reloadIfRestoredStale());
|
|
745
|
+
}
|
|
746
|
+
if (this.user !== null && this.appId && typeof window !== 'undefined') {
|
|
747
|
+
checkManifestDrift({
|
|
748
|
+
appId: this.appId,
|
|
749
|
+
origin: window.location.origin,
|
|
750
|
+
apiFetch: createApiFetch({
|
|
751
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
752
|
+
getAccessToken: () => this.getAccessToken(),
|
|
753
|
+
getAppAssertion: () => this.getAppAssertion(),
|
|
754
|
+
}),
|
|
755
|
+
});
|
|
756
|
+
}
|
|
547
757
|
}
|
|
548
758
|
}
|
|
549
759
|
}
|
|
@@ -560,7 +770,9 @@ export class BrassClient {
|
|
|
560
770
|
if (!this.initialized || !this.appId) {
|
|
561
771
|
this.reportUncatchable(
|
|
562
772
|
new Error(
|
|
563
|
-
this.
|
|
773
|
+
this.initFailure
|
|
774
|
+
? this.initFailure.message
|
|
775
|
+
: this.initialized
|
|
564
776
|
? 'Brass.init was called without an app ID'
|
|
565
777
|
: 'Call Brass.init(appId) first',
|
|
566
778
|
),
|
|
@@ -574,7 +786,8 @@ export class BrassClient {
|
|
|
574
786
|
// An export frame stays where it is. It runs a serializer over bytes
|
|
575
787
|
// Brass handed it and never calls the API, so navigating to sign-in
|
|
576
788
|
// would abandon the export mid-flight for a session it has no use for.
|
|
577
|
-
if (this.
|
|
789
|
+
if (this.isHeadlessGuest()) return;
|
|
790
|
+
if (this.guestLinkTokenValue !== null) markClaimIntent(appId, this.guestLinkTokenValue);
|
|
578
791
|
window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
|
|
579
792
|
}
|
|
580
793
|
|
|
@@ -625,9 +838,11 @@ export class BrassClient {
|
|
|
625
838
|
// memberships are their own, so both the stored choice and this page
|
|
626
839
|
// load's resolution go with the session.
|
|
627
840
|
clearActingOrganization(appId);
|
|
628
|
-
this.
|
|
629
|
-
this
|
|
630
|
-
|
|
841
|
+
this.retireActingOrganizationState();
|
|
842
|
+
// And the guest documents this browser made, with the secrets that claim
|
|
843
|
+
// them, so the next person here is neither offered one nor able to keep it.
|
|
844
|
+
clearUnsavedWork(appId);
|
|
845
|
+
clearClaimSecret(appId);
|
|
631
846
|
const signedOutUrl = buildSignedOutOfAppUrl(this.authBaseUrl, appId, window.location.href);
|
|
632
847
|
// On a Brass-hosted origin, route the sign-out through the edge's
|
|
633
848
|
// /__brass/leave so the first-party brass_site gate cookie is cleared
|
|
@@ -664,8 +879,8 @@ export class BrassClient {
|
|
|
664
879
|
// render from it.
|
|
665
880
|
get authStatus(): AuthStatus {
|
|
666
881
|
if (!this.bootstrapped) return 'unknown';
|
|
667
|
-
if (this.
|
|
668
|
-
return this.
|
|
882
|
+
if (this.user !== null) return 'signed-in';
|
|
883
|
+
return this.guestLinkTokenValue !== null ? 'guest' : 'signed-out';
|
|
669
884
|
}
|
|
670
885
|
|
|
671
886
|
// The organization this app is acting in, as far as this page load already
|
|
@@ -681,7 +896,7 @@ export class BrassClient {
|
|
|
681
896
|
}
|
|
682
897
|
|
|
683
898
|
get isGuest(): boolean {
|
|
684
|
-
return this.
|
|
899
|
+
return this.authStatus === 'guest';
|
|
685
900
|
}
|
|
686
901
|
|
|
687
902
|
// The resolved dashboard origin after `init` (the option's value if
|
|
@@ -700,7 +915,7 @@ export class BrassClient {
|
|
|
700
915
|
// a flicker through the initial `null`. Post-init subscribers get an
|
|
701
916
|
// immediate emit with the current value, the usual "register and
|
|
702
917
|
// read current state" idiom.
|
|
703
|
-
if (this.bootstrapped && this.
|
|
918
|
+
if (this.bootstrapped && !this.isHeadlessGuest()) {
|
|
704
919
|
listener(this.user);
|
|
705
920
|
}
|
|
706
921
|
return () => {
|
|
@@ -738,6 +953,8 @@ export class BrassClient {
|
|
|
738
953
|
apiBaseUrl: this.apiBaseUrl,
|
|
739
954
|
getAccessToken: () => this.getAccessToken(),
|
|
740
955
|
getAppAssertion: () => this.getAppAssertion(),
|
|
956
|
+
getSocket: () =>
|
|
957
|
+
this.appId === null ? undefined : this.getDocWsClient(this.appId),
|
|
741
958
|
});
|
|
742
959
|
}
|
|
743
960
|
return this.documentsNamespace;
|
|
@@ -752,13 +969,36 @@ export class BrassClient {
|
|
|
752
969
|
this.documentSession = createDocumentSession({
|
|
753
970
|
isSignedIn: () => this.user !== null,
|
|
754
971
|
authStatus: () => this.authStatus,
|
|
755
|
-
|
|
756
|
-
|
|
972
|
+
initFailure: () => this.initFailure,
|
|
973
|
+
onUserChange: (listener) => {
|
|
974
|
+
const wrapped: UserChangeListener = (user) => listener(user !== null);
|
|
975
|
+
this.sessionUserListeners.add(wrapped);
|
|
976
|
+
return this.onUserChange(wrapped);
|
|
977
|
+
},
|
|
757
978
|
openDocumentById: (docId, options, consent) =>
|
|
758
|
-
this.openDocumentInternal(docId, options, consent),
|
|
979
|
+
this.asOwnAccess(() => this.openDocumentInternal(docId, options, consent)),
|
|
759
980
|
guestLinkToken: () => this.guestLinkTokenValue,
|
|
981
|
+
openReadOnly: () => this.openReadOnly,
|
|
760
982
|
openDocumentByLink: (token, options) =>
|
|
761
983
|
this.openByLinkInternal(token, options),
|
|
984
|
+
mintAnonymousDocument: (args) =>
|
|
985
|
+
this.getDocumentFactory().mintAnonymousDocument(args),
|
|
986
|
+
enterGuestMode: (token) => this.enterGuestMode(token),
|
|
987
|
+
chooseAnonymousFile: () => this.chooseAnonymousFile(),
|
|
988
|
+
signedOutUseRefusal: () => this.signedOutUseRefusal(),
|
|
989
|
+
appOffersNew: () => this.appOffersNew(),
|
|
990
|
+
mintAnonymousDocumentFromFile: (args) =>
|
|
991
|
+
this.getDocumentFactory().mintAnonymousDocumentFromFile(args),
|
|
992
|
+
unsavedWork: () => this.readLiveUnsavedWork(),
|
|
993
|
+
clearUnsavedWork: () => {
|
|
994
|
+
if (this.appId !== null) clearUnsavedWork(this.appId);
|
|
995
|
+
},
|
|
996
|
+
leaveGuestMode: () => this.leaveGuestMode(),
|
|
997
|
+
followClaimedLink: (token) => this.followClaimedLink(token),
|
|
998
|
+
markTrialEdited: (token, expiresAt) => markTrialEdited(token, expiresAt),
|
|
999
|
+
trialEdited: (token) => trialWasEdited(token),
|
|
1000
|
+
confirmReplaceTrial: (expiresAt) => askReplaceTrial(expiresAt),
|
|
1001
|
+
signIn: () => this.signIn(),
|
|
762
1002
|
createDocumentId: (args) => this.createDocumentIdWithConsent(args),
|
|
763
1003
|
selectDocument: () => this.selectDocumentInternal(),
|
|
764
1004
|
selectCreateDestination: (options) =>
|
|
@@ -868,7 +1108,15 @@ export class BrassClient {
|
|
|
868
1108
|
onUserChange: (listener) => this.onUserChange(listener),
|
|
869
1109
|
signIn: () => this.signIn(),
|
|
870
1110
|
signOut: () => this.signOut(),
|
|
871
|
-
|
|
1111
|
+
canKeep: () => this.canKeepTemporaryWork(),
|
|
1112
|
+
keep: () => {
|
|
1113
|
+
void this.keepTemporaryWork();
|
|
1114
|
+
},
|
|
1115
|
+
// Both environment-dependent values are read at use rather than copied
|
|
1116
|
+
// in here: a piece can be mounted before `init()` resolves, and until it
|
|
1117
|
+
// does these hold the production defaults. See `AccountMenuDeps`.
|
|
1118
|
+
dashboardBaseUrl: () => this._dashboardBaseUrl,
|
|
1119
|
+
appId: () => this.appId ?? undefined,
|
|
872
1120
|
getActingOrganization: () => this.visibleActingOrganization(),
|
|
873
1121
|
subscribeActingOrganization: (listener) => {
|
|
874
1122
|
this.actingOrganizationListeners.add(listener);
|
|
@@ -885,9 +1133,10 @@ export class BrassClient {
|
|
|
885
1133
|
void this.switchActingOrganization();
|
|
886
1134
|
},
|
|
887
1135
|
getDocument: (docId) => this.documents.get(docId),
|
|
1136
|
+
onLink: () => this.guestLinkTokenValue !== null,
|
|
888
1137
|
// The version-preview bar is a hosted embed; it takes the same theme
|
|
889
1138
|
// as the other hosted surfaces.
|
|
890
|
-
hostedUiTheme: this.hostedUiTheme,
|
|
1139
|
+
hostedUiTheme: () => this.hostedUiTheme,
|
|
891
1140
|
};
|
|
892
1141
|
}
|
|
893
1142
|
|
|
@@ -915,6 +1164,33 @@ export class BrassClient {
|
|
|
915
1164
|
return result;
|
|
916
1165
|
}
|
|
917
1166
|
|
|
1167
|
+
// Opens the hosted picker for a SET of documents and resolves with every
|
|
1168
|
+
// one the person chose: `[]` when they took out everything `selected`
|
|
1169
|
+
// held, `null` when they dismissed it. The set is confined
|
|
1170
|
+
// to one organization. `options.accept` narrows what the picker offers to
|
|
1171
|
+
// this request alone, which is how an app that has read a file names the
|
|
1172
|
+
// siblings it is missing; `options.prompt` says why it is asking; and
|
|
1173
|
+
// `options.selected` names what the app already holds, so a later call
|
|
1174
|
+
// edits that set and resolves with the whole of it.
|
|
1175
|
+
//
|
|
1176
|
+
// Opening is unchanged: pass each ref's `doc_id` to `openDocumentById`. The
|
|
1177
|
+
// URL carries one document, so a set is the app's to hold; nothing here
|
|
1178
|
+
// writes to it. Throws before init.
|
|
1179
|
+
async chooseInputs(
|
|
1180
|
+
families: DocumentFamilies,
|
|
1181
|
+
options?: ChooseInputsOptions,
|
|
1182
|
+
): Promise<InputReference[] | null> {
|
|
1183
|
+
return this.getHostedUiLauncher().chooseInputs(families, options);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// One file as a reference, from the ordinary Open list. Throws before init.
|
|
1187
|
+
async chooseInput(
|
|
1188
|
+
families: DocumentFamilies,
|
|
1189
|
+
options?: ChooseInputOptions,
|
|
1190
|
+
): Promise<InputReference | null> {
|
|
1191
|
+
return this.getHostedUiLauncher().chooseInput(families, options);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
918
1194
|
// The value the most recent `selectDocument()` resolved with
|
|
919
1195
|
// (per-app sessionStorage), or `null` if nothing was picked this
|
|
920
1196
|
// tab. The session's boot restore reads it as the bare-URL
|
|
@@ -1007,7 +1283,15 @@ export class BrassClient {
|
|
|
1007
1283
|
docId: string,
|
|
1008
1284
|
options?: OpenDocumentByIdOptions,
|
|
1009
1285
|
): Promise<OpenedDocument> {
|
|
1010
|
-
|
|
1286
|
+
// A frozen copy is read beside whatever the page shows, usually as one of
|
|
1287
|
+
// many, so it claims no URL and raises no consent dialog of its own.
|
|
1288
|
+
if (options?.frozen === true) {
|
|
1289
|
+
return this.reopensBeside(
|
|
1290
|
+
await this.openDocumentInternal(docId, options, 'skip', true),
|
|
1291
|
+
options,
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
const opened = this.reopensBeside(await this.openDocumentInternal(docId, options), options);
|
|
1011
1295
|
// The URL names the document the reader is looking at, and the session
|
|
1012
1296
|
// owns that whenever it holds one: this open is detached, so claiming the
|
|
1013
1297
|
// URL would leave the address bar naming a document the session is not
|
|
@@ -1018,6 +1302,32 @@ export class BrassClient {
|
|
|
1018
1302
|
return opened;
|
|
1019
1303
|
}
|
|
1020
1304
|
|
|
1305
|
+
// `doc.reopen` for a copy this method opened: the same open in the asked
|
|
1306
|
+
// mode, on the branch the copy is on, after which this copy closes.
|
|
1307
|
+
private reopensBeside(
|
|
1308
|
+
doc: OpenedDocument,
|
|
1309
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
1310
|
+
): OpenedDocument {
|
|
1311
|
+
doc._setReopener(async (mode) => {
|
|
1312
|
+
const { readOnly: _readOnly, frozen: _frozen, version: _version, ...rest } = options ?? {};
|
|
1313
|
+
const { readOnly: _alias, readOnlyReason: _reason, ...documentOptions } =
|
|
1314
|
+
rest.documentOptions ?? {};
|
|
1315
|
+
// A copy opened at a stream carries the stream's line as `branchId`,
|
|
1316
|
+
// which the join refuses as a branch, so it reopens on the branch the
|
|
1317
|
+
// open named.
|
|
1318
|
+
const branch = rest.stream !== undefined ? rest.branch : (doc.branchId ?? undefined);
|
|
1319
|
+
const next = await this.openDocumentById(doc.id, {
|
|
1320
|
+
...rest,
|
|
1321
|
+
documentOptions,
|
|
1322
|
+
...(branch !== undefined ? { branch } : {}),
|
|
1323
|
+
...(mode === 'frozen' ? { frozen: true } : { readOnly: mode === 'read' }),
|
|
1324
|
+
});
|
|
1325
|
+
doc.close();
|
|
1326
|
+
return next;
|
|
1327
|
+
});
|
|
1328
|
+
return doc;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1021
1331
|
// Open a document READ-ONLY from a public-link token (the "anyone with
|
|
1022
1332
|
// the link" anonymous path). No sign-in: the token is the credential and
|
|
1023
1333
|
// the server serves a read-only, member-stripped snapshot with no live
|
|
@@ -1027,35 +1337,197 @@ export class BrassClient {
|
|
|
1027
1337
|
token: string,
|
|
1028
1338
|
options?: OpenDocumentByIdOptions,
|
|
1029
1339
|
): Promise<OpenedDocument> {
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
this.
|
|
1340
|
+
const opened = await this.importedByLink(await this.joinByLink(token, options), token, options);
|
|
1341
|
+
// A document the platform will remove was created without an account,
|
|
1342
|
+
// and its token is the only way back to it once this tab is gone. Every
|
|
1343
|
+
// guest open of one lands here (a create while signed out, the boot of
|
|
1344
|
+
// its URL, a shared link to one), so this is where the app's signed-out
|
|
1345
|
+
// gate learns of it (`session.unsavedWork`).
|
|
1346
|
+
// A token this browser has claimed is no longer unsaved work, whatever
|
|
1347
|
+
// the open answered: a page the browser kept from before the claim can
|
|
1348
|
+
// still open through it.
|
|
1349
|
+
const appId = this.requireAppId();
|
|
1350
|
+
if (opened.expiresAt !== null && readClaimedLink(appId, token) === null) {
|
|
1351
|
+
storeUnsavedWork(appId, token, opened.expiresAt);
|
|
1352
|
+
}
|
|
1033
1353
|
return opened;
|
|
1034
1354
|
}
|
|
1035
1355
|
|
|
1356
|
+
private async joinByLink(
|
|
1357
|
+
token: string,
|
|
1358
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
1359
|
+
): Promise<OpenedDocument> {
|
|
1360
|
+
const opened = await this.getDocumentFactory().openByLink(token, options);
|
|
1361
|
+
// A link the person opened as themselves was tracked by that open.
|
|
1362
|
+
if (this.openDocuments.get(opened.id) !== opened) this.trackOpenDocument(opened);
|
|
1363
|
+
return opened;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// A trial document started from a file is imported by this page before the
|
|
1367
|
+
// open resolves, as `openDocumentInternal` imports a member's: an open whose
|
|
1368
|
+
// import does not land rejects rather than resolving the empty document.
|
|
1369
|
+
private async importedByLink(
|
|
1370
|
+
opened: OpenedDocument,
|
|
1371
|
+
token: string,
|
|
1372
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
1373
|
+
): Promise<OpenedDocument> {
|
|
1374
|
+
if (!opened._awaitsImport || !this.importsInto(opened)) return opened;
|
|
1375
|
+
if (await this.importBeforeResolving(opened, options?.frozen === true, options?.onSnapshotProgress)) {
|
|
1376
|
+
return opened;
|
|
1377
|
+
}
|
|
1378
|
+
const imported = await this.joinByLink(token, options);
|
|
1379
|
+
if (!imported._awaitsImport) return imported;
|
|
1380
|
+
imported._reopen();
|
|
1381
|
+
imported.close();
|
|
1382
|
+
throw notImported(opened);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1036
1385
|
// The open path shared by the public `openDocumentById` and the
|
|
1037
1386
|
// session. It does NOT write the URL: the public method pins the id
|
|
1038
1387
|
// (`replaceState`) for direct multi-doc callers, while the session
|
|
1039
1388
|
// owns its own push / replace history writes so a single-doc open
|
|
1040
1389
|
// becomes a back-navigable entry without the two layers double-writing.
|
|
1390
|
+
//
|
|
1391
|
+
// A file this app imports and nobody has imported yet is imported before the
|
|
1392
|
+
// open resolves, in every mode. The platform refuses the open of a file only
|
|
1393
|
+
// another app imports (`not_imported`), so a join that still awaits an
|
|
1394
|
+
// import names this app as the importer.
|
|
1041
1395
|
private async openDocumentInternal(
|
|
1042
1396
|
docId: string,
|
|
1043
1397
|
options?: OpenDocumentByIdOptions,
|
|
1044
1398
|
consent: 'offer' | 'skip' = 'offer',
|
|
1399
|
+
// A copy read beside whatever the page shows (one of a set a pass reads,
|
|
1400
|
+
// or a frozen copy) is not the session's copy of its id, so it is not
|
|
1401
|
+
// tracked as the open copy of that id.
|
|
1402
|
+
beside = false,
|
|
1403
|
+
): Promise<OpenedDocument> {
|
|
1404
|
+
const opened = await this.joinDocument(docId, options, consent, beside);
|
|
1405
|
+
if (!opened._awaitsImport || !this.importsInto(opened)) return opened;
|
|
1406
|
+
if (await this.importBeforeResolving(opened, options?.frozen === true, options?.onSnapshotProgress)) {
|
|
1407
|
+
return opened;
|
|
1408
|
+
}
|
|
1409
|
+
const imported = await this.joinDocument(docId, options, 'skip', beside);
|
|
1410
|
+
if (!imported._awaitsImport) return imported;
|
|
1411
|
+
// The import ended without landing (the claim was refused, or this client
|
|
1412
|
+
// stopped waiting on another window's), which the app's error handler has
|
|
1413
|
+
// already been told.
|
|
1414
|
+
imported._reopen();
|
|
1415
|
+
imported.close();
|
|
1416
|
+
throw notImported(opened);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Runs this app's importer on a document opened before its import, and
|
|
1420
|
+
// resolves once the import has ended: true when the copy now holds the
|
|
1421
|
+
// import (a frozen copy, reloaded in place), false when the open joins again
|
|
1422
|
+
// for it. Rejects with the import's error when it failed.
|
|
1423
|
+
private async importBeforeResolving(
|
|
1424
|
+
doc: OpenedDocument,
|
|
1425
|
+
frozen: boolean,
|
|
1426
|
+
progress: OpenDocumentByIdOptions['onSnapshotProgress'],
|
|
1427
|
+
): Promise<boolean> {
|
|
1428
|
+
// An open that ran before the app registered its importer waits for it.
|
|
1429
|
+
if (this.importer === null && !(await this.waitForImporter(doc))) {
|
|
1430
|
+
if (doc._isLive) {
|
|
1431
|
+
doc._reopen();
|
|
1432
|
+
doc.close();
|
|
1433
|
+
}
|
|
1434
|
+
// The message reaches the app's users, so the cause goes to the console.
|
|
1435
|
+
console.warn(
|
|
1436
|
+
`[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.`,
|
|
1437
|
+
);
|
|
1438
|
+
const refused = new BrassError(NOT_IMPORTED_MESSAGE, { status: 0, code: 'not_imported' });
|
|
1439
|
+
importerWaitRejections.add(refused);
|
|
1440
|
+
throw refused;
|
|
1441
|
+
}
|
|
1442
|
+
// A tracked copy had its attempt when it was tracked or when the importer
|
|
1443
|
+
// was registered, so a call for the same open is a no-op. A frozen copy
|
|
1444
|
+
// takes the import under the same handles.
|
|
1445
|
+
void this.importRunner.attempt(doc, frozen ? { reloadInPlace: true } : undefined);
|
|
1446
|
+
await new Promise<void>((resolve) => {
|
|
1447
|
+
const stops: Array<() => void> = [];
|
|
1448
|
+
let settled = false;
|
|
1449
|
+
const done = (): void => {
|
|
1450
|
+
if (settled) return;
|
|
1451
|
+
settled = true;
|
|
1452
|
+
queueMicrotask(() => stops.forEach((stop) => stop()));
|
|
1453
|
+
resolve();
|
|
1454
|
+
};
|
|
1455
|
+
stops.push(doc.onGone(done));
|
|
1456
|
+
stops.push(
|
|
1457
|
+
doc.onStatus((status) => {
|
|
1458
|
+
// A settled status from the work that stored the file starts the
|
|
1459
|
+
// import, whose hold is set before this listener hears that status.
|
|
1460
|
+
if (!status.pending && doc._importHold === null) done();
|
|
1461
|
+
// The import's own progress is what the opener shows while it waits.
|
|
1462
|
+
else progress?.(0, null, 'importing', status.message);
|
|
1463
|
+
}),
|
|
1464
|
+
);
|
|
1465
|
+
});
|
|
1466
|
+
if (doc._isLive && doc._contentLoadFailed) {
|
|
1467
|
+
const message = doc._workStatusNow?.message;
|
|
1468
|
+
doc._reopen();
|
|
1469
|
+
doc.close();
|
|
1470
|
+
throw new BrassError(message !== undefined && message !== '' ? message : IMPORT_FAILED_MESSAGE, {
|
|
1471
|
+
status: 0,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
if (frozen && doc._isLive && doc._goneReason === null) {
|
|
1475
|
+
// A reload the importer started is shared.
|
|
1476
|
+
await doc.reload();
|
|
1477
|
+
if (doc._isLive) return true;
|
|
1478
|
+
}
|
|
1479
|
+
// Retired as moved rather than parked, so the join that follows builds a
|
|
1480
|
+
// copy from the imported content instead of taking this empty one back.
|
|
1481
|
+
if (doc._isLive) {
|
|
1482
|
+
doc._reopen();
|
|
1483
|
+
doc.close();
|
|
1484
|
+
}
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Resolves true when `registerImporter` runs, false at the ceiling.
|
|
1489
|
+
private waitForImporter(doc: OpenedDocument): Promise<boolean> {
|
|
1490
|
+
return new Promise<boolean>((resolve) => {
|
|
1491
|
+
const warning = inDevelopment()
|
|
1492
|
+
? setTimeout(() => {
|
|
1493
|
+
const dot = doc.name?.lastIndexOf('.') ?? -1;
|
|
1494
|
+
const type = dot > 0 ? `${doc.name!.slice(dot)} files` : 'this file';
|
|
1495
|
+
console.warn(
|
|
1496
|
+
`[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.`,
|
|
1497
|
+
);
|
|
1498
|
+
}, IMPORTER_WAIT_WARN_MS)
|
|
1499
|
+
: undefined;
|
|
1500
|
+
const settle = (registered: boolean): void => {
|
|
1501
|
+
clearTimeout(warning);
|
|
1502
|
+
clearTimeout(ceiling);
|
|
1503
|
+
const at = this.importerWaiters.indexOf(waiter);
|
|
1504
|
+
if (at !== -1) this.importerWaiters.splice(at, 1);
|
|
1505
|
+
resolve(registered);
|
|
1506
|
+
};
|
|
1507
|
+
const waiter = (): void => settle(true);
|
|
1508
|
+
const ceiling = setTimeout(() => {
|
|
1509
|
+
this.importerWaitExpired = true;
|
|
1510
|
+
settle(false);
|
|
1511
|
+
}, IMPORTER_WAIT_CEILING_MS);
|
|
1512
|
+
this.importerWaiters.push(waiter);
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
private async joinDocument(
|
|
1517
|
+
docId: string,
|
|
1518
|
+
options: OpenDocumentByIdOptions | undefined,
|
|
1519
|
+
consent: 'offer' | 'skip',
|
|
1520
|
+
beside: boolean,
|
|
1045
1521
|
): Promise<OpenedDocument> {
|
|
1046
1522
|
const factory = this.getDocumentFactory();
|
|
1047
1523
|
// After getDocumentFactory() resolves, requireAppId() has run and
|
|
1048
1524
|
// this.appId is a string.
|
|
1049
1525
|
const appId = this.appId as string;
|
|
1050
|
-
// An app that declared it opens these carries the answer on every open,
|
|
1051
|
-
// so nothing is asked and nothing is refused.
|
|
1052
|
-
const withConsent: OpenDocumentByIdOptions | undefined = this
|
|
1053
|
-
.openIncompatibleDocuments
|
|
1054
|
-
? { ...options, acceptSchemaConflict: true }
|
|
1055
|
-
: options;
|
|
1056
1526
|
try {
|
|
1057
|
-
const opened = await factory.open(docId,
|
|
1058
|
-
|
|
1527
|
+
const opened = await factory.open(docId, options);
|
|
1528
|
+
// The tracked set is the live copy of each document, keyed by id; a
|
|
1529
|
+
// copy read beside it must not replace it there.
|
|
1530
|
+
if (!beside) this.trackOpenDocument(opened);
|
|
1059
1531
|
return opened;
|
|
1060
1532
|
} catch (err) {
|
|
1061
1533
|
// The platform refuses to join a document this app's schema cannot
|
|
@@ -1067,10 +1539,10 @@ export class BrassClient {
|
|
|
1067
1539
|
const { openAnyway } = await askSchemaConflict(err.message, err.title);
|
|
1068
1540
|
if (!openAnyway) throw err;
|
|
1069
1541
|
const opened = await factory.open(docId, {
|
|
1070
|
-
...
|
|
1542
|
+
...options,
|
|
1071
1543
|
acceptSchemaConflict: true,
|
|
1072
1544
|
});
|
|
1073
|
-
this.trackOpenDocument(opened);
|
|
1545
|
+
if (!beside) this.trackOpenDocument(opened);
|
|
1074
1546
|
return opened;
|
|
1075
1547
|
}
|
|
1076
1548
|
// A denial because the document's org has no install of this app
|
|
@@ -1083,7 +1555,7 @@ export class BrassClient {
|
|
|
1083
1555
|
// before they touch anything.
|
|
1084
1556
|
if (consent === 'offer' && (await this.offerInstallConsent(err))) {
|
|
1085
1557
|
const opened = await factory.open(docId, options);
|
|
1086
|
-
this.trackOpenDocument(opened);
|
|
1558
|
+
if (!beside) this.trackOpenDocument(opened);
|
|
1087
1559
|
return opened;
|
|
1088
1560
|
}
|
|
1089
1561
|
if (isAppNotInstalled(err)) throw err;
|
|
@@ -1115,7 +1587,9 @@ export class BrassClient {
|
|
|
1115
1587
|
|
|
1116
1588
|
async createDocument(options?: CreateDocumentOptions): Promise<OpenedDocument> {
|
|
1117
1589
|
const opened = await this.createDocumentInternal(options);
|
|
1118
|
-
|
|
1590
|
+
// A document minted without an account is reached through the link
|
|
1591
|
+
// token the URL already carries, never through its id.
|
|
1592
|
+
if (this.guestLinkTokenValue === null) this.maybeWriteDocIdToUrl(opened.id);
|
|
1119
1593
|
return opened;
|
|
1120
1594
|
}
|
|
1121
1595
|
|
|
@@ -1125,18 +1599,34 @@ export class BrassClient {
|
|
|
1125
1599
|
options?: CreateDocumentOptions,
|
|
1126
1600
|
): Promise<OpenedDocument> {
|
|
1127
1601
|
const factory = this.getDocumentFactory();
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1602
|
+
// No account: mint the document without one and open it through its
|
|
1603
|
+
// link token, which becomes this page's credential.
|
|
1604
|
+
if (this.authStatus === 'signed-out') {
|
|
1605
|
+
const minted = await factory.mintAnonymousDocument(
|
|
1606
|
+
options?.name !== undefined ? { name: options.name } : {},
|
|
1607
|
+
);
|
|
1608
|
+
this.enterGuestMode(minted.linkToken);
|
|
1609
|
+
return this.openByLinkInternal(
|
|
1610
|
+
minted.linkToken,
|
|
1611
|
+
options?.documentOptions
|
|
1612
|
+
? { documentOptions: options.documentOptions }
|
|
1613
|
+
: undefined,
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
return this.asOwnAccess(async () => {
|
|
1617
|
+
try {
|
|
1134
1618
|
const opened = await factory.create(options);
|
|
1135
1619
|
this.trackOpenDocument(opened);
|
|
1136
1620
|
return opened;
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
if (await this.offerInstallConsent(err)) {
|
|
1623
|
+
const opened = await factory.create(options);
|
|
1624
|
+
this.trackOpenDocument(opened);
|
|
1625
|
+
return opened;
|
|
1626
|
+
}
|
|
1627
|
+
throw err;
|
|
1137
1628
|
}
|
|
1138
|
-
|
|
1139
|
-
}
|
|
1629
|
+
});
|
|
1140
1630
|
}
|
|
1141
1631
|
|
|
1142
1632
|
// Create-only for the session: classify the outcome (created / needs a
|
|
@@ -1158,15 +1648,388 @@ export class BrassClient {
|
|
|
1158
1648
|
}
|
|
1159
1649
|
}
|
|
1160
1650
|
|
|
1651
|
+
// The claim's install-consent retry, the same reactive install
|
|
1652
|
+
// `createDocumentIdWithConsent` gives a create.
|
|
1653
|
+
private async claimAnonymousDocumentWithConsent(
|
|
1654
|
+
docId: string,
|
|
1655
|
+
args: ClaimAnonymousDocumentArgs,
|
|
1656
|
+
): Promise<ClaimDocumentResult> {
|
|
1657
|
+
const factory = this.getDocumentFactory();
|
|
1658
|
+
try {
|
|
1659
|
+
return await factory.claimAnonymousDocument(docId, args);
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
if (await this.offerInstallConsent(err)) {
|
|
1662
|
+
return factory.claimAnonymousDocument(docId, args);
|
|
1663
|
+
}
|
|
1664
|
+
throw err;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// The app's upload-types read, once per page. A failed read is dropped so
|
|
1669
|
+
// the next asks again.
|
|
1670
|
+
private anonymousUploadTypes(): Promise<AnonymousUploadTypes> {
|
|
1671
|
+
if (this.anonymousUploadTypesRead === null) {
|
|
1672
|
+
const read = this.getDocumentFactory().anonymousUploadTypes();
|
|
1673
|
+
this.anonymousUploadTypesRead = read;
|
|
1674
|
+
read.catch(() => {
|
|
1675
|
+
if (this.anonymousUploadTypesRead === read) this.anonymousUploadTypesRead = null;
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
return this.anonymousUploadTypesRead;
|
|
1679
|
+
}
|
|
1680
|
+
private anonymousUploadTypesRead: Promise<AnonymousUploadTypes> | null = null;
|
|
1681
|
+
|
|
1682
|
+
// Whether this app offers New, from its row on the platform. A failed read
|
|
1683
|
+
// offers New, and the create reports at the press.
|
|
1684
|
+
private async appOffersNew(): Promise<boolean> {
|
|
1685
|
+
try {
|
|
1686
|
+
return (await this.anonymousUploadTypes()).offersNew;
|
|
1687
|
+
} catch {
|
|
1688
|
+
return true;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// The file types a visitor can start a document of this app from.
|
|
1693
|
+
private async anonymousUploadAccept(): Promise<AnonymousUploadAccept> {
|
|
1694
|
+
const read = await this.anonymousUploadTypes();
|
|
1695
|
+
if ('refusal' in read) throw read.refusal;
|
|
1696
|
+
return read.accept;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// The refusal this app meets without an account, read off the same
|
|
1700
|
+
// upload-types read a signed-out pick makes, so a later Upload reuses it.
|
|
1701
|
+
// Only the `sign_in_required` answer withdraws New and Open; any
|
|
1702
|
+
// other failure leaves them to report at the press.
|
|
1703
|
+
private async signedOutUseRefusal(): Promise<BrassError | null> {
|
|
1704
|
+
try {
|
|
1705
|
+
await this.anonymousUploadAccept();
|
|
1706
|
+
return null;
|
|
1707
|
+
} catch (err) {
|
|
1708
|
+
return err instanceof BrassError && err.code === 'sign_in_required' ? err : null;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// The file a signed-out pick starts a document from. An app that can start
|
|
1713
|
+
// from no file type refuses rather than opening a dialog every file in
|
|
1714
|
+
// which would be refused. The types are read at the press, inside the
|
|
1715
|
+
// several seconds a browser lets that press open a file dialog.
|
|
1716
|
+
private async chooseAnonymousFile(): Promise<File | null> {
|
|
1717
|
+
const { accept, maxBytes } = await this.anonymousUploadAccept();
|
|
1718
|
+
if (accept === '') {
|
|
1719
|
+
throw new BrassError('This app cannot open a file without an account. Sign in to open one.', {
|
|
1720
|
+
status: 400,
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
const file = await chooseFile(accept);
|
|
1724
|
+
if (file !== null && file.size > maxBytes) {
|
|
1725
|
+
const mb = Math.floor(maxBytes / (1024 * 1024));
|
|
1726
|
+
throw new BrassError(
|
|
1727
|
+
`That file is too large to open without an account (limit ${mb} MB). Sign in to import it.`,
|
|
1728
|
+
{ status: 400 },
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
return file;
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// Make a link token the open document's credential, with the URL carrying
|
|
1735
|
+
// it so a reload and a sign-in both return to the document. With nobody
|
|
1736
|
+
// signed in `authStatus` reads `'guest'`; a signed-in person who did not
|
|
1737
|
+
// keep the document stays signed in.
|
|
1738
|
+
private enterGuestMode(token: string): void {
|
|
1739
|
+
const credentialChanged = this.guestLinkTokenValue !== token;
|
|
1740
|
+
this.guestLinkTokenValue = token;
|
|
1741
|
+
writeGuestLinkToken(token);
|
|
1742
|
+
// A guest socket is admitted to the one document its link names, so a
|
|
1743
|
+
// socket opened under another credential cannot carry this one.
|
|
1744
|
+
if (credentialChanged) this.docWsClient?.reconnect();
|
|
1745
|
+
this.emitGuestModeChange();
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// `authStatus` moved between 'signed-out' (or 'signed-in') and 'guest'
|
|
1749
|
+
// without a sign-in or sign-out, which is still an auth change to an app
|
|
1750
|
+
// that renders its shells off `onUserChange`.
|
|
1751
|
+
private emitGuestModeChange(): void {
|
|
1752
|
+
// With someone signed in, a link coming or going leaves `authStatus` as it
|
|
1753
|
+
// was, so there is nothing to announce.
|
|
1754
|
+
if (!this.bootstrapped || this.isHeadlessGuest() || this.user !== null) return;
|
|
1755
|
+
for (const listener of Array.from(this.listeners)) {
|
|
1756
|
+
if (this.sessionUserListeners.has(listener)) continue;
|
|
1757
|
+
listener(this.user);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// Take a token back out of the page's credential and URL after it failed
|
|
1762
|
+
// to open, so `authStatus` reads `'signed-out'` again. Only the session's
|
|
1763
|
+
// continue path reaches this, from a page that held no session.
|
|
1764
|
+
private leaveGuestMode(): void {
|
|
1765
|
+
this.guestLinkTokenValue = null;
|
|
1766
|
+
clearGuestLinkToken();
|
|
1767
|
+
this.emitGuestModeChange();
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Where a refused link leads this person, when their own claim revoked it.
|
|
1771
|
+
// For the claimant the page leaves the link signed in, with the document's
|
|
1772
|
+
// id on the URL. A page with no session, in the browser that made the
|
|
1773
|
+
// claim, leaves the link signed out with the id on the URL, so the sign-in
|
|
1774
|
+
// the app offers returns to the document. Anyone else keeps the refusal.
|
|
1775
|
+
private async followClaimedLink(token: string): Promise<ClaimedLinkTarget> {
|
|
1776
|
+
if (this.guestLinkTokenValue !== token) return null;
|
|
1777
|
+
const tokens = this.tokens;
|
|
1778
|
+
if (this.user === null || tokens === null) {
|
|
1779
|
+
const claimedHere = this.appId === null ? null : readClaimedLink(this.appId, token);
|
|
1780
|
+
if (claimedHere === null) return null;
|
|
1781
|
+
this.forgetUnsavedWorkFor(token);
|
|
1782
|
+
this.leaveClaimedLink(claimedHere);
|
|
1783
|
+
this.emitGuestModeChange();
|
|
1784
|
+
return 'sign-in';
|
|
1785
|
+
}
|
|
1786
|
+
const docId = await this.readClaimedLink(token, tokens).catch(() => null);
|
|
1787
|
+
if (docId === null || this.guestLinkTokenValue !== token) return null;
|
|
1788
|
+
if (this.appId !== null) storeClaimedLink(this.appId, token, docId);
|
|
1789
|
+
this.forgetUnsavedWorkFor(token);
|
|
1790
|
+
this.leaveClaimedLink(docId);
|
|
1791
|
+
await this.hydrateProfile();
|
|
1792
|
+
this.docWsClient?.reconnect();
|
|
1793
|
+
this.emitGuestModeChange();
|
|
1794
|
+
return { docId };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// The way back to unsaved work, unless its token is one this browser has
|
|
1798
|
+
// claimed: that work has an owner and a document id, and a page that opened
|
|
1799
|
+
// the token from before the claim may have written it back.
|
|
1800
|
+
private readLiveUnsavedWork(): UnsavedWorkEntry | null {
|
|
1801
|
+
if (this.appId === null) return null;
|
|
1802
|
+
const entry = readUnsavedWork(this.appId);
|
|
1803
|
+
if (entry === null || readClaimedLink(this.appId, entry.token) === null) return entry;
|
|
1804
|
+
clearUnsavedWork(this.appId);
|
|
1805
|
+
return null;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
private forgetUnsavedWorkFor(token: string): void {
|
|
1809
|
+
if (this.appId !== null && readUnsavedWork(this.appId)?.token === token) {
|
|
1810
|
+
clearUnsavedWork(this.appId);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
private leaveClaimedLink(docId: string): void {
|
|
1815
|
+
this.guestLinkTokenValue = null;
|
|
1816
|
+
clearGuestLinkToken();
|
|
1817
|
+
this.maybeWriteDocIdToUrl(docId);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
private async readClaimedLink(token: string, tokens: TokenSet): Promise<string | null> {
|
|
1821
|
+
const headers: Record<string, string> = {
|
|
1822
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
1823
|
+
};
|
|
1824
|
+
applyClientVersionHeader(headers);
|
|
1825
|
+
if (tokens.appAssertion) headers['x-brass-app-assertion'] = tokens.appAssertion;
|
|
1826
|
+
const response = await fetch(
|
|
1827
|
+
`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/claimed`,
|
|
1828
|
+
{ method: 'GET', headers },
|
|
1829
|
+
);
|
|
1830
|
+
if (!response.ok) return null;
|
|
1831
|
+
const body = (await response.json()) as { doc_id?: unknown };
|
|
1832
|
+
return typeof body.doc_id === 'string' && body.doc_id !== '' ? body.doc_id : null;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// A page back from the back-forward cache, checked against what may have
|
|
1836
|
+
// moved while it was frozen; see `page-restore.ts`.
|
|
1837
|
+
private async reloadIfRestoredStale(): Promise<void> {
|
|
1838
|
+
const appId = this.appId;
|
|
1839
|
+
if (appId === null) return;
|
|
1840
|
+
const stale = await restoredPageIsStale(
|
|
1841
|
+
{
|
|
1842
|
+
guestLinkToken: this.guestLinkTokenValue,
|
|
1843
|
+
signedIn: this.user !== null,
|
|
1844
|
+
sessionHinted: readLoginHint(appId),
|
|
1845
|
+
},
|
|
1846
|
+
(token) => this.linkStillOpen(token),
|
|
1847
|
+
);
|
|
1848
|
+
if (stale) window.location.reload();
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
private async linkStillOpen(token: string): Promise<boolean | null> {
|
|
1852
|
+
try {
|
|
1853
|
+
const response = await fetch(
|
|
1854
|
+
`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/view`,
|
|
1855
|
+
{ method: 'GET', cache: 'no-store' },
|
|
1856
|
+
);
|
|
1857
|
+
if (response.ok) return true;
|
|
1858
|
+
return response.status === 403 || response.status === 404 || response.status === 410
|
|
1859
|
+
? false
|
|
1860
|
+
: null;
|
|
1861
|
+
} catch {
|
|
1862
|
+
return null;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// Whether the signed-in person can keep the temporary document this page is
|
|
1867
|
+
// on: only the browser that made it holds the secret its claim takes.
|
|
1868
|
+
private canKeepTemporaryWork(): boolean {
|
|
1869
|
+
const token = this.guestLinkTokenValue;
|
|
1870
|
+
if (token === null || this.user === null || this.appId === null) return false;
|
|
1871
|
+
if (this.session.document?.expiresAt == null) return false;
|
|
1872
|
+
return readClaimSecret(this.appId, token) !== null;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// Keep the temporary document this page is on, signed in, without leaving
|
|
1876
|
+
// the page: the claim a sign-in from the page would make, then the document
|
|
1877
|
+
// opened by id. The claim evicts the link's own connections, so the page
|
|
1878
|
+
// leaves the link before it reopens rather than following the eviction.
|
|
1879
|
+
private async keepTemporaryWork(): Promise<boolean> {
|
|
1880
|
+
const token = this.guestLinkTokenValue;
|
|
1881
|
+
if (token === null || this.user === null || !this.canKeepTemporaryWork()) return false;
|
|
1882
|
+
const appId = this.requireAppId();
|
|
1883
|
+
const claimSecret = readClaimSecret(appId, token);
|
|
1884
|
+
const anonymous = await this.probeAnonymousLink(token);
|
|
1885
|
+
if (claimSecret === null || anonymous === null) return false;
|
|
1886
|
+
if (!(await this.claimAnonymousLink({ ...anonymous, claimSecret }))) return false;
|
|
1887
|
+
this.leaveLinkForOwnAccess();
|
|
1888
|
+
try {
|
|
1889
|
+
await this.session.open(anonymous.docId);
|
|
1890
|
+
} catch (err) {
|
|
1891
|
+
this.reportUncatchable(err instanceof Error ? err : new Error(String(err)));
|
|
1892
|
+
}
|
|
1893
|
+
return true;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// A signed-in person on a link page who opens or creates another document
|
|
1897
|
+
// does so as themselves, so the page stops being on the link: the URL drops
|
|
1898
|
+
// it and the socket reconnects with their own credential.
|
|
1899
|
+
private leaveLinkForOwnAccess(): void {
|
|
1900
|
+
if (this.guestLinkTokenValue === null || this.user === null) return;
|
|
1901
|
+
this.guestLinkTokenValue = null;
|
|
1902
|
+
clearGuestLinkToken();
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Run an open or create the person makes as themselves, off the link. One
|
|
1906
|
+
// that fails leaves the page where it was, still on the link: the document
|
|
1907
|
+
// it shows is the link's, and the URL is what brings a reload back to it.
|
|
1908
|
+
private async asOwnAccess<T>(run: () => Promise<T>): Promise<T> {
|
|
1909
|
+
const token = this.user !== null ? this.guestLinkTokenValue : null;
|
|
1910
|
+
this.leaveLinkForOwnAccess();
|
|
1911
|
+
try {
|
|
1912
|
+
return await run();
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
if (token !== null && this.guestLinkTokenValue === null && this.user !== null) {
|
|
1915
|
+
this.guestLinkTokenValue = token;
|
|
1916
|
+
writeGuestLinkToken(token);
|
|
1917
|
+
}
|
|
1918
|
+
throw err;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// The signed-in person's credential, carried beside a link on its open: an
|
|
1923
|
+
// app that opens links only for people signed in to it asks for it. Read
|
|
1924
|
+
// through `getAccessToken`, which renews a token set near its expiry.
|
|
1925
|
+
private async currentLinkCredential(): Promise<LinkCredential | null> {
|
|
1926
|
+
if (this.user === null || (await this.getAccessToken()) === null) return null;
|
|
1927
|
+
const tokens = this.tokens;
|
|
1928
|
+
if (this.user === null || tokens === null || tokens.appAssertion === undefined) return null;
|
|
1929
|
+
return { accessToken: tokens.accessToken, appAssertion: tokens.appAssertion };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// Whether a link token names a document created without an account, read
|
|
1933
|
+
// off `GET /links/{token}/view` (no snapshot, no credential). Null for an
|
|
1934
|
+
// ordinary share link, and for a token the server no longer answers.
|
|
1935
|
+
private async probeAnonymousLink(
|
|
1936
|
+
token: string,
|
|
1937
|
+
): Promise<Omit<AnonymousLinkClaim, 'claimSecret'> | null> {
|
|
1938
|
+
try {
|
|
1939
|
+
const response = await fetch(
|
|
1940
|
+
`${this.apiBaseUrl}/links/${encodeURIComponent(token)}/view`,
|
|
1941
|
+
{ method: 'GET' },
|
|
1942
|
+
);
|
|
1943
|
+
if (!response.ok) return null;
|
|
1944
|
+
const body = (await response.json()) as {
|
|
1945
|
+
doc_id?: unknown;
|
|
1946
|
+
anonymous?: unknown;
|
|
1947
|
+
name?: unknown;
|
|
1948
|
+
};
|
|
1949
|
+
if (body.anonymous !== true || typeof body.doc_id !== 'string') {
|
|
1950
|
+
return null;
|
|
1951
|
+
}
|
|
1952
|
+
return {
|
|
1953
|
+
token,
|
|
1954
|
+
docId: body.doc_id,
|
|
1955
|
+
...(typeof body.name === 'string' ? { name: body.name } : {}),
|
|
1956
|
+
};
|
|
1957
|
+
} catch {
|
|
1958
|
+
return null;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// Claim a document created without an account into the signed-in person's
|
|
1963
|
+
// organization, through the placement loop a create runs. On success the
|
|
1964
|
+
// claimed id replaces the token in the URL and is stashed as the last
|
|
1965
|
+
// selection, so the session's restore and an app reading `?doc=` both
|
|
1966
|
+
// find it. A refused or dismissed claim reports through `onError` and
|
|
1967
|
+
// enters guest mode on the token, editable until it expires; a reload
|
|
1968
|
+
// carries no code, so it does not claim again.
|
|
1969
|
+
private async claimAnonymousLink(claim: AnonymousLinkClaim): Promise<boolean> {
|
|
1970
|
+
const appId = this.requireAppId();
|
|
1971
|
+
let chosenName: string | undefined;
|
|
1972
|
+
let result: ClaimDocumentResult | null = null;
|
|
1973
|
+
try {
|
|
1974
|
+
result = await placeThroughHostedSurfaces(
|
|
1975
|
+
this.getHostedUiLauncher(),
|
|
1976
|
+
(destination) =>
|
|
1977
|
+
this.claimAnonymousDocumentWithConsent(claim.docId, {
|
|
1978
|
+
linkToken: claim.token,
|
|
1979
|
+
claimSecret: claim.claimSecret,
|
|
1980
|
+
...(destination !== undefined ? { destination } : {}),
|
|
1981
|
+
...(chosenName !== undefined ? { name: chosenName } : {}),
|
|
1982
|
+
}),
|
|
1983
|
+
claim.name,
|
|
1984
|
+
(chosen) => {
|
|
1985
|
+
chosenName = chosen.name;
|
|
1986
|
+
},
|
|
1987
|
+
);
|
|
1988
|
+
} catch (err) {
|
|
1989
|
+
this.reportUncatchable(err instanceof Error ? err : new Error(String(err)));
|
|
1990
|
+
}
|
|
1991
|
+
if (result === null) {
|
|
1992
|
+
this.enterGuestMode(claim.token);
|
|
1993
|
+
return false;
|
|
1994
|
+
}
|
|
1995
|
+
clearGuestLinkToken();
|
|
1996
|
+
// Claimed, the document is reached by id like any other, so there is
|
|
1997
|
+
// no unsaved work to come back to.
|
|
1998
|
+
clearUnsavedWork(appId);
|
|
1999
|
+
clearClaimSecret(appId, claim.token);
|
|
2000
|
+
storeClaimedLink(appId, claim.token, claim.docId);
|
|
2001
|
+
this.maybeWriteDocIdToUrl(claim.docId);
|
|
2002
|
+
if (result.record) storeLastDocument(appId, result.record);
|
|
2003
|
+
return true;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
1161
2006
|
// The `app_not_installed` denial names the org; open the hosted
|
|
1162
2007
|
// install-consent dialog for it so the person decides: an admin (or a
|
|
1163
2008
|
// member of an `open`-policy org) installs, a member under `request`
|
|
1164
2009
|
// sends the admin request from the same dialog. Nothing is installed
|
|
1165
2010
|
// or requested implicitly. Resolves true only when an install landed,
|
|
1166
2011
|
// in which case the caller retries the denied call once.
|
|
1167
|
-
private
|
|
1168
|
-
if (!isAppNotInstalled(err) || !err.organizationId) return false;
|
|
2012
|
+
private offerInstallConsent(err: unknown): Promise<boolean> {
|
|
2013
|
+
if (!isAppNotInstalled(err) || !err.organizationId) return Promise.resolve(false);
|
|
1169
2014
|
const organizationId = err.organizationId;
|
|
2015
|
+
// One dialog per organization, however many calls were refused. An app
|
|
2016
|
+
// that opens a SET hits the same denial once per document, and each raise
|
|
2017
|
+
// is a modal: the reader answers the one on top and finds another behind
|
|
2018
|
+
// it, for work they already consented to. The install a dialog lands is
|
|
2019
|
+
// the whole answer for every caller waiting on it, and each still retries
|
|
2020
|
+
// its own denied call afterwards.
|
|
2021
|
+
const inFlight = this.installConsentByOrganization.get(organizationId);
|
|
2022
|
+
if (inFlight) return inFlight;
|
|
2023
|
+
const raised = this.raiseInstallConsent(organizationId).finally(() => {
|
|
2024
|
+
this.installConsentByOrganization.delete(organizationId);
|
|
2025
|
+
});
|
|
2026
|
+
this.installConsentByOrganization.set(organizationId, raised);
|
|
2027
|
+
return raised;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// The dialog itself, held apart so the memo above is the only thing that
|
|
2031
|
+
// decides whether one opens.
|
|
2032
|
+
private async raiseInstallConsent(organizationId: string): Promise<boolean> {
|
|
1170
2033
|
try {
|
|
1171
2034
|
const result = await this.getHostedUiLauncher().installApp(organizationId);
|
|
1172
2035
|
if (result === null) {
|
|
@@ -1188,20 +2051,44 @@ export class BrassClient {
|
|
|
1188
2051
|
}
|
|
1189
2052
|
}
|
|
1190
2053
|
|
|
2054
|
+
// Everything this page load holds about which organization it acts in, and
|
|
2055
|
+
// what it is called. Enumerated here rather than at each transition,
|
|
2056
|
+
// because the memberships behind every one of these belong to the person
|
|
2057
|
+
// who was signed in: a name, a launch, an open document's organization and
|
|
2058
|
+
// a resolution are all answers about them. A caller that clears a subset
|
|
2059
|
+
// hands the next person the rest, which is what the account menu reads.
|
|
2060
|
+
private retireActingOrganizationState(): void {
|
|
2061
|
+
this.actingOrganization = null;
|
|
2062
|
+
this.launchOrganizationId = null;
|
|
2063
|
+
this.chromeDocumentOrganization = null;
|
|
2064
|
+
this.chromeOrganizationToken += 1;
|
|
2065
|
+
this.organizationNames.clear();
|
|
2066
|
+
this.documentOrganizationIds.clear();
|
|
2067
|
+
this.actingOrganizationResolve = null;
|
|
2068
|
+
this.emitActingOrganization();
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1191
2071
|
// Drop a resolution that never became one the app can act in. Scoped to the
|
|
1192
2072
|
// organization named, so a resolution the app has moved on from is left
|
|
1193
2073
|
// alone.
|
|
1194
2074
|
private forgetActingOrganization(organizationId: string): void {
|
|
1195
2075
|
if (this.actingOrganization?.id !== organizationId) return;
|
|
1196
2076
|
this.actingOrganization = null;
|
|
1197
|
-
|
|
2077
|
+
// The tab's stored choice is a separate answer from this page load's
|
|
2078
|
+
// resolution: the memo may hold an organization the launch or an open
|
|
2079
|
+
// document supplied while the slot holds a deliberate choice made
|
|
2080
|
+
// earlier, so it is cleared only when it names the same organization.
|
|
2081
|
+
const appId = this.requireAppId();
|
|
2082
|
+
if (readActingOrganization(appId) === organizationId) {
|
|
2083
|
+
clearActingOrganization(appId);
|
|
2084
|
+
}
|
|
1198
2085
|
this.emitActingOrganization();
|
|
1199
2086
|
}
|
|
1200
2087
|
|
|
1201
2088
|
// Register the app's export backend. The platform calls it when a
|
|
1202
2089
|
// hosted surface downloads an app-owned (blob) document: it receives
|
|
1203
2090
|
// the open Document and returns the source file's bytes, the inverse of
|
|
1204
|
-
// the
|
|
2091
|
+
// the app's importer. The app never invokes it
|
|
1205
2092
|
// and never delivers the bytes itself; the platform stages + downloads
|
|
1206
2093
|
// what it returns. One serializer per app.
|
|
1207
2094
|
registerSerializer(serializer: DocumentSerializer): void {
|
|
@@ -1211,20 +2098,39 @@ export class BrassClient {
|
|
|
1211
2098
|
this.exportGuest?.setSerializer(serializer);
|
|
1212
2099
|
}
|
|
1213
2100
|
|
|
1214
|
-
// Register the app's import backend
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
2101
|
+
// Register the app's import backend, `(file) => { streams, report? }`.
|
|
2102
|
+
// The SDK claims the import with the platform on each open, so exactly one
|
|
2103
|
+
// client runs it, and the platform seals what it returns as the document's
|
|
2104
|
+
// first content. One importer per app; a later registration
|
|
1218
2105
|
// replaces the earlier. Documents already open (registration raced the
|
|
1219
|
-
// first open) get their attempt here
|
|
2106
|
+
// first open) get their attempt here, and so do inputs whose open stopped
|
|
2107
|
+
// waiting for it.
|
|
1220
2108
|
registerImporter(importer: DocumentImporter): void {
|
|
2109
|
+
this.importer = importer;
|
|
1221
2110
|
this.importRunner.setImporter(importer);
|
|
1222
|
-
|
|
2111
|
+
for (const registered of this.importerWaiters.splice(0)) registered();
|
|
2112
|
+
if (this.importGuest !== null) {
|
|
2113
|
+
this.importGuest.setImporter(importer);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (this.importerWaitExpired) {
|
|
2117
|
+
this.importerWaitExpired = false;
|
|
2118
|
+
console.warn(
|
|
2119
|
+
`[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.`,
|
|
2120
|
+
);
|
|
2121
|
+
}
|
|
1223
2122
|
for (const doc of this.liveOpenDocuments()) {
|
|
1224
|
-
void this.importRunner.attempt(doc);
|
|
2123
|
+
if (this.importsInto(doc)) void this.importRunner.attempt(doc);
|
|
2124
|
+
doc._importerRegistered();
|
|
1225
2125
|
}
|
|
1226
2126
|
}
|
|
1227
2127
|
|
|
2128
|
+
// A bundle Brass loaded to run its serializer or importer headless holds
|
|
2129
|
+
// no session and emits none.
|
|
2130
|
+
private isHeadlessGuest(): boolean {
|
|
2131
|
+
return this.exportGuest !== null || this.importGuest !== null;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
1228
2134
|
// Track an opened document so an export request can resolve its id to
|
|
1229
2135
|
// the live Document. A re-open overwrites.
|
|
1230
2136
|
private trackOpenDocument(doc: OpenedDocument): void {
|
|
@@ -1235,11 +2141,8 @@ export class BrassClient {
|
|
|
1235
2141
|
}
|
|
1236
2142
|
});
|
|
1237
2143
|
// Every open path funnels through here, so this is where a registered
|
|
1238
|
-
// importer gets its per-document attempt.
|
|
1239
|
-
|
|
1240
|
-
if (this.guestLinkTokenValue === null) {
|
|
1241
|
-
void this.importRunner.attempt(doc);
|
|
1242
|
-
}
|
|
2144
|
+
// importer gets its per-document attempt.
|
|
2145
|
+
if (this.importsInto(doc)) void this.importRunner.attempt(doc);
|
|
1243
2146
|
}
|
|
1244
2147
|
|
|
1245
2148
|
// The tracked documents still open, dropping the rest as it goes.
|
|
@@ -1291,7 +2194,7 @@ export class BrassClient {
|
|
|
1291
2194
|
const serialized = await this.serializeForExport(docId);
|
|
1292
2195
|
if (!serialized.ok) {
|
|
1293
2196
|
throw new BrassError(
|
|
1294
|
-
`This app could not write
|
|
2197
|
+
`This app could not write this out as a file to publish it: ${serialized.message}`,
|
|
1295
2198
|
{ status: 400 },
|
|
1296
2199
|
);
|
|
1297
2200
|
}
|
|
@@ -1318,7 +2221,7 @@ export class BrassClient {
|
|
|
1318
2221
|
this.liveOpenDocument(docId) ??
|
|
1319
2222
|
(sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
|
|
1320
2223
|
if (!doc) {
|
|
1321
|
-
return { ok: false, code: 'no_document', message: '
|
|
2224
|
+
return { ok: false, code: 'no_document', message: 'This is not open in the app.' };
|
|
1322
2225
|
}
|
|
1323
2226
|
try {
|
|
1324
2227
|
// A serializer runs detached, with no subscription to have been fed by,
|
|
@@ -1369,8 +2272,11 @@ export class BrassClient {
|
|
|
1369
2272
|
apiBaseUrl: this.apiBaseUrl,
|
|
1370
2273
|
getAccessToken: () => this.getAccessToken(),
|
|
1371
2274
|
getAppAssertion: () => this.getAppAssertion(),
|
|
2275
|
+
getLinkCredential: () => this.currentLinkCredential(),
|
|
2276
|
+
openAsMember: (docId, options) => this.openDocumentInternal(docId, options, 'skip'),
|
|
1372
2277
|
identity: () => this.user?.sub ?? null,
|
|
1373
2278
|
...(wsClient ? { wsClient } : {}),
|
|
2279
|
+
linkWsClient: (token) => this.getLinkWsClient(appId, token),
|
|
1374
2280
|
branchProvider: {
|
|
1375
2281
|
list: (docId) => docsInternal.listBranches(docId),
|
|
1376
2282
|
create: (docId, input) => docsInternal.createBranch(docId, input),
|
|
@@ -1383,11 +2289,20 @@ export class BrassClient {
|
|
|
1383
2289
|
create: (docId, branchId, name) =>
|
|
1384
2290
|
docsInternal.createStream(docId, branchId, name),
|
|
1385
2291
|
},
|
|
1386
|
-
thumbnailUploader: (doc, image, width, height) =>
|
|
1387
|
-
|
|
1388
|
-
|
|
2292
|
+
thumbnailUploader: (doc, image, width, height, version) =>
|
|
2293
|
+
version === undefined
|
|
2294
|
+
? docsNs.setThumbnail(doc, { image, width, height })
|
|
2295
|
+
: docsInternal.setThumbnailAt(doc, { image, width, height }, version),
|
|
2296
|
+
checkpointCreator: (doc, anchor) =>
|
|
2297
|
+
docsInternal.createCheckpoint(doc, anchor),
|
|
1389
2298
|
drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
|
|
1390
2299
|
sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
|
|
2300
|
+
provisionalInputsPoster: (docId, body, opts) =>
|
|
2301
|
+
docsInternal.postProvisionalInputs(docId, body, opts),
|
|
2302
|
+
inputsRuntime: (docId) => ({
|
|
2303
|
+
readKey: (key, query) => docsInternal.readInputKeyStatus(docId, key, query),
|
|
2304
|
+
open: (inputId, options) => this.openDocumentById(inputId, options),
|
|
2305
|
+
}),
|
|
1391
2306
|
hostedUiProvider: {
|
|
1392
2307
|
documentHistory: (docId, options) =>
|
|
1393
2308
|
this.documentHistory(docId, options),
|
|
@@ -1420,7 +2335,7 @@ export class BrassClient {
|
|
|
1420
2335
|
// the active-doc swap consistent (URL sync, command list
|
|
1421
2336
|
// re-render, branch-list refresh on the new doc).
|
|
1422
2337
|
openSiblingDoc: async (docId, options) => {
|
|
1423
|
-
await this.session.
|
|
2338
|
+
await this.session._openSibling(docId, options);
|
|
1424
2339
|
},
|
|
1425
2340
|
selectBranch: (docId, options) =>
|
|
1426
2341
|
this.getHostedUiLauncher().selectBranch(docId, options),
|
|
@@ -1450,18 +2365,33 @@ export class BrassClient {
|
|
|
1450
2365
|
wsBaseUrl: this.wsBaseUrl,
|
|
1451
2366
|
appId,
|
|
1452
2367
|
getAccessToken: () => this.getAccessToken(),
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
// the
|
|
1458
|
-
// resolves null here and connects un-gated.
|
|
2368
|
+
// With nobody signed in the page socket connects with the link token;
|
|
2369
|
+
// a signed-in person's carries their own credential, and a document
|
|
2370
|
+
// opened through a link uses that link's socket (`getLinkWsClient`).
|
|
2371
|
+
getGuestLinkToken: () => (this.user === null ? this.guestLinkTokenValue : null),
|
|
2372
|
+
// Attests the socket with the same assertion the HTTP calls carry.
|
|
1459
2373
|
getAppAssertion: () => this.getAppAssertion(),
|
|
1460
2374
|
fireError: (error) => this.fireError(error),
|
|
1461
2375
|
});
|
|
1462
2376
|
return this.docWsClient;
|
|
1463
2377
|
}
|
|
1464
2378
|
|
|
2379
|
+
private getLinkWsClient(appId: string, token: string): BrassWsClient | undefined {
|
|
2380
|
+
if (!this.wsBaseUrl) return undefined;
|
|
2381
|
+
let client = this.linkWsClients.get(token);
|
|
2382
|
+
if (client === undefined) {
|
|
2383
|
+
client = new BrassWsClient({
|
|
2384
|
+
wsBaseUrl: this.wsBaseUrl,
|
|
2385
|
+
appId,
|
|
2386
|
+
getAccessToken: () => Promise.resolve(null),
|
|
2387
|
+
getGuestLinkToken: () => token,
|
|
2388
|
+
fireError: (error) => this.fireError(error),
|
|
2389
|
+
});
|
|
2390
|
+
this.linkWsClients.set(token, client);
|
|
2391
|
+
}
|
|
2392
|
+
return client;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
1465
2395
|
// Resolves null only when the server answered that this app has no live
|
|
1466
2396
|
// session, and rejects with a 503 `BrassError` when a `/refresh` found no
|
|
1467
2397
|
// answer at all. The two outcomes drive opposite handling: null is the
|
|
@@ -1471,13 +2401,11 @@ export class BrassClient {
|
|
|
1471
2401
|
// blipped, and hides the transient from every layer that self-heals on it.
|
|
1472
2402
|
async getAccessToken(): Promise<string | null> {
|
|
1473
2403
|
this.requireInit();
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
|
|
1479
|
-
// present membership where the page's whole authority is the link.
|
|
1480
|
-
if (this.guestLinkTokenValue !== null) return null;
|
|
2404
|
+
// A `?brass_link=` page with nobody signed in answers null rather than
|
|
2405
|
+
// probing /refresh, which would draw a 401 the browser logs. A signed-in
|
|
2406
|
+
// person's token serves what they do as themselves; the link document's
|
|
2407
|
+
// own requests and the socket carry the link instead.
|
|
2408
|
+
if (this.guestLinkTokenValue !== null && this.user === null) return null;
|
|
1481
2409
|
const current = this.tokens;
|
|
1482
2410
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
1483
2411
|
return current.accessToken;
|
|
@@ -1500,9 +2428,27 @@ export class BrassClient {
|
|
|
1500
2428
|
// names one, so this is the only place the answer comes from.
|
|
1501
2429
|
// `ask: false` answers null where the chooser would open, for a read an app
|
|
1502
2430
|
// makes before the person has pressed anything.
|
|
2431
|
+
//
|
|
2432
|
+
// One resolve at a time per mode: concurrent acts share the in-flight one,
|
|
2433
|
+
// so the chooser opens once and every caller hears the same answer. The two
|
|
2434
|
+
// modes stay apart, since a read must not wait on a dialog and an act must
|
|
2435
|
+
// not take a read's null as its answer.
|
|
1503
2436
|
private resolveActingOrganizationId(options?: {
|
|
1504
2437
|
ask: false;
|
|
1505
2438
|
}): Promise<string | null> {
|
|
2439
|
+
const ask = options === undefined;
|
|
2440
|
+
const inFlight = this.actingOrganizationResolve;
|
|
2441
|
+
if (inFlight !== null && inFlight.ask === ask) return inFlight.promise;
|
|
2442
|
+
const promise = this.runActingOrganizationResolve(ask).finally(() => {
|
|
2443
|
+
if (this.actingOrganizationResolve?.promise === promise) {
|
|
2444
|
+
this.actingOrganizationResolve = null;
|
|
2445
|
+
}
|
|
2446
|
+
});
|
|
2447
|
+
this.actingOrganizationResolve = { ask, promise };
|
|
2448
|
+
return promise;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
private runActingOrganizationResolve(ask: boolean): Promise<string | null> {
|
|
1506
2452
|
const appId = this.requireAppId();
|
|
1507
2453
|
return resolveActingOrganization({
|
|
1508
2454
|
activeDocumentOrganizationId: () => this.activeDocumentOrganizationId(),
|
|
@@ -1518,7 +2464,7 @@ export class BrassClient {
|
|
|
1518
2464
|
remember: (organizationId, organizationName, persist) => {
|
|
1519
2465
|
this.setActingOrganization(organizationId, organizationName, persist);
|
|
1520
2466
|
},
|
|
1521
|
-
},
|
|
2467
|
+
}, ask ? {} : { ask: false });
|
|
1522
2468
|
}
|
|
1523
2469
|
|
|
1524
2470
|
// The same resolve for a caller that has nowhere to put a null: the ladder
|
|
@@ -1607,7 +2553,8 @@ export class BrassClient {
|
|
|
1607
2553
|
// document picked through the hosted picker already named its organization
|
|
1608
2554
|
// on the way in, which answers with no request at all.
|
|
1609
2555
|
private activeDocumentOrganizationId(): Promise<string | null> {
|
|
1610
|
-
// A
|
|
2556
|
+
// A document opened through a link is read through the link alone, so its
|
|
2557
|
+
// record is not the person's to read.
|
|
1611
2558
|
if (this.guestLinkTokenValue !== null) return Promise.resolve(null);
|
|
1612
2559
|
const docId = this.session.document?.id;
|
|
1613
2560
|
if (!docId) return Promise.resolve(null);
|
|
@@ -1930,10 +2877,9 @@ export class BrassClient {
|
|
|
1930
2877
|
|
|
1931
2878
|
async getAppAssertion(): Promise<string | null> {
|
|
1932
2879
|
this.requireInit();
|
|
1933
|
-
// Paired with `getAccessToken`: a link
|
|
1934
|
-
// an acting app against
|
|
1935
|
-
|
|
1936
|
-
if (this.guestLinkTokenValue !== null) return null;
|
|
2880
|
+
// Paired with `getAccessToken`: a link page with nobody signed in holds
|
|
2881
|
+
// no token to assert an acting app against.
|
|
2882
|
+
if (this.guestLinkTokenValue !== null && this.user === null) return null;
|
|
1937
2883
|
const current = this.tokens;
|
|
1938
2884
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
1939
2885
|
return current.appAssertion ?? null;
|
|
@@ -1948,16 +2894,15 @@ export class BrassClient {
|
|
|
1948
2894
|
const appId = this.requireAppId();
|
|
1949
2895
|
// Every successful sign-in 302s back to return_to with the
|
|
1950
2896
|
// platform-session cookie set AND a one-time `brass_code` query
|
|
1951
|
-
// parameter. The cookie
|
|
1952
|
-
//
|
|
1953
|
-
//
|
|
1954
|
-
//
|
|
2897
|
+
// parameter. The first refresh exchanges the code with the cookie
|
|
2898
|
+
// alongside, and the server answers in cookie mode when the cookie came
|
|
2899
|
+
// with it, or hands back the fallback pointer when the browser dropped
|
|
2900
|
+
// it (incognito / Safari ITP / post-third-party-cookie browsers).
|
|
1955
2901
|
//
|
|
1956
2902
|
// Pull the code off the URL immediately (before any /refresh)
|
|
1957
2903
|
// and strip it via history.replaceState so it never lingers in the
|
|
1958
2904
|
// address bar, browser history, or a referrer header. It is held in
|
|
1959
|
-
// memory and consumed at most once
|
|
1960
|
-
// turns out to fail (see refresh()).
|
|
2905
|
+
// memory and consumed at most once (see runRefresh()).
|
|
1961
2906
|
//
|
|
1962
2907
|
// No app-controlled silent-mint path: the SDK never auto-fires
|
|
1963
2908
|
// Brass.signIn() based on a URL hint. New app approvals always
|
|
@@ -2065,12 +3010,12 @@ export class BrassClient {
|
|
|
2065
3010
|
return this.inflightRefresh;
|
|
2066
3011
|
}
|
|
2067
3012
|
|
|
2068
|
-
// Picks the credential channel for a single /refresh round-trip.
|
|
2069
|
-
//
|
|
2070
|
-
//
|
|
2071
|
-
// cookie
|
|
2072
|
-
// token is in hand (the cross-site cookie is proven
|
|
2073
|
-
// this page's life), every later refresh uses it directly.
|
|
3013
|
+
// Picks the credential channel for a single /refresh round-trip. A landed
|
|
3014
|
+
// one-time code goes first, on the same credentialed request as the cookie,
|
|
3015
|
+
// so a browser that drops the cross-site cookie signs in on one request
|
|
3016
|
+
// rather than after a cookie refresh it was always going to refuse. Once a
|
|
3017
|
+
// fallback session token is in hand (the cross-site cookie is proven
|
|
3018
|
+
// unavailable for this page's life), every later refresh uses it directly.
|
|
2074
3019
|
//
|
|
2075
3020
|
// A rung falls through to the next only on `no_session`. Each one discards
|
|
2076
3021
|
// a credential as it passes (the stale sid, the one-time code), and an
|
|
@@ -2091,15 +3036,15 @@ export class BrassClient {
|
|
|
2091
3036
|
this.sessionToken = null;
|
|
2092
3037
|
clearSessionToken(appId);
|
|
2093
3038
|
}
|
|
2094
|
-
|
|
2095
|
-
if (viaCookie.status !== 'no_session') return viaCookie;
|
|
2096
|
-
// Cookie channel resolved no session. Fall back to the one-time code if
|
|
2097
|
-
// we landed with one. Clear it first: it is single-use server-side, so a
|
|
3039
|
+
// Clear the code before spending it: it is single-use server-side, so a
|
|
2098
3040
|
// failed exchange must not be retried.
|
|
2099
3041
|
const code = this.pendingAuthCode;
|
|
2100
|
-
if (
|
|
2101
|
-
|
|
2102
|
-
|
|
3042
|
+
if (code) {
|
|
3043
|
+
this.pendingAuthCode = null;
|
|
3044
|
+
const viaCode = await refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
|
|
3045
|
+
if (viaCode.status !== 'no_session') return viaCode;
|
|
3046
|
+
}
|
|
3047
|
+
return refreshTokens(this.authBaseUrl, appId);
|
|
2103
3048
|
}
|
|
2104
3049
|
|
|
2105
3050
|
private setTokens(next: TokenSet | null): void {
|
|
@@ -2116,9 +3061,7 @@ export class BrassClient {
|
|
|
2116
3061
|
this.user.sub !== nextUser.sub
|
|
2117
3062
|
) {
|
|
2118
3063
|
this.setTokens(null);
|
|
2119
|
-
this.
|
|
2120
|
-
this.emitActingOrganization();
|
|
2121
|
-
this.documentOrganizationIds.clear();
|
|
3064
|
+
this.retireActingOrganizationState();
|
|
2122
3065
|
}
|
|
2123
3066
|
// A session ENDING, which is where the per-app stashes are dropped.
|
|
2124
3067
|
//
|
|
@@ -2156,8 +3099,10 @@ export class BrassClient {
|
|
|
2156
3099
|
// it hears the sign-out and a close PARKS the Document. A parked Document
|
|
2157
3100
|
// answers the next open of its key out of memory, with no token and no
|
|
2158
3101
|
// request, so one left behind here is the previous person's document
|
|
2159
|
-
// handed to whoever signs in next.
|
|
3102
|
+
// handed to whoever signs in next. Frozen bytes kept on the device go with
|
|
3103
|
+
// it for the same reason.
|
|
2160
3104
|
if (nextUser === null) clearParkedDocuments();
|
|
3105
|
+
adoptSealedCacheOwner(nextUser?.sub ?? null);
|
|
2161
3106
|
}
|
|
2162
3107
|
|
|
2163
3108
|
private async hydrateProfile(): Promise<void> {
|
|
@@ -2165,10 +3110,24 @@ export class BrassClient {
|
|
|
2165
3110
|
if (!this.apiBaseUrlConfigured) return;
|
|
2166
3111
|
const token = this.tokens?.accessToken;
|
|
2167
3112
|
if (!token) return;
|
|
3113
|
+
// The same headers every other data-API call carries. The bearer names
|
|
3114
|
+
// the person and never the app, so a platform that requires the acting
|
|
3115
|
+
// app refuses a request holding only it, and this one degrades silently
|
|
3116
|
+
// (see the catch below), which would leave a reader looking at a raw sub
|
|
3117
|
+
// where their name belongs with nothing reporting why.
|
|
3118
|
+
//
|
|
3119
|
+
// Read straight off the held token set rather than through
|
|
3120
|
+
// `getAppAssertion()`, mirroring the access token above: this is
|
|
3121
|
+
// best-effort hydration, so it must not drive a refresh of its own.
|
|
2168
3122
|
try {
|
|
3123
|
+
const headers: Record<string, string> = { authorization: `Bearer ${token}` };
|
|
3124
|
+
applyClientVersionHeader(headers);
|
|
3125
|
+
await applyAppAssertionHeader(headers, () =>
|
|
3126
|
+
Promise.resolve(this.tokens?.appAssertion ?? null),
|
|
3127
|
+
);
|
|
2169
3128
|
const res = await fetch(
|
|
2170
3129
|
`${this.apiBaseUrl}/users/${encodeURIComponent(this.user.sub)}`,
|
|
2171
|
-
{ headers
|
|
3130
|
+
{ headers },
|
|
2172
3131
|
);
|
|
2173
3132
|
if (!res.ok) return;
|
|
2174
3133
|
const profile = (await res.json()) as {
|
|
@@ -2212,6 +3171,18 @@ export class BrassClient {
|
|
|
2212
3171
|
// works and the proxy's code went unused). Best-effort: any failure
|
|
2213
3172
|
// (exotic URL, history API unavailable) just yields null and the SDK
|
|
2214
3173
|
// falls back to the cookie-only path.
|
|
3174
|
+
// Whether a sign-in this page started has just returned: the auth proxy
|
|
3175
|
+
// appends `brass_code` to `return_to` on every successful sign-in. Read
|
|
3176
|
+
// without consuming, since `bootstrap` spends it.
|
|
3177
|
+
function signInReturned(): boolean {
|
|
3178
|
+
if (typeof window === 'undefined') return false;
|
|
3179
|
+
try {
|
|
3180
|
+
return new URL(window.location.href).searchParams.has('brass_code');
|
|
3181
|
+
} catch {
|
|
3182
|
+
return false;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
|
|
2215
3186
|
function consumeAuthCodeParam(): string | null {
|
|
2216
3187
|
try {
|
|
2217
3188
|
const url = new URL(window.location.href);
|