@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
@@ -0,0 +1,191 @@
1
+ # Text fields
2
+
3
+ `doc.text(key)` is the container for prose several people edit at once. The
4
+ [README](../README.md#prose-several-people-edit) covers what it is and how
5
+ to wire a field to it. This page covers what a real editor needs on top:
6
+ painting a peer's edit without throwing away the caret, the IME window,
7
+ what a keystroke costs, and where a text container belongs in a document.
8
+
9
+ ## Paint a peer's edit without losing the caret
10
+
11
+ Assigning `field.value` moves the caret to the end, so an editor that
12
+ assigns unconditionally throws away the reader's place on every peer
13
+ keystroke, and one that skips a focused field never shows a peer's typing
14
+ to anyone who is typing themselves. Find the span the incoming value
15
+ replaced, and move the caret across it:
16
+
17
+ ```ts
18
+ const report = doc.text('report');
19
+ let painted = report.read();
20
+ field.value = painted;
21
+
22
+ report.subscribe((value, origin) => {
23
+ if (origin?.source === 'local') { painted = value; return; }
24
+ if (value === painted) return;
25
+
26
+ // The one span that differs, found by trimming the common prefix and
27
+ // the common suffix. A keystroke, a paste, and a peer's edit each look
28
+ // like this.
29
+ const max = Math.min(painted.length, value.length);
30
+ let start = 0;
31
+ while (start < max && painted.charCodeAt(start) === value.charCodeAt(start)) start += 1;
32
+ let tail = 0;
33
+ while (tail < max - start
34
+ && painted.charCodeAt(painted.length - 1 - tail)
35
+ === value.charCodeAt(value.length - 1 - tail)) tail += 1;
36
+ const removed = painted.length - tail - start;
37
+ const inserted = value.length - tail - start;
38
+
39
+ // The caret binds to the text on its right, so a peer typing where it
40
+ // sits pushes it past what they wrote and leaves it in front of the same
41
+ // words. One before the span does not move, one after it shifts by the
42
+ // span's change in length.
43
+ const carry = (caret: number): number =>
44
+ caret < start ? caret
45
+ : caret >= start + removed ? caret + inserted - removed
46
+ : start + Math.min(caret - start, inserted);
47
+
48
+ // A reader with text selected has two offsets, and `selectionDirection`
49
+ // says which of them their next keystroke extends from.
50
+ const focused = document.activeElement === field;
51
+ const anchor = carry(field.selectionStart ?? 0);
52
+ const head = carry(field.selectionEnd ?? field.selectionStart ?? 0);
53
+ const direction = field.selectionDirection;
54
+ // Assigning `value` resets the scroll, which throws a reader who is
55
+ // partway down a long field back to the top on every peer keystroke.
56
+ const scroll = field.scrollTop;
57
+
58
+ field.value = value;
59
+ painted = value;
60
+ field.scrollTop = scroll;
61
+ if (focused) {
62
+ field.setSelectionRange(nudgeOffPair(value, anchor), nudgeOffPair(value, head), direction);
63
+ }
64
+ });
65
+ ```
66
+
67
+ Offsets count UTF-16 code units, which is what a text field's
68
+ `selectionStart` reports, so the two agree with no conversion. The same
69
+ units are why an offset needs one more check before it is used: a character
70
+ outside the Basic Multilingual Plane, an emoji above all, is two code units,
71
+ and the `charCodeAt` walk above stops between them whenever the edit sits
72
+ next to one. `nudgeOffPair` moves such an offset off the seam:
73
+
74
+ ```ts
75
+ function nudgeOffPair(text: string, at: number): number {
76
+ const bounded = Math.max(0, Math.min(at, text.length));
77
+ if (bounded <= 0 || bounded >= text.length) return bounded;
78
+ const before = text.charCodeAt(bounded - 1);
79
+ const after = text.charCodeAt(bounded);
80
+ const splits = before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
81
+ return splits ? bounded - 1 : bounded;
82
+ }
83
+ ```
84
+
85
+ Restoring the selection only while the field is focused keeps the paint
86
+ from setting a selection nobody asked for, and from scrolling an offscreen
87
+ field into view.
88
+
89
+ `origin` carries who wrote the change, the way a map subscriber's does:
90
+ `local`, `remote`, `undo`, or `redo`, and undefined on a fire describing no
91
+ authored transition (registration, and the catch-up a version checkout
92
+ produces). Splitting on `local` is what keeps this work off the typing
93
+ path, where it would otherwise run on every keystroke.
94
+
95
+ ## Defer the paint, not the write, while an IME is open
96
+
97
+ Assigning to the field while an IME is open discards what it is holding,
98
+ which costs a reader typing Japanese, Chinese, or Korean the word they were
99
+ in the middle of. So track `compositionstart` / `compositionend` and skip
100
+ the paint in between.
101
+
102
+ Keep writing through `update` the whole time. `input` fires during a
103
+ composition, the write touches no DOM, and the container merges it with
104
+ whatever a peer sends meanwhile:
105
+
106
+ ```ts
107
+ let composing = false;
108
+ let missed = false;
109
+
110
+ field.addEventListener('compositionstart', () => { composing = true; });
111
+ field.addEventListener('compositionend', () => {
112
+ composing = false;
113
+ painted = field.value;
114
+ report.update(painted);
115
+ // What arrived while the composition was open is older than what the
116
+ // reader just committed, so paint the container's own value, which
117
+ // carries both.
118
+ if (missed) {
119
+ missed = false;
120
+ const latest = report.read();
121
+ if (latest !== painted) paintPeerEdit(latest);
122
+ }
123
+ });
124
+ ```
125
+
126
+ Holding the incoming value and applying it at `compositionend` is the
127
+ version that looks right and loses data: that value predates the
128
+ composition, so painting it discards the word the reader just committed.
129
+ Deferring the write instead has the same shape of bug one layer down, since
130
+ the field's value then has no record of the peer's edit and `update` diffs
131
+ it back out.
132
+
133
+ ## Publish the caret too
134
+
135
+ `state.textCaret` in [`presence.md`](./presence.md) is
136
+ `{ key, anchor, head }`: the container the caret sits in, and its two
137
+ offsets in the same UTF-16 code units this page uses. Publishing it on
138
+ every selection change lets peers draw where you are working, and the 10 Hz
139
+ presence debounce already paces it.
140
+
141
+ ## What a keystroke costs
142
+
143
+ `update` compares against the whole stored string to find the smallest
144
+ edit, so its cost scales with the field's own length. Measured through the
145
+ handle on a 2.1 GHz core, after 20,000 edits, typing at a realistic
146
+ revision pattern:
147
+
148
+ | field | ms per keystroke |
149
+ | --- | --- |
150
+ | 20 KB | 0.72 |
151
+ | 100 KB | 1.72 |
152
+ | 300 KB | 4.09 |
153
+
154
+ One paragraph, one description, or one report is far inside a frame at
155
+ typing speed. A field holding a book is not, and an app whose document
156
+ grows that large wants its prose split across containers, one per section,
157
+ so an edit compares against a section rather than the book.
158
+
159
+ A second, smaller term grows with the document's accumulated edit history,
160
+ because materializing a text container costs more as its rope fragments.
161
+ Composing sentences barely fragments it (20,000 keystrokes on a 20 KB field
162
+ stay at 0.46 ms and leave a 2 KB snapshot); revising in short runs across
163
+ the field reaches 0.64 ms. The history term is permanent rather than
164
+ per-session, since a snapshot carries the history.
165
+
166
+ ## One root per document-shaped thing, not per row
167
+
168
+ A text container is a top-level root, and no nested-text surface exists, so
169
+ per-element prose that merges costs one root per element. Loro prices a
170
+ container against how many the document holds, so that does not stay flat
171
+ the way the record store does, where paging holds container count down past
172
+ `UNPAGED_MAX_RECORDS`. Creating one text root per element against the same
173
+ elements written as records:
174
+
175
+ | elements | a root each | as records | roots heap | records heap |
176
+ | --- | --- | --- | --- | --- |
177
+ | 5,000 | 599 ms | 403 ms | 27 MB | 3 MB |
178
+ | 10,000 | 1,460 ms | 182 ms | 52 MB | 2 MB |
179
+ | 20,000 | 9,318 ms | 404 ms | 104 MB | 3 MB |
180
+ | 40,000 | 26,419 ms | 1,206 ms | 194 MB | 10 MB |
181
+
182
+ Registering a subscriber costs the same per root, so an editor that shows
183
+ every row's note pays that cost again on every open.
184
+
185
+ So prose that belongs to each row of a repeated collection stays in the
186
+ row's record, where the row is the merge unit. Two people typing in one
187
+ row's note then keep one of the two, which is the trade a repeated
188
+ collection makes. Where one particular row is the thing people write
189
+ together, give that row's prose a root of its own and leave the rest in the
190
+ store. Roots in the hundreds are cheap; the curve is what decides, not a
191
+ fixed cap.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brass-build/client",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Client SDK for Brass",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,16 +11,15 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js"
13
13
  },
