@brass-build/client 0.1.0 → 0.2.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 (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
package/docs/streams.md CHANGED
@@ -2,19 +2,18 @@
2
2
 
3
3
  A document is a container of **streams**: named units of document
4
4
  state that load independently. Opening a document costs only the
5
- streams your app touches: snapshot download, CRDT import, and memory
6
- all scale with what you use, not with the document's total size. The
7
- motivating shape is a multi-discipline design: architectural,
8
- plumbing, HVAC, and structural as four streams of one document, where
9
- a plumbing tool opens one of them and pays a quarter of the cost.
10
- Every stream is collaborative CRDT state.
5
+ streams your app touches, so download, load, and memory scale with what
6
+ you use rather than with the document's total size. Split a
7
+ multi-discipline design into architectural, plumbing, HVAC, and
8
+ structural streams, and a plumbing tool opens one of them. Every stream
9
+ is live, collaborative state that merges the same way the rest of the
10
+ document does.
11
11
 
12
12
  Every document has a stream named **`default`**, and an open that names
13
13
  no stream opens that one, so the doc-level handles (`doc.map(...)`,
14
- `doc.orderedMap(...)`) read it. An unpartitioned document is just a
15
- document whose content lives entirely there: nothing about streams needs
16
- to be known or paid for until you partition. Name another stream on the
17
- open (below) and the doc-level handles read that one instead.
14
+ `doc.orderedMap(...)`) read it. An unpartitioned document is one whose
15
+ content lives entirely there. Name another stream on the open (below)
16
+ and the doc-level handles read that one instead.
18
17
 
19
18
  ## Opening and editing a stream
20
19
 
@@ -40,37 +39,37 @@ surface scoped to that stream: `map` / `list` / `orderedMap` handles,
40
39
  ## Opening at a stream
41
40
 
42
41
  `Brass.openDocumentById(docId, { stream })` opens the document with that
43
- stream as its own state, so `doc.map(...)` reads it and the rest of the
44
- document is never downloaded. An app that works in one part of a large
42
+ stream as its own state, so `doc.map(...)` hands you its records and the rest
43
+ of the document is never downloaded. An app that works in one part of a large
45
44
  document pays for that part alone:
46
45
 
47
46
  ```ts
48
47
  const doc = await Brass.openDocumentById(docId, { stream: 'hvac' });
49
- doc.orderedMap('instances', 'instance_order').read(); // hvac's records
48
+ doc.orderedMap('instances', 'instance_order').subscribe((change) => {
49
+ for (const [key, record] of change.set) hold(key, record); // hvac's records
50
+ });
50
51
  ```
51
52
 
52
53
  `stream` defaults to `'default'`, the stream every document has, so an
53
54
  open always names a stream and the usual one needs no argument.
54
55
  `doc.stream(name)` resolves the name the document was opened at back to
55
56
  the document itself, with no second join, and any other name to its own
56
- line. So the loop below reads the same whichever stream you opened at:
57
+ stream, so the loop below reads the same whichever stream you opened at:
57
58
 
58
59
  ```ts
59
60
  for (const info of await doc.listStreams()) {
60
61
  const stream = await doc.stream(info.name);
61
- summarize(stream.map('instances').read());
62
+ stream.map('instances').subscribe((change) => summarize(change.set));
62
63
  }
63
64
  ```
64
65
 
65
- `listStreams()` includes it like any other name, so a loop over the
66
- manifest needs no separate step for the stream you opened at. Names are
67
- `a-z 0-9 . _ -`, 1 to 64 chars, starting with a letter or digit, the same
68
- rule a schema family follows. `default` is
69
- one of them: every document already holds it, so `getOrCreateStream`
70
- resolves it without a request, and a loop over the names your schema
71
- declares needs no case for it. `getOrCreateStream` writes, so a read-only
72
- open reads with `doc.stream` instead and shows a degraded view when the
73
- document does not hold the stream.
66
+ Names are `a-z 0-9 . _ -`, 1 to 64 chars, starting with a letter or
67
+ digit, the same rule a schema family follows. `default` is one of them:
68
+ every document already holds it, so `getOrCreateStream` resolves it
69
+ without a request, and a loop over the names your schema declares needs
70
+ no case for it. `getOrCreateStream` writes, so a read-only open reads
71
+ with `doc.stream` instead and shows a degraded view when the document
72
+ does not hold the stream.
74
73
 
75
74
  ## Undo and editing model
76
75
 
