@brass-build/client 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/AGENTS.md +50 -7
  2. package/CHANGELOG.md +63 -2
  3. package/README.md +233 -77
  4. package/dist/acting-organization.d.ts +131 -0
  5. package/dist/acting-organization.d.ts.map +1 -0
  6. package/dist/acting-organization.js +273 -0
  7. package/dist/acting-organization.js.map +1 -0
  8. package/dist/api-headers.js +7 -7
  9. package/dist/api-headers.js.map +1 -1
  10. package/dist/call-service.d.ts +74 -0
  11. package/dist/call-service.d.ts.map +1 -0
  12. package/dist/call-service.js +253 -0
  13. package/dist/call-service.js.map +1 -0
  14. package/dist/client.d.ts +31 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +611 -12
  17. package/dist/client.js.map +1 -1
  18. package/dist/document-contents.d.ts +15 -0
  19. package/dist/document-contents.d.ts.map +1 -0
  20. package/dist/document-contents.js +88 -0
  21. package/dist/document-contents.js.map +1 -0
  22. package/dist/document-streams.d.ts +4 -2
  23. package/dist/document-streams.d.ts.map +1 -1
  24. package/dist/document-streams.js +6 -0
  25. package/dist/document-streams.js.map +1 -1
  26. package/dist/documents.d.ts +45 -1
  27. package/dist/documents.d.ts.map +1 -1
  28. package/dist/documents.js +305 -22
  29. package/dist/documents.js.map +1 -1
  30. package/dist/export-guest.d.ts.map +1 -1
  31. package/dist/export-guest.js +5 -0
  32. package/dist/export-guest.js.map +1 -1
  33. package/dist/handle-types.d.ts +23 -0
  34. package/dist/handle-types.d.ts.map +1 -1
  35. package/dist/handles.d.ts +1 -1
  36. package/dist/handles.d.ts.map +1 -1
  37. package/dist/handles.js +1 -1
  38. package/dist/handles.js.map +1 -1
  39. package/dist/hosted-ui.d.ts +10 -0
  40. package/dist/hosted-ui.d.ts.map +1 -1
  41. package/dist/hosted-ui.js +115 -30
  42. package/dist/hosted-ui.js.map +1 -1
  43. package/dist/importer.d.ts.map +1 -1
  44. package/dist/importer.js +17 -6
  45. package/dist/importer.js.map +1 -1
  46. package/dist/index.d.ts +8 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +25 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal.d.ts +1 -0
  51. package/dist/internal.d.ts.map +1 -1
  52. package/dist/internal.js +1 -0
  53. package/dist/internal.js.map +1 -1
  54. package/dist/mutate-batching.js +2 -2
  55. package/dist/mutate-batching.js.map +1 -1
  56. package/dist/open-document.d.ts +1 -1
  57. package/dist/open-document.d.ts.map +1 -1
  58. package/dist/open-document.js +69 -34
  59. package/dist/open-document.js.map +1 -1
  60. package/dist/presence.d.ts +10 -0
  61. package/dist/presence.d.ts.map +1 -1
  62. package/dist/presence.js.map +1 -1
  63. package/dist/session-toolbar.d.ts +9 -0
  64. package/dist/session-toolbar.d.ts.map +1 -1
  65. package/dist/session-toolbar.js +60 -12
  66. package/dist/session-toolbar.js.map +1 -1
  67. package/dist/tail-ops.js +2 -2
  68. package/dist/tail-ops.js.map +1 -1
  69. package/dist/types.d.ts +0 -42
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/undo.d.ts +1 -1
  72. package/dist/undo.d.ts.map +1 -1
  73. package/dist/undo.js +1 -1
  74. package/dist/undo.js.map +1 -1
  75. package/dist/version-bar.d.ts.map +1 -1
  76. package/dist/version-bar.js +52 -2
  77. package/dist/version-bar.js.map +1 -1
  78. package/dist/version.d.ts +2 -2
  79. package/dist/version.js +1 -1
  80. package/dist/wasm-ready-gate.d.ts +13 -0
  81. package/dist/wasm-ready-gate.d.ts.map +1 -0
  82. package/dist/wasm-ready-gate.js +28 -0
  83. package/dist/wasm-ready-gate.js.map +1 -0
  84. package/dist/wasm-runtime.browser.d.ts +11 -0
  85. package/dist/wasm-runtime.browser.d.ts.map +1 -0
  86. package/dist/wasm-runtime.browser.js +44 -0
  87. package/dist/wasm-runtime.browser.js.map +1 -0
  88. package/dist/wasm-runtime.d.ts +6 -0
  89. package/dist/wasm-runtime.d.ts.map +1 -0
  90. package/dist/wasm-runtime.js +20 -0
  91. package/dist/wasm-runtime.js.map +1 -0
  92. package/docs/app-manifest.md +335 -5
  93. package/docs/custom-chrome.md +3 -0
  94. package/docs/presence.md +12 -0
  95. package/docs/services.md +265 -0
  96. package/docs/text-fields.md +191 -0
  97. package/package.json +4 -5
  98. package/src/acting-organization.ts +390 -0
  99. package/src/api-headers.ts +7 -7
  100. package/src/call-service.ts +354 -0
  101. package/src/client.ts +680 -14
  102. package/src/document-contents.ts +104 -0
  103. package/src/document-streams.ts +15 -1
  104. package/src/documents.ts +340 -20
  105. package/src/export-guest.ts +5 -0
  106. package/src/handle-types.ts +35 -0
  107. package/src/handles.ts +1 -1
  108. package/src/hosted-ui.ts +155 -29
  109. package/src/importer.ts +17 -6
  110. package/src/index.ts +43 -4
  111. package/src/internal.ts +4 -0
  112. package/src/mutate-batching.ts +2 -2
  113. package/src/open-document.ts +79 -38
  114. package/src/presence.ts +6 -0
  115. package/src/session-toolbar.ts +80 -12
  116. package/src/tail-ops.ts +2 -2
  117. package/src/types.ts +6 -4
  118. package/src/undo.ts +1 -1
  119. package/src/version-bar.ts +53 -2
  120. package/src/version.ts +1 -1
  121. package/src/wasm-ready-gate.ts +30 -0
  122. package/src/wasm-runtime.browser.ts +48 -0
  123. package/src/wasm-runtime.ts +21 -0
  124. package/dist/vite.d.ts +0 -31
  125. package/dist/vite.d.ts.map +0 -1
  126. package/dist/vite.js +0 -161
  127. package/dist/vite.js.map +0 -1
  128. package/src/vite.ts +0 -214
