@brass-build/client 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/AGENTS.md +26 -5
  2. package/CHANGELOG.md +61 -2
  3. package/README.md +247 -87
  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 +278 -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 +29 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +583 -12
  17. package/dist/client.js.map +1 -1
  18. package/dist/document-checkpoints.d.ts +1 -0
  19. package/dist/document-checkpoints.d.ts.map +1 -1
  20. package/dist/document-checkpoints.js +27 -0
  21. package/dist/document-checkpoints.js.map +1 -1
  22. package/dist/document-contents.d.ts +15 -0
  23. package/dist/document-contents.d.ts.map +1 -0
  24. package/dist/document-contents.js +88 -0
  25. package/dist/document-contents.js.map +1 -0
  26. package/dist/document-signals.d.ts +31 -1
  27. package/dist/document-signals.d.ts.map +1 -1
  28. package/dist/document-signals.js +59 -0
  29. package/dist/document-signals.js.map +1 -1
  30. package/dist/document-streams.d.ts +4 -2
  31. package/dist/document-streams.d.ts.map +1 -1
  32. package/dist/document-streams.js +6 -0
  33. package/dist/document-streams.js.map +1 -1
  34. package/dist/document-thumbnail-capture.d.ts +20 -0
  35. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  36. package/dist/document-thumbnail-capture.js +247 -9
  37. package/dist/document-thumbnail-capture.js.map +1 -1
  38. package/dist/documents-namespace.d.ts.map +1 -1
  39. package/dist/documents-namespace.js +3 -0
  40. package/dist/documents-namespace.js.map +1 -1
  41. package/dist/documents.d.ts +51 -3
  42. package/dist/documents.d.ts.map +1 -1
  43. package/dist/documents.js +415 -36
  44. package/dist/documents.js.map +1 -1
  45. package/dist/export-guest.d.ts.map +1 -1
  46. package/dist/export-guest.js +5 -0
  47. package/dist/export-guest.js.map +1 -1
  48. package/dist/handle-types.d.ts +23 -0
  49. package/dist/handle-types.d.ts.map +1 -1
  50. package/dist/handles.d.ts +1 -1
  51. package/dist/handles.d.ts.map +1 -1
  52. package/dist/handles.js +3 -3
  53. package/dist/handles.js.map +1 -1
  54. package/dist/hosted-ui.d.ts +10 -0
  55. package/dist/hosted-ui.d.ts.map +1 -1
  56. package/dist/hosted-ui.js +115 -30
  57. package/dist/hosted-ui.js.map +1 -1
  58. package/dist/importer.d.ts.map +1 -1
  59. package/dist/importer.js +28 -66
  60. package/dist/importer.js.map +1 -1
  61. package/dist/index.d.ts +8 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +25 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/internal.d.ts +2 -0
  66. package/dist/internal.d.ts.map +1 -1
  67. package/dist/internal.js +2 -0
  68. package/dist/internal.js.map +1 -1
  69. package/dist/mutate-batching.js +2 -2
  70. package/dist/mutate-batching.js.map +1 -1
  71. package/dist/open-document.d.ts +16 -1
  72. package/dist/open-document.d.ts.map +1 -1
  73. package/dist/open-document.js +123 -35
  74. package/dist/open-document.js.map +1 -1
  75. package/dist/presence.d.ts +10 -0
  76. package/dist/presence.d.ts.map +1 -1
  77. package/dist/presence.js.map +1 -1
  78. package/dist/record-paging.d.ts +13 -14
  79. package/dist/record-paging.d.ts.map +1 -1
  80. package/dist/record-paging.js +13 -14
  81. package/dist/record-paging.js.map +1 -1
  82. package/dist/serializer.d.ts +3 -3
  83. package/dist/session-toolbar.d.ts +9 -0
  84. package/dist/session-toolbar.d.ts.map +1 -1
  85. package/dist/session-toolbar.js +191 -21
  86. package/dist/session-toolbar.js.map +1 -1
  87. package/dist/tail-ops.js +2 -2
  88. package/dist/tail-ops.js.map +1 -1
  89. package/dist/types.d.ts +0 -42
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/undo.d.ts +1 -1
  92. package/dist/undo.d.ts.map +1 -1
  93. package/dist/undo.js +1 -1
  94. package/dist/undo.js.map +1 -1
  95. package/dist/version-bar.d.ts.map +1 -1
  96. package/dist/version-bar.js +75 -4
  97. package/dist/version-bar.js.map +1 -1
  98. package/dist/version.d.ts +2 -2
  99. package/dist/version.js +1 -1
  100. package/dist/wasm-ready-gate.d.ts +13 -0
  101. package/dist/wasm-ready-gate.d.ts.map +1 -0
  102. package/dist/wasm-ready-gate.js +28 -0
  103. package/dist/wasm-ready-gate.js.map +1 -0
  104. package/dist/wasm-runtime.browser.d.ts +11 -0
  105. package/dist/wasm-runtime.browser.d.ts.map +1 -0
  106. package/dist/wasm-runtime.browser.js +44 -0
  107. package/dist/wasm-runtime.browser.js.map +1 -0
  108. package/dist/wasm-runtime.d.ts +6 -0
  109. package/dist/wasm-runtime.d.ts.map +1 -0
  110. package/dist/wasm-runtime.js +20 -0
  111. package/dist/wasm-runtime.js.map +1 -0
  112. package/dist/when-saved.d.ts +2 -0
  113. package/dist/when-saved.d.ts.map +1 -0
  114. package/dist/when-saved.js +70 -0
  115. package/dist/when-saved.js.map +1 -0
  116. package/dist/ws-client.d.ts +4 -0
  117. package/dist/ws-client.d.ts.map +1 -1
  118. package/dist/ws-client.js +70 -1
  119. package/dist/ws-client.js.map +1 -1
  120. package/dist/ws-frame-limit.d.ts.map +1 -1
  121. package/dist/ws-frame-limit.js +9 -10
  122. package/dist/ws-frame-limit.js.map +1 -1
  123. package/docs/app-manifest.md +335 -5
  124. package/docs/custom-chrome.md +7 -2
  125. package/docs/presence.md +12 -0
  126. package/docs/services.md +265 -0
  127. package/docs/text-fields.md +191 -0
  128. package/package.json +4 -5
  129. package/src/acting-organization.ts +396 -0
  130. package/src/api-headers.ts +7 -7
  131. package/src/call-service.ts +354 -0
  132. package/src/client.ts +655 -15
  133. package/src/document-checkpoints.ts +30 -0
  134. package/src/document-contents.ts +104 -0
  135. package/src/document-signals.ts +113 -1
  136. package/src/document-streams.ts +15 -1
  137. package/src/document-thumbnail-capture.ts +253 -9
  138. package/src/documents-namespace.ts +20 -10
  139. package/src/documents.ts +460 -33
  140. package/src/export-guest.ts +5 -0
  141. package/src/handle-types.ts +35 -0
  142. package/src/handles.ts +7 -6
  143. package/src/hosted-ui.ts +155 -29
  144. package/src/importer.ts +28 -62
  145. package/src/index.ts +43 -4
  146. package/src/internal.ts +10 -0
  147. package/src/mutate-batching.ts +2 -2
  148. package/src/open-document.ts +179 -39
  149. package/src/presence.ts +6 -0
  150. package/src/record-paging.ts +13 -14
  151. package/src/serializer.ts +3 -3
  152. package/src/session-toolbar.ts +219 -22
  153. package/src/tail-ops.ts +2 -2
  154. package/src/types.ts +6 -4
  155. package/src/undo.ts +1 -1
  156. package/src/version-bar.ts +77 -7
  157. package/src/version.ts +1 -1
  158. package/src/wasm-ready-gate.ts +30 -0
  159. package/src/wasm-runtime.browser.ts +48 -0
  160. package/src/wasm-runtime.ts +21 -0
  161. package/src/when-saved.ts +66 -0
  162. package/src/ws-client.ts +70 -1
  163. package/src/ws-frame-limit.ts +9 -10
  164. package/dist/vite.d.ts +0 -31
  165. package/dist/vite.d.ts.map +0 -1
  166. package/dist/vite.js +0 -161
  167. package/dist/vite.js.map +0 -1
  168. package/src/vite.ts +0 -214
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildSignInUrl, buildSignedOutOfAppUrl, refreshTokens, revokeAppApproval, } from './auth-flow.js';
2
+ import { ensureWasmReady } from './wasm-runtime.js';
2
3
  import { decodeIdToken } from './jwt.js';
