@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.
Files changed (128) hide show
  1. package/AGENTS.md +50 -7
  2. package/CHANGELOG.md +63 -2
  3. package/README.md +233 -77
  4. package/dist/acting-organization.d.ts +131 -0
  5. package/dist/acting-organization.d.ts.map +1 -0
  6. package/dist/acting-organization.js +273 -0
  7. package/dist/acting-organization.js.map +1 -0
  8. package/dist/api-headers.js +7 -7
  9. package/dist/api-headers.js.map +1 -1
  10. package/dist/call-service.d.ts +74 -0
  11. package/dist/call-service.d.ts.map +1 -0
  12. package/dist/call-service.js +253 -0
  13. package/dist/call-service.js.map +1 -0
  14. package/dist/client.d.ts +31 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +611 -12
  17. package/dist/client.js.map +1 -1
  18. package/dist/document-contents.d.ts +15 -0
  19. package/dist/document-contents.d.ts.map +1 -0
  20. package/dist/document-contents.js +88 -0
  21. package/dist/document-contents.js.map +1 -0
  22. package/dist/document-streams.d.ts +4 -2
  23. package/dist/document-streams.d.ts.map +1 -1
  24. package/dist/document-streams.js +6 -0
  25. package/dist/document-streams.js.map +1 -1
  26. package/dist/documents.d.ts +45 -1
  27. package/dist/documents.d.ts.map +1 -1
  28. package/dist/documents.js +305 -22
  29. package/dist/documents.js.map +1 -1
  30. package/dist/export-guest.d.ts.map +1 -1
  31. package/dist/export-guest.js +5 -0
  32. package/dist/export-guest.js.map +1 -1
  33. package/dist/handle-types.d.ts +23 -0
  34. package/dist/handle-types.d.ts.map +1 -1
  35. package/dist/handles.d.ts +1 -1
  36. package/dist/handles.d.ts.map +1 -1
  37. package/dist/handles.js +1 -1
  38. package/dist/handles.js.map +1 -1
  39. package/dist/hosted-ui.d.ts +10 -0
  40. package/dist/hosted-ui.d.ts.map +1 -1
  41. package/dist/hosted-ui.js +115 -30
  42. package/dist/hosted-ui.js.map +1 -1
  43. package/dist/importer.d.ts.map +1 -1
  44. package/dist/importer.js +17 -6
  45. package/dist/importer.js.map +1 -1
  46. package/dist/index.d.ts +8 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +25 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal.d.ts +1 -0
  51. package/dist/internal.d.ts.map +1 -1
  52. package/dist/internal.js +1 -0
  53. package/dist/internal.js.map +1 -1
  54. package/dist/mutate-batching.js +2 -2
  55. package/dist/mutate-batching.js.map +1 -1
  56. package/dist/open-document.d.ts +1 -1
  57. package/dist/open-document.d.ts.map +1 -1
  58. package/dist/open-document.js +69 -34
  59. package/dist/open-document.js.map +1 -1
  60. package/dist/presence.d.ts +10 -0
  61. package/dist/presence.d.ts.map +1 -1
  62. package/dist/presence.js.map +1 -1
  63. package/dist/session-toolbar.d.ts +9 -0
  64. package/dist/session-toolbar.d.ts.map +1 -1
  65. package/dist/session-toolbar.js +60 -12
  66. package/dist/session-toolbar.js.map +1 -1
  67. package/dist/tail-ops.js +2 -2
  68. package/dist/tail-ops.js.map +1 -1
  69. package/dist/types.d.ts +0 -42
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/undo.d.ts +1 -1
  72. package/dist/undo.d.ts.map +1 -1
  73. package/dist/undo.js +1 -1
  74. package/dist/undo.js.map +1 -1
  75. package/dist/version-bar.d.ts.map +1 -1
  76. package/dist/version-bar.js +52 -2
  77. package/dist/version-bar.js.map +1 -1
  78. package/dist/version.d.ts +2 -2
  79. package/dist/version.js +1 -1
  80. package/dist/wasm-ready-gate.d.ts +13 -0
  81. package/dist/wasm-ready-gate.d.ts.map +1 -0
  82. package/dist/wasm-ready-gate.js +28 -0
  83. package/dist/wasm-ready-gate.js.map +1 -0
  84. package/dist/wasm-runtime.browser.d.ts +11 -0
  85. package/dist/wasm-runtime.browser.d.ts.map +1 -0
  86. package/dist/wasm-runtime.browser.js +44 -0
  87. package/dist/wasm-runtime.browser.js.map +1 -0
  88. package/dist/wasm-runtime.d.ts +6 -0
  89. package/dist/wasm-runtime.d.ts.map +1 -0
  90. package/dist/wasm-runtime.js +20 -0
  91. package/dist/wasm-runtime.js.map +1 -0
  92. package/docs/app-manifest.md +335 -5
  93. package/docs/custom-chrome.md +3 -0
  94. package/docs/presence.md +12 -0
  95. package/docs/services.md +265 -0
  96. package/docs/text-fields.md +191 -0
  97. package/package.json +4 -5
  98. package/src/acting-organization.ts +390 -0
  99. package/src/api-headers.ts +7 -7
  100. package/src/call-service.ts +354 -0
  101. package/src/client.ts +680 -14
  102. package/src/document-contents.ts +104 -0
  103. package/src/document-streams.ts +15 -1
  104. package/src/documents.ts +340 -20
  105. package/src/export-guest.ts +5 -0
  106. package/src/handle-types.ts +35 -0
  107. package/src/handles.ts +1 -1
  108. package/src/hosted-ui.ts +155 -29
  109. package/src/importer.ts +17 -6
  110. package/src/index.ts +43 -4
  111. package/src/internal.ts +4 -0
  112. package/src/mutate-batching.ts +2 -2
  113. package/src/open-document.ts +79 -38
  114. package/src/presence.ts +6 -0
  115. package/src/session-toolbar.ts +80 -12
  116. package/src/tail-ops.ts +2 -2
  117. package/src/types.ts +6 -4
  118. package/src/undo.ts +1 -1
  119. package/src/version-bar.ts +53 -2
  120. package/src/version.ts +1 -1
  121. package/src/wasm-ready-gate.ts +30 -0
  122. package/src/wasm-runtime.browser.ts +48 -0
  123. package/src/wasm-runtime.ts +21 -0
  124. package/dist/vite.d.ts +0 -31
  125. package/dist/vite.d.ts.map +0 -1
  126. package/dist/vite.js +0 -161
  127. package/dist/vite.js.map +0 -1
  128. 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
- // console.error so the failure is visible in DevTools even when no
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
- const appId = this.requireAppId();
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 anonymous public-link token this page was opened with
453
- // (`?brass_link=`), or null on the ordinary signed-in path. Set during
454
- // `init` and read by the session to boot the read-only guest document.
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(err.organizationId);
874
- return result !== null;
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
- launchOrganizationId: () => this.launchOrganizationId,
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 res = await fetch(`${this.apiBaseUrl}/users/${encodeURIComponent(this.user.sub)}`, { headers: { Authorization: `Bearer ${token}` } });
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');