@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,354 @@
1
+ // One brokered call to an outside service.
2
+ //
3
+ // The app names a HOST its manifest declares and a path on it. Brass holds the
4
+ // credential for that host, attaches it, applies the owner's caps, and returns
5
+ // the service's answer, so the key never reaches the browser.
6
+
7
+ import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
8
+ import { BrassError } from './errors.js';
9
+
10
+ // A body the SDK may send twice. An install-consent retry re-sends whatever
11
+ // the caller passed, and a stream cannot be read a second time, so the stream
12
+ // members of `BodyInit` are deliberately not here.
13
+ export type CallServiceBody = string | Blob | FormData | URLSearchParams;
14
+
15
+ export interface CallServiceOptions {
16
+ /** Defaults to `'POST'`. */
17
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
18
+ /**
19
+ * Sent to the service as-is, whatever a vendor asks for. The credential is
20
+ * not among them; Brass attaches that.
21
+ */
22
+ headers?: Record<string, string>;
23
+ body?: CallServiceBody;
24
+ /**
25
+ * Whether Brass asks the user for anything the call is missing: a
26
+ * credential at the host, consent to install the app in their
27
+ * organization, or which organization to act in. The call is sent again
28
+ * once they answer.
29
+ *
30
+ * Defaults to false, so the refusal comes back with its `code` for the
31
+ * app's own surface to answer, and `Brass.connectService(host)` opens the
32
+ * credential dialog when the reader asks for it.
33
+ */
34
+ requestMissingCredentials?: boolean;
35
+ }
36
+
37
+ // The headers the app named for the SERVICE travel as the value of one header
38
+ // on the Brass request, rather than spread across it. Two things follow, and
39
+ // they are the reason this shape exists. A browser preflights a cross-origin
40
+ // request header by name, so a vendor header spread onto the request would
41
+ // have to be allow-listed by the api before an app could send it at all, and
42
+ // `anthropic-version` could not be sent; carried as data, one allow-listed
43
+ // name covers every vendor. And what the platform sends the service is then
44
+ // exactly this set, with no rule needed to keep Brass's own headers out of it.
45
+ //
46
+ // Percent-encoded JSON, so a value carrying a newline or a character outside
47
+ // ASCII is representable on the header line that carries it.
48
+ export function encodeServiceHeaders(headers: Record<string, string>): string {
49
+ return encodeURIComponent(JSON.stringify(headers));
50
+ }
51
+
52
+ // The path reaches the service verbatim, so a caller's own percent-encoding
53
+ // survives: a service is entitled to whatever encoding it was given. Only the
54
+ // leading separator is the SDK's to normalize, since the caller writing
55
+ // `/v1/messages` and `v1/messages` means the same request.
56
+ //
57
+ // A call at the service's ROOT ends here rather than at a separator with
58
+ // nothing after it, because a trailing slash is a different path to a gateway
59
+ // and to some services, and `callService(host, '/')` is asking for the root
60
+ // rather than for a child of it.
61
+ // A host is case-insensitive to DNS and exact to the lookup that finds the
62
+ // declaration, so the two are reconciled here rather than at each caller: an
63
+ // app that spells one the way its vendor's console does is asking for the
64
+ // service it declared, not for one nothing declares.
65
+ export function normalizeServiceHost(host: string): string {
66
+ return host.toLowerCase();
67
+ }
68
+
69
+ export function brokeredCallUrl(
70
+ apiBaseUrl: string,
71
+ organizationId: string,
72
+ host: string,
73
+ path: string,
74
+ ): string {
75
+ const base = apiBaseUrl.replace(/\/$/, '');
76
+ const tail = path.replace(/^\//, '');
77
+ const call = `${base}/organizations/${encodeURIComponent(organizationId)}/call/${encodeURIComponent(host)}`;
78
+ return tail === '' || tail.startsWith('?') ? `${call}${tail}` : `${call}/${tail}`;
79
+ }
80
+
81
+ // The path is appended to the broker URL, and a URL parser resolves dot and
82
+ // backslash segments before the request leaves the browser: `../x` and
83
+ // `..\x` climb out of the `/call/<host>/` prefix and land the credentialed
84
+ // request on another Brass route, and a `#` truncates the request at the
85
+ // fragment. The server refuses each of these per DECODED segment
86
+ // (`resolveTargetPath`), but that refusal is unreachable once the parser has
87
+ // already rewritten the path, so the same check runs here before anything is
88
+ // sent. It matches the server's verdicts: whole `/`-segments only (an encoded
89
+ // `%2f` stays inside one segment and is the service's own to decode), the tail
90
+ // after the one leading slash the broker URL itself normalizes.
91
+ export function invalidServicePath(path: string): boolean {
92
+ const tail = path.replace(/^\//, '');
93
+ // A fragment is dropped by the parser, so the request past it is silently
94
+ // lost; a service path carries a literal `#` as `%23`. Read across the
95
+ // whole tail rather than the pathname, since a `#` after the query starts
96
+ // the fragment exactly as one before it does.
97
+ if (tail.includes('#')) return true;
98
+ const pathname = tail.split('?', 1)[0] ?? '';
99
+ // Browsers fold `\` into `/`, so a raw backslash is a separator that this
100
+ // split would miss; the server refuses one outright, and a service path
101
+ // carries a literal backslash as `%5C`.
102
+ if (pathname.includes('\\')) return true;
103
+ // A scheme prefix (`https:`) makes the tail an absolute URL to the parser.
104
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(pathname)) return true;
105
+ for (const segment of pathname.split('/')) {
106
+ let decoded: string;
107
+ try {
108
+ decoded = decodeURIComponent(segment);
109
+ } catch {
110
+ return true;
111
+ }
112
+ if (decoded === '..' || decoded === '.') return true;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ // The same shape every other refusal on this path takes: a Response the app
118
+ // reads a `code` off, carrying the server's own code for a path it would
119
+ // itself refuse. Returned before the request is built, so a path that would
120
+ // escape the broker never travels with the caller's credentials.
121
+ function invalidPathRefusal(): Response {
122
+ return new Response(
123
+ JSON.stringify({
124
+ error: 'The service path is not a valid request path',
125
+ code: 'path_invalid',
126
+ }),
127
+ { status: 403, headers: { 'content-type': 'application/json' } },
128
+ );
129
+ }
130
+
131
+ export interface CallServiceDeps {
132
+ apiBaseUrl: string;
133
+ getAccessToken: () => Promise<string | null>;
134
+ getAppAssertion: () => Promise<string | null>;
135
+ /**
136
+ * Which organization this call uses, resolved from context. `ask` is true
137
+ * only where the caller asked for the missing pieces, and the resolve
138
+ * otherwise answers null for a page whose reader would have to be asked.
139
+ */
140
+ resolveOrganization: (ask: boolean) => Promise<string | null>;
141
+ /** Offers the hosted install dialog; true once an install landed. */
142
+ offerInstallConsent: (err: unknown) => Promise<boolean>;
143
+ /**
144
+ * Offers the hosted service-credential dialog for one host; true once that
145
+ * host became usable.
146
+ */
147
+ offerServiceCredential: (
148
+ organizationId: string,
149
+ host: string,
150
+ ) => Promise<boolean>;
151
+ fetchImpl?: typeof fetch;
152
+ }
153
+
154
+ export async function callService(
155
+ deps: CallServiceDeps,
156
+ host: string,
157
+ path: string,
158
+ options: CallServiceOptions = {},
159
+ ): Promise<Response> {
160
+ // Resolved once, before the first send, so a retry below uses the same
161
+ // organization and cannot open the resolve dialog a second time.
162
+ //
163
+ // The chooser is a question for the person exactly as the two dialogs below
164
+ // are, so the one option governs it too.
165
+ const mayAsk = options.requestMissingCredentials === true;
166
+ // Refused before resolving an organization or opening a chooser: a path that
167
+ // cannot reach the service is answered without spending either.
168
+ if (invalidServicePath(path)) return invalidPathRefusal();
169
+ const organizationId = await deps.resolveOrganization(mayAsk);
170
+ if (organizationId === null) return unresolvedOrganization();
171
+ const send = async (): Promise<Response> => {
172
+ const token = await deps.getAccessToken();
173
+ if (!token) throw new BrassError('Not signed in', { status: 401 });
174
+ const headers: Record<string, string> = {
175
+ authorization: `Bearer ${token}`,
176
+ };
177
+ if (options.headers && Object.keys(options.headers).length > 0) {
178
+ headers['x-brass-service-headers'] = encodeServiceHeaders(options.headers);
179
+ }
180
+ applyClientVersionHeader(headers);
181
+ await applyAppAssertionHeader(headers, deps.getAppAssertion);
182
+ const doFetch = deps.fetchImpl ?? fetch;
183
+ return doFetch(brokeredCallUrl(deps.apiBaseUrl, organizationId, host, path), {
184
+ method: options.method ?? 'POST',
185
+ headers,
186
+ ...(options.body !== undefined ? { body: options.body } : {}),
187
+ });
188
+ };
189
+
190
+ const response = await send();
191
+ // The service's own status is the answer, so nothing here throws on one.
192
+ // The exceptions are the refusals that are a question for the person rather
193
+ // than a result for the app: offer the hosted surface that asks it, and send
194
+ // the call again once the answer lands.
195
+ if (response.ok) return response;
196
+ if (!mayAsk) return response;
197
+ const ask = response.headers.get(BRASS_ASK_HEADER);
198
+ if (ask === 'credential') {
199
+ return (await deps.offerServiceCredential(organizationId, host))
200
+ ? send()
201
+ : response;
202
+ }
203
+ if (ask !== 'install') return response;
204
+ const denial = await notInstalledDenial(response);
205
+ if (!denial) return response;
206
+ return (await deps.offerInstallConsent(denial)) ? send() : response;
207
+ }
208
+
209
+ // No organization resolved, for a caller that did not ask for the chooser.
210
+ //
211
+ // Answered as a refusal rather than thrown, because every other refusal on
212
+ // this path arrives as a Response the app reads a `code` off, and a caller
213
+ // reading ahead should not need a second shape for the case where it has not
214
+ // acted yet. The code is the server's own for the same state.
215
+ function unresolvedOrganization(): Response {
216
+ return new Response(
217
+ JSON.stringify({
218
+ error: 'No organization resolved for this app yet',
219
+ code: 'destination_required',
220
+ }),
221
+ { status: 409, headers: { 'content-type': 'application/json' } },
222
+ );
223
+ }
224
+
225
+ // Which hosted surface a refusal is asking for, in Brass's own words.
226
+ //
227
+ // Brass states this rather than the SDK inferring it from the `code`, for two
228
+ // reasons that both come down to the body being the service's. A declared
229
+ // service answers with whatever status and body it likes, so a refusal shaped
230
+ // like Brass's own would open a dialog the SERVICE asked for; the platform
231
+ // refuses to relay any header under this prefix, so this one is Brass's alone.
232
+ // And two of the codes cover a credential the app's author owns as well as one
233
+ // the reader can supply, which are the same word for a question somebody can
234
+ // answer and one nobody can. Only Brass knows which. A credential-shaped
235
+ // refusal added after this SDK shipped therefore still opens the dialog.
236
+ const BRASS_ASK_HEADER = 'x-brass-ask';
237
+
238
+ // Reads the machine-readable install denial out of a refusal, leaving the
239
+ // response untouched for a caller that gets it back: the body is read from a
240
+ // clone, since a caller reading a consumed body sees an empty one.
241
+ async function notInstalledDenial(response: Response): Promise<BrassError | null> {
242
+ let parsed: unknown;
243
+ try {
244
+ parsed = await response.clone().json();
245
+ } catch {
246
+ return null;
247
+ }
248
+ if (
249
+ typeof parsed !== 'object' ||
250
+ parsed === null ||
251
+ (parsed as { code?: unknown }).code !== 'app_not_installed' ||
252
+ typeof (parsed as { organization_id?: unknown }).organization_id !== 'string'
253
+ ) {
254
+ return null;
255
+ }
256
+ const body = parsed as { error?: unknown; organization_id: string };
257
+ return new BrassError(typeof body.error === 'string' ? body.error : 'App not installed', {
258
+ status: response.status,
259
+ code: 'app_not_installed',
260
+ organizationId: body.organization_id,
261
+ });
262
+ }
263
+
264
+ // One service this app declares, told against the organization it is acting
265
+ // in. `ready: false` carries the same `code` a refused call answers with, so an
266
+ // app reading ahead and an app reacting to a refusal branch on one vocabulary.
267
+ export interface AppServiceState {
268
+ host: string;
269
+ /**
270
+ * The pattern your manifest declared, where it declared one
271
+ * (`*.myshopify.com`). One entry per tenant connected under it carries it,
272
+ * plus one naming the pattern itself while none is connected. Absent on a
273
+ * host declared literally.
274
+ */
275
+ declared_as?: string;
276
+ /**
277
+ * Whether `Brass.connectService(host)` can lead somewhere here. True where
278
+ * somebody using your app can supply what this host needs; false where it is
279
+ * waiting on the credential you enter as the app's author, which nobody on
280
+ * the page can settle.
281
+ */
282
+ connectable: boolean;
283
+ ready: boolean;
284
+ code?: string;
285
+ /**
286
+ * Values your manifest marked `readable`, for the account this call would
287
+ * spend. Use them to build a path the platform cannot build for you, such as
288
+ * the account id Cloudflare puts in every URL. Present only on a ready
289
+ * service, since only then is there an account to name.
290
+ */
291
+ values?: Record<string, string>;
292
+ }
293
+
294
+ // Which of this app's declared services would serve a call right now.
295
+ //
296
+ // The platform answers with the CALL's own verdict rather than a second
297
+ // derivation of it, so a host reported ready is one the broker will spend.
298
+ export async function listAppServices(
299
+ deps: Pick<
300
+ CallServiceDeps,
301
+ 'apiBaseUrl' | 'getAccessToken' | 'getAppAssertion' | 'fetchImpl'
302
+ >,
303
+ organizationId: string,
304
+ ): Promise<AppServiceState[]> {
305
+ const token = await deps.getAccessToken();
306
+ if (!token) throw new BrassError('Not signed in', { status: 401 });
307
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` };
308
+ applyClientVersionHeader(headers);
309
+ await applyAppAssertionHeader(headers, deps.getAppAssertion);
310
+ const base = deps.apiBaseUrl.replace(/\/$/, '');
311
+ const doFetch = deps.fetchImpl ?? fetch;
312
+ const response = await doFetch(
313
+ `${base}/organizations/${encodeURIComponent(organizationId)}/services`,
314
+ { method: 'GET', headers },
315
+ );
316
+ if (!response.ok) {
317
+ let message = 'Could not read this app\'s services';
318
+ let notInstalled = false;
319
+ try {
320
+ const body = (await response.json()) as { error?: unknown; code?: unknown };
321
+ if (typeof body.error === 'string') message = body.error;
322
+ notInstalled = body.code === 'app_not_installed';
323
+ } catch {
324
+ // Leaves the generic message, which the status already qualifies.
325
+ }
326
+ // Only the code the SDK itself acts on is typed onto the error. The rest
327
+ // of the platform's refusal vocabulary describes one host rather than this
328
+ // read, and none of it reaches a read that returned nothing.
329
+ //
330
+ // The organization rides the install denial because that is what the
331
+ // hosted install dialog installs into: the read named one organization, so
332
+ // the refusal is about that one and the dialog does not have to ask where
333
+ // a second time.
334
+ throw new BrassError(message, {
335
+ status: response.status,
336
+ ...(notInstalled
337
+ ? { code: 'app_not_installed' as const, organizationId }
338
+ : {}),
339
+ });
340
+ }
341
+ // An answer that is not the platform's own reaches here as a 200: an edge
342
+ // or a proxy serves its own page under whatever status it chooses. Reading
343
+ // it through the same error type as every refusal on this path leaves one
344
+ // shape for an app to catch, and a `services` that is not a list is read as
345
+ // none rather than handed to the app typed as an array it can iterate.
346
+ let parsed: unknown;
347
+ try {
348
+ parsed = await response.json();
349
+ } catch {
350
+ throw new BrassError('Could not read this app\'s services', { status: 502 });
351
+ }
352
+ const services = (parsed as { services?: unknown } | null)?.services;
353
+ return Array.isArray(services) ? (services as AppServiceState[]) : [];
354
+ }