@@ -0,0 +1,390 @@
1
+ // Which organization an act that has no document spends.
2
+ //
3
+ // An app opened from a dashboard is launched with one, and an app holding an
4
+ // open document inherits that document's. Everything else is resolved by the
5
+ // server from the caller's own context at the first act that needs it, so the
6
+ // app is never handed a list and never asks at boot.
7
+ //
8
+ // The per-app sessionStorage slot below carries the answer forward within a
9
+ // tab: keys are namespaced per app id so integrations on one origin don't
10
+ // collide, and the slot is cleared on sign-out (session storage also drops on
11
+ // tab close).
12
+
13
+ import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
14
+ import { BrassError } from './errors.js';
15
+
16
+ const KEY_PREFIX = '__brass_acting_org_';
17
+
18
+ // Single per-page-load warn: a resolve runs on every act that needs an
19
+ // organization, so without the latch a server-rendered host would flood its
20
+ // logs.
21
+ let warnedUnavailable = false;
22
+
23
+ function getStorage(): Storage | null {
24
+ if (typeof sessionStorage === 'undefined') return null;
25
+ try {
26
+ return sessionStorage;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function warnOnceUnavailable(): void {
33
+ if (warnedUnavailable) return;
34
+ warnedUnavailable = true;
35
+ console.warn(
36
+ 'Brass: sessionStorage is unavailable, so the organization this app acts in is resolved again on every page load.',
37
+ );
38
+ }
39
+
40
+ function orgKey(appId: string): string {
41
+ return `${KEY_PREFIX}${appId}`;
42
+ }
43
+
44
+ export function storeActingOrganization(appId: string, organizationId: string): void {
45
+ const storage = getStorage();
46
+ if (!storage) {
47
+ warnOnceUnavailable();
48
+ return;
49
+ }
50
+ try {
51
+ storage.setItem(orgKey(appId), organizationId);
52
+ } catch {
53
+ // Quota / disabled storage; drop silently. The stored value is a
54
+ // preference the server re-derives without it, so losing it costs a
55
+ // resolve rather than the act that needed one.
56
+ }
57
+ }
58
+
59
+ export function readActingOrganization(appId: string): string | null {
60
+ const storage = getStorage();
61
+ let raw: string | null;
62
+ if (!storage) return null;
63
+ try {
64
+ raw = storage.getItem(orgKey(appId));
65
+ } catch {
66
+ // A storage that holds a handle but refuses reads (a partitioned or
67
+ // locked-down context). See `storeActingOrganization`.
68
+ return null;
69
+ }
70
+ const trimmed = raw?.trim() ?? '';
71
+ return trimmed === '' ? null : trimmed;
72
+ }
73
+
74
+ export function clearActingOrganization(appId: string): void {
75
+ const storage = getStorage();
76
+ if (!storage) return;
77
+ try {
78
+ storage.removeItem(orgKey(appId));
79
+ } catch {
80
+ // See `storeActingOrganization`.
81
+ }
82
+ }
83
+
84
+ // What the server answers when asked which organization this caller acts in:
85
+ // one organization, or the reason it cannot name one. Never a list.
86
+ export type ResolveOutcome =
87
+ | {
88
+ kind: 'resolved';
89
+ organizationId: string;
90
+ organizationName: string;
91
+ /**
92
+ * Whether this app is installed on the organization. A deliberate
93
+ * switch reads it so the install is raised before the switch takes
94
+ * effect, rather than leaving the reader acting somewhere the next call
95
+ * is refused.
96
+ */
97
+ installed: boolean;
98
+ }
99
+ | { kind: 'needs-choice' }
100
+ | { kind: 'no-organization' };
101
+
102
+ export interface ResolveDeps {
103
+ /** The open document's organization, where one is open. */
104
+ activeDocumentOrganizationId: () => Promise<string | null>;
105
+ /** An organization already resolved on this page load. */
106
+ cachedResolution: () => string | null;
107
+ /**
108
+ * The organization the launch named, where this page load carried one. A
109
+ * person clicking Open from an organization's dashboard has just said where
110
+ * they are working, so it outranks a carried choice and is never read as
111
+ * stale: an app the organization has not installed is what the install
112
+ * consent behind the refusal is for.
113
+ */
114
+ launchPreference: () => string | null;
115
+ /** The choice this tab carries for this app, from an earlier act. */
116
+ storedPreference: () => string | null;
117
+ fetchResolved: (prefer: string | null) => Promise<ResolveOutcome>;
118
+ /** The hosted dialog that asks the person; null when they dismissed it. */
119
+ pickOrganization: () => Promise<string | null>;
120
+ /**
121
+ * Drop the stored choice, for one the app can no longer act in. The server
122
+ * validates a preference against MEMBERSHIP, so a choice naming an
123
+ * organization the app has since been uninstalled from stays resolvable
124
+ * forever while serving nothing.
125
+ */
126
+ forgetPreference: () => void;
127
+ remember: (
128
+ organizationId: string,
129
+ /** Empty where the resolution carried an id and no name. */
130
+ organizationName: string,
131
+ /**
132
+ * Whether to carry the choice past this page load. An organization this
133
+ * app is not installed on has not become one the app can act in yet: the
134
+ * act that resolved it is about to be refused, and the reader may decline
135
+ * the install that follows. Recording it anyway pins every later visit to
136
+ * an organization nothing they press can work in, with the chooser out of
137
+ * reach. It is persisted when the install lands instead.
138
+ */
139
+ persist: boolean,
140
+ ) => void;
141
+ }
142
+
143
+ /**
144
+ * Which organization the next act uses.
145
+ *
146
+ * `ask: false` runs the same ladder and stops where it would raise the hosted
147
+ * chooser, answering null instead. That is what an app reading its own state
148
+ * before it acts needs: the question the chooser asks is one only the person
149
+ * can answer, and asking it unbidden takes the page over for something the
150
+ * reader never pressed.
151
+ */
152
+ export async function resolveActingOrganization(
153
+ deps: ResolveDeps,
154
+ options: { ask?: boolean } = {},
155
+ ): Promise<string | null> {
156
+ const fromDocument = await deps.activeDocumentOrganizationId();
157
+ // Answered, not recorded. A document can live in an organization the
158
+ // reader reaches through a shared project and is not a member of, which is
159
+ // a place to act from while it is open and not one to keep once it closes.
160
+ // The caller records a document's organization only after the server
161
+ // confirms membership and the install.
162
+ if (fromDocument) return fromDocument;
163
+ const cached = deps.cachedResolution();
164
+ if (cached) return cached;
165
+
166
+ // The stored choice is a preference rather than an answer: the server
167
+ // validates it against membership and ignores one that no longer holds, so
168
+ // a stale value self-heals into whatever the caller's context resolves to.
169
+ const launch = deps.launchPreference();
170
+ const stored = launch === null ? deps.storedPreference() : null;
171
+ const first = await deps.fetchResolved(launch ?? stored);
172
+ if (first.kind === 'resolved') {
173
+ // Membership is not the whole of what makes a choice usable. An
174
+ // organization the app has been uninstalled from still resolves, so a
175
+ // stored choice naming one would raise the install for a place somebody
176
+ // deliberately removed this app from, on every load, for as long as the
177
+ // tab lives. Read as stale instead: drop it and ask again with no
178
+ // preference, which reaches whatever this caller's context resolves to
179
+ // and otherwise the chooser, where the reader says where to work now.
180
+ //
181
+ // Only for a choice this tab was CARRYING. A launch, a resolution the
182
+ // reader just made, or their single membership is the answer either way,
183
+ // and the install is what it needs.
184
+ if (!first.installed && stored !== null && first.organizationId === stored) {
185
+ deps.forgetPreference();
186
+ const healed = await deps.fetchResolved(null);
187
+ if (healed.kind === 'resolved') return keep(deps, healed);
188
+ } else {
189
+ return keep(deps, first);
190
+ }
191
+ }
192
+
193
+ // Context could not answer. Only the person can, so a caller that asked not
194
+ // to raise a surface is told that rather than asked.
195
+ if (options.ask === false) return null;
196
+
197
+ // The surface offers their own memberships and creates one where they have
198
+ // none, so the answer it gives is always one the server can resolve: asking
199
+ // twice would be asking the same question again rather than making progress.
200
+ const picked = await deps.pickOrganization();
201
+ if (!picked) {
202
+ throw new BrassError(
203
+ first.kind === 'no-organization'
204
+ ? 'No organization yet. Create one to continue.'
205
+ : 'No organization chosen.',
206
+ { status: 400 },
207
+ );
208
+ }
209
+ const second = await deps.fetchResolved(picked);
210
+ if (second.kind === 'resolved') return keep(deps, second);
211
+ throw new BrassError('Brass could not use the organization you chose.', {
212
+ status: 400,
213
+ });
214
+ }
215
+
216
+ function keep(
217
+ deps: ResolveDeps,
218
+ outcome: Extract<ResolveOutcome, { kind: 'resolved' }>,
219
+ ): string {
220
+ deps.remember(outcome.organizationId, outcome.organizationName, outcome.installed);
221
+ return outcome.organizationId;
222
+ }
223
+
224
+ /**
225
+ * What the chrome names: the organization the NEXT act would spend.
226
+ *
227
+ * An open document takes it over, because the document's organization is what
228
+ * every act made while it is open spends. That holds even when the name is
229
+ * unknown, which is a document in an organization this reader reaches through
230
+ * a directly shared project: the menu says nothing rather than naming the memo
231
+ * over it. Closing the document hands it back, so the two never disagree with
232
+ * what the app is about to do.
233
+ *
234
+ * Named whether or not the caller belongs to anywhere else. The menu offers a
235
+ * switch, and the surface behind it is also how a first or a second
236
+ * organization gets created, so what a caller with one has is a choice they
237
+ * have not made yet rather than no choice; naming the organization is what
238
+ * makes that switch legible instead of an offer to change something the menu
239
+ * never states.
240
+ *
241
+ * Null where neither is known, which is an ordinary state rather than a gap
242
+ * to fill: an app reached by its own URL, holding no document and nothing
243
+ * stored, has no organization until an act needs one, and resolving one to
244
+ * decorate a menu is exactly the boot-time question this design refuses.
245
+ */
246
+ export function visibleOrganization(
247
+ openDocument: { organization: Named | null } | null,
248
+ memo: Named | null,
249
+ ): { id: string; name: string } | null {
250
+ // An open document takes the menu over ENTIRELY, naming its organization
251
+ // or nothing. Falling back to the memo here would be the exact fault this
252
+ // exists to prevent: the document's organization is what its acts spend,
253
+ // so a menu naming the memo over an open document names an organization
254
+ // the app is not using.
255
+ if (openDocument) return named(openDocument.organization);
256
+ return named(memo);
257
+ }
258
+
259
+ interface Named {
260
+ id: string;
261
+ name: string;
262
+ }
263
+
264
+ function named(organization: Named | null): { id: string; name: string } | null {
265
+ // An id with no name is a resolution taken from an open document, which
266
+ // names no organization. There is nothing to draw until the chrome asks the
267
+ // server for one.
268
+ if (!organization || organization.name === '') return null;
269
+ return { id: organization.id, name: organization.name };
270
+ }
271
+
272
+ export interface ActingOrganizationFetchDeps {
273
+ apiBaseUrl: string;
274
+ getAccessToken: () => Promise<string | null>;
275
+ getAppAssertion: () => Promise<string | null>;
276
+ fetchImpl?: typeof fetch;
277
+ }
278
+
279
+ // The refusals that are a question for the person rather than a fault: the
280
+ // caller belongs to several organizations and none of the context named one,
281
+ // or belongs to none at all.
282
+ const OUTCOME_CODES: ReadonlyMap<string, 'needs-choice' | 'no-organization'> = new Map([
283
+ ['destination_required', 'needs-choice'],
284
+ ['no_organization', 'no-organization'],
285
+ ]);
286
+
287
+ export async function fetchActingOrganization(
288
+ deps: ActingOrganizationFetchDeps,
289
+ prefer: string | null,
290
+ ): Promise<ResolveOutcome> {
291
+ const token = await deps.getAccessToken();
292
+ if (!token) throw new BrassError('Not signed in', { status: 401 });
293
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` };
294
+ applyClientVersionHeader(headers);
295
+ await applyAppAssertionHeader(headers, deps.getAppAssertion);
296
+ const base = deps.apiBaseUrl.replace(/\/$/, '');
297
+ const query = prefer ? `?prefer=${encodeURIComponent(prefer)}` : '';
298
+ const doFetch = deps.fetchImpl ?? fetch;
299
+ const response = await doFetch(`${base}/me/acting-organization${query}`, {
300
+ method: 'GET',
301
+ headers,
302
+ });
303
+ type Body = {
304
+ organization_id?: unknown;
305
+ organization_name?: unknown;
306
+ installed?: unknown;
307
+ error?: unknown;
308
+ code?: unknown;
309
+ };
310
+ let body: Body = {};
311
+ try {
312
+ body = (await response.json()) as Body;
313
+ } catch {
314
+ // Leaves the generic message below, which the status already qualifies.
315
+ }
316
+ if (response.ok && typeof body.organization_id === 'string') {
317
+ return {
318
+ kind: 'resolved',
319
+ organizationId: body.organization_id,
320
+ organizationName:
321
+ typeof body.organization_name === 'string' ? body.organization_name : '',
322
+ // Absent reads as installed, so an older api that does not send it
323
+ // leaves the act to raise the refusal as it always did rather than
324
+ // asking for an install nothing established was missing.
325
+ installed: body.installed !== false,
326
+ };
327
+ }
328
+ const kind =
329
+ typeof body.code === 'string' ? OUTCOME_CODES.get(body.code) : undefined;
330
+ if (response.status === 409 && kind) return { kind };
331
+ throw new BrassError(
332
+ typeof body.error === 'string' ? body.error : 'Could not resolve an organization',
333
+ { status: response.status },
334
+ );
335
+ }
336
+
337
+ /**
338
+ * A deliberate switch, as the account menu drives it. Separate from
339
+ * `resolveActingOrganization` because the two differ in what follows the
340
+ * choice: a resolve is followed by the act that asked for it, which raises
341
+ * its own refusal, while a switch is followed by nothing at all. So the
342
+ * switch has to establish for itself that the app can act where the reader
343
+ * sent it, and has to tell the app that everything it read is now about a
344
+ * different organization.
345
+ */
346
+ export interface SwitchDeps {
347
+ /** The organization being acted in now, so the surface can mark it. */
348
+ current: () => string | null;
349
+ pickOrganization: (current: string | undefined) => Promise<string | null>;
350
+ fetchResolved: (prefer: string) => Promise<ResolveOutcome>;
351
+ /** The hosted install dialog, pinned; null when the reader declined. */
352
+ offerInstall: (organizationId: string) => Promise<boolean>;
353
+ remember: (organizationId: string, organizationName: string) => void;
354
+ /** Re-reads the app under the new organization. */
355
+ reload: () => void;
356
+ }
357
+
358
+ export async function switchActingOrganization(deps: SwitchDeps): Promise<boolean> {
359
+ const current = deps.current() ?? undefined;
360
+ const picked = await deps.pickOrganization(current);
361
+ if (!picked) return false;
362
+ // Re-picking the organization the app is already acting in is a valid
363
+ // answer to the question the surface asks, and it moves nothing. The
364
+ // ordinary way to reach it is a caller with one organization opening the
365
+ // chooser to see what is there, so it must not cost them their open
366
+ // document and a page reload to say where they already are.
367
+ if (picked === current) return false;
368
+ // The pick goes back to the server, which names the organization as the
369
+ // platform holds it and refuses one the caller cannot act in.
370
+ const named = await deps.fetchResolved(picked);
371
+ if (named.kind !== 'resolved') return false;
372
+ // An organization this app is not installed on cannot serve it, so the
373
+ // install is asked for here rather than left to the next act. Without this
374
+ // the switch appears to succeed and the refusal arrives later, or on a
375
+ // reload, against work the reader has already started.
376
+ if (!named.installed && !(await deps.offerInstall(named.organizationId))) {
377
+ // Declining abandons the switch. Recording an organization the app cannot
378
+ // act in leaves the reader on one organization's screen with nothing they
379
+ // press able to work.
380
+ return false;
381
+ }
382
+ deps.remember(named.organizationId, named.organizationName);
383
+ // Everything the app read under the previous organization is about a
384
+ // different one now, and the app has no way to know which of its own state
385
+ // that covers. The reader asked for this, so re-reading the app is the
386
+ // expected consequence of their own act rather than a surprise, and the
387
+ // choice is already recorded so it survives.
388
+ deps.reload();
389
+ return true;
390
+ }
@@ -16,13 +16,13 @@ export async function applyAppAssertionHeader(
16
16
  if (assertion) headers['x-brass-app-assertion'] = assertion;
17
17
  }
18
18
 
19
- // Attaches the SDK's version as `x-brass-client` (`sdk/<version>`), the same
20
- // value reported on /refresh and the WS connect URL, so the server sees the
21
- // deployed SDK version at the data-API surface too. The minimum-version gate
22
- // reads it at document open to serve a too-old client an upgrade message
23
- // (rendered verbatim through `session.error`). Same boundary as the
24
- // app-assertion header: data API only, never a presigned URL. Unconditional, so the
25
- // version distribution covers read-only apps that never refresh.
19
+ // Names the SDK and its version. Brass answers a build too old to serve with
20
+ // an upgrade message, which reaches the app as `session.error` and is
21
+ // rendered verbatim.
22
+ //
23
+ // Same boundary as the app-assertion header: Brass's own API calls carry it,
24
+ // a presigned URL never does. Attached unconditionally, so an app that only
25
+ // ever reads carries it too.
26
26
  export function applyClientVersionHeader(headers: Record<string, string>): void {
27
27
  headers['x-brass-client'] = SDK_CLIENT_ID;
28
28
  }