@brass-build/client 0.1.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 (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
@@ -0,0 +1,127 @@
1
+ // The shared HTTP request wrapper: resolve token → 401 if missing →
2
+ // auth header → app-assertion → fetch → parse → throw `BrassError` on
3
+ // non-2xx → return parsed. Read paths that need the raw status without
4
+ // throwing stay hand-written but still throw `BrassError`.
5
+
6
+ import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
7
+ import { BrassError } from './errors.js';
8
+ import { fetchWithRetry } from './fetch-retry.js';
9
+
10
+ export interface ApiRequest {
11
+ // Defaults to `GET`.
12
+ method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
13
+ path: string;
14
+ // JSON-encoded when present; sets `content-type: application/json`.
15
+ body?: unknown;
16
+ }
17
+
18
+ interface ApiFetchOptions {
19
+ apiBaseUrl: string;
20
+ getAccessToken: () => Promise<string | null>;
21
+ getAppAssertion?: () => Promise<string | null>;
22
+ }
23
+
24
+ export type ApiFetch = <T>(req: ApiRequest) => Promise<T>;
25
+
26
+ export function createApiFetch(opts: ApiFetchOptions): ApiFetch {
27
+ const baseUrl = opts.apiBaseUrl.replace(/\/$/, '');
28
+
29
+ return async function apiFetch<T>(req: ApiRequest): Promise<T> {
30
+ const token = await opts.getAccessToken();
31
+ if (!token) {
32
+ // Reject before fetch: a deterministic 401, no network round-trip.
33
+ throw new BrassError('Not signed in', { status: 401 });
34
+ }
35
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` };
36
+ if (req.body !== undefined) headers['content-type'] = 'application/json';
37
+ applyClientVersionHeader(headers);
38
+ await applyAppAssertionHeader(headers, opts.getAppAssertion);
39
+
40
+ const method = req.method ?? 'GET';
41
+ const init: RequestInit = { method, headers };
42
+ if (req.body !== undefined) init.body = JSON.stringify(req.body);
43
+
44
+ const url = `${baseUrl}${req.path}`;
45
+ // A GET is idempotent, so ride out a cold-API transient 5xx / 429
46
+ // (e.g. a 503 while the API scales into a concurrent read burst)
47
+ // rather than surfacing it as a hard failure; a write is never
48
+ // replayed here.
49
+ const response =
50
+ method === 'GET'
51
+ ? await fetchWithRetry(() => fetch(url, init))
52
+ : await fetch(url, init);
53
+ if (response.status === 204) return undefined as T;
54
+
55
+ const text = await response.text();
56
+ let parsed: unknown = undefined;
57
+ if (text) {
58
+ try {
59
+ parsed = JSON.parse(text);
60
+ } catch {
61
+ // Leave `parsed` undefined; a non-2xx falls back to the generic
62
+ // message, a 2xx surfaces as the caller's own malformed-body guard.
63
+ }
64
+ }
65
+ if (!response.ok) {
66
+ throw brassErrorFromBody(response.status, parsed);
67
+ }
68
+ return parsed as T;
69
+ };
70
+ }
71
+
72
+ function isErrorBody(body: unknown): body is { error: string } {
73
+ return (
74
+ typeof body === 'object' &&
75
+ body !== null &&
76
+ typeof (body as { error?: unknown }).error === 'string'
77
+ );
78
+ }
79
+
80
+ // Build the BrassError for a non-2xx response body. Every SDK fetch
81
+ // (apiFetch and the hand-written document open / create paths) throws
82
+ // through here so the server's machine-readable denials, in particular
83
+ // `code: "app_not_installed"`, survive into the error the caller (and
84
+ // the SDK's own install-consent offer) can branch on.
85
+ export function brassErrorFromBody(status: number, parsed: unknown): BrassError {
86
+ const message = isErrorBody(parsed) ? parsed.error : 'Request failed';
87
+ if (isNotInstalledBody(parsed)) {
88
+ return new BrassError(message, {
89
+ status,
90
+ code: 'app_not_installed',
91
+ organizationId: parsed.organization_id,
92
+ });
93
+ }
94
+ if (isSchemaConflictBody(parsed)) {
95
+ const title = (parsed as Record<string, unknown>)['title'];
96
+ return new BrassError(message, {
97
+ status,
98
+ code: 'schema_conflict',
99
+ ...(typeof title === 'string' ? { title } : {}),
100
+ });
101
+ }
102
+ return new BrassError(message, { status });
103
+ }
104
+
105
+ // The platform refuses to join a document this app's declared schema cannot
106
+ // read, so a bookmark or a reload does not silently open one. The message is
107
+ // the server's own line, which the client's dialog renders verbatim rather
108
+ // than composing a reason of its own out of a shape it would have to keep in
109
+ // step with the platform's.
110
+ function isSchemaConflictBody(value: unknown): value is { error: string } {
111
+ return (
112
+ typeof value === 'object' &&
113
+ value !== null &&
114
+ (value as { code?: unknown }).code === 'schema_conflict'
115
+ );
116
+ }
117
+
118
+ function isNotInstalledBody(
119
+ value: unknown,
120
+ ): value is { error: string; code: 'app_not_installed'; organization_id: string } {
121
+ return (
122
+ typeof value === 'object' &&
123
+ value !== null &&
124
+ (value as { code?: unknown }).code === 'app_not_installed' &&
125
+ typeof (value as { organization_id?: unknown }).organization_id === 'string'
126
+ );
127
+ }
@@ -0,0 +1,28 @@
1
+ import { SDK_CLIENT_ID } from './version.js';
2
+
3
+ // Attaches the acting-app assertion as `X-Brass-App-Assertion` when one
4
+ // is available; a no-op when the caller has none.
5
+ //
6
+ // MUST only be applied to requests to the Brass data API. Never attach
7
+ // it to other origins, in particular the presigned snapshot/upload
8
+ // URLs the document path fetches, which must carry no Brass headers (a
9
+ // stray header would also break the presigned signature's allowed-header
10
+ // set).
11
+ export async function applyAppAssertionHeader(
12
+ headers: Record<string, string>,
13
+ getAppAssertion?: () => Promise<string | null>,
14
+ ): Promise<void> {
15
+ const assertion = await getAppAssertion?.();
16
+ if (assertion) headers['x-brass-app-assertion'] = assertion;
17
+ }
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.
26
+ export function applyClientVersionHeader(headers: Record<string, string>): void {
27
+ headers['x-brass-client'] = SDK_CLIENT_ID;
28
+ }
@@ -0,0 +1,125 @@
1
+ import { SDK_CLIENT_ID } from './version.js';
2
+ import { fetchWithRetry, RETRY_READ, RETRY_WRITE_503 } from './fetch-retry.js';
3
+
4
+ // Fallback token lifetime if a /refresh response omits `expires_in`. The
5
+ // server always sends it (so this is forward-compat robustness), but without a
6
+ // fallback `Date.now() + undefined * 1000` is NaN, and the client's freshness
7
+ // check `expiresAt - leeway > Date.now()` is then always false, so every
8
+ // getAccessToken() would fire a fresh /refresh round-trip. Mirrors the CLI's
9
+ // same guard.
10
+ const DEFAULT_TOKEN_TTL_SECONDS = 3600;
11
+
12
+ export interface TokenSet {
13
+ accessToken: string;
14
+ idToken: string;
15
+ expiresAt: number;
16
+ // The acting-app assertion minted at /refresh, forwarded to the data
17
+ // API as `X-Brass-App-Assertion`. Absent when the proxy has no signing
18
+ // secret configured.
19
+ appAssertion?: string;
20
+ }
21
+
22
+ interface RefreshResponse {
23
+ access_token: string;
24
+ id_token: string;
25
+ expires_in: number;
26
+ app_assertion?: string;
27
+ // Present only for a fallback-mode request (a `brass_code` exchange or
28
+ // a `sid` echo): the opaque platform-session pointer the SDK holds in
29
+ // memory to keep refreshing without the cross-site cookie.
30
+ session_token?: string;
31
+ }
32
+
33
+ // The credential the SDK presents to POST /refresh.
34
+ // - `cookie`: the default; the browser attaches the `brass_auth` cookie
35
+ // and nothing is sent in the body. Works wherever the third-party
36
+ // cookie is allowed.
37
+ // - `code`: a one-time `brass_code` from the sign-in redirect, exchanged
38
+ // for tokens + a `session_token`. The incognito / Safari ITP fallback,
39
+ // used only after a `cookie` refresh came back empty.
40
+ // - `sessionToken`: the opaque pointer from a prior `code` exchange,
41
+ // echoed on every refresh once the cookie channel is known unavailable.
42
+ export type RefreshCredential =
43
+ | { kind: 'cookie' }
44
+ | { kind: 'code'; code: string }
45
+ | { kind: 'sessionToken'; sessionToken: string };
46
+
47
+ export interface RefreshResult {
48
+ tokens: TokenSet;
49
+ // Set only for fallback-mode refreshes (`code` / `sessionToken`). The
50
+ // SDK stores this and presents it as the `sessionToken` credential on
51
+ // later refreshes.
52
+ sessionToken?: string;
53
+ }
54
+
55
+ export function buildSignInUrl(authBaseUrl: string, appId: string, returnTo: string): string {
56
+ const url = new URL(`${authBaseUrl}/sign-in`);
57
+ url.searchParams.set('app_id', appId);
58
+ url.searchParams.set('return_to', returnTo);
59
+ return url.toString();
60
+ }
61
+
62
+ export function buildSignedOutOfAppUrl(
63
+ authBaseUrl: string,
64
+ appId: string,
65
+ returnTo: string,
66
+ ): string {
67
+ const url = new URL(`${authBaseUrl}/signed-out-of-app`);
68
+ url.searchParams.set('app_id', appId);
69
+ url.searchParams.set('return_to', returnTo);
70
+ return url.toString();
71
+ }
72
+
73
+ export async function refreshTokens(
74
+ authBaseUrl: string,
75
+ appId: string,
76
+ credential: RefreshCredential = { kind: 'cookie' },
77
+ ): Promise<RefreshResult | null> {
78
+ // `client` rides a query param, not a header: /refresh is deliberately
79
+ // a CORS simple request (no preflight), and any custom header would
80
+ // trip one. The server folds it into the token_refresh audit so the
81
+ // deployed SDK version distribution is measurable.
82
+ const url = `${authBaseUrl}/refresh?${new URLSearchParams({
83
+ app_id: appId,
84
+ client: SDK_CLIENT_ID,
85
+ }).toString()}`;
86
+ const init: RequestInit = { method: 'POST', credentials: 'include' };
87
+ if (credential.kind !== 'cookie') {
88
+ // A URLSearchParams body sends `Content-Type:
89
+ // application/x-www-form-urlencoded`, which keeps this a CORS
90
+ // "simple request" with no preflight OPTIONS, which the auth API
91
+ // does not serve. A JSON content-type would trip preflight.
92
+ const body = new URLSearchParams();
93
+ if (credential.kind === 'code') body.set('brass_code', credential.code);
94
+ else body.set('sid', credential.sessionToken);
95
+ init.body = body;
96
+ }
97
+ // Retry a transient failure (a cold-start 5xx / 429, or a network blip)
98
+ // rather than collapsing it into the same `null` a definitive 401 returns:
99
+ // `null` signs the user out and clears the login hint, so without a retry one
100
+ // hourly-refresh blip tears down a live session that was fine server-side. A
101
+ // definitive 401/403 is not in either replay set, so it still returns fast.
102
+ // The one-time `brass_code` exchange is single-use server-side, so its
103
+ // channel replays ONLY a pre-handler 503 (never a network throw, which might
104
+ // mean the code was already consumed); the cookie / sid channels are
105
+ // idempotent and replay the full transient set.
106
+ const policy = credential.kind === 'code' ? RETRY_WRITE_503 : RETRY_READ;
107
+ let response: Response;
108
+ try {
109
+ response = await fetchWithRetry(() => fetch(url, init), { policy });
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (!response.ok) return null;
114
+ const json = (await response.json()) as RefreshResponse;
115
+ const result: RefreshResult = {
116
+ tokens: {
117
+ accessToken: json.access_token,
118
+ idToken: json.id_token,
119
+ expiresAt: Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL_SECONDS) * 1000,
120
+ ...(json.app_assertion ? { appAssertion: json.app_assertion } : {}),
121
+ },
122
+ };
123
+ if (json.session_token) result.sessionToken = json.session_token;
124
+ return result;
125
+ }