@@ -81,9 +80,8 @@ only the default stream.
81
80
 
82
81
  A stream's local edits group into one undo step until the group is
83
82
  **sealed** with `stream.sealUndoStep()`, the same same-stroke
84
- collapsing doc-level undo gets from checkpoints, exposed directly
85
- because streams carry no checkpoint machinery. Seal when a user
86
- operation completes; an app that never seals gets one giant step per
83
+ collapsing doc-level undo gets from checkpoints. Seal when a user
84
+ operation completes. An app that never seals gets one giant step per
87
85
  session.
88
86
 
89
87
  ### Undoing an operation that spans streams
@@ -103,6 +101,8 @@ Two rules make the stacks line up:
103
101
  breaks that.
104
102
 
105
103
  ```ts
104
+ import type { DocumentStream } from '@brass-build/client';
105
+
106
106
  type Op = { label: string; streams: string[] };
107
107
  const undoJournal: Op[] = [];
108
108
  const redoJournal: Op[] = [];
@@ -159,14 +159,14 @@ Collaborators' edits do not disturb the alignment: undo stacks are
159
159
  local-peer-scoped, and each stream's undo rebases your reverted step
160
160
  over remote changes the same way single-document undo does.
161
161
 
162
- Two properties to design around rather than against. The cross-stream
163
- undo is **not atomic**: each stream's revert is its own commit, so a
164
- collaborator can briefly observe one stream reverted before the other
165
- (the same window any multi-stream operation has). And the journal is
166
- **in-memory**: a reload starts it empty, exactly like single-document
167
- undo. If an operation genuinely cannot tolerate the window, that is
168
- the signal its data belongs in one stream: partition by load unit, and
169
- keep records that must change together in the same stream.
162
+ Design around two properties of it. The cross-stream undo is **not
163
+ atomic**: each stream's revert is its own commit, so a collaborator can
164
+ briefly observe one stream reverted before the other (the same window
165
+ any multi-stream operation has). And the journal is **in-memory**: a
166
+ reload starts it empty, exactly like single-document undo. When an
167
+ operation cannot tolerate that window, its data belongs in one stream:
168
+ partition by load unit, and keep records that must change together in
169
+ the same stream.
170
170
 
171
171
  ## When to partition
172
172
 
@@ -174,19 +174,9 @@ Opening a stream costs a request for its snapshot, and the first one on a
174
174
  document also reads the stream manifest, so add a stream when it carries
175
175
  real content that some opens can skip. Partition by **load unit** (a
176
176
  discipline, a heavy layer, a dataset a specific tool reads), not by
177
- concept. A handful of streams with megabytes each works best; dozens of
178
- tiny streams make opening everything slower
179
- than a monolith, and content every open needs belongs in the default
180
- stream.
181
-
182
- Branches and streams compose: creating a branch forks every stream, so a
183
- branch is fully isolated from its source across all of them, exactly like an
184
- unpartitioned document, and each forked stream keeps the shape its records
185
- conform to. Branching from a historical version addresses the default
186
- stream's timeline; the other streams fork at their current state. Deleting a
187
- branch deletes its streams with it. Two branches that then diverge in shape
188
- report different contracts, because a contract belongs to a stream on a
189
- branch rather than to the document.
177
+ concept. Aim for a handful of streams carrying megabytes each. Dozens
178
+ of tiny streams make opening everything slower than a monolith, and
179
+ content every open needs belongs in the default stream.
190
180
 
191
181
  ## Reading a document spread across streams
192
182
 
@@ -203,21 +193,29 @@ DOCUMENT holds, because a referenced record may live in one the default
203
193
  stream does not. `doc.listStreams()` is that list, so merge by id:
204
194
 
205
195
  ```ts
206
- const store: Record<string, { type: string; params: unknown }> = {
207
- ...doc.orderedMap('instances', 'instance_order').read(),
208
- };
196
+ import type { OrderedMapHandle } from '@brass-build/client';
197
+
198
+ type StoredRecord = { type: string; params: unknown };
199
+ const store: Record<string, StoredRecord> = {};
200
+ const fold = (records: OrderedMapHandle<StoredRecord>) =>
201
+ records.subscribe((change) => {
202
+ for (const [key, record] of change.set) store[key] = record;
203
+ for (const key of change.deleted) delete store[key];
204
+ });
205
+
206
+ fold(doc.orderedMap<StoredRecord>('instances', 'instance_order'));
209
207
  for (const info of await doc.listStreams()) {
210
208
  const stream = await doc.stream(info.name);
211
- Object.assign(store, stream.orderedMap('instances', 'instance_order').read());
209
+ fold(stream.orderedMap<StoredRecord>('instances', 'instance_order'));
212
210
  }
