@brass-build/client 0.1.0 → 0.3.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.
- package/AGENTS.md +72 -63
- package/CHANGELOG.md +97 -0
- package/README.md +325 -235
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +245 -76
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +22 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +49 -16
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +981 -237
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +204 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +30 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1905 -322
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +335 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +590 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +356 -83
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +162 -20
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +27 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +408 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +23 -17
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +21 -22
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +262 -80
- package/src/document-checkpoints.ts +30 -0
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +120 -8
- package/src/document-streams.ts +95 -9
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +38 -13
- package/src/documents.ts +1103 -290
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3407 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +216 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +7 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2441 -502
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +686 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +387 -89
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +183 -26
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +461 -92
- package/src/ws-frame-limit.ts +23 -17
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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(...)`
|
|
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').
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
stream.map('instances').subscribe((change) => summarize(change.set));
|
|
62
63
|
}
|
|
63
64
|
```
|
|
64
65
|
|
|
65
|
-
`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
85
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
178
|
-
tiny streams make opening everything slower
|
|
179
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.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.
|
|
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
|
|
5
|
-
// server always sends
|
|
6
|
-
//
|
|
7
|
-
// check `expiresAt - leeway > Date.now()`
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
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<
|
|
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
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
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), {
|
|
169
|
+
response = await fetchWithRetry(() => fetch(url, init), {
|
|
170
|
+
policy,
|
|
171
|
+
maxAttempts: REFRESH_ATTEMPTS,
|
|
172
|
+
});
|
|
110
173
|
} catch {
|
|
111
|
-
return
|
|
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
|
|
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
|
}
|