@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/src/client.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type RefreshOutcome,
8
8
  type TokenSet,
9
9
  } from './auth-flow.js';
10
+ import { ensureWasmReady } from './wasm-runtime.js';
10
11
  import { decodeIdToken } from './jwt.js';
11
12
  import { createEmitter } from './emitter.js';
12
13
  import {
@@ -44,11 +45,28 @@ import {
44
45
  } from './export-guest.js';
45
46
  import { readGuestLinkToken } from './guest-link.js';
46
47
  import { consumeLaunchOrganizationId } from './launch-context.js';
48
+ import {
49
+ callService,
50
+ normalizeServiceHost,
51
+ listAppServices,
52
+ type AppServiceState,
53
+ type CallServiceOptions,
54
+ } from './call-service.js';
47
55
  import {
48
56
  clearAll as clearLastSelections,
49
57
  readLastDocument,
50
58
  storeLastDocument,
51
59
  } from './selection-persistence.js';
60
+ import {
61
+ clearActingOrganization,
62
+ fetchActingOrganization,
63
+ readActingOrganization,
64
+ resolveActingOrganization,
65
+ switchActingOrganization,
66
+ storeActingOrganization,
67
+ visibleOrganization,
68
+ type ResolveOutcome,
69
+ } from './acting-organization.js';
52
70
  import {
53
71
  DEFAULT_BRANCH_URL_PARAM,
54
72
  DEFAULT_DOC_URL_PARAM,
@@ -76,6 +94,8 @@ import {
76
94
  createDocumentsNamespace,
77
95
  type DocumentsNamespace,
78
96
  type DocumentsInternalNamespace,
97
+ type PushToDriveInput,
98
+ type PushToDriveResult,
79
99
  } from './documents-namespace.js';
80
100
  import {
81
101
  createDocumentSession,
@@ -301,6 +321,41 @@ export class BrassClient {
301
321
  // when the app was reached some other way. A create that names no
302
322
  // destination of its own lands here; see `launch-context.ts`.
303
323
  private launchOrganizationId: string | null = null;
324
+ // The organization this page has resolved to act in, and the name the
325
+ // server gave it. Held for the life of the page load so repeated acts
326
+ // resolve once; dropped on sign-out, and replaced by a later switch.
327
+ private actingOrganization: {
328
+ id: string;
329
+ /** Empty for a resolution taken from an open document, which names an
330
+ * id and nothing else. `nameStoredOrganization` fills it in for the
331
+ * chrome. */
332
+ name: string;
333
+ } | null = null;
334
+ // Pushed to the account menu, which names the organization once one has a
335
+ // name. Resolution happens at the first act that needs one, and a document
336
+ // arrives later still, so the menu is rendered before there is anything to
337
+ // show and has to hear about it rather than poll.
338
+ private documentWatchForActingOrganization: (() => void) | null = null;
339
+ // The open document's organization, named. Null while no document is open,
340
+ // while its organization is one this reader is not a member of, and until
341
+ // the name has been read.
342
+ private chromeDocumentOrganization: { id: string; name: string } | null = null;
343
+ // Drops a name that arrived after the document it was for moved on.
344
+ private chromeOrganizationToken = 0;
345
+ // Names by organization id, so opening five documents in one organization
346
+ // reads it once. A null value records that the server did not confirm the
347
+ // id, which is as durable an answer as a name.
348
+ private organizationNames = new Map<
349
+ string,
350
+ { id: string; name: string; installed: boolean } | null
351
+ >();
352
+ private actingOrganizationListeners = new Set<
353
+ (organization: { id: string; name: string } | null) => void
354
+ >();
355
+ // One document's organization, by document id. A document's placement
356
+ // does not move under an open app, so the read behind it runs once per
357
+ // id; the in-flight promise is shared so concurrent acts make one call.
358
+ private documentOrganizationIds = new Map<string, Promise<string | null>>();
304
359
  // The app's export backend (`Brass.registerSerializer`), invoked by the
305
360
  // platform when a hosted surface downloads an app-owned blob doc. One
306
361
  // per app; a later registration replaces the earlier.
@@ -337,6 +392,15 @@ export class BrassClient {
337
392
  // the click handler that opens a surface, so it would miss the press that
338
393
  // opened it and have nothing to hand focus back to on dismissal.
339
394
  trackHostedUiActivations();
395
+ // The WASM this package runs on is fetched from here, so the download
396
+ // overlaps the auth round-trip below and is usually ready before anything
397
+ // asks for it. It is deliberately NOT awaited here: an app's sign-in
398
+ // screen needs no engine, and gating `init` on the download puts several
399
+ // megabytes in front of the first paint of every app that has one. The
400
+ // paths that construct a document await it instead. The rejection is
401
+ // absorbed because this call is only the head start; a failure has to
402
+ // surface at the open that needs it, which retries the fetch.
403
+ void ensureWasmReady().catch(() => undefined);
340
404
  try {
341
405
  // A Brass-hosted bundle resolves its app id and all four origins from
342
406
  // the serving slot's runtime descriptor, which is authoritative: the
@@ -454,10 +518,7 @@ export class BrassClient {
454
518
  await this.hydrateProfile();
455
519
  } catch (err) {
456
520
  const error = err instanceof Error ? err : new Error(String(err));
457
- // console.error so the failure is visible in DevTools even when no
458
- // onError listener was registered. Prefixed for grep-ability.
459
- console.error('[Brass]', error.message);
460
- this.fireError(error);
521
+ this.reportUncatchable(error);
461
522
  // Deliberately do not re-throw: callers using
462
523
  // `void Brass.init(...).then(setup)` should see .then() fire so the
463
524
  // page transitions out of its loading state. signIn() etc. continue
@@ -488,7 +549,25 @@ export class BrassClient {
488
549
  }
489
550
 
490
551
  signIn(): void {
491
- const appId = this.requireAppId();
552
+ // Reported rather than thrown, because this is the one gesture an app
553
+ // offers in a state the SDK itself invited. A failed init resolves with
554
+ // `authStatus === 'signed-out'`, indistinguishable from a reader who is
555
+ // genuinely signed out, so every app renders its sign-in control and the
556
+ // reader clicks it. `signIn` returns void and runs from a click handler,
557
+ // so a throw there is an uncaught error the app has no way to catch,
558
+ // raised over a decision the app did not get wrong. The error channel
559
+ // carries it to the same places, and to the listener the app registered.
560
+ if (!this.initialized || !this.appId) {
561
+ this.reportUncatchable(
562
+ new Error(
563
+ this.initialized
564
+ ? 'Brass.init was called without an app ID'
565
+ : 'Call Brass.init(appId) first',
566
+ ),
567
+ );
568
+ return;
569
+ }
570
+ const appId = this.appId;
492
571
  if (typeof window === 'undefined') {
493
572
  throw new Error('Brass.signIn must be called in a browser');
494
573
  }
@@ -542,6 +621,13 @@ export class BrassClient {
542
621
  // Clear the per-app selection-persistence layer so the next user
543
622
  // on this tab doesn't see the previous user's last selection.
544
623
  clearLastSelections(appId);
624
+ // Same for the organization this app was acting in: the next user's
625
+ // memberships are their own, so both the stored choice and this page
626
+ // load's resolution go with the session.
627
+ clearActingOrganization(appId);
628
+ this.actingOrganization = null;
629
+ this.emitActingOrganization();
630
+ this.documentOrganizationIds.clear();
545
631
  const signedOutUrl = buildSignedOutOfAppUrl(this.authBaseUrl, appId, window.location.href);
546
632
  // On a Brass-hosted origin, route the sign-out through the edge's
547
633
  // /__brass/leave so the first-party brass_site gate cookie is cleared
@@ -582,9 +668,18 @@ export class BrassClient {
582
668
  return this.user !== null ? 'signed-in' : 'signed-out';
583
669
  }
584
670
 
585
- // The anonymous public-link token this page was opened with
586
- // (`?brass_link=`), or null on the ordinary signed-in path. Set during
587
- // `init` and read by the session to boot the read-only guest document.
671
+ // The organization this app is acting in, as far as this page load already
672
+ // knows. Synchronous, so nothing is resolved to answer it: the open
673
+ // document's organization outranks the rest for the same reason it does in
674
+ // the chrome, since it is what every act made while it is open spends.
675
+ private knownOrganizationId(): string | null {
676
+ return (
677
+ this.chromeDocumentOrganization?.id ??
678
+ this.actingOrganization?.id ??
679
+ this.launchOrganizationId
680
+ );
681
+ }
682
+
588
683
  get isGuest(): boolean {
589
684
  return this.guestLinkTokenValue !== null;
590
685
  }
@@ -774,6 +869,21 @@ export class BrassClient {
774
869
  signIn: () => this.signIn(),
775
870
  signOut: () => this.signOut(),
776
871
  dashboardBaseUrl: this._dashboardBaseUrl,
872
+ getActingOrganization: () => this.visibleActingOrganization(),
873
+ subscribeActingOrganization: (listener) => {
874
+ this.actingOrganizationListeners.add(listener);
875
+ this.watchDocumentForActingOrganization();
876
+ // A toolbar mounted after a document opened missed the watch's edge,
877
+ // so ask once on the way in. This is what makes the read pay only for
878
+ // an app that renders the chrome.
879
+ void this.refreshChromeOrganization();
880
+ return () => {
881
+ this.actingOrganizationListeners.delete(listener);
882
+ };
883
+ },
884
+ switchOrganization: () => {
885
+ void this.switchActingOrganization();
886
+ },
777
887
  getDocument: (docId) => this.documents.get(docId),
778
888
  // The version-preview bar is a hosted embed; it takes the same theme
779
889
  // as the other hosted surfaces.
@@ -841,6 +951,10 @@ export class BrassClient {
841
951
  this.hostedUiLauncher = createHostedUiLauncher({
842
952
  dashboardBaseUrl: this._dashboardBaseUrl,
843
953
  callingAppId: appId,
954
+ // The organization the picker opens on: the one this app is working
955
+ // in, and otherwise the choice this tab carries for it.
956
+ actingOrganizationId: () =>
957
+ this.knownOrganizationId() ?? this.preferredOrganizationId(),
844
958
  theme: this.hostedUiTheme,
845
959
  serializeForExport: (docId) => this.serializeForExport(docId),
846
960
  // The hosted delete surface reports a local delete over the bridge so
@@ -1052,11 +1166,21 @@ export class BrassClient {
1052
1166
  // in which case the caller retries the denied call once.
1053
1167
  private async offerInstallConsent(err: unknown): Promise<boolean> {
1054
1168
  if (!isAppNotInstalled(err) || !err.organizationId) return false;
1169
+ const organizationId = err.organizationId;
1055
1170
  try {
1056
- const result = await this.getHostedUiLauncher().installApp(
1057
- err.organizationId,
1058
- );
1059
- return result !== null;
1171
+ const result = await this.getHostedUiLauncher().installApp(organizationId);
1172
+ if (result === null) {
1173
+ // Declining abandons the choice, the way declining the install behind
1174
+ // a switch abandons the switch. Kept, it would pin every later act,
1175
+ // and every later visit, to an organization this app cannot serve,
1176
+ // with the chooser only reachable through the account menu.
1177
+ this.forgetActingOrganization(organizationId);
1178
+ return false;
1179
+ }
1180
+ // The choice became one the app can act in, so now it carries past this
1181
+ // page load.
1182
+ storeActingOrganization(this.requireAppId(), organizationId);
1183
+ return true;
1060
1184
  } catch {
1061
1185
  // Hosted UI unavailable here (no DOM, or the dialog failed); the
1062
1186
  // original denial stands.
@@ -1064,6 +1188,16 @@ export class BrassClient {
1064
1188
  }
1065
1189
  }
1066
1190
 
1191
+ // Drop a resolution that never became one the app can act in. Scoped to the
1192
+ // organization named, so a resolution the app has moved on from is left
1193
+ // alone.
1194
+ private forgetActingOrganization(organizationId: string): void {
1195
+ if (this.actingOrganization?.id !== organizationId) return;
1196
+ this.actingOrganization = null;
1197
+ clearActingOrganization(this.requireAppId());
1198
+ this.emitActingOrganization();
1199
+ }
1200
+
1067
1201
  // Register the app's export backend. The platform calls it when a
1068
1202
  // hosted surface downloads an app-owned (blob) document: it receives
1069
1203
  // the open Document and returns the source file's bytes, the inverse of
@@ -1133,6 +1267,40 @@ export class BrassClient {
1133
1267
  return null;
1134
1268
  }
1135
1269
 
1270
+ // Publish a document to a drive, supplying the bytes when the platform has
1271
+ // no writer for it.
1272
+ //
1273
+ // Where the export of this document runs in THIS app, the SDK runs the
1274
+ // registered serializer in process and the push carries its bytes, rather
1275
+ // than sending the reader through the hosted export frame. That is what
1276
+ // makes a publish from inside an app one click, and it is the same on the
1277
+ // publish that creates the drive file and on every push after it: the
1278
+ // caller's own app either writes this document or it does not.
1279
+ //
1280
+ // Everything else is the route's, including refusing bytes from an app the
1281
+ // exporter setting does not name, which it does by name rather than the
1282
+ // client predicting it.
1283
+ private async pushDocumentToDrive(
1284
+ doc: Document,
1285
+ input: PushToDriveInput,
1286
+ ): Promise<PushToDriveResult> {
1287
+ const docId = doc.id;
1288
+ if (docId === null || !doc._exportRunsHere) {
1289
+ return await this.docsInternal().pushToDrive(doc, input);
1290
+ }
1291
+ const serialized = await this.serializeForExport(docId);
1292
+ if (!serialized.ok) {
1293
+ throw new BrassError(
1294
+ `This app could not write the document out to publish it: ${serialized.message}`,
1295
+ { status: 400 },
1296
+ );
1297
+ }
1298
+ return await this.docsInternal().pushToDrive(doc, {
1299
+ ...input,
1300
+ bytes: serialized.bytes,
1301
+ });
1302
+ }
1303
+
1136
1304
  // Run the registered serializer for an export request from a hosted
1137
1305
  // surface. Resolves the doc from the open set (the active session doc is
1138
1306
  // the fallback for an app that drives it through the session), then hands
@@ -1218,8 +1386,7 @@ export class BrassClient {
1218
1386
  thumbnailUploader: (doc, image, width, height) =>
1219
1387
  docsNs.setThumbnail(doc, { image, width, height }),
1220
1388
  checkpointCreator: (doc) => docsInternal.createCheckpoint(doc),
1221
- drivePusher: (doc, input) =>
1222
- docsInternal.pushToDrive(doc, input),
1389
+ drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
1223
1390
  sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
1224
1391
  hostedUiProvider: {
1225
1392
  documentHistory: (docId, options) =>
@@ -1269,7 +1436,7 @@ export class BrassClient {
1269
1436
  acquireOrganization: () =>
1270
1437
  this.getHostedUiLauncher().acquireOrganization(),
1271
1438
  },
1272
- launchOrganizationId: () => this.launchOrganizationId,
1439
+ preferredOrganizationId: () => this.knownOrganizationId(),
1273
1440
  reportError: (err) => this.fireError(err),
1274
1441
  });
1275
1442
  }
@@ -1326,6 +1493,441 @@ export class BrassClient {
1326
1493
  // calls `getAccessToken()` then this reads the same (possibly just
1327
1494
  // refreshed) token set without forcing a second refresh. Rejects on a
1328
1495
  // transient outage for the same reason `getAccessToken` does.
1496
+ // Which organization the next act spends, resolved at the act rather than
1497
+ // at boot: the open document's organization, one already resolved on this
1498
+ // page load, and otherwise the server's answer from the caller's context,
1499
+ // with the hosted dialog asking where context cannot decide. An app never
1500
+ // names one, so this is the only place the answer comes from.
1501
+ // `ask: false` answers null where the chooser would open, for a read an app
1502
+ // makes before the person has pressed anything.
1503
+ private resolveActingOrganizationId(options?: {
1504
+ ask: false;
1505
+ }): Promise<string | null> {
1506
+ const appId = this.requireAppId();
1507
+ return resolveActingOrganization({
1508
+ activeDocumentOrganizationId: () => this.activeDocumentOrganizationId(),
1509
+ cachedResolution: () => this.actingOrganization?.id ?? null,
1510
+ // The launch value is written into the same slot at boot, so this one
1511
+ // read carries both the stored choice and the launch.
1512
+ launchPreference: () => this.launchOrganizationId,
1513
+ storedPreference: () => readActingOrganization(appId),
1514
+ forgetPreference: () => clearActingOrganization(appId),
1515
+ fetchResolved: (prefer) => this.fetchActingOrganization(prefer),
1516
+ pickOrganization: () =>
1517
+ this.pickActingOrganization(this.actingOrganization?.id),
1518
+ remember: (organizationId, organizationName, persist) => {
1519
+ this.setActingOrganization(organizationId, organizationName, persist);
1520
+ },
1521
+ }, options === undefined ? {} : { ask: false });
1522
+ }
1523
+
1524
+ // The same resolve for a caller that has nowhere to put a null: the ladder
1525
+ // asks, and a person who dismisses the chooser is a BrassError rather than
1526
+ // an organization.
1527
+ private async requireActingOrganizationId(): Promise<string> {
1528
+ const resolved = await this.resolveActingOrganizationId();
1529
+ // `resolveActingOrganization` throws rather than answering null while it
1530
+ // may ask, so this is a type narrowing rather than a reachable branch.
1531
+ if (resolved === null) throw new BrassError('No organization chosen.', { status: 400 });
1532
+ return resolved;
1533
+ }
1534
+
1535
+ private fetchActingOrganization(prefer: string | null): Promise<ResolveOutcome> {
1536
+ return fetchActingOrganization(
1537
+ {
1538
+ apiBaseUrl: this.apiBaseUrl,
1539
+ getAccessToken: () => this.getAccessToken(),
1540
+ getAppAssertion: () => this.getAppAssertion(),
1541
+ },
1542
+ prefer,
1543
+ );
1544
+ }
1545
+
1546
+ // The hosted organization picker: it lists the caller's own organizations
1547
+ // and offers to create or join one, which is the whole question being asked
1548
+ // here. An install is a consequence of the answer rather than the question,
1549
+ // so it is left to the act that needs one: the call the resolution feeds
1550
+ // refuses `app_not_installed` and raises the install dialog against the
1551
+ // organization just chosen. Null when the person closed it.
1552
+ //
1553
+ // The surface is iframe-only, since a chosen organization cannot survive a
1554
+ // redirect fallback, so a context that cannot host one (a browser
1555
+ // partitioning third-party storage) cannot ask this question at all. That
1556
+ // reaches the caller as a rejection rather than a dismissal: an act
1557
+ // refuses with it, and a deliberate switch reports it, which is what keeps
1558
+ // the menu's own control from reading as dead.
1559
+ private async pickActingOrganization(current?: string): Promise<string | null> {
1560
+ const result = await this.getHostedUiLauncher().selectOrganization(current);
1561
+ return result?.organization_id ?? null;
1562
+ }
1563
+
1564
+ // The account menu's switch. The same hosted dialog the resolution opens,
1565
+ // reached deliberately rather than from a refused act, so the reader can
1566
+ // move an app to another organization without going to the dashboard.
1567
+ // Dismissal leaves the current one standing.
1568
+ private async switchActingOrganization(): Promise<void> {
1569
+ try {
1570
+ await switchActingOrganization({
1571
+ current: () => this.knownOrganizationId(),
1572
+ pickOrganization: (current) => this.pickActingOrganization(current),
1573
+ fetchResolved: (prefer) => this.fetchActingOrganization(prefer),
1574
+ offerInstall: (organizationId) =>
1575
+ this.getHostedUiLauncher()
1576
+ .installApp(organizationId)
1577
+ .then((r) => r !== null),
1578
+ remember: (organizationId, organizationName) => {
1579
+ this.setActingOrganization(organizationId, organizationName);
1580
+ },
1581
+ reload: () => {
1582
+ // A document belongs to one organization, and it decides every act
1583
+ // made while it is open, so it cannot survive a move to another
1584
+ // one: leaving it open would put the reader in an organization they
1585
+ // did not switch to for everything they then did. Closing it is the
1586
+ // switch, rather than a side effect of it.
1587
+ //
1588
+ // The tab's remembered pick goes whether or not one is open, and
1589
+ // that is the half a close alone does not cover. A reload restores
1590
+ // the last document a person picked, so a switch made with nothing
1591
+ // open reopens the document the reader just moved away from, whose
1592
+ // organization then outranks the one they chose. The switch would
1593
+ // read as a page reload that changed nothing.
1594
+ if (this.appId) clearLastSelections(this.appId);
1595
+ if (this.session.document !== null) this.session.close();
1596
+ if (typeof window !== 'undefined') window.location.reload();
1597
+ },
1598
+ });
1599
+ } catch (err) {
1600
+ this.fireError(err instanceof Error ? err : new Error(String(err)));
1601
+ }
1602
+ }
1603
+
1604
+
1605
+ // The open document's organization. The Document itself does not carry one,
1606
+ // so this reads the record once per document id and remembers it; a
1607
+ // document picked through the hosted picker already named its organization
1608
+ // on the way in, which answers with no request at all.
1609
+ private activeDocumentOrganizationId(): Promise<string | null> {
1610
+ // A link guest holds no API credential, so there is nothing to read with.
1611
+ if (this.guestLinkTokenValue !== null) return Promise.resolve(null);
1612
+ const docId = this.session.document?.id;
1613
+ if (!docId) return Promise.resolve(null);
1614
+ const known = this.documentOrganizationIds.get(docId);
1615
+ if (known) return known;
1616
+ const lastPicked = readLastDocument(this.requireAppId());
1617
+ const pending =
1618
+ lastPicked?.doc_id === docId
1619
+ ? Promise.resolve(lastPicked.organization_id)
1620
+ : this.documents
1621
+ .get(docId)
1622
+ .then((record) => record.organization_id)
1623
+ .catch(() => {
1624
+ // The server resolves an organization from the caller's context
1625
+ // without this, so a failed read costs a request rather than the
1626
+ // act that needed one. It is dropped rather than remembered,
1627
+ // since what the read would have answered still outranks that
1628
+ // fallback for every later act on this document.
1629
+ this.documentOrganizationIds.delete(docId);
1630
+ return null;
1631
+ });
1632
+ this.documentOrganizationIds.set(docId, pending);
1633
+ return pending;
1634
+ }
1635
+
1636
+ // Record the organization this app acts in, from a surface where the person
1637
+ // chose it. Replaces whatever this page load had resolved, so the next act
1638
+ // spends the new one rather than a memo of the old.
1639
+ // @internal
1640
+
1641
+ // The one writer of the memo, the stored slot and the menu, so a resolution
1642
+ // and a switch cannot report different things to the chrome.
1643
+ // What this tab would offer the server as its preference: the launch value
1644
+ // first, since it is the freshest thing this tab knows, then the choice a
1645
+ // previous act on this page confirmed. Neither is an answer; the server
1646
+ // validates both.
1647
+ private preferredOrganizationId(): string | null {
1648
+ if (this.launchOrganizationId) return this.launchOrganizationId;
1649
+ return this.appId ? readActingOrganization(this.appId) : null;
1650
+ }
1651
+
1652
+ private setActingOrganization(
1653
+ organizationId: string,
1654
+ organizationName: string,
1655
+ // False while the app is not installed there: the choice holds for this
1656
+ // page load, so the install the refusal raises is about the organization
1657
+ // the reader just picked, and it is written to the slot once that install
1658
+ // lands rather than before. False again for a resolution taken from an
1659
+ // open document, which is where the app is working rather than a choice
1660
+ // the person made.
1661
+ persist = true,
1662
+ ): void {
1663
+ const appId = this.requireAppId();
1664
+ // An id can arrive with no name, from a resolution an open document
1665
+ // answered. Keep a name the chrome has already read for that same
1666
+ // organization rather than blanking the line the reader is looking at.
1667
+ const name =
1668
+ organizationName === '' && this.actingOrganization?.id === organizationId
1669
+ ? this.actingOrganization.name
1670
+ : organizationName;
1671
+ this.actingOrganization = { id: organizationId, name };
1672
+ if (persist) storeActingOrganization(appId, organizationId);
1673
+ this.emitActingOrganization();
1674
+ }
1675
+
1676
+ private visibleActingOrganization(): { id: string; name: string } | null {
1677
+ return visibleOrganization(
1678
+ this.session.document === null
1679
+ ? null
1680
+ : { organization: this.chromeDocumentOrganization },
1681
+ this.actingOrganization,
1682
+ );
1683
+ }
1684
+
1685
+ // Opening or closing a document changes which organization the next act
1686
+ // spends, so the chrome has to hear about it. Keyed on the document's id
1687
+ // rather than on the session event, which also fires on every mutation.
1688
+ private watchDocumentForActingOrganization(): void {
1689
+ if (this.documentWatchForActingOrganization) return;
1690
+ let lastDocId = this.session.document?.id ?? null;
1691
+ this.documentWatchForActingOrganization = this.session.subscribe(() => {
1692
+ const docId = this.session.document?.id ?? null;
1693
+ if (docId === lastDocId) return;
1694
+ lastDocId = docId;
1695
+ void this.refreshChromeOrganization();
1696
+ });
1697
+ }
1698
+
1699
+ // Name the organization the chrome would show, where one is already known.
1700
+ //
1701
+ // This never RESOLVES an organization: it asks the server to name an id the
1702
+ // app already holds, and takes the answer only when the server names the
1703
+ // same one back. An app reached by its own URL with nothing stored has no
1704
+ // id, so nothing is asked and the menu stays silent, which is the state a
1705
+ // person who has not acted yet is genuinely in.
1706
+ //
1707
+ // The mismatch case is the load-bearing half. A document can live in an
1708
+ // organization the reader reaches through a directly shared project and is
1709
+ // not a member of; the route then resolves something else, and naming that
1710
+ // would put an organization on screen that has nothing to do with what is
1711
+ // open. So a name is shown only when the server confirms the id asked
1712
+ // about.
1713
+ private async refreshChromeOrganization(): Promise<void> {
1714
+ const token = ++this.chromeOrganizationToken;
1715
+ const documentOrganizationId = await this.activeDocumentOrganizationId();
1716
+ const named =
1717
+ documentOrganizationId === null
1718
+ ? null
1719
+ : await this.namedOrganization(documentOrganizationId);
1720
+ // A later document (or a switch) started its own resolve while this one
1721
+ // was in flight, and its answer is the current one.
1722
+ if (token !== this.chromeOrganizationToken) return;
1723
+ this.chromeDocumentOrganization = named;
1724
+ // The document is where this app is working, so its organization is what
1725
+ // the next act spends once the document closes: a reader who opens a
1726
+ // model, closes it and creates another would otherwise be asked where to
1727
+ // put the second one, having just worked in the answer. Held for the page
1728
+ // load rather than written to the tab's slot, because it is where the app
1729
+ // was taken rather than a choice the person made.
1730
+ if (named !== null && named.installed) {
1731
+ this.setActingOrganization(named.id, named.name, false);
1732
+ }
1733
+ this.emitActingOrganization();
1734
+ if (documentOrganizationId === null) await this.nameStoredOrganization(token);
1735
+ }
1736
+
1737
+ // An organization can reach this app as an id with no name: a launch
1738
+ // parameter, the choice this tab carries, or the organization of a document
1739
+ // the reader has just closed. Each is what a cold create and every service
1740
+ // call spend until something else resolves, so the menu owes it a name once
1741
+ // an app renders the chrome, and this is where the memo learns one.
1742
+ //
1743
+ // Nothing happens without an id in hand, which is the case the design turns
1744
+ // on: an app reached by its own URL has none, and asking the server to pick
1745
+ // one so a menu has something to say is the boot-time question this refuses.
1746
+ private async nameStoredOrganization(token: number): Promise<void> {
1747
+ if (!this.appId) return;
1748
+ // A memo carrying a name was named by the server already.
1749
+ if (this.actingOrganization !== null && this.actingOrganization.name !== '') {
1750
+ return;
1751
+ }
1752
+ const stored = this.actingOrganization?.id ?? this.preferredOrganizationId();
1753
+ if (stored === null) return;
1754
+ const named = await this.namedOrganization(stored);
1755
+ // A stored id the caller has since left resolves as some OTHER
1756
+ // organization, which `namedOrganization` refuses. Staying silent is the
1757
+ // right answer: adopting what the server picked would be resolving one to
1758
+ // decorate a menu.
1759
+ if (named === null || token !== this.chromeOrganizationToken) return;
1760
+ // An organization this app is no longer installed on is not one it is
1761
+ // acting in, whatever the tab is carrying. Adopting it here would be the
1762
+ // chrome deciding where acts land: the memo it writes is what the
1763
+ // resolver answers from before it asks the server anything, so the
1764
+ // staleness check on the way in never runs and every act is refused for
1765
+ // an organization somebody deliberately removed this app from. Drop the
1766
+ // carried value and stay silent, which is the state the reader is in.
1767
+ if (!named.installed) {
1768
+ this.forgetActingOrganization(named.id);
1769
+ clearActingOrganization(this.appId);
1770
+ return;
1771
+ }
1772
+ this.setActingOrganization(named.id, named.name);
1773
+ }
1774
+
1775
+ private async namedOrganization(
1776
+ organizationId: string,
1777
+ ): Promise<{ id: string; name: string; installed: boolean } | null> {
1778
+ const cached = this.organizationNames.get(organizationId);
1779
+ if (cached !== undefined) return cached;
1780
+ let named: { id: string; name: string; installed: boolean } | null = null;
1781
+ try {
1782
+ const outcome = await this.fetchActingOrganization(organizationId);
1783
+ if (outcome.kind === 'resolved' && outcome.organizationId === organizationId) {
1784
+ named = {
1785
+ id: outcome.organizationId,
1786
+ name: outcome.organizationName,
1787
+ installed: outcome.installed,
1788
+ };
1789
+ }
1790
+ } catch {
1791
+ // A name is decoration. A failed read leaves the menu quiet rather than
1792
+ // costing the app anything, and is not cached, so the next document
1793
+ // change asks again.
1794
+ return null;
1795
+ }
1796
+ // A name is durable; whether this app is installed there is not, since an
1797
+ // admin can install it while the page is open. So an answer carrying
1798
+ // `installed: false` is returned and not kept, and the next read asks
1799
+ // again rather than reporting what was true once.
1800
+ if (named === null || named.installed) {
1801
+ this.organizationNames.set(organizationId, named);
1802
+ }
1803
+ return named;
1804
+ }
1805
+
1806
+ private emitActingOrganization(): void {
1807
+ const visible = this.visibleActingOrganization();
1808
+ for (const listener of this.actingOrganizationListeners) {
1809
+ try {
1810
+ listener(visible);
1811
+ } catch (err) {
1812
+ this.fireError(err instanceof Error ? err : new Error(String(err)));
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ // One brokered call to an outside service the app's manifest declares.
1818
+ // Brass attaches the credential, so the app holds none and the key never
1819
+ // reaches the browser.
1820
+ //
1821
+ // Returns the service's own answer rather than throwing on it: a `401` from
1822
+ // the service, a `409` for a service nobody has keyed yet, and a `429` at a
1823
+ // cap are all results the app renders differently.
1824
+ async callService(
1825
+ host: string,
1826
+ path: string,
1827
+ options?: CallServiceOptions,
1828
+ ): Promise<Response> {
1829
+ this.requireInit();
1830
+ const service = normalizeServiceHost(host);
1831
+ return callService(
1832
+ {
1833
+ apiBaseUrl: this.apiBaseUrl,
1834
+ getAccessToken: () => this.getAccessToken(),
1835
+ getAppAssertion: () => this.getAppAssertion(),
1836
+ resolveOrganization: (ask) =>
1837
+ ask
1838
+ ? this.requireActingOrganizationId()
1839
+ : this.resolveActingOrganizationId({ ask: false }),
1840
+ offerInstallConsent: (err) => this.offerInstallConsent(err),
1841
+ offerServiceCredential: (organizationId, serviceHost) =>
1842
+ this.offerServiceCredential(organizationId, serviceHost),
1843
+ },
1844
+ service,
1845
+ path,
1846
+ options,
1847
+ );
1848
+ }
1849
+
1850
+ // Which of the services this app declares would serve a call right now, for
1851
+ // the organization it is acting in. An app whose credential is optional
1852
+ // renders the feature as available or as an upgrade from this, instead of
1853
+ // learning by making a call the reader has already pressed for.
1854
+ //
1855
+ // `ready: false` carries the same `code` the call itself answers with, so an
1856
+ // app branches on one vocabulary rather than two.
1857
+ async listServices(): Promise<AppServiceState[] | null> {
1858
+ this.requireInit();
1859
+ // A reader with no session is acting nowhere, which is the state null
1860
+ // reports, so a page that calls this while it loads gets an answer to
1861
+ // render on rather than a rejection. A transient outage rejects from
1862
+ // here, since that is not an answer about where the app is acting.
1863
+ if ((await this.getAccessToken()) === null) return null;
1864
+ // Resolved without the chooser: an app calls this to decide what to
1865
+ // render, before the reader has pressed anything, so a question put here
1866
+ // is one they did not ask for. `callService` is the act, and the act is
1867
+ // where a question belongs.
1868
+ const orgId = await this.resolveActingOrganizationId({ ask: false });
1869
+ // Null is a state the app renders on: it is acting nowhere yet.
1870
+ if (orgId === null) return null;
1871
+ const deps = {
1872
+ apiBaseUrl: this.apiBaseUrl,
1873
+ getAccessToken: () => this.getAccessToken(),
1874
+ getAppAssertion: () => this.getAppAssertion(),
1875
+ };
1876
+ try {
1877
+ return await listAppServices(deps, orgId);
1878
+ } catch (err) {
1879
+ // An organization that has not installed this app is the same kind of
1880
+ // answer as having none resolved: something only a person can settle, so
1881
+ // it is reported here and raised by `callService`, where the reader has
1882
+ // acted.
1883
+ if (isAppNotInstalled(err)) return null;
1884
+ throw err;
1885
+ }
1886
+ }
1887
+
1888
+ // Open the hosted dialog for one service, from the app's own affordance
1889
+ // rather than from a refused call. Resolves true when that host became
1890
+ // usable, which is when a caller re-reads or retries.
1891
+ //
1892
+ // Takes a host the manifest declares, or a pattern it declares where the
1893
+ // address names the account (`*.myshopify.com`). One method rather than two,
1894
+ // because what the caller is asking for is the same in both: an account this
1895
+ // app can spend at a service it declared.
1896
+ async connectService(host: string): Promise<boolean> {
1897
+ this.requireInit();
1898
+ const orgId = await this.requireActingOrganizationId();
1899
+ return this.offerServiceCredential(orgId, normalizeServiceHost(host));
1900
+ }
1901
+
1902
+ // A brokered call was refused for a credential somebody using the app can
1903
+ // supply, which the platform states on the refusal rather than the SDK
1904
+ // reading it off the code. Open the hosted dialog, which asks whoever is
1905
+ // there for whatever the host is missing and tells anyone who cannot answer
1906
+ // who to ask. Resolves true only when this host became usable, in which case
1907
+ // the caller sends the refused call again.
1908
+ private async offerServiceCredential(
1909
+ organizationId: string,
1910
+ host: string,
1911
+ ): Promise<boolean> {
1912
+ try {
1913
+ const result = await this.getHostedUiLauncher().serviceCredential(
1914
+ organizationId,
1915
+ host,
1916
+ );
1917
+ if (result === null) return false;
1918
+ // A pattern names a host per account, so what became usable is the
1919
+ // account the reader named rather than the term the app asked under.
1920
+ // Anything under the pattern is what the app can now call.
1921
+ return host.startsWith('*.')
1922
+ ? result.ready.some((ready) => ready.endsWith(host.slice(1)))
1923
+ : result.ready.includes(host);
1924
+ } catch {
1925
+ // Hosted UI unavailable here (no DOM, or the dialog failed); the
1926
+ // original refusal stands.
1927
+ return false;
1928
+ }
1929
+ }
1930
+
1329
1931
  async getAppAssertion(): Promise<string | null> {
1330
1932
  this.requireInit();
1331
1933
  // Paired with `getAccessToken`: a link guest holds no token to assert
@@ -1364,6 +1966,12 @@ export class BrassClient {
1364
1966
  // and strip it here, so a client-side navigation later in the session
1365
1967
  // cannot lose it and the address bar stays the app's own.
1366
1968
  this.launchOrganizationId = consumeLaunchOrganizationId();
1969
+ // The launch value is held rather than stored. It names the organization
1970
+ // the person was standing in when they clicked Open, so it outranks an
1971
+ // older choice from an earlier load, but nothing has validated it yet:
1972
+ // written straight to the slot, a launch naming an organization this app
1973
+ // cannot act in would be carried by the tab for as long as it lives. The
1974
+ // first resolve that confirms it is what writes it.
1367
1975
  this.pendingAuthCode = consumeAuthCodeParam();
1368
1976
  if (this.pendingAuthCode) {
1369
1977
  // A fresh sign-in just landed. Burn any previously-stored
@@ -1508,6 +2116,27 @@ export class BrassClient {
1508
2116
  this.user.sub !== nextUser.sub
1509
2117
  ) {
1510
2118
  this.setTokens(null);
2119
+ this.actingOrganization = null;
2120
+ this.emitActingOrganization();
2121
+ this.documentOrganizationIds.clear();
2122
+ }
2123
+ // A session ENDING, which is where the per-app stashes are dropped.
2124
+ //
2125
+ // They are keyed by app and survive the token set, so they describe the
2126
+ // previous person's pick: their document's name, organization and role,
2127
+ // answered out of storage with no request the server could refuse. The
2128
+ // session's `start()` restores from them, so a stash left behind puts
2129
+ // whoever signs in next into the document the previous person chose.
2130
+ //
2131
+ // On the transition to null rather than on the sub changing, because the
2132
+ // two identities need never meet: a refresh that settles `no_session` in
2133
+ // between (an expiry, a reconnect) drops the tokens, and the arriving
2134
+ // person is then a null-to-user transition that has nothing to compare
2135
+ // against. This branch is also what the retirement above reaches, so the
2136
+ // sub-change path clears through here too.
2137
+ if (this.user !== null && nextUser === null && this.appId) {
2138
+ clearLastSelections(this.appId);
2139
+ clearActingOrganization(this.appId);
1511
2140
  }
1512
2141
  this.tokens = next;
1513
2142
  if (sameUser(this.user, nextUser)) return;
@@ -1555,6 +2184,17 @@ export class BrassClient {
1555
2184
  }
1556
2185
  }
1557
2186
 
2187
+ // One failure reaches a reader exactly once: the listener when one is
2188
+ // registered, the console otherwise. Used by `init`, which resolves rather
2189
+ // than rejecting, and by the void gestures that cannot throw usefully.
2190
+ // Logging unconditionally would double-report, since the README's own
2191
+ // sample logs under this same `[Brass]` prefix. `_reportError` in
2192
+ // documents.ts and open-document.ts is the same rule.
2193
+ private reportUncatchable(error: Error): void {
2194
+ if (this.errorListeners.size === 0) console.error('[Brass]', error.message);
2195
+ this.fireError(error);
2196
+ }
2197
+
1558
2198
  private requireInit(): void {
1559
2199
  if (!this.initialized) throw new Error('Call Brass.init(appId) first');
1560
2200
  }