213
211
  // store['#137'] resolves wherever #137 lives.
214
212
  ```
215
213
 
216
- The loop opens the stream the document was opened at without a second
217
- join, so it reads the same for an unpartitioned document as for a split
218
- one. Open only what you read: an app that needs just the default view's
219
- records reads the default stream alone; one that resolves references
220
- reads them all and pays for the streams it opens.
214
+ The loop reads an unpartitioned document the same way it reads a split
215
+ one, so it needs no special case. Open only what you read: an app that
216
+ needs just the default view's records reads the default stream alone,
217
+ and one that resolves references reads them all and pays for the
218
+ streams it opens.
221
219
 
222
220
  A document can hold streams your schema does not name (computed results,
223
221
  review notes, a cache), so the merge above skips them and an exporter that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brass-build/client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Client SDK for Brass",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,7 +18,8 @@
18
18
  "./internal": {
19
19
  "types": "./dist/internal.d.ts",
20
20
  "import": "./dist/internal.js"
21
- }
21
+ },
22
+ "./package.json": "./package.json"
22
23
  },
23
24
  "files": [
24
25
  "dist",
@@ -40,7 +41,7 @@
40
41
  },
41
42
  "dependencies": {
42
43
  "fflate": "^0.8.3",
43
- "loro-crdt": "^1.12.1"
44
+ "loro-crdt": "^1.13.0"
44
45
  },
45
46
  "devDependencies": {
46
47
  "typescript": "~6.0.0"
package/src/auth-flow.ts CHANGED
@@ -1,14 +1,23 @@
1
1
  import { SDK_CLIENT_ID } from './version.js';
2
2
  import { fetchWithRetry, RETRY_READ, RETRY_WRITE_503 } from './fetch-retry.js';
3
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.
4
+ // Fallback token lifetime when a /refresh response carries no usable
5
+ // `expires_in`. The server always sends a positive number (so this is
6
+ // forward-compat robustness), and every other value stamps an `expiresAt` the
7
+ // client's freshness check `expiresAt - leeway > Date.now()` can never
8
+ // satisfy: `undefined` and a non-numeric make it NaN, and 0 or a negative
9
+ // makes it already past. Either way every getAccessToken() would fire a fresh
10
+ // /refresh round-trip. Mirrors the CLI's same guard.
10
11
  const DEFAULT_TOKEN_TTL_SECONDS = 3600;
11
12
 
13
+ // The lifetime to stamp a token set with, given whatever `expires_in` the
14
+ // response carried.
15
+ function tokenTtlSeconds(expiresIn: unknown): number {
16
+ return typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0
17
+ ? expiresIn
18
+ : DEFAULT_TOKEN_TTL_SECONDS;
19
+ }
20
+
12
21
  export interface TokenSet {
13
22
  accessToken: string;
14
23
  idToken: string;
@@ -24,9 +33,9 @@ interface RefreshResponse {
24
33
  id_token: string;
25
34
  expires_in: number;
26
35
  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.
36
+ // Present only for a fallback-mode request (a `brass_code` exchange or a
37
+ // `sid` echo): an opaque pointer, bound server-side to this app id, that the
38
+ // SDK holds in memory to keep refreshing without the cross-site cookie.
30
39
  session_token?: string;
31
40
  }
32
41
 
@@ -52,6 +61,33 @@ export interface RefreshResult {
52
61
  sessionToken?: string;
53
62
  }
54
63
 
64
+ // The result of one /refresh attempt, carrying whether a failure was the
65
+ // server's answer or the absence of one.
66
+ //
67
+ // `no_session`: this credential resolves no live session (revoked, expired,
68
+ // or never approved for this app). The caller signs the user out.
69
+ //
70
+ // `unavailable`: the request threw, or a transient status outlived the
71
+ // retries. Nothing was learned about the session, so the caller keeps the
72
+ // credentials it holds and lets a later attempt settle it. Treating this as
73
+ // `no_session` ends a live session over a momentary network failure.
74
+ export type RefreshOutcome =
75
+ | { status: 'ok'; result: RefreshResult }
76
+ | { status: 'no_session' }
77
+ | { status: 'unavailable' };
78
+
79
+ // Attempts for a /refresh, raised from `fetchWithRetry`'s default of 3
80
+ // (~0.9s of backoff) so the call outlasts the few-second network gap a
81
+ // machine waking from sleep produces.
82
+ const REFRESH_ATTEMPTS = 5;
83
+
84
+ // Statuses that mean the request found no answer. A 5xx is in the set
85
+ // because the auth proxy reports an upstream failure as one, so it says
86
+ // nothing about whether the session is live.
87
+ function isTransientStatus(status: number): boolean {
88
+ return status >= 500 || status === 429 || status === 408;
89
+ }
90
+
55
91
  export function buildSignInUrl(authBaseUrl: string, appId: string, returnTo: string): string {
56
92
  const url = new URL(`${authBaseUrl}/sign-in`);
57
93
  url.searchParams.set('app_id', appId);
@@ -70,11 +106,38 @@ export function buildSignedOutOfAppUrl(
70
106
  return url.toString();
71
107
  }
72
108
 
109
+ // Drops this app's approval off the platform session, presenting the same
110
+ // credential a refresh does. Never rejects: the sign-out navigation follows
111
+ // either way, and the page it lands on offers the button that finishes a
112
+ // revocation this call could not deliver.
113
+ export async function revokeAppApproval(
114
+ authBaseUrl: string,
115
+ appId: string,
116
+ credential: RefreshCredential = { kind: 'cookie' },
117
+ ): Promise<void> {
118
+ const url = `${authBaseUrl}/sign-out-of-app?${new URLSearchParams({
119
+ app_id: appId,
120
+ }).toString()}`;
121
+ const init: RequestInit = { method: 'POST', credentials: 'include' };
122
+ if (credential.kind === 'sessionToken') {
123
+ // A URLSearchParams body keeps this a CORS simple request, as on
124
+ // /refresh: the auth API serves no preflight OPTIONS.
125
+ const body = new URLSearchParams();
126
+ body.set('sid', credential.sessionToken);
127
+ init.body = body;
128
+ }
129
+ try {
130
+ await fetch(url, init);
131
+ } catch {
132
+ // Swallowed on purpose: see above.
133
+ }
134
+ }
135
+
73
136
  export async function refreshTokens(
74
137
  authBaseUrl: string,
75
138
  appId: string,
76
139
  credential: RefreshCredential = { kind: 'cookie' },
77
- ): Promise<RefreshResult | null> {
140
+ ): Promise<RefreshOutcome> {
78
141
  // `client` rides a query param, not a header: /refresh is deliberately
79
142
  // a CORS simple request (no preflight), and any custom header would
80
143
  // trip one. The server folds it into the token_refresh audit so the
@@ -95,31 +158,35 @@ export async function refreshTokens(
95
158
  init.body = body;
96
159
  }
97
160
  // 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.
161
+ // before reporting it. A definitive 401/403 is not in either replay set, so
162
+ // it still returns fast. The one-time `brass_code` exchange is single-use
163
+ // server-side, so its channel replays ONLY a pre-handler 503 (never a
164
+ // network throw, which might mean the code was already consumed); the
165
+ // cookie / sid channels are idempotent and replay the full transient set.
106
166
  const policy = credential.kind === 'code' ? RETRY_WRITE_503 : RETRY_READ;
107
167
  let response: Response;
108
168
  try {
109
- response = await fetchWithRetry(() => fetch(url, init), { policy });
169
+ response = await fetchWithRetry(() => fetch(url, init), {
170
+ policy,
171
+ maxAttempts: REFRESH_ATTEMPTS,
172
+ });
110
173
  } catch {
111
- return null;
174
+ return { status: 'unavailable' };
175
+ }
176
+ if (!response.ok) {
177
+ return isTransientStatus(response.status)
178
+ ? { status: 'unavailable' }
179
+ : { status: 'no_session' };
112
180
  }
113
- if (!response.ok) return null;
114
181
  const json = (await response.json()) as RefreshResponse;
115
182
  const result: RefreshResult = {
116
183
  tokens: {
117
184
  accessToken: json.access_token,
118
185
  idToken: json.id_token,
119
- expiresAt: Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL_SECONDS) * 1000,
186
+ expiresAt: Date.now() + tokenTtlSeconds(json.expires_in) * 1000,
120
187
  ...(json.app_assertion ? { appAssertion: json.app_assertion } : {}),
121
188
  },
122
189
  };
123
190
  if (json.session_token) result.sessionToken = json.session_token;
124
- return result;
191
+ return { status: 'ok', result };
125
192
  }