@brass-build/client 0.3.0 → 0.4.1
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 +50 -7
- package/CHANGELOG.md +63 -2
- package/README.md +233 -77
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +273 -0
- package/dist/acting-organization.js.map +1 -0
- package/dist/api-headers.js +7 -7
- package/dist/api-headers.js.map +1 -1
- package/dist/call-service.d.ts +74 -0
- package/dist/call-service.d.ts.map +1 -0
- package/dist/call-service.js +253 -0
- package/dist/call-service.js.map +1 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +611 -12
- package/dist/client.js.map +1 -1
- package/dist/document-contents.d.ts +15 -0
- package/dist/document-contents.d.ts.map +1 -0
- package/dist/document-contents.js +88 -0
- package/dist/document-contents.js.map +1 -0
- package/dist/document-streams.d.ts +4 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +6 -0
- package/dist/document-streams.js.map +1 -1
- package/dist/documents.d.ts +45 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +305 -22
- package/dist/documents.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +5 -0
- package/dist/export-guest.js.map +1 -1
- package/dist/handle-types.d.ts +23 -0
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +1 -1
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +10 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +115 -30
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +17 -6
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/mutate-batching.js +2 -2
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +1 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +69 -34
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +10 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js.map +1 -1
- package/dist/session-toolbar.d.ts +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +60 -12
- package/dist/session-toolbar.js.map +1 -1
- package/dist/tail-ops.js +2 -2
- package/dist/tail-ops.js.map +1 -1
- package/dist/types.d.ts +0 -42
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +1 -1
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +1 -1
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +52 -2
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/wasm-ready-gate.d.ts +13 -0
- package/dist/wasm-ready-gate.d.ts.map +1 -0
- package/dist/wasm-ready-gate.js +28 -0
- package/dist/wasm-ready-gate.js.map +1 -0
- package/dist/wasm-runtime.browser.d.ts +11 -0
- package/dist/wasm-runtime.browser.d.ts.map +1 -0
- package/dist/wasm-runtime.browser.js +44 -0
- package/dist/wasm-runtime.browser.js.map +1 -0
- package/dist/wasm-runtime.d.ts +6 -0
- package/dist/wasm-runtime.d.ts.map +1 -0
- package/dist/wasm-runtime.js +20 -0
- package/dist/wasm-runtime.js.map +1 -0
- package/docs/app-manifest.md +335 -5
- package/docs/custom-chrome.md +3 -0
- package/docs/presence.md +12 -0
- package/docs/services.md +265 -0
- package/docs/text-fields.md +191 -0
- package/package.json +4 -5
- package/src/acting-organization.ts +390 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +680 -14
- package/src/document-contents.ts +104 -0
- package/src/document-streams.ts +15 -1
- package/src/documents.ts +340 -20
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +1 -1
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +17 -6
- package/src/index.ts +43 -4
- package/src/internal.ts +4 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +79 -38
- package/src/presence.ts +6 -0
- package/src/session-toolbar.ts +80 -12
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +53 -2
- package/src/version.ts +1 -1
- package/src/wasm-ready-gate.ts +30 -0
- package/src/wasm-runtime.browser.ts +48 -0
- package/src/wasm-runtime.ts +21 -0
- package/dist/vite.d.ts +0 -31
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -161
- package/dist/vite.js.map +0 -1
- package/src/vite.ts +0 -214
package/dist/client.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { buildSignInUrl, buildSignedOutOfAppUrl, refreshTokens, revokeAppApproval, } from './auth-flow.js';
|
|
2
|
+
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
3
|
+
import { ensureWasmReady } from './wasm-runtime.js';
|
|
2
4
|
import { decodeIdToken } from './jwt.js';
|
|
3
5
|
import { createEmitter } from './emitter.js';
|
|
4
6
|
import { clearParkedDocuments, createOpenDocument, } from './open-document.js';
|
|
@@ -9,7 +11,9 @@ import { createImportRunner, } from './importer.js';
|
|
|
9
11
|
import { isExportMode, startExportGuest, } from './export-guest.js';
|
|
10
12
|
import { readGuestLinkToken } from './guest-link.js';
|
|
11
13
|
import { consumeLaunchOrganizationId } from './launch-context.js';
|
|
14
|
+
import { callService, normalizeServiceHost, listAppServices, } from './call-service.js';
|
|
12
15
|
import { clearAll as clearLastSelections, readLastDocument, storeLastDocument, } from './selection-persistence.js';
|
|
16
|
+
import { clearActingOrganization, fetchActingOrganization, readActingOrganization, resolveActingOrganization, switchActingOrganization, storeActingOrganization, visibleOrganization, } from './acting-organization.js';
|
|
13
17
|
import { DEFAULT_BRANCH_URL_PARAM, DEFAULT_DOC_URL_PARAM, PICK_BRANCH_PARAM, PICK_DOC_PARAM, clearUrlSelection, readUrlSelection, subscribePopState, takePickFromUrl, writeDocIdToUrl, writeUrlSelection, } from './doc-url-sync.js';
|
|
14
18
|
import { composePageTitle, setPageTitle } from './page-title.js';
|
|
15
19
|
import { clearSessionToken, readSessionToken, storeSessionToken, } from './session-persistence.js';
|
|
@@ -178,6 +182,35 @@ export class BrassClient {
|
|
|
178
182
|
// when the app was reached some other way. A create that names no
|
|
179
183
|
// destination of its own lands here; see `launch-context.ts`.
|
|
180
184
|
launchOrganizationId = null;
|
|
185
|
+
// The organization this page has resolved to act in, and the name the
|
|
186
|
+
// server gave it. Held for the life of the page load so repeated acts
|
|
187
|
+
// resolve once; dropped on sign-out, and replaced by a later switch.
|
|
188
|
+
actingOrganization = null;
|
|
189
|
+
// Pushed to the account menu, which names the organization once one has a
|
|
190
|
+
// name. Resolution happens at the first act that needs one, and a document
|
|
191
|
+
// arrives later still, so the menu is rendered before there is anything to
|
|
192
|
+
// show and has to hear about it rather than poll.
|
|
193
|
+
documentWatchForActingOrganization = null;
|
|
194
|
+
// The open document's organization, named. Null while no document is open,
|
|
195
|
+
// while its organization is one this reader is not a member of, and until
|
|
196
|
+
// the name has been read.
|
|
197
|
+
chromeDocumentOrganization = null;
|
|
198
|
+
// Drops a name that arrived after the document it was for moved on.
|
|
199
|
+
chromeOrganizationToken = 0;
|
|
200
|
+
// Names by organization id, so opening five documents in one organization
|
|
201
|
+
// reads it once. A null value records that the server did not confirm the
|
|
202
|
+
// id, which is as durable an answer as a name.
|
|
203
|
+
organizationNames = new Map();
|
|
204
|
+
actingOrganizationListeners = new Set();
|
|
205
|
+
// The resolve in flight, so concurrent acts share one answer and the
|
|
206
|
+
// chooser opens once. Keyed by whether the caller may be asked, because a
|
|
207
|
+
// read that answers null where the chooser would open must not settle an
|
|
208
|
+
// act that is waiting for the person, and an act must not take that null.
|
|
209
|
+
actingOrganizationResolve = null;
|
|
210
|
+
// One document's organization, by document id. A document's placement
|
|
211
|
+
// does not move under an open app, so the read behind it runs once per
|
|
212
|
+
// id; the in-flight promise is shared so concurrent acts make one call.
|
|
213
|
+
documentOrganizationIds = new Map();
|
|
181
214
|
// The app's export backend (`Brass.registerSerializer`), invoked by the
|
|
182
215
|
// platform when a hosted surface downloads an app-owned blob doc. One
|
|
183
216
|
// per app; a later registration replaces the earlier.
|
|
@@ -213,6 +246,15 @@ export class BrassClient {
|
|
|
213
246
|
// the click handler that opens a surface, so it would miss the press that
|
|
214
247
|
// opened it and have nothing to hand focus back to on dismissal.
|
|
215
248
|
trackHostedUiActivations();
|
|
249
|
+
// The WASM this package runs on is fetched from here, so the download
|
|
250
|
+
// overlaps the auth round-trip below and is usually ready before anything
|
|
251
|
+
// asks for it. It is deliberately NOT awaited here: an app's sign-in
|
|
252
|
+
// screen needs no engine, and gating `init` on the download puts several
|
|
253
|
+
// megabytes in front of the first paint of every app that has one. The
|
|
254
|
+
// paths that construct a document await it instead. The rejection is
|
|
255
|
+
// absorbed because this call is only the head start; a failure has to
|
|
256
|
+
// surface at the open that needs it, which retries the fetch.
|
|
257
|
+
void ensureWasmReady().catch(() => undefined);
|
|
216
258
|
try {
|
|
217
259
|
// A Brass-hosted bundle resolves its app id and all four origins from
|
|
218
260
|
// the serving slot's runtime descriptor, which is authoritative: the
|
|
@@ -321,10 +363,7 @@ export class BrassClient {
|
|
|
321
363
|
}
|
|
322
364
|
catch (err) {
|
|
323
365
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
324
|
-
|
|
325
|
-
// onError listener was registered. Prefixed for grep-ability.
|
|
326
|
-
console.error('[Brass]', error.message);
|
|
327
|
-
this.fireError(error);
|
|
366
|
+
this.reportUncatchable(error);
|
|
328
367
|
// Deliberately do not re-throw: callers using
|
|
329
368
|
// `void Brass.init(...).then(setup)` should see .then() fire so the
|
|
330
369
|
// page transitions out of its loading state. signIn() etc. continue
|
|
@@ -355,7 +394,21 @@ export class BrassClient {
|
|
|
355
394
|
}
|
|
356
395
|
}
|
|
357
396
|
signIn() {
|
|
358
|
-
|
|
397
|
+
// Reported rather than thrown, because this is the one gesture an app
|
|
398
|
+
// offers in a state the SDK itself invited. A failed init resolves with
|
|
399
|
+
// `authStatus === 'signed-out'`, indistinguishable from a reader who is
|
|
400
|
+
// genuinely signed out, so every app renders its sign-in control and the
|
|
401
|
+
// reader clicks it. `signIn` returns void and runs from a click handler,
|
|
402
|
+
// so a throw there is an uncaught error the app has no way to catch,
|
|
403
|
+
// raised over a decision the app did not get wrong. The error channel
|
|
404
|
+
// carries it to the same places, and to the listener the app registered.
|
|
405
|
+
if (!this.initialized || !this.appId) {
|
|
406
|
+
this.reportUncatchable(new Error(this.initialized
|
|
407
|
+
? 'Brass.init was called without an app ID'
|
|
408
|
+
: 'Call Brass.init(appId) first'));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const appId = this.appId;
|
|
359
412
|
if (typeof window === 'undefined') {
|
|
360
413
|
throw new Error('Brass.signIn must be called in a browser');
|
|
361
414
|
}
|
|
@@ -409,6 +462,11 @@ export class BrassClient {
|
|
|
409
462
|
// Clear the per-app selection-persistence layer so the next user
|
|
410
463
|
// on this tab doesn't see the previous user's last selection.
|
|
411
464
|
clearLastSelections(appId);
|
|
465
|
+
// Same for the organization this app was acting in: the next user's
|
|
466
|
+
// memberships are their own, so both the stored choice and this page
|
|
467
|
+
// load's resolution go with the session.
|
|
468
|
+
clearActingOrganization(appId);
|
|
469
|
+
this.retireActingOrganizationState();
|
|
412
470
|
const signedOutUrl = buildSignedOutOfAppUrl(this.authBaseUrl, appId, window.location.href);
|
|
413
471
|
// On a Brass-hosted origin, route the sign-out through the edge's
|
|
414
472
|
// /__brass/leave so the first-party brass_site gate cookie is cleared
|
|
@@ -449,9 +507,15 @@ export class BrassClient {
|
|
|
449
507
|
return 'guest';
|
|
450
508
|
return this.user !== null ? 'signed-in' : 'signed-out';
|
|
451
509
|
}
|
|
452
|
-
// The
|
|
453
|
-
//
|
|
454
|
-
//
|
|
510
|
+
// The organization this app is acting in, as far as this page load already
|
|
511
|
+
// knows. Synchronous, so nothing is resolved to answer it: the open
|
|
512
|
+
// document's organization outranks the rest for the same reason it does in
|
|
513
|
+
// the chrome, since it is what every act made while it is open spends.
|
|
514
|
+
knownOrganizationId() {
|
|
515
|
+
return (this.chromeDocumentOrganization?.id ??
|
|
516
|
+
this.actingOrganization?.id ??
|
|
517
|
+
this.launchOrganizationId);
|
|
518
|
+
}
|
|
455
519
|
get isGuest() {
|
|
456
520
|
return this.guestLinkTokenValue !== null;
|
|
457
521
|
}
|
|
@@ -612,6 +676,21 @@ export class BrassClient {
|
|
|
612
676
|
signIn: () => this.signIn(),
|
|
613
677
|
signOut: () => this.signOut(),
|
|
614
678
|
dashboardBaseUrl: this._dashboardBaseUrl,
|
|
679
|
+
getActingOrganization: () => this.visibleActingOrganization(),
|
|
680
|
+
subscribeActingOrganization: (listener) => {
|
|
681
|
+
this.actingOrganizationListeners.add(listener);
|
|
682
|
+
this.watchDocumentForActingOrganization();
|
|
683
|
+
// A toolbar mounted after a document opened missed the watch's edge,
|
|
684
|
+
// so ask once on the way in. This is what makes the read pay only for
|
|
685
|
+
// an app that renders the chrome.
|
|
686
|
+
void this.refreshChromeOrganization();
|
|
687
|
+
return () => {
|
|
688
|
+
this.actingOrganizationListeners.delete(listener);
|
|
689
|
+
};
|
|
690
|
+
},
|
|
691
|
+
switchOrganization: () => {
|
|
692
|
+
void this.switchActingOrganization();
|
|
693
|
+
},
|
|
615
694
|
getDocument: (docId) => this.documents.get(docId),
|
|
616
695
|
// The version-preview bar is a hosted embed; it takes the same theme
|
|
617
696
|
// as the other hosted surfaces.
|
|
@@ -672,6 +751,9 @@ export class BrassClient {
|
|
|
672
751
|
this.hostedUiLauncher = createHostedUiLauncher({
|
|
673
752
|
dashboardBaseUrl: this._dashboardBaseUrl,
|
|
674
753
|
callingAppId: appId,
|
|
754
|
+
// The organization the picker opens on: the one this app is working
|
|
755
|
+
// in, and otherwise the choice this tab carries for it.
|
|
756
|
+
actingOrganizationId: () => this.knownOrganizationId() ?? this.preferredOrganizationId(),
|
|
675
757
|
theme: this.hostedUiTheme,
|
|
676
758
|
serializeForExport: (docId) => this.serializeForExport(docId),
|
|
677
759
|
// The hosted delete surface reports a local delete over the bridge so
|
|
@@ -869,9 +951,21 @@ export class BrassClient {
|
|
|
869
951
|
async offerInstallConsent(err) {
|
|
870
952
|
if (!isAppNotInstalled(err) || !err.organizationId)
|
|
871
953
|
return false;
|
|
954
|
+
const organizationId = err.organizationId;
|
|
872
955
|
try {
|
|
873
|
-
const result = await this.getHostedUiLauncher().installApp(
|
|
874
|
-
|
|
956
|
+
const result = await this.getHostedUiLauncher().installApp(organizationId);
|
|
957
|
+
if (result === null) {
|
|
958
|
+
// Declining abandons the choice, the way declining the install behind
|
|
959
|
+
// a switch abandons the switch. Kept, it would pin every later act,
|
|
960
|
+
// and every later visit, to an organization this app cannot serve,
|
|
961
|
+
// with the chooser only reachable through the account menu.
|
|
962
|
+
this.forgetActingOrganization(organizationId);
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
// The choice became one the app can act in, so now it carries past this
|
|
966
|
+
// page load.
|
|
967
|
+
storeActingOrganization(this.requireAppId(), organizationId);
|
|
968
|
+
return true;
|
|
875
969
|
}
|
|
876
970
|
catch {
|
|
877
971
|
// Hosted UI unavailable here (no DOM, or the dialog failed); the
|
|
@@ -879,6 +973,39 @@ export class BrassClient {
|
|
|
879
973
|
return false;
|
|
880
974
|
}
|
|
881
975
|
}
|
|
976
|
+
// Everything this page load holds about which organization it acts in, and
|
|
977
|
+
// what it is called. Enumerated here rather than at each transition,
|
|
978
|
+
// because the memberships behind every one of these belong to the person
|
|
979
|
+
// who was signed in: a name, a launch, an open document's organization and
|
|
980
|
+
// a resolution are all answers about them. A caller that clears a subset
|
|
981
|
+
// hands the next person the rest, which is what the account menu reads.
|
|
982
|
+
retireActingOrganizationState() {
|
|
983
|
+
this.actingOrganization = null;
|
|
984
|
+
this.launchOrganizationId = null;
|
|
985
|
+
this.chromeDocumentOrganization = null;
|
|
986
|
+
this.chromeOrganizationToken += 1;
|
|
987
|
+
this.organizationNames.clear();
|
|
988
|
+
this.documentOrganizationIds.clear();
|
|
989
|
+
this.actingOrganizationResolve = null;
|
|
990
|
+
this.emitActingOrganization();
|
|
991
|
+
}
|
|
992
|
+
// Drop a resolution that never became one the app can act in. Scoped to the
|
|
993
|
+
// organization named, so a resolution the app has moved on from is left
|
|
994
|
+
// alone.
|
|
995
|
+
forgetActingOrganization(organizationId) {
|
|
996
|
+
if (this.actingOrganization?.id !== organizationId)
|
|
997
|
+
return;
|
|
998
|
+
this.actingOrganization = null;
|
|
999
|
+
// The tab's stored choice is a separate answer from this page load's
|
|
1000
|
+
// resolution: the memo may hold an organization the launch or an open
|
|
1001
|
+
// document supplied while the slot holds a deliberate choice made
|
|
1002
|
+
// earlier, so it is cleared only when it names the same organization.
|
|
1003
|
+
const appId = this.requireAppId();
|
|
1004
|
+
if (readActingOrganization(appId) === organizationId) {
|
|
1005
|
+
clearActingOrganization(appId);
|
|
1006
|
+
}
|
|
1007
|
+
this.emitActingOrganization();
|
|
1008
|
+
}
|
|
882
1009
|
// Register the app's export backend. The platform calls it when a
|
|
883
1010
|
// hosted surface downloads an app-owned (blob) document: it receives
|
|
884
1011
|
// the open Document and returns the source file's bytes, the inverse of
|
|
@@ -1105,7 +1232,7 @@ export class BrassClient {
|
|
|
1105
1232
|
// against it.
|
|
1106
1233
|
acquireOrganization: () => this.getHostedUiLauncher().acquireOrganization(),
|
|
1107
1234
|
},
|
|
1108
|
-
|
|
1235
|
+
preferredOrganizationId: () => this.knownOrganizationId(),
|
|
1109
1236
|
reportError: (err) => this.fireError(err),
|
|
1110
1237
|
});
|
|
1111
1238
|
}
|
|
@@ -1163,6 +1290,430 @@ export class BrassClient {
|
|
|
1163
1290
|
// calls `getAccessToken()` then this reads the same (possibly just
|
|
1164
1291
|
// refreshed) token set without forcing a second refresh. Rejects on a
|
|
1165
1292
|
// transient outage for the same reason `getAccessToken` does.
|
|
1293
|
+
// Which organization the next act spends, resolved at the act rather than
|
|
1294
|
+
// at boot: the open document's organization, one already resolved on this
|
|
1295
|
+
// page load, and otherwise the server's answer from the caller's context,
|
|
1296
|
+
// with the hosted dialog asking where context cannot decide. An app never
|
|
1297
|
+
// names one, so this is the only place the answer comes from.
|
|
1298
|
+
// `ask: false` answers null where the chooser would open, for a read an app
|
|
1299
|
+
// makes before the person has pressed anything.
|
|
1300
|
+
//
|
|
1301
|
+
// One resolve at a time per mode: concurrent acts share the in-flight one,
|
|
1302
|
+
// so the chooser opens once and every caller hears the same answer. The two
|
|
1303
|
+
// modes stay apart, since a read must not wait on a dialog and an act must
|
|
1304
|
+
// not take a read's null as its answer.
|
|
1305
|
+
resolveActingOrganizationId(options) {
|
|
1306
|
+
const ask = options === undefined;
|
|
1307
|
+
const inFlight = this.actingOrganizationResolve;
|
|
1308
|
+
if (inFlight !== null && inFlight.ask === ask)
|
|
1309
|
+
return inFlight.promise;
|
|
1310
|
+
const promise = this.runActingOrganizationResolve(ask).finally(() => {
|
|
1311
|
+
if (this.actingOrganizationResolve?.promise === promise) {
|
|
1312
|
+
this.actingOrganizationResolve = null;
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
this.actingOrganizationResolve = { ask, promise };
|
|
1316
|
+
return promise;
|
|
1317
|
+
}
|
|
1318
|
+
runActingOrganizationResolve(ask) {
|
|
1319
|
+
const appId = this.requireAppId();
|
|
1320
|
+
return resolveActingOrganization({
|
|
1321
|
+
activeDocumentOrganizationId: () => this.activeDocumentOrganizationId(),
|
|
1322
|
+
cachedResolution: () => this.actingOrganization?.id ?? null,
|
|
1323
|
+
// The launch value is written into the same slot at boot, so this one
|
|
1324
|
+
// read carries both the stored choice and the launch.
|
|
1325
|
+
launchPreference: () => this.launchOrganizationId,
|
|
1326
|
+
storedPreference: () => readActingOrganization(appId),
|
|
1327
|
+
forgetPreference: () => clearActingOrganization(appId),
|
|
1328
|
+
fetchResolved: (prefer) => this.fetchActingOrganization(prefer),
|
|
1329
|
+
pickOrganization: () => this.pickActingOrganization(this.actingOrganization?.id),
|
|
1330
|
+
remember: (organizationId, organizationName, persist) => {
|
|
1331
|
+
this.setActingOrganization(organizationId, organizationName, persist);
|
|
1332
|
+
},
|
|
1333
|
+
}, ask ? {} : { ask: false });
|
|
1334
|
+
}
|
|
1335
|
+
// The same resolve for a caller that has nowhere to put a null: the ladder
|
|
1336
|
+
// asks, and a person who dismisses the chooser is a BrassError rather than
|
|
1337
|
+
// an organization.
|
|
1338
|
+
async requireActingOrganizationId() {
|
|
1339
|
+
const resolved = await this.resolveActingOrganizationId();
|
|
1340
|
+
// `resolveActingOrganization` throws rather than answering null while it
|
|
1341
|
+
// may ask, so this is a type narrowing rather than a reachable branch.
|
|
1342
|
+
if (resolved === null)
|
|
1343
|
+
throw new BrassError('No organization chosen.', { status: 400 });
|
|
1344
|
+
return resolved;
|
|
1345
|
+
}
|
|
1346
|
+
fetchActingOrganization(prefer) {
|
|
1347
|
+
return fetchActingOrganization({
|
|
1348
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
1349
|
+
getAccessToken: () => this.getAccessToken(),
|
|
1350
|
+
getAppAssertion: () => this.getAppAssertion(),
|
|
1351
|
+
}, prefer);
|
|
1352
|
+
}
|
|
1353
|
+
// The hosted organization picker: it lists the caller's own organizations
|
|
1354
|
+
// and offers to create or join one, which is the whole question being asked
|
|
1355
|
+
// here. An install is a consequence of the answer rather than the question,
|
|
1356
|
+
// so it is left to the act that needs one: the call the resolution feeds
|
|
1357
|
+
// refuses `app_not_installed` and raises the install dialog against the
|
|
1358
|
+
// organization just chosen. Null when the person closed it.
|
|
1359
|
+
//
|
|
1360
|
+
// The surface is iframe-only, since a chosen organization cannot survive a
|
|
1361
|
+
// redirect fallback, so a context that cannot host one (a browser
|
|
1362
|
+
// partitioning third-party storage) cannot ask this question at all. That
|
|
1363
|
+
// reaches the caller as a rejection rather than a dismissal: an act
|
|
1364
|
+
// refuses with it, and a deliberate switch reports it, which is what keeps
|
|
1365
|
+
// the menu's own control from reading as dead.
|
|
1366
|
+
async pickActingOrganization(current) {
|
|
1367
|
+
const result = await this.getHostedUiLauncher().selectOrganization(current);
|
|
1368
|
+
return result?.organization_id ?? null;
|
|
1369
|
+
}
|
|
1370
|
+
// The account menu's switch. The same hosted dialog the resolution opens,
|
|
1371
|
+
// reached deliberately rather than from a refused act, so the reader can
|
|
1372
|
+
// move an app to another organization without going to the dashboard.
|
|
1373
|
+
// Dismissal leaves the current one standing.
|
|
1374
|
+
async switchActingOrganization() {
|
|
1375
|
+
try {
|
|
1376
|
+
await switchActingOrganization({
|
|
1377
|
+
current: () => this.knownOrganizationId(),
|
|
1378
|
+
pickOrganization: (current) => this.pickActingOrganization(current),
|
|
1379
|
+
fetchResolved: (prefer) => this.fetchActingOrganization(prefer),
|
|
1380
|
+
offerInstall: (organizationId) => this.getHostedUiLauncher()
|
|
1381
|
+
.installApp(organizationId)
|
|
1382
|
+
.then((r) => r !== null),
|
|
1383
|
+
remember: (organizationId, organizationName) => {
|
|
1384
|
+
this.setActingOrganization(organizationId, organizationName);
|
|
1385
|
+
},
|
|
1386
|
+
reload: () => {
|
|
1387
|
+
// A document belongs to one organization, and it decides every act
|
|
1388
|
+
// made while it is open, so it cannot survive a move to another
|
|
1389
|
+
// one: leaving it open would put the reader in an organization they
|
|
1390
|
+
// did not switch to for everything they then did. Closing it is the
|
|
1391
|
+
// switch, rather than a side effect of it.
|
|
1392
|
+
//
|
|
1393
|
+
// The tab's remembered pick goes whether or not one is open, and
|
|
1394
|
+
// that is the half a close alone does not cover. A reload restores
|
|
1395
|
+
// the last document a person picked, so a switch made with nothing
|
|
1396
|
+
// open reopens the document the reader just moved away from, whose
|
|
1397
|
+
// organization then outranks the one they chose. The switch would
|
|
1398
|
+
// read as a page reload that changed nothing.
|
|
1399
|
+
if (this.appId)
|
|
1400
|
+
clearLastSelections(this.appId);
|
|
1401
|
+
if (this.session.document !== null)
|
|
1402
|
+
this.session.close();
|
|
1403
|
+
if (typeof window !== 'undefined')
|
|
1404
|
+
window.location.reload();
|
|
1405
|
+
},
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
catch (err) {
|
|
1409
|
+
this.fireError(err instanceof Error ? err : new Error(String(err)));
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
// The open document's organization. The Document itself does not carry one,
|
|
1413
|
+
// so this reads the record once per document id and remembers it; a
|
|
1414
|
+
// document picked through the hosted picker already named its organization
|
|
1415
|
+
// on the way in, which answers with no request at all.
|
|
1416
|
+
activeDocumentOrganizationId() {
|
|
1417
|
+
// A link guest holds no API credential, so there is nothing to read with.
|
|
1418
|
+
if (this.guestLinkTokenValue !== null)
|
|
1419
|
+
return Promise.resolve(null);
|
|
1420
|
+
const docId = this.session.document?.id;
|
|
1421
|
+
if (!docId)
|
|
1422
|
+
return Promise.resolve(null);
|
|
1423
|
+
const known = this.documentOrganizationIds.get(docId);
|
|
1424
|
+
if (known)
|
|
1425
|
+
return known;
|
|
1426
|
+
const lastPicked = readLastDocument(this.requireAppId());
|
|
1427
|
+
const pending = lastPicked?.doc_id === docId
|
|
1428
|
+
? Promise.resolve(lastPicked.organization_id)
|
|
1429
|
+
: this.documents
|
|
1430
|
+
.get(docId)
|
|
1431
|
+
.then((record) => record.organization_id)
|
|
1432
|
+
.catch(() => {
|
|
1433
|
+
// The server resolves an organization from the caller's context
|
|
1434
|
+
// without this, so a failed read costs a request rather than the
|
|
1435
|
+
// act that needed one. It is dropped rather than remembered,
|
|
1436
|
+
// since what the read would have answered still outranks that
|
|
1437
|
+
// fallback for every later act on this document.
|
|
1438
|
+
this.documentOrganizationIds.delete(docId);
|
|
1439
|
+
return null;
|
|
1440
|
+
});
|
|
1441
|
+
this.documentOrganizationIds.set(docId, pending);
|
|
1442
|
+
return pending;
|
|
1443
|
+
}
|
|
1444
|
+
// Record the organization this app acts in, from a surface where the person
|
|
1445
|
+
// chose it. Replaces whatever this page load had resolved, so the next act
|
|
1446
|
+
// spends the new one rather than a memo of the old.
|
|
1447
|
+
// @internal
|
|
1448
|
+
// The one writer of the memo, the stored slot and the menu, so a resolution
|
|
1449
|
+
// and a switch cannot report different things to the chrome.
|
|
1450
|
+
// What this tab would offer the server as its preference: the launch value
|
|
1451
|
+
// first, since it is the freshest thing this tab knows, then the choice a
|
|
1452
|
+
// previous act on this page confirmed. Neither is an answer; the server
|
|
1453
|
+
// validates both.
|
|
1454
|
+
preferredOrganizationId() {
|
|
1455
|
+
if (this.launchOrganizationId)
|
|
1456
|
+
return this.launchOrganizationId;
|
|
1457
|
+
return this.appId ? readActingOrganization(this.appId) : null;
|
|
1458
|
+
}
|
|
1459
|
+
setActingOrganization(organizationId, organizationName,
|
|
1460
|
+
// False while the app is not installed there: the choice holds for this
|
|
1461
|
+
// page load, so the install the refusal raises is about the organization
|
|
1462
|
+
// the reader just picked, and it is written to the slot once that install
|
|
1463
|
+
// lands rather than before. False again for a resolution taken from an
|
|
1464
|
+
// open document, which is where the app is working rather than a choice
|
|
1465
|
+
// the person made.
|
|
1466
|
+
persist = true) {
|
|
1467
|
+
const appId = this.requireAppId();
|
|
1468
|
+
// An id can arrive with no name, from a resolution an open document
|
|
1469
|
+
// answered. Keep a name the chrome has already read for that same
|
|
1470
|
+
// organization rather than blanking the line the reader is looking at.
|
|
1471
|
+
const name = organizationName === '' && this.actingOrganization?.id === organizationId
|
|
1472
|
+
? this.actingOrganization.name
|
|
1473
|
+
: organizationName;
|
|
1474
|
+
this.actingOrganization = { id: organizationId, name };
|
|
1475
|
+
if (persist)
|
|
1476
|
+
storeActingOrganization(appId, organizationId);
|
|
1477
|
+
this.emitActingOrganization();
|
|
1478
|
+
}
|
|
1479
|
+
visibleActingOrganization() {
|
|
1480
|
+
return visibleOrganization(this.session.document === null
|
|
1481
|
+
? null
|
|
1482
|
+
: { organization: this.chromeDocumentOrganization }, this.actingOrganization);
|
|
1483
|
+
}
|
|
1484
|
+
// Opening or closing a document changes which organization the next act
|
|
1485
|
+
// spends, so the chrome has to hear about it. Keyed on the document's id
|
|
1486
|
+
// rather than on the session event, which also fires on every mutation.
|
|
1487
|
+
watchDocumentForActingOrganization() {
|
|
1488
|
+
if (this.documentWatchForActingOrganization)
|
|
1489
|
+
return;
|
|
1490
|
+
let lastDocId = this.session.document?.id ?? null;
|
|
1491
|
+
this.documentWatchForActingOrganization = this.session.subscribe(() => {
|
|
1492
|
+
const docId = this.session.document?.id ?? null;
|
|
1493
|
+
if (docId === lastDocId)
|
|
1494
|
+
return;
|
|
1495
|
+
lastDocId = docId;
|
|
1496
|
+
void this.refreshChromeOrganization();
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
// Name the organization the chrome would show, where one is already known.
|
|
1500
|
+
//
|
|
1501
|
+
// This never RESOLVES an organization: it asks the server to name an id the
|
|
1502
|
+
// app already holds, and takes the answer only when the server names the
|
|
1503
|
+
// same one back. An app reached by its own URL with nothing stored has no
|
|
1504
|
+
// id, so nothing is asked and the menu stays silent, which is the state a
|
|
1505
|
+
// person who has not acted yet is genuinely in.
|
|
1506
|
+
//
|
|
1507
|
+
// The mismatch case is the load-bearing half. A document can live in an
|
|
1508
|
+
// organization the reader reaches through a directly shared project and is
|
|
1509
|
+
// not a member of; the route then resolves something else, and naming that
|
|
1510
|
+
// would put an organization on screen that has nothing to do with what is
|
|
1511
|
+
// open. So a name is shown only when the server confirms the id asked
|
|
1512
|
+
// about.
|
|
1513
|
+
async refreshChromeOrganization() {
|
|
1514
|
+
const token = ++this.chromeOrganizationToken;
|
|
1515
|
+
const documentOrganizationId = await this.activeDocumentOrganizationId();
|
|
1516
|
+
const named = documentOrganizationId === null
|
|
1517
|
+
? null
|
|
1518
|
+
: await this.namedOrganization(documentOrganizationId);
|
|
1519
|
+
// A later document (or a switch) started its own resolve while this one
|
|
1520
|
+
// was in flight, and its answer is the current one.
|
|
1521
|
+
if (token !== this.chromeOrganizationToken)
|
|
1522
|
+
return;
|
|
1523
|
+
this.chromeDocumentOrganization = named;
|
|
1524
|
+
// The document is where this app is working, so its organization is what
|
|
1525
|
+
// the next act spends once the document closes: a reader who opens a
|
|
1526
|
+
// model, closes it and creates another would otherwise be asked where to
|
|
1527
|
+
// put the second one, having just worked in the answer. Held for the page
|
|
1528
|
+
// load rather than written to the tab's slot, because it is where the app
|
|
1529
|
+
// was taken rather than a choice the person made.
|
|
1530
|
+
if (named !== null && named.installed) {
|
|
1531
|
+
this.setActingOrganization(named.id, named.name, false);
|
|
1532
|
+
}
|
|
1533
|
+
this.emitActingOrganization();
|
|
1534
|
+
if (documentOrganizationId === null)
|
|
1535
|
+
await this.nameStoredOrganization(token);
|
|
1536
|
+
}
|
|
1537
|
+
// An organization can reach this app as an id with no name: a launch
|
|
1538
|
+
// parameter, the choice this tab carries, or the organization of a document
|
|
1539
|
+
// the reader has just closed. Each is what a cold create and every service
|
|
1540
|
+
// call spend until something else resolves, so the menu owes it a name once
|
|
1541
|
+
// an app renders the chrome, and this is where the memo learns one.
|
|
1542
|
+
//
|
|
1543
|
+
// Nothing happens without an id in hand, which is the case the design turns
|
|
1544
|
+
// on: an app reached by its own URL has none, and asking the server to pick
|
|
1545
|
+
// one so a menu has something to say is the boot-time question this refuses.
|
|
1546
|
+
async nameStoredOrganization(token) {
|
|
1547
|
+
if (!this.appId)
|
|
1548
|
+
return;
|
|
1549
|
+
// A memo carrying a name was named by the server already.
|
|
1550
|
+
if (this.actingOrganization !== null && this.actingOrganization.name !== '') {
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
const stored = this.actingOrganization?.id ?? this.preferredOrganizationId();
|
|
1554
|
+
if (stored === null)
|
|
1555
|
+
return;
|
|
1556
|
+
const named = await this.namedOrganization(stored);
|
|
1557
|
+
// A stored id the caller has since left resolves as some OTHER
|
|
1558
|
+
// organization, which `namedOrganization` refuses. Staying silent is the
|
|
1559
|
+
// right answer: adopting what the server picked would be resolving one to
|
|
1560
|
+
// decorate a menu.
|
|
1561
|
+
if (named === null || token !== this.chromeOrganizationToken)
|
|
1562
|
+
return;
|
|
1563
|
+
// An organization this app is no longer installed on is not one it is
|
|
1564
|
+
// acting in, whatever the tab is carrying. Adopting it here would be the
|
|
1565
|
+
// chrome deciding where acts land: the memo it writes is what the
|
|
1566
|
+
// resolver answers from before it asks the server anything, so the
|
|
1567
|
+
// staleness check on the way in never runs and every act is refused for
|
|
1568
|
+
// an organization somebody deliberately removed this app from. Drop the
|
|
1569
|
+
// carried value and stay silent, which is the state the reader is in.
|
|
1570
|
+
if (!named.installed) {
|
|
1571
|
+
this.forgetActingOrganization(named.id);
|
|
1572
|
+
clearActingOrganization(this.appId);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
this.setActingOrganization(named.id, named.name);
|
|
1576
|
+
}
|
|
1577
|
+
async namedOrganization(organizationId) {
|
|
1578
|
+
const cached = this.organizationNames.get(organizationId);
|
|
1579
|
+
if (cached !== undefined)
|
|
1580
|
+
return cached;
|
|
1581
|
+
let named = null;
|
|
1582
|
+
try {
|
|
1583
|
+
const outcome = await this.fetchActingOrganization(organizationId);
|
|
1584
|
+
if (outcome.kind === 'resolved' && outcome.organizationId === organizationId) {
|
|
1585
|
+
named = {
|
|
1586
|
+
id: outcome.organizationId,
|
|
1587
|
+
name: outcome.organizationName,
|
|
1588
|
+
installed: outcome.installed,
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
catch {
|
|
1593
|
+
// A name is decoration. A failed read leaves the menu quiet rather than
|
|
1594
|
+
// costing the app anything, and is not cached, so the next document
|
|
1595
|
+
// change asks again.
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
// A name is durable; whether this app is installed there is not, since an
|
|
1599
|
+
// admin can install it while the page is open. So an answer carrying
|
|
1600
|
+
// `installed: false` is returned and not kept, and the next read asks
|
|
1601
|
+
// again rather than reporting what was true once.
|
|
1602
|
+
if (named === null || named.installed) {
|
|
1603
|
+
this.organizationNames.set(organizationId, named);
|
|
1604
|
+
}
|
|
1605
|
+
return named;
|
|
1606
|
+
}
|
|
1607
|
+
emitActingOrganization() {
|
|
1608
|
+
const visible = this.visibleActingOrganization();
|
|
1609
|
+
for (const listener of this.actingOrganizationListeners) {
|
|
1610
|
+
try {
|
|
1611
|
+
listener(visible);
|
|
1612
|
+
}
|
|
1613
|
+
catch (err) {
|
|
1614
|
+
this.fireError(err instanceof Error ? err : new Error(String(err)));
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
// One brokered call to an outside service the app's manifest declares.
|
|
1619
|
+
// Brass attaches the credential, so the app holds none and the key never
|
|
1620
|
+
// reaches the browser.
|
|
1621
|
+
//
|
|
1622
|
+
// Returns the service's own answer rather than throwing on it: a `401` from
|
|
1623
|
+
// the service, a `409` for a service nobody has keyed yet, and a `429` at a
|
|
1624
|
+
// cap are all results the app renders differently.
|
|
1625
|
+
async callService(host, path, options) {
|
|
1626
|
+
this.requireInit();
|
|
1627
|
+
const service = normalizeServiceHost(host);
|
|
1628
|
+
return callService({
|
|
1629
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
1630
|
+
getAccessToken: () => this.getAccessToken(),
|
|
1631
|
+
getAppAssertion: () => this.getAppAssertion(),
|
|
1632
|
+
resolveOrganization: (ask) => ask
|
|
1633
|
+
? this.requireActingOrganizationId()
|
|
1634
|
+
: this.resolveActingOrganizationId({ ask: false }),
|
|
1635
|
+
offerInstallConsent: (err) => this.offerInstallConsent(err),
|
|
1636
|
+
offerServiceCredential: (organizationId, serviceHost) => this.offerServiceCredential(organizationId, serviceHost),
|
|
1637
|
+
}, service, path, options);
|
|
1638
|
+
}
|
|
1639
|
+
// Which of the services this app declares would serve a call right now, for
|
|
1640
|
+
// the organization it is acting in. An app whose credential is optional
|
|
1641
|
+
// renders the feature as available or as an upgrade from this, instead of
|
|
1642
|
+
// learning by making a call the reader has already pressed for.
|
|
1643
|
+
//
|
|
1644
|
+
// `ready: false` carries the same `code` the call itself answers with, so an
|
|
1645
|
+
// app branches on one vocabulary rather than two.
|
|
1646
|
+
async listServices() {
|
|
1647
|
+
this.requireInit();
|
|
1648
|
+
// A reader with no session is acting nowhere, which is the state null
|
|
1649
|
+
// reports, so a page that calls this while it loads gets an answer to
|
|
1650
|
+
// render on rather than a rejection. A transient outage rejects from
|
|
1651
|
+
// here, since that is not an answer about where the app is acting.
|
|
1652
|
+
if ((await this.getAccessToken()) === null)
|
|
1653
|
+
return null;
|
|
1654
|
+
// Resolved without the chooser: an app calls this to decide what to
|
|
1655
|
+
// render, before the reader has pressed anything, so a question put here
|
|
1656
|
+
// is one they did not ask for. `callService` is the act, and the act is
|
|
1657
|
+
// where a question belongs.
|
|
1658
|
+
const orgId = await this.resolveActingOrganizationId({ ask: false });
|
|
1659
|
+
// Null is a state the app renders on: it is acting nowhere yet.
|
|
1660
|
+
if (orgId === null)
|
|
1661
|
+
return null;
|
|
1662
|
+
const deps = {
|
|
1663
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
1664
|
+
getAccessToken: () => this.getAccessToken(),
|
|
1665
|
+
getAppAssertion: () => this.getAppAssertion(),
|
|
1666
|
+
};
|
|
1667
|
+
try {
|
|
1668
|
+
return await listAppServices(deps, orgId);
|
|
1669
|
+
}
|
|
1670
|
+
catch (err) {
|
|
1671
|
+
// An organization that has not installed this app is the same kind of
|
|
1672
|
+
// answer as having none resolved: something only a person can settle, so
|
|
1673
|
+
// it is reported here and raised by `callService`, where the reader has
|
|
1674
|
+
// acted.
|
|
1675
|
+
if (isAppNotInstalled(err))
|
|
1676
|
+
return null;
|
|
1677
|
+
throw err;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
// Open the hosted dialog for one service, from the app's own affordance
|
|
1681
|
+
// rather than from a refused call. Resolves true when that host became
|
|
1682
|
+
// usable, which is when a caller re-reads or retries.
|
|
1683
|
+
//
|
|
1684
|
+
// Takes a host the manifest declares, or a pattern it declares where the
|
|
1685
|
+
// address names the account (`*.myshopify.com`). One method rather than two,
|
|
1686
|
+
// because what the caller is asking for is the same in both: an account this
|
|
1687
|
+
// app can spend at a service it declared.
|
|
1688
|
+
async connectService(host) {
|
|
1689
|
+
this.requireInit();
|
|
1690
|
+
const orgId = await this.requireActingOrganizationId();
|
|
1691
|
+
return this.offerServiceCredential(orgId, normalizeServiceHost(host));
|
|
1692
|
+
}
|
|
1693
|
+
// A brokered call was refused for a credential somebody using the app can
|
|
1694
|
+
// supply, which the platform states on the refusal rather than the SDK
|
|
1695
|
+
// reading it off the code. Open the hosted dialog, which asks whoever is
|
|
1696
|
+
// there for whatever the host is missing and tells anyone who cannot answer
|
|
1697
|
+
// who to ask. Resolves true only when this host became usable, in which case
|
|
1698
|
+
// the caller sends the refused call again.
|
|
1699
|
+
async offerServiceCredential(organizationId, host) {
|
|
1700
|
+
try {
|
|
1701
|
+
const result = await this.getHostedUiLauncher().serviceCredential(organizationId, host);
|
|
1702
|
+
if (result === null)
|
|
1703
|
+
return false;
|
|
1704
|
+
// A pattern names a host per account, so what became usable is the
|
|
1705
|
+
// account the reader named rather than the term the app asked under.
|
|
1706
|
+
// Anything under the pattern is what the app can now call.
|
|
1707
|
+
return host.startsWith('*.')
|
|
1708
|
+
? result.ready.some((ready) => ready.endsWith(host.slice(1)))
|
|
1709
|
+
: result.ready.includes(host);
|
|
1710
|
+
}
|
|
1711
|
+
catch {
|
|
1712
|
+
// Hosted UI unavailable here (no DOM, or the dialog failed); the
|
|
1713
|
+
// original refusal stands.
|
|
1714
|
+
return false;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1166
1717
|
async getAppAssertion() {
|
|
1167
1718
|
this.requireInit();
|
|
1168
1719
|
// Paired with `getAccessToken`: a link guest holds no token to assert
|
|
@@ -1203,6 +1754,12 @@ export class BrassClient {
|
|
|
1203
1754
|
// and strip it here, so a client-side navigation later in the session
|
|
1204
1755
|
// cannot lose it and the address bar stays the app's own.
|
|
1205
1756
|
this.launchOrganizationId = consumeLaunchOrganizationId();
|
|
1757
|
+
// The launch value is held rather than stored. It names the organization
|
|
1758
|
+
// the person was standing in when they clicked Open, so it outranks an
|
|
1759
|
+
// older choice from an earlier load, but nothing has validated it yet:
|
|
1760
|
+
// written straight to the slot, a launch naming an organization this app
|
|
1761
|
+
// cannot act in would be carried by the tab for as long as it lives. The
|
|
1762
|
+
// first resolve that confirms it is what writes it.
|
|
1206
1763
|
this.pendingAuthCode = consumeAuthCodeParam();
|
|
1207
1764
|
if (this.pendingAuthCode) {
|
|
1208
1765
|
// A fresh sign-in just landed. Burn any previously-stored
|
|
@@ -1348,6 +1905,25 @@ export class BrassClient {
|
|
|
1348
1905
|
nextUser !== null &&
|
|
1349
1906
|
this.user.sub !== nextUser.sub) {
|
|
1350
1907
|
this.setTokens(null);
|
|
1908
|
+
this.retireActingOrganizationState();
|
|
1909
|
+
}
|
|
1910
|
+
// A session ENDING, which is where the per-app stashes are dropped.
|
|
1911
|
+
//
|
|
1912
|
+
// They are keyed by app and survive the token set, so they describe the
|
|
1913
|
+
// previous person's pick: their document's name, organization and role,
|
|
1914
|
+
// answered out of storage with no request the server could refuse. The
|
|
1915
|
+
// session's `start()` restores from them, so a stash left behind puts
|
|
1916
|
+
// whoever signs in next into the document the previous person chose.
|
|
1917
|
+
//
|
|
1918
|
+
// On the transition to null rather than on the sub changing, because the
|
|
1919
|
+
// two identities need never meet: a refresh that settles `no_session` in
|
|
1920
|
+
// between (an expiry, a reconnect) drops the tokens, and the arriving
|
|
1921
|
+
// person is then a null-to-user transition that has nothing to compare
|
|
1922
|
+
// against. This branch is also what the retirement above reaches, so the
|
|
1923
|
+
// sub-change path clears through here too.
|
|
1924
|
+
if (this.user !== null && nextUser === null && this.appId) {
|
|
1925
|
+
clearLastSelections(this.appId);
|
|
1926
|
+
clearActingOrganization(this.appId);
|
|
1351
1927
|
}
|
|
1352
1928
|
this.tokens = next;
|
|
1353
1929
|
if (sameUser(this.user, nextUser))
|
|
@@ -1381,8 +1957,20 @@ export class BrassClient {
|
|
|
1381
1957
|
const token = this.tokens?.accessToken;
|
|
1382
1958
|
if (!token)
|
|
1383
1959
|
return;
|
|
1960
|
+
// The same headers every other data-API call carries. The bearer names
|
|
1961
|
+
// the person and never the app, so a platform that requires the acting
|
|
1962
|
+
// app refuses a request holding only it, and this one degrades silently
|
|
1963
|
+
// (see the catch below), which would leave a reader looking at a raw sub
|
|
1964
|
+
// where their name belongs with nothing reporting why.
|
|
1965
|
+
//
|
|
1966
|
+
// Read straight off the held token set rather than through
|
|
1967
|
+
// `getAppAssertion()`, mirroring the access token above: this is
|
|
1968
|
+
// best-effort hydration, so it must not drive a refresh of its own.
|
|
1384
1969
|
try {
|
|
1385
|
-
const
|
|
1970
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
1971
|
+
applyClientVersionHeader(headers);
|
|
1972
|
+
await applyAppAssertionHeader(headers, () => Promise.resolve(this.tokens?.appAssertion ?? null));
|
|
1973
|
+
const res = await fetch(`${this.apiBaseUrl}/users/${encodeURIComponent(this.user.sub)}`, { headers });
|
|
1386
1974
|
if (!res.ok)
|
|
1387
1975
|
return;
|
|
1388
1976
|
const profile = (await res.json());
|
|
@@ -1397,6 +1985,17 @@ export class BrassClient {
|
|
|
1397
1985
|
// Profile hydration is best-effort; the app works without it.
|
|
1398
1986
|
}
|
|
1399
1987
|
}
|
|
1988
|
+
// One failure reaches a reader exactly once: the listener when one is
|
|
1989
|
+
// registered, the console otherwise. Used by `init`, which resolves rather
|
|
1990
|
+
// than rejecting, and by the void gestures that cannot throw usefully.
|
|
1991
|
+
// Logging unconditionally would double-report, since the README's own
|
|
1992
|
+
// sample logs under this same `[Brass]` prefix. `_reportError` in
|
|
1993
|
+
// documents.ts and open-document.ts is the same rule.
|
|
1994
|
+
reportUncatchable(error) {
|
|
1995
|
+
if (this.errorListeners.size === 0)
|
|
1996
|
+
console.error('[Brass]', error.message);
|
|
1997
|
+
this.fireError(error);
|
|
1998
|
+
}
|
|
1400
1999
|
requireInit() {
|
|
1401
2000
|
if (!this.initialized)
|
|
1402
2001
|
throw new Error('Call Brass.init(appId) first');
|