3
4
  import { createEmitter } from './emitter.js';
4
5
  import { clearParkedDocuments, createOpenDocument, } from './open-document.js';
@@ -9,7 +10,9 @@ import { createImportRunner, } from './importer.js';
9
10
  import { isExportMode, startExportGuest, } from './export-guest.js';
10
11
  import { readGuestLinkToken } from './guest-link.js';
11
12
  import { consumeLaunchOrganizationId } from './launch-context.js';
13
+ import { callService, normalizeServiceHost, listAppServices, } from './call-service.js';
12
14
  import { clearAll as clearLastSelections, readLastDocument, storeLastDocument, } from './selection-persistence.js';
15
+ import { clearActingOrganization, fetchActingOrganization, readActingOrganization, resolveActingOrganization, switchActingOrganization, storeActingOrganization, visibleOrganization, } from './acting-organization.js';
13
16
  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
17
  import { composePageTitle, setPageTitle } from './page-title.js';
15
18
  import { clearSessionToken, readSessionToken, storeSessionToken, } from './session-persistence.js';
@@ -178,6 +181,30 @@ export class BrassClient {
178
181
  // when the app was reached some other way. A create that names no
179
182
  // destination of its own lands here; see `launch-context.ts`.
180
183
  launchOrganizationId = null;
184
+ // The organization this page has resolved to act in, and the name the
185
+ // server gave it. Held for the life of the page load so repeated acts
186
+ // resolve once; dropped on sign-out, and replaced by a later switch.
187
+ actingOrganization = null;
188
+ // Pushed to the account menu, which names the organization once one has a
189
+ // name. Resolution happens at the first act that needs one, and a document
190
+ // arrives later still, so the menu is rendered before there is anything to
191
+ // show and has to hear about it rather than poll.
192
+ documentWatchForActingOrganization = null;
193
+ // The open document's organization, named. Null while no document is open,
194
+ // while its organization is one this reader is not a member of, and until
195
+ // the name has been read.
196
+ chromeDocumentOrganization = null;
197
+ // Drops a name that arrived after the document it was for moved on.
198
+ chromeOrganizationToken = 0;
199
+ // Names by organization id, so opening five documents in one organization
200
+ // reads it once. A null value records that the server did not confirm the
201
+ // id, which is as durable an answer as a name.
202
+ organizationNames = new Map();
203
+ actingOrganizationListeners = new Set();
204
+ // One document's organization, by document id. A document's placement
205
+ // does not move under an open app, so the read behind it runs once per
206
+ // id; the in-flight promise is shared so concurrent acts make one call.
207
+ documentOrganizationIds = new Map();
181
208
  // The app's export backend (`Brass.registerSerializer`), invoked by the
182
209
  // platform when a hosted surface downloads an app-owned blob doc. One
183
210
  // per app; a later registration replaces the earlier.
@@ -213,6 +240,15 @@ export class BrassClient {
213
240
  // the click handler that opens a surface, so it would miss the press that
214
241
  // opened it and have nothing to hand focus back to on dismissal.
215
242
  trackHostedUiActivations();
243
+ // The WASM this package runs on is fetched from here, so the download
244
+ // overlaps the auth round-trip below and is usually ready before anything
245
+ // asks for it. It is deliberately NOT awaited here: an app's sign-in
246
+ // screen needs no engine, and gating `init` on the download puts several
247
+ // megabytes in front of the first paint of every app that has one. The
248
+ // paths that construct a document await it instead. The rejection is
249
+ // absorbed because this call is only the head start; a failure has to
250
+ // surface at the open that needs it, which retries the fetch.
251
+ void ensureWasmReady().catch(() => undefined);
216
252
  try {
217
253
  // A Brass-hosted bundle resolves its app id and all four origins from
218
254
  // the serving slot's runtime descriptor, which is authoritative: the
@@ -321,10 +357,7 @@ export class BrassClient {
321
357
  }
322
358
  catch (err) {
323
359
  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);
360
+ this.reportUncatchable(error);
328
361
  // Deliberately do not re-throw: callers using
329
362
  // `void Brass.init(...).then(setup)` should see .then() fire so the
330
363
  // page transitions out of its loading state. signIn() etc. continue
@@ -355,7 +388,21 @@ export class BrassClient {
355
388
  }
356
389
  }
357
390
  signIn() {
358
- const appId = this.requireAppId();
391
+ // Reported rather than thrown, because this is the one gesture an app
392
+ // offers in a state the SDK itself invited. A failed init resolves with
393
+ // `authStatus === 'signed-out'`, indistinguishable from a reader who is
394
+ // genuinely signed out, so every app renders its sign-in control and the
395
+ // reader clicks it. `signIn` returns void and runs from a click handler,
396
+ // so a throw there is an uncaught error the app has no way to catch,
397
+ // raised over a decision the app did not get wrong. The error channel
398
+ // carries it to the same places, and to the listener the app registered.
399
+ if (!this.initialized || !this.appId) {
400
+ this.reportUncatchable(new Error(this.initialized
401
+ ? 'Brass.init was called without an app ID'
402
+ : 'Call Brass.init(appId) first'));
403
+ return;
404
+ }
405
+ const appId = this.appId;
359
406
  if (typeof window === 'undefined') {
360
407
  throw new Error('Brass.signIn must be called in a browser');
361
408
  }
@@ -409,6 +456,13 @@ export class BrassClient {
409
456
  // Clear the per-app selection-persistence layer so the next user
410
457
  // on this tab doesn't see the previous user's last selection.
411
458
  clearLastSelections(appId);
459
+ // Same for the organization this app was acting in: the next user's
460
+ // memberships are their own, so both the stored choice and this page
461
+ // load's resolution go with the session.
462
+ clearActingOrganization(appId);
463
+ this.actingOrganization = null;
464
+ this.emitActingOrganization();
465
+ this.documentOrganizationIds.clear();
412
466
  const signedOutUrl = buildSignedOutOfAppUrl(this.authBaseUrl, appId, window.location.href);
413
467
  // On a Brass-hosted origin, route the sign-out through the edge's
414
468
  // /__brass/leave so the first-party brass_site gate cookie is cleared
@@ -449,9 +503,15 @@ export class BrassClient {
449
503
  return 'guest';
450
504
  return this.user !== null ? 'signed-in' : 'signed-out';
451
505
  }
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.
506
+ // The organization this app is acting in, as far as this page load already
507
+ // knows. Synchronous, so nothing is resolved to answer it: the open
508
+ // document's organization outranks the rest for the same reason it does in
509
+ // the chrome, since it is what every act made while it is open spends.
510
+ knownOrganizationId() {
511
+ return (this.chromeDocumentOrganization?.id ??
512
+ this.actingOrganization?.id ??
513
+ this.launchOrganizationId);
514
+ }
455
515
  get isGuest() {
456
516
  return this.guestLinkTokenValue !== null;
457
517
  }
@@ -612,6 +672,21 @@ export class BrassClient {
612
672
  signIn: () => this.signIn(),
613
673
  signOut: () => this.signOut(),
614
674
  dashboardBaseUrl: this._dashboardBaseUrl,
675
+ getActingOrganization: () => this.visibleActingOrganization(),
676
+ subscribeActingOrganization: (listener) => {
677
+ this.actingOrganizationListeners.add(listener);
678
+ this.watchDocumentForActingOrganization();
679
+ // A toolbar mounted after a document opened missed the watch's edge,
680
+ // so ask once on the way in. This is what makes the read pay only for
681
+ // an app that renders the chrome.
682
+ void this.refreshChromeOrganization();
683
+ return () => {
684
+ this.actingOrganizationListeners.delete(listener);
685
+ };
686
+ },
687
+ switchOrganization: () => {
688
+ void this.switchActingOrganization();
689
+ },
615
690
  getDocument: (docId) => this.documents.get(docId),
616
691
  // The version-preview bar is a hosted embed; it takes the same theme
617
692
  // as the other hosted surfaces.
@@ -672,6 +747,9 @@ export class BrassClient {
672
747
  this.hostedUiLauncher = createHostedUiLauncher({
673
748
  dashboardBaseUrl: this._dashboardBaseUrl,
674
749
  callingAppId: appId,
750
+ // The organization the picker opens on: the one this app is working
751
+ // in, and otherwise the choice this tab carries for it.
752
+ actingOrganizationId: () => this.knownOrganizationId() ?? this.preferredOrganizationId(),
675
753
  theme: this.hostedUiTheme,
676
754
  serializeForExport: (docId) => this.serializeForExport(docId),
677
755
  // The hosted delete surface reports a local delete over the bridge so
@@ -869,9 +947,21 @@ export class BrassClient {
869
947
  async offerInstallConsent(err) {
870
948
  if (!isAppNotInstalled(err) || !err.organizationId)
871
949
  return false;
950
+ const organizationId = err.organizationId;
872
951
  try {
873
- const result = await this.getHostedUiLauncher().installApp(err.organizationId);
874
- return result !== null;
952
+ const result = await this.getHostedUiLauncher().installApp(organizationId);
953
+ if (result === null) {
954
+ // Declining abandons the choice, the way declining the install behind
955
+ // a switch abandons the switch. Kept, it would pin every later act,
956
+ // and every later visit, to an organization this app cannot serve,
957
+ // with the chooser only reachable through the account menu.
958
+ this.forgetActingOrganization(organizationId);
959
+ return false;
960
+ }
961
+ // The choice became one the app can act in, so now it carries past this
962
+ // page load.
963
+ storeActingOrganization(this.requireAppId(), organizationId);
964
+ return true;
875
965
  }
876
966
  catch {
877
967
  // Hosted UI unavailable here (no DOM, or the dialog failed); the
@@ -879,6 +969,16 @@ export class BrassClient {
879
969
  return false;
880
970
  }
881
971
  }
972
+ // Drop a resolution that never became one the app can act in. Scoped to the
973
+ // organization named, so a resolution the app has moved on from is left
974
+ // alone.
975
+ forgetActingOrganization(organizationId) {
976
+ if (this.actingOrganization?.id !== organizationId)
977
+ return;
978
+ this.actingOrganization = null;
979
+ clearActingOrganization(this.requireAppId());
980
+ this.emitActingOrganization();
981
+ }
882
982
  // Register the app's export backend. The platform calls it when a
883
983
  // hosted surface downloads an app-owned (blob) document: it receives
884
984
  // the open Document and returns the source file's bytes, the inverse of
@@ -948,6 +1048,33 @@ export class BrassClient {
948
1048
  this.openDocuments.delete(docId);
949
1049
  return null;
950
1050
  }
1051
+ // Publish a document to a drive, supplying the bytes when the platform has
1052
+ // no writer for it.
1053
+ //
1054
+ // Where the export of this document runs in THIS app, the SDK runs the
1055
+ // registered serializer in process and the push carries its bytes, rather
1056
+ // than sending the reader through the hosted export frame. That is what
1057
+ // makes a publish from inside an app one click, and it is the same on the
1058
+ // publish that creates the drive file and on every push after it: the
1059
+ // caller's own app either writes this document or it does not.
1060
+ //
1061
+ // Everything else is the route's, including refusing bytes from an app the
1062
+ // exporter setting does not name, which it does by name rather than the
1063
+ // client predicting it.
1064
+ async pushDocumentToDrive(doc, input) {
1065
+ const docId = doc.id;
1066
+ if (docId === null || !doc._exportRunsHere) {
1067
+ return await this.docsInternal().pushToDrive(doc, input);
1068
+ }
1069
+ const serialized = await this.serializeForExport(docId);
1070
+ if (!serialized.ok) {
1071
+ throw new BrassError(`This app could not write the document out to publish it: ${serialized.message}`, { status: 400 });
1072
+ }
1073
+ return await this.docsInternal().pushToDrive(doc, {
1074
+ ...input,
1075
+ bytes: serialized.bytes,
1076
+ });
1077
+ }
951
1078
  // Run the registered serializer for an export request from a hosted
952
1079
  // surface. Resolves the doc from the open set (the active session doc is
953
1080
  // the fallback for an app that drives it through the session), then hands
@@ -1031,7 +1158,7 @@ export class BrassClient {
1031
1158
  },
1032
1159
  thumbnailUploader: (doc, image, width, height) => docsNs.setThumbnail(doc, { image, width, height }),
1033
1160
  checkpointCreator: (doc) => docsInternal.createCheckpoint(doc),
1034
- drivePusher: (doc, input) => docsInternal.pushToDrive(doc, input),
1161
+ drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
1035
1162
  sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
1036
1163
  hostedUiProvider: {
1037
1164
  documentHistory: (docId, options) => this.documentHistory(docId, options),
@@ -1078,7 +1205,7 @@ export class BrassClient {
1078
1205
  // against it.
1079
1206
  acquireOrganization: () => this.getHostedUiLauncher().acquireOrganization(),
1080
1207
  },
1081
- launchOrganizationId: () => this.launchOrganizationId,
1208
+ preferredOrganizationId: () => this.knownOrganizationId(),
1082
1209
  reportError: (err) => this.fireError(err),
1083
1210
  });
1084
1211
  }
@@ -1136,6 +1263,412 @@ export class BrassClient {
1136
1263
  // calls `getAccessToken()` then this reads the same (possibly just
1137
1264
  // refreshed) token set without forcing a second refresh. Rejects on a
1138
1265
  // transient outage for the same reason `getAccessToken` does.
1266
+ // Which organization the next act spends, resolved at the act rather than
1267
+ // at boot: the open document's organization, one already resolved on this
1268
+ // page load, and otherwise the server's answer from the caller's context,
1269
+ // with the hosted dialog asking where context cannot decide. An app never
1270
+ // names one, so this is the only place the answer comes from.
1271
+ // `ask: false` answers null where the chooser would open, for a read an app
1272
+ // makes before the person has pressed anything.
1273
+ resolveActingOrganizationId(options) {
1274
+ const appId = this.requireAppId();
1275
+ return resolveActingOrganization({
1276
+ activeDocumentOrganizationId: () => this.activeDocumentOrganizationId(),
1277
+ cachedResolution: () => this.actingOrganization?.id ?? null,
1278
+ // The launch value is written into the same slot at boot, so this one
1279
+ // read carries both the stored choice and the launch.
1280
+ launchPreference: () => this.launchOrganizationId,
1281
+ storedPreference: () => readActingOrganization(appId),
1282
+ forgetPreference: () => clearActingOrganization(appId),
1283
+ fetchResolved: (prefer) => this.fetchActingOrganization(prefer),
1284
+ pickOrganization: () => this.pickActingOrganization(this.actingOrganization?.id),
1285
+ remember: (organizationId, organizationName, persist) => {
1286
+ this.setActingOrganization(organizationId, organizationName, persist);
1287
+ },
1288
+ }, options === undefined ? {} : { ask: false });
1289
+ }
1290
+ // The same resolve for a caller that has nowhere to put a null: the ladder
1291
+ // asks, and a person who dismisses the chooser is a BrassError rather than
1292
+ // an organization.
1293
+ async requireActingOrganizationId() {
1294
+ const resolved = await this.resolveActingOrganizationId();
1295
+ // `resolveActingOrganization` throws rather than answering null while it
1296
+ // may ask, so this is a type narrowing rather than a reachable branch.
1297
+ if (resolved === null)
1298
+ throw new BrassError('No organization chosen.', { status: 400 });
1299
+ return resolved;
1300
+ }
1301
+ fetchActingOrganization(prefer) {
1302
+ return fetchActingOrganization({
1303
+ apiBaseUrl: this.apiBaseUrl,
1304
+ getAccessToken: () => this.getAccessToken(),
1305
+ getAppAssertion: () => this.getAppAssertion(),
1306
+ }, prefer);
1307
+ }
1308
+ // The hosted organization picker: it lists the caller's own organizations
1309
+ // and offers to create or join one, which is the whole question being asked
1310
+ // here. An install is a consequence of the answer rather than the question,
1311
+ // so it is left to the act that needs one: the call the resolution feeds
1312
+ // refuses `app_not_installed` and raises the install dialog against the
1313
+ // organization just chosen. Null when the person closed it.
1314
+ //
1315
+ // The surface is iframe-only, since a chosen organization cannot survive a
1316
+ // redirect fallback, so a context that cannot host one (a browser
1317
+ // partitioning third-party storage) cannot ask this question at all. That
1318
+ // reaches the caller as a rejection rather than a dismissal: an act
1319
+ // refuses with it, and a deliberate switch reports it, which is what keeps
1320
+ // the menu's own control from reading as dead.
1321
+ async pickActingOrganization(current) {
1322
+ const result = await this.getHostedUiLauncher().selectOrganization(current);
1323
+ return result?.organization_id ?? null;
1324
+ }
1325
+ // The account menu's switch. The same hosted dialog the resolution opens,
1326
+ // reached deliberately rather than from a refused act, so the reader can
1327
+ // move an app to another organization without going to the dashboard.
1328
+ // Dismissal leaves the current one standing.
1329
+ async switchActingOrganization() {
1330
+ try {
1331
+ await switchActingOrganization({
1332
+ current: () => this.knownOrganizationId(),
1333
+ pickOrganization: (current) => this.pickActingOrganization(current),
1334
+ fetchResolved: (prefer) => this.fetchActingOrganization(prefer),
1335
+ offerInstall: (organizationId) => this.getHostedUiLauncher()
1336
+ .installApp(organizationId)
1337
+ .then((r) => r !== null),
1338
+ remember: (organizationId, organizationName) => {
1339
+ this.setActingOrganization(organizationId, organizationName);
1340
+ },
1341
+ reload: () => {
1342
+ // A document belongs to one organization, and it decides every act
1343
+ // made while it is open, so it cannot survive a move to another
1344
+ // one: leaving it open would put the reader in an organization they
1345
+ // did not switch to for everything they then did. Closing it is the
1346
+ // switch, rather than a side effect of it.
1347
+ //
1348
+ // The tab's remembered pick goes whether or not one is open, and
1349
+ // that is the half a close alone does not cover. A reload restores
1350
+ // the last document a person picked, so a switch made with nothing
1351
+ // open reopens the document the reader just moved away from, whose
1352
+ // organization then outranks the one they chose. The switch would
1353
+ // read as a page reload that changed nothing.
1354
+ if (this.appId)
1355
+ clearLastSelections(this.appId);
1356
+ if (this.session.document !== null)
1357
+ this.session.close();
1358
+ if (typeof window !== 'undefined')
1359
+ window.location.reload();
1360
+ },
1361
+ });
1362
+ }
1363
+ catch (err) {
1364
+ this.fireError(err instanceof Error ? err : new Error(String(err)));
1365
+ }
1366
+ }
1367
+ // The open document's organization. The Document itself does not carry one,
1368
+ // so this reads the record once per document id and remembers it; a
1369
+ // document picked through the hosted picker already named its organization
1370
+ // on the way in, which answers with no request at all.
1371
+ activeDocumentOrganizationId() {
1372
+ // A link guest holds no API credential, so there is nothing to read with.
1373
+ if (this.guestLinkTokenValue !== null)
1374
+ return Promise.resolve(null);
1375
+ const docId = this.session.document?.id;
1376
+ if (!docId)
1377
+ return Promise.resolve(null);
1378
+ const known = this.documentOrganizationIds.get(docId);
1379
+ if (known)
1380
+ return known;
1381
+ const lastPicked = readLastDocument(this.requireAppId());
1382
+ const pending = lastPicked?.doc_id === docId
1383
+ ? Promise.resolve(lastPicked.organization_id)
1384
+ : this.documents
1385
+ .get(docId)
1386
+ .then((record) => record.organization_id)
1387
+ .catch(() => {
1388
+ // The server resolves an organization from the caller's context
1389
+ // without this, so a failed read costs a request rather than the
1390
+ // act that needed one. It is dropped rather than remembered,
1391
+ // since what the read would have answered still outranks that
1392
+ // fallback for every later act on this document.
1393
+ this.documentOrganizationIds.delete(docId);
1394
+ return null;
1395
+ });
1396
+ this.documentOrganizationIds.set(docId, pending);
1397
+ return pending;
1398
+ }
1399
+ // Record the organization this app acts in, from a surface where the person
1400
+ // chose it. Replaces whatever this page load had resolved, so the next act
1401
+ // spends the new one rather than a memo of the old.
1402
+ // @internal
1403
+ // The one writer of the memo, the stored slot and the menu, so a resolution
1404
+ // and a switch cannot report different things to the chrome.
1405
+ // What this tab would offer the server as its preference: the launch value
1406
+ // first, since it is the freshest thing this tab knows, then the choice a
1407
+ // previous act on this page confirmed. Neither is an answer; the server
1408
+ // validates both.
1409
+ preferredOrganizationId() {
1410
+ if (this.launchOrganizationId)
1411
+ return this.launchOrganizationId;
1412
+ return this.appId ? readActingOrganization(this.appId) : null;
1413
+ }
1414
+ setActingOrganization(organizationId, organizationName,
1415
+ // False while the app is not installed there: the choice holds for this
1416
+ // page load, so the install the refusal raises is about the organization
1417
+ // the reader just picked, and it is written to the slot once that install
1418
+ // lands rather than before. False again for a resolution taken from an
1419
+ // open document, which is where the app is working rather than a choice
1420
+ // the person made.
1421
+ persist = true) {
1422
+ const appId = this.requireAppId();
1423
+ // An id can arrive with no name, from a resolution an open document
1424
+ // answered. Keep a name the chrome has already read for that same
1425
+ // organization rather than blanking the line the reader is looking at.
1426
+ const name = organizationName === '' && this.actingOrganization?.id === organizationId
1427
+ ? this.actingOrganization.name
1428
+ : organizationName;
1429
+ this.actingOrganization = { id: organizationId, name };
1430
+ if (persist)
1431
+ storeActingOrganization(appId, organizationId);
1432
+ this.emitActingOrganization();
1433
+ }
1434
+ visibleActingOrganization() {
1435
+ return visibleOrganization(this.session.document === null
1436
+ ? null
1437
+ : { organization: this.chromeDocumentOrganization }, this.actingOrganization);
1438
+ }
1439
+ // Opening or closing a document changes which organization the next act
1440
+ // spends, so the chrome has to hear about it. Keyed on the document's id
1441
+ // rather than on the session event, which also fires on every mutation.
1442
+ watchDocumentForActingOrganization() {
1443
+ if (this.documentWatchForActingOrganization)
1444
+ return;
1445
+ let lastDocId = this.session.document?.id ?? null;
1446
+ this.documentWatchForActingOrganization = this.session.subscribe(() => {
1447
+ const docId = this.session.document?.id ?? null;
1448
+ if (docId === lastDocId)
1449
+ return;
1450
+ lastDocId = docId;
1451
+ void this.refreshChromeOrganization();
1452
+ });
1453
+ }
1454
+ // Name the organization the chrome would show, where one is already known.
1455
+ //
1456
+ // This never RESOLVES an organization: it asks the server to name an id the
1457
+ // app already holds, and takes the answer only when the server names the
1458
+ // same one back. An app reached by its own URL with nothing stored has no
1459
+ // id, so nothing is asked and the menu stays silent, which is the state a
1460
+ // person who has not acted yet is genuinely in.
1461
+ //
1462
+ // The mismatch case is the load-bearing half. A document can live in an
1463
+ // organization the reader reaches through a directly shared project and is
1464
+ // not a member of; the route then resolves something else, and naming that
1465
+ // would put an organization on screen that has nothing to do with what is
1466
+ // open. So a name is shown only when the server confirms the id asked
1467
+ // about.
1468
+ async refreshChromeOrganization() {
1469
+ const token = ++this.chromeOrganizationToken;
1470
+ const documentOrganizationId = await this.activeDocumentOrganizationId();
1471
+ const named = documentOrganizationId === null
1472
+ ? null
1473
+ : await this.namedOrganization(documentOrganizationId);
1474
+ // A later document (or a switch) started its own resolve while this one
1475
+ // was in flight, and its answer is the current one.
1476
+ if (token !== this.chromeOrganizationToken)
1477
+ return;
1478
+ this.chromeDocumentOrganization = named;
1479
+ // The document is where this app is working, so its organization is what
1480
+ // the next act spends once the document closes: a reader who opens a
1481
+ // model, closes it and creates another would otherwise be asked where to
1482
+ // put the second one, having just worked in the answer. Held for the page
1483
+ // load rather than written to the tab's slot, because it is where the app
1484
+ // was taken rather than a choice the person made.
1485
+ if (named !== null && named.installed) {
1486
+ this.setActingOrganization(named.id, named.name, false);
1487
+ }
1488
+ this.emitActingOrganization();
1489
+ if (documentOrganizationId === null)
1490
+ await this.nameStoredOrganization(token);
1491
+ }
1492
+ // An organization can reach this app as an id with no name: a launch
1493
+ // parameter, the choice this tab carries, or the organization of a document
1494
+ // the reader has just closed. Each is what a cold create and every service
1495
+ // call spend until something else resolves, so the menu owes it a name once
1496
+ // an app renders the chrome, and this is where the memo learns one.
1497
+ //
1498
+ // Nothing happens without an id in hand, which is the case the design turns
1499
+ // on: an app reached by its own URL has none, and asking the server to pick
1500
+ // one so a menu has something to say is the boot-time question this refuses.
1501
+ async nameStoredOrganization(token) {
1502
+ if (!this.appId)
1503
+ return;
1504
+ // A memo carrying a name was named by the server already.
1505
+ if (this.actingOrganization !== null && this.actingOrganization.name !== '') {
1506
+ return;
1507
+ }
1508
+ const stored = this.actingOrganization?.id ?? this.preferredOrganizationId();
1509
+ if (stored === null)
1510
+ return;
1511
+ const named = await this.namedOrganization(stored);
1512
+ // A stored id the caller has since left resolves as some OTHER
1513
+ // organization, which `namedOrganization` refuses. Staying silent is the
1514
+ // right answer: adopting what the server picked would be resolving one to
1515
+ // decorate a menu.
1516
+ if (named === null || token !== this.chromeOrganizationToken)
1517
+ return;
1518
+ // An organization this app is no longer installed on is not one it is
1519
+ // acting in, whatever the tab is carrying. Adopting it here would be the
1520
+ // chrome deciding where acts land: the memo it writes is what the
1521
+ // resolver answers from before it asks the server anything, so the
1522
+ // staleness check on the way in never runs and every act is refused for
1523
+ // an organization somebody deliberately removed this app from. Drop the
1524
+ // carried value and stay silent, which is the state the reader is in.
1525
+ if (!named.installed) {
1526
+ this.forgetActingOrganization(named.id);
1527
+ clearActingOrganization(this.appId);
1528
+ return;
1529
+ }
1530
+ this.setActingOrganization(named.id, named.name);
1531
+ }
1532
+ async namedOrganization(organizationId) {
1533
+ const cached = this.organizationNames.get(organizationId);
1534
+ if (cached !== undefined)
1535
+ return cached;
1536
+ let named = null;
1537
+ try {
1538
+ const outcome = await this.fetchActingOrganization(organizationId);
1539
+ if (outcome.kind === 'resolved' && outcome.organizationId === organizationId) {
1540
+ named = {
1541
+ id: outcome.organizationId,
1542
+ name: outcome.organizationName,
1543
+ installed: outcome.installed,
1544
+ };
1545
+ }
1546
+ }
1547
+ catch {
1548
+ // A name is decoration. A failed read leaves the menu quiet rather than
1549
+ // costing the app anything, and is not cached, so the next document
1550
+ // change asks again.
1551
+ return null;
1552
+ }
1553
+ // A name is durable; whether this app is installed there is not, since an
1554
+ // admin can install it while the page is open. So an answer carrying
1555
+ // `installed: false` is returned and not kept, and the next read asks
1556
+ // again rather than reporting what was true once.
1557
+ if (named === null || named.installed) {
1558
+ this.organizationNames.set(organizationId, named);
1559
+ }
1560
+ return named;
1561
+ }
1562
+ emitActingOrganization() {
1563
+ const visible = this.visibleActingOrganization();
1564
+ for (const listener of this.actingOrganizationListeners) {
1565
+ try {
1566
+ listener(visible);
1567
+ }
1568
+ catch (err) {
1569
+ this.fireError(err instanceof Error ? err : new Error(String(err)));
1570
+ }
1571
+ }
1572
+ }
1573
+ // One brokered call to an outside service the app's manifest declares.
1574
+ // Brass attaches the credential, so the app holds none and the key never
1575
+ // reaches the browser.
1576
+ //
1577
+ // Returns the service's own answer rather than throwing on it: a `401` from
1578
+ // the service, a `409` for a service nobody has keyed yet, and a `429` at a
1579
+ // cap are all results the app renders differently.
1580
+ async callService(host, path, options) {
1581
+ this.requireInit();
1582
+ const service = normalizeServiceHost(host);
1583
+ return callService({
1584
+ apiBaseUrl: this.apiBaseUrl,
1585
+ getAccessToken: () => this.getAccessToken(),
1586
+ getAppAssertion: () => this.getAppAssertion(),
1587
+ resolveOrganization: (ask) => ask
1588
+ ? this.requireActingOrganizationId()
1589
+ : this.resolveActingOrganizationId({ ask: false }),
1590
+ offerInstallConsent: (err) => this.offerInstallConsent(err),
1591
+ offerServiceCredential: (organizationId, serviceHost) => this.offerServiceCredential(organizationId, serviceHost),
1592
+ }, service, path, options);
1593
+ }
1594
+ // Which of the services this app declares would serve a call right now, for
1595
+ // the organization it is acting in. An app whose credential is optional
1596
+ // renders the feature as available or as an upgrade from this, instead of
1597
+ // learning by making a call the reader has already pressed for.
1598
+ //
1599
+ // `ready: false` carries the same `code` the call itself answers with, so an
1600
+ // app branches on one vocabulary rather than two.
1601
+ async listServices() {
1602
+ this.requireInit();
1603
+ // A reader with no session is acting nowhere, which is the state null
1604
+ // reports, so a page that calls this while it loads gets an answer to
1605
+ // render on rather than a rejection. A transient outage rejects from
1606
+ // here, since that is not an answer about where the app is acting.
1607
+ if ((await this.getAccessToken()) === null)
1608
+ return null;
1609
+ // Resolved without the chooser: an app calls this to decide what to
1610
+ // render, before the reader has pressed anything, so a question put here
1611
+ // is one they did not ask for. `callService` is the act, and the act is
1612
+ // where a question belongs.
1613
+ const orgId = await this.resolveActingOrganizationId({ ask: false });
1614
+ // Null is a state the app renders on: it is acting nowhere yet.
1615
+ if (orgId === null)
1616
+ return null;
1617
+ const deps = {
1618
+ apiBaseUrl: this.apiBaseUrl,
1619
+ getAccessToken: () => this.getAccessToken(),
1620
+ getAppAssertion: () => this.getAppAssertion(),
1621
+ };
1622
+ try {
1623
+ return await listAppServices(deps, orgId);
1624
+ }
1625
+ catch (err) {
1626
+ // An organization that has not installed this app is the same kind of
1627
+ // answer as having none resolved: something only a person can settle, so
1628
+ // it is reported here and raised by `callService`, where the reader has
1629
+ // acted.
1630
+ if (isAppNotInstalled(err))
1631
+ return null;
1632
+ throw err;
1633
+ }
1634
+ }
1635
+ // Open the hosted dialog for one service, from the app's own affordance
1636
+ // rather than from a refused call. Resolves true when that host became
1637
+ // usable, which is when a caller re-reads or retries.
1638
+ //
1639
+ // Takes a host the manifest declares, or a pattern it declares where the
1640
+ // address names the account (`*.myshopify.com`). One method rather than two,
1641
+ // because what the caller is asking for is the same in both: an account this
1642
+ // app can spend at a service it declared.
1643
+ async connectService(host) {
1644
+ this.requireInit();
1645
+ const orgId = await this.requireActingOrganizationId();
1646
+ return this.offerServiceCredential(orgId, normalizeServiceHost(host));
1647
+ }
1648
+ // A brokered call was refused for a credential somebody using the app can
1649
+ // supply, which the platform states on the refusal rather than the SDK
1650
+ // reading it off the code. Open the hosted dialog, which asks whoever is
1651
+ // there for whatever the host is missing and tells anyone who cannot answer
1652
+ // who to ask. Resolves true only when this host became usable, in which case
1653
+ // the caller sends the refused call again.
1654
+ async offerServiceCredential(organizationId, host) {
1655
+ try {
1656
+ const result = await this.getHostedUiLauncher().serviceCredential(organizationId, host);
1657
+ if (result === null)
1658
+ return false;
1659
+ // A pattern names a host per account, so what became usable is the
1660
+ // account the reader named rather than the term the app asked under.
1661
+ // Anything under the pattern is what the app can now call.
1662
+ return host.startsWith('*.')
1663
+ ? result.ready.some((ready) => ready.endsWith(host.slice(1)))
1664
+ : result.ready.includes(host);
1665
+ }
1666
+ catch {
1667
+ // Hosted UI unavailable here (no DOM, or the dialog failed); the
1668
+ // original refusal stands.
1669
+ return false;
1670
+ }
1671
+ }
1139
1672
  async getAppAssertion() {
1140
1673
  this.requireInit();
1141
1674
  // Paired with `getAccessToken`: a link guest holds no token to assert
@@ -1176,6 +1709,12 @@ export class BrassClient {
1176
1709
  // and strip it here, so a client-side navigation later in the session
1177
1710
  // cannot lose it and the address bar stays the app's own.
1178
1711
  this.launchOrganizationId = consumeLaunchOrganizationId();
1712
+ // The launch value is held rather than stored. It names the organization
1713
+ // the person was standing in when they clicked Open, so it outranks an
1714
+ // older choice from an earlier load, but nothing has validated it yet:
1715
+ // written straight to the slot, a launch naming an organization this app
1716
+ // cannot act in would be carried by the tab for as long as it lives. The
1717
+ // first resolve that confirms it is what writes it.
1179
1718
  this.pendingAuthCode = consumeAuthCodeParam();
1180
1719
  if (this.pendingAuthCode) {
1181
1720
  // A fresh sign-in just landed. Burn any previously-stored
@@ -1321,6 +1860,27 @@ export class BrassClient {
1321
1860
  nextUser !== null &&
1322
1861
  this.user.sub !== nextUser.sub) {
1323
1862
  this.setTokens(null);
1863
+ this.actingOrganization = null;
1864
+ this.emitActingOrganization();
1865
+ this.documentOrganizationIds.clear();
1866
+ }
1867
+ // A session ENDING, which is where the per-app stashes are dropped.
1868
+ //
1869
+ // They are keyed by app and survive the token set, so they describe the
1870
+ // previous person's pick: their document's name, organization and role,
1871
+ // answered out of storage with no request the server could refuse. The
1872
+ // session's `start()` restores from them, so a stash left behind puts
1873
+ // whoever signs in next into the document the previous person chose.
1874
+ //
1875
+ // On the transition to null rather than on the sub changing, because the
1876
+ // two identities need never meet: a refresh that settles `no_session` in
1877
+ // between (an expiry, a reconnect) drops the tokens, and the arriving
1878
+ // person is then a null-to-user transition that has nothing to compare
1879
+ // against. This branch is also what the retirement above reaches, so the
1880
+ // sub-change path clears through here too.
1881
+ if (this.user !== null && nextUser === null && this.appId) {
1882
+ clearLastSelections(this.appId);
1883
+ clearActingOrganization(this.appId);
1324
1884
  }
1325
1885
  this.tokens = next;
1326
1886
  if (sameUser(this.user, nextUser))
@@ -1370,6 +1930,17 @@ export class BrassClient {
1370
1930
  // Profile hydration is best-effort; the app works without it.
1371
1931
  }
1372
1932
  }
1933
+ // One failure reaches a reader exactly once: the listener when one is
1934
+ // registered, the console otherwise. Used by `init`, which resolves rather
1935
+ // than rejecting, and by the void gestures that cannot throw usefully.
1936
+ // Logging unconditionally would double-report, since the README's own
1937
+ // sample logs under this same `[Brass]` prefix. `_reportError` in
1938
+ // documents.ts and open-document.ts is the same rule.
1939
+ reportUncatchable(error) {
1940
+ if (this.errorListeners.size === 0)
1941
+ console.error('[Brass]', error.message);
1942
+ this.fireError(error);
1943
+ }
1373
1944
  requireInit() {
1374
1945
  if (!this.initialized)
1375
1946
  throw new Error('Call Brass.init(appId) first');