14
- "./vite": {
15
- "types": "./dist/vite.d.ts",
16
- "import": "./dist/vite.js"
17
- },
18
14
  "./internal": {
19
15
  "types": "./dist/internal.d.ts",
20
16
  "import": "./dist/internal.js"
21
17
  },
22
18
  "./package.json": "./package.json"
23
19
  },
20
+ "browser": {
21
+ "./dist/wasm-runtime.js": "./dist/wasm-runtime.browser.js"
22
+ },
24
23
  "files": [
25
24
  "dist",
26
25
  "src",
@@ -0,0 +1,396 @@
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
+ if (fromDocument) {
158
+ // Recorded, not merely returned. This is where the app is working, so it
159
+ // is what the next act spends once the document closes; without it a
160
+ // reader who opens a document, closes it and creates another is asked
161
+ // where to put the second one, having just worked in the answer. It carries no name (the
162
+ // document named an id) and does not reach the tab's slot: a document
163
+ // can live in an organization the reader reaches through a shared
164
+ // project and is not a member of, which is a place to act from while it
165
+ // is open rather than a choice to carry.
166
+ deps.remember(fromDocument, '', false);
167
+ return fromDocument;
168
+ }
169
+ const cached = deps.cachedResolution();
170
+ if (cached) return cached;
171
+
172
+ // The stored choice is a preference rather than an answer: the server
173
+ // validates it against membership and ignores one that no longer holds, so
174
+ // a stale value self-heals into whatever the caller's context resolves to.
175
+ const launch = deps.launchPreference();
176
+ const stored = launch === null ? deps.storedPreference() : null;
177
+ const first = await deps.fetchResolved(launch ?? stored);
178
+ if (first.kind === 'resolved') {
179
+ // Membership is not the whole of what makes a choice usable. An
180
+ // organization the app has been uninstalled from still resolves, so a
181
+ // stored choice naming one would raise the install for a place somebody
182
+ // deliberately removed this app from, on every load, for as long as the
183
+ // tab lives. Read as stale instead: drop it and ask again with no
184
+ // preference, which reaches whatever this caller's context resolves to
185
+ // and otherwise the chooser, where the reader says where to work now.
186
+ //
187
+ // Only for a choice this tab was CARRYING. A launch, a resolution the
188
+ // reader just made, or their single membership is the answer either way,
189
+ // and the install is what it needs.
190
+ if (!first.installed && stored !== null && first.organizationId === stored) {
191
+ deps.forgetPreference();
192
+ const healed = await deps.fetchResolved(null);
193
+ if (healed.kind === 'resolved') return keep(deps, healed);
194
+ } else {
195
+ return keep(deps, first);
196
+ }
197
+ }
198
+
199
+ // Context could not answer. Only the person can, so a caller that asked not
200
+ // to raise a surface is told that rather than asked.
201
+ if (options.ask === false) return null;
202
+
203
+ // The surface offers their own memberships and creates one where they have
204
+ // none, so the answer it gives is always one the server can resolve: asking
205
+ // twice would be asking the same question again rather than making progress.
206
+ const picked = await deps.pickOrganization();
207
+ if (!picked) {
208
+ throw new BrassError(
209
+ first.kind === 'no-organization'
210
+ ? 'No organization yet. Create one to continue.'
211
+ : 'No organization chosen.',
212
+ { status: 400 },
213
+ );
214
+ }
215
+ const second = await deps.fetchResolved(picked);
216
+ if (second.kind === 'resolved') return keep(deps, second);
217
+ throw new BrassError('Brass could not use the organization you chose.', {
218
+ status: 400,
219
+ });
220
+ }
221
+
222
+ function keep(
223
+ deps: ResolveDeps,
224
+ outcome: Extract<ResolveOutcome, { kind: 'resolved' }>,
225
+ ): string {
226
+ deps.remember(outcome.organizationId, outcome.organizationName, outcome.installed);
227
+ return outcome.organizationId;
228
+ }
229
+
230
+ /**
231
+ * What the chrome names: the organization the NEXT act would spend.
232
+ *
233
+ * An open document takes it over, because the document's organization is what
234
+ * every act made while it is open spends. That holds even when the name is
235
+ * unknown, which is a document in an organization this reader reaches through
236
+ * a directly shared project: the menu says nothing rather than naming the memo
237
+ * over it. Closing the document hands it back, so the two never disagree with
238
+ * what the app is about to do.
239
+ *
240
+ * Named whether or not the caller belongs to anywhere else. The menu offers a
241
+ * switch, and the surface behind it is also how a first or a second
242
+ * organization gets created, so what a caller with one has is a choice they
243
+ * have not made yet rather than no choice; naming the organization is what
244
+ * makes that switch legible instead of an offer to change something the menu
245
+ * never states.
246
+ *
247
+ * Null where neither is known, which is an ordinary state rather than a gap
248
+ * to fill: an app reached by its own URL, holding no document and nothing
249
+ * stored, has no organization until an act needs one, and resolving one to
250
+ * decorate a menu is exactly the boot-time question this design refuses.
251
+ */
252
+ export function visibleOrganization(
253
+ openDocument: { organization: Named | null } | null,
254
+ memo: Named | null,
255
+ ): { id: string; name: string } | null {
256
+ // An open document takes the menu over ENTIRELY, naming its organization
257
+ // or nothing. Falling back to the memo here would be the exact fault this
258
+ // exists to prevent: the document's organization is what its acts spend,
259
+ // so a menu naming the memo over an open document names an organization
260
+ // the app is not using.
261
+ if (openDocument) return named(openDocument.organization);
262
+ return named(memo);
263
+ }
264
+
265
+ interface Named {
266
+ id: string;
267
+ name: string;
268
+ }
269
+
270
+ function named(organization: Named | null): { id: string; name: string } | null {
271
+ // An id with no name is a resolution taken from an open document, which
272
+ // names no organization. There is nothing to draw until the chrome asks the
273
+ // server for one.
274
+ if (!organization || organization.name === '') return null;
275
+ return { id: organization.id, name: organization.name };
276
+ }
277
+
278
+ export interface ActingOrganizationFetchDeps {
279
+ apiBaseUrl: string;
280
+ getAccessToken: () => Promise<string | null>;
281
+ getAppAssertion: () => Promise<string | null>;
282
+ fetchImpl?: typeof fetch;
283
+ }
284
+
285
+ // The refusals that are a question for the person rather than a fault: the
286
+ // caller belongs to several organizations and none of the context named one,
287
+ // or belongs to none at all.
288
+ const OUTCOME_CODES: ReadonlyMap<string, 'needs-choice' | 'no-organization'> = new Map([
289
+ ['destination_required', 'needs-choice'],
290
+ ['no_organization', 'no-organization'],
291
+ ]);
292
+
293
+ export async function fetchActingOrganization(
294
+ deps: ActingOrganizationFetchDeps,
295
+ prefer: string | null,
296
+ ): Promise<ResolveOutcome> {
297
+ const token = await deps.getAccessToken();
298
+ if (!token) throw new BrassError('Not signed in', { status: 401 });
299
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` };
300
+ applyClientVersionHeader(headers);
301
+ await applyAppAssertionHeader(headers, deps.getAppAssertion);
302
+ const base = deps.apiBaseUrl.replace(/\/$/, '');
303
+ const query = prefer ? `?prefer=${encodeURIComponent(prefer)}` : '';
304
+ const doFetch = deps.fetchImpl ?? fetch;
305
+ const response = await doFetch(`${base}/me/acting-organization${query}`, {
306
+ method: 'GET',
307
+ headers,
308
+ });
309
+ type Body = {
310
+ organization_id?: unknown;
311
+ organization_name?: unknown;
312
+ installed?: unknown;
313
+ error?: unknown;
314
+ code?: unknown;
315
+ };
316
+ let body: Body = {};
317
+ try {
318
+ body = (await response.json()) as Body;
319
+ } catch {
320
+ // Leaves the generic message below, which the status already qualifies.
321
+ }
322
+ if (response.ok && typeof body.organization_id === 'string') {
323
+ return {
324
+ kind: 'resolved',
325
+ organizationId: body.organization_id,
326
+ organizationName:
327
+ typeof body.organization_name === 'string' ? body.organization_name : '',
328
+ // Absent reads as installed, so an older api that does not send it
329
+ // leaves the act to raise the refusal as it always did rather than
330
+ // asking for an install nothing established was missing.
331
+ installed: body.installed !== false,
332
+ };
333
+ }
334
+ const kind =
335
+ typeof body.code === 'string' ? OUTCOME_CODES.get(body.code) : undefined;
336
+ if (response.status === 409 && kind) return { kind };
337
+ throw new BrassError(
338
+ typeof body.error === 'string' ? body.error : 'Could not resolve an organization',
339
+ { status: response.status },
340
+ );
341
+ }
342
+
343
+ /**
344
+ * A deliberate switch, as the account menu drives it. Separate from
345
+ * `resolveActingOrganization` because the two differ in what follows the
346
+ * choice: a resolve is followed by the act that asked for it, which raises
347
+ * its own refusal, while a switch is followed by nothing at all. So the
348
+ * switch has to establish for itself that the app can act where the reader
349
+ * sent it, and has to tell the app that everything it read is now about a
350
+ * different organization.
351
+ */
352
+ export interface SwitchDeps {
353
+ /** The organization being acted in now, so the surface can mark it. */
354
+ current: () => string | null;
355
+ pickOrganization: (current: string | undefined) => Promise<string | null>;
356
+ fetchResolved: (prefer: string) => Promise<ResolveOutcome>;
357
+ /** The hosted install dialog, pinned; null when the reader declined. */
358
+ offerInstall: (organizationId: string) => Promise<boolean>;
359
+ remember: (organizationId: string, organizationName: string) => void;
360
+ /** Re-reads the app under the new organization. */
361
+ reload: () => void;
362
+ }
363
+
364
+ export async function switchActingOrganization(deps: SwitchDeps): Promise<boolean> {
365
+ const current = deps.current() ?? undefined;
366
+ const picked = await deps.pickOrganization(current);
367
+ if (!picked) return false;
368
+ // Re-picking the organization the app is already acting in is a valid
369
+ // answer to the question the surface asks, and it moves nothing. The
370
+ // ordinary way to reach it is a caller with one organization opening the
371
+ // chooser to see what is there, so it must not cost them their open
372
+ // document and a page reload to say where they already are.
373
+ if (picked === current) return false;
374
+ // The pick goes back to the server, which names the organization as the
375
+ // platform holds it and refuses one the caller cannot act in.
376
+ const named = await deps.fetchResolved(picked);
377
+ if (named.kind !== 'resolved') return false;
378
+ // An organization this app is not installed on cannot serve it, so the
379
+ // install is asked for here rather than left to the next act. Without this
380
+ // the switch appears to succeed and the refusal arrives later, or on a
381
+ // reload, against work the reader has already started.
382
+ if (!named.installed && !(await deps.offerInstall(named.organizationId))) {
383
+ // Declining abandons the switch. Recording an organization the app cannot
384
+ // act in leaves the reader on one organization's screen with nothing they
385
+ // press able to work.
386
+ return false;
387
+ }
388
+ deps.remember(named.organizationId, named.organizationName);
389
+ // Everything the app read under the previous organization is about a
390
+ // different one now, and the app has no way to know which of its own state
391
+ // that covers. The reader asked for this, so re-reading the app is the
392
+ // expected consequence of their own act rather than a surprise, and the
393
+ // choice is already recorded so it survives.
394
+ deps.reload();
395
+ return true;
396
+ }
@@ -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
  }