@synap-core/sdk 1.4.0 → 1.5.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.
- package/CHANGELOG.md +215 -0
- package/README.md +152 -10
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-C5sF20f1.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-C5sF20f1.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +419 -64
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +416 -32
- package/dist/connect.d.ts +416 -32
- package/dist/connect.js +414 -65
- package/dist/connect.js.map +1 -1
- package/dist/public.cjs +9 -2
- package/dist/public.cjs.map +1 -1
- package/dist/public.d.cts +19 -4
- package/dist/public.d.ts +19 -4
- package/dist/public.js +8 -2
- package/dist/public.js.map +1 -1
- package/dist/testing.cjs +350 -15
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +171 -27
- package/dist/testing.d.ts +171 -27
- package/dist/testing.js +346 -15
- package/dist/testing.js.map +1 -1
- package/dist/{types-C2KGNXEn.d.cts → types-DZYNBCq7.d.cts} +28 -5
- package/dist/{types-C2KGNXEn.d.ts → types-DZYNBCq7.d.ts} +28 -5
- package/package.json +30 -6
- package/src/connect/auth.ts +169 -27
- package/src/connect/client.ts +377 -34
- package/src/connect/cloud.ts +14 -10
- package/src/connect/discovery.ts +66 -32
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +7 -0
- package/src/connect/flow.ts +42 -14
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +271 -0
- package/src/connect/index.ts +61 -42
- package/src/connect/rules.ts +18 -0
- package/src/connect/store.ts +9 -5
- package/src/connect/types.ts +28 -4
- package/src/public/client.ts +14 -2
- package/src/public/safe-markdown.ts +13 -0
- package/src/public/share-model.ts +6 -2
- package/src/public/site-model.ts +5 -3
- package/src/testing/auth-server.ts +350 -17
- package/src/testing/cloud.ts +3 -4
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +8 -6
- package/src/testing/roles.ts +27 -0
- package/src/testing/seed.ts +196 -0
- package/dist/chunk-J7XHAAFJ.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synap-core/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Type-safe tRPC client SDK for Synap data pods, public-door client, and server-only pod-app sign-in",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -8,22 +8,34 @@
|
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
-
"types":
|
|
11
|
+
"types": {
|
|
12
|
+
"import": "./dist/index.d.ts",
|
|
13
|
+
"require": "./dist/index.d.cts"
|
|
14
|
+
},
|
|
12
15
|
"import": "./dist/index.js",
|
|
13
16
|
"require": "./dist/index.cjs"
|
|
14
17
|
},
|
|
15
18
|
"./public": {
|
|
16
|
-
"types":
|
|
19
|
+
"types": {
|
|
20
|
+
"import": "./dist/public.d.ts",
|
|
21
|
+
"require": "./dist/public.d.cts"
|
|
22
|
+
},
|
|
17
23
|
"import": "./dist/public.js",
|
|
18
24
|
"require": "./dist/public.cjs"
|
|
19
25
|
},
|
|
20
26
|
"./testing": {
|
|
21
|
-
"types":
|
|
27
|
+
"types": {
|
|
28
|
+
"import": "./dist/testing.d.ts",
|
|
29
|
+
"require": "./dist/testing.d.cts"
|
|
30
|
+
},
|
|
22
31
|
"import": "./dist/testing.js",
|
|
23
32
|
"require": "./dist/testing.cjs"
|
|
24
33
|
},
|
|
25
34
|
"./connect": {
|
|
26
|
-
"types":
|
|
35
|
+
"types": {
|
|
36
|
+
"import": "./dist/connect.d.ts",
|
|
37
|
+
"require": "./dist/connect.d.cts"
|
|
38
|
+
},
|
|
27
39
|
"edge-light": "./dist/connect.js",
|
|
28
40
|
"workerd": "./dist/connect.js",
|
|
29
41
|
"browser": "./dist/connect.browser.js",
|
|
@@ -31,11 +43,23 @@
|
|
|
31
43
|
"require": "./dist/connect.cjs"
|
|
32
44
|
}
|
|
33
45
|
},
|
|
46
|
+
"sideEffects": [
|
|
47
|
+
"./dist/connect.js",
|
|
48
|
+
"./dist/connect.cjs",
|
|
49
|
+
"./dist/connect.browser.js",
|
|
50
|
+
"./dist/connect.browser.cjs",
|
|
51
|
+
"./src/connect/index.ts",
|
|
52
|
+
"./src/connect/browser.ts"
|
|
53
|
+
],
|
|
34
54
|
"files": [
|
|
35
55
|
"dist",
|
|
36
56
|
"src",
|
|
37
|
-
"!src/__tests__"
|
|
57
|
+
"!src/__tests__",
|
|
58
|
+
"CHANGELOG.md"
|
|
38
59
|
],
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=20"
|
|
62
|
+
},
|
|
39
63
|
"keywords": [
|
|
40
64
|
"synap",
|
|
41
65
|
"sdk",
|
package/src/connect/auth.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { serializeCookie, parseCookies } from "./encoding";
|
|
|
34
34
|
import { createPodClient, type PodClient, type PodClientOptions } from "./client";
|
|
35
35
|
import { assertPermissions, handleCallback, refreshConnection, revokeConnection, startConnect, TRANSACTION_TTL_MS } from "./flow";
|
|
36
36
|
import { assertServerRuntime } from "./guard";
|
|
37
|
+
import { isIpLiteral, LOOPBACK_HOSTS, SPACE_HINT_RE } from "./rules";
|
|
37
38
|
import { assertAppSecret, type AppSecret } from "./seal";
|
|
38
39
|
import { applySetCookies, cookieNames, createSealedCookieStore, markerCookie, readMarker, type ConnectionStore } from "./store";
|
|
39
40
|
import type { AppRequest, ClientMetadataDocument, PodConnection } from "./types";
|
|
@@ -69,7 +70,12 @@ export const DEFAULT_PATHS: PodAppPaths = {
|
|
|
69
70
|
export const REFRESH_WINDOW_MS = 5 * 60 * 1000;
|
|
70
71
|
|
|
71
72
|
export interface PodAppAuthConfig {
|
|
72
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* The app's origin, e.g. https://studio.example.com. `http://localhost` is
|
|
75
|
+
* accepted for the SDK's MOCK pod only: every real pod, a local one
|
|
76
|
+
* included, refuses an http client_id and redirect_uri, so an app that signs
|
|
77
|
+
* in to a real pod runs on https (a tunnel in development).
|
|
78
|
+
*/
|
|
73
79
|
appUrl: string;
|
|
74
80
|
/** Shown on the pod's consent screen (at most 100 characters). */
|
|
75
81
|
clientName: string;
|
|
@@ -89,7 +95,11 @@ export interface PodAppAuthConfig {
|
|
|
89
95
|
* "Continue with Synap Cloud" (`cloudConnectAvailable`). Unset = address only.
|
|
90
96
|
*/
|
|
91
97
|
cloudUrl?: string | null;
|
|
92
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Accept a pod (and a Control Plane) on http://localhost: development
|
|
100
|
+
* against the SDK's mock pod (`@synap-core/sdk/testing`). A real local pod
|
|
101
|
+
* still refuses an app that is not on https (see `appUrl`).
|
|
102
|
+
*/
|
|
93
103
|
allowInsecureLoopback?: boolean;
|
|
94
104
|
/** Developer log of every refused sign-in; messages never carry a token. Default: console.warn. */
|
|
95
105
|
onError?: (error: ConnectError, where: "connect" | "callback" | "disconnect" | "refresh") => void;
|
|
@@ -108,9 +118,9 @@ export type RefreshStatus =
|
|
|
108
118
|
| "refreshed"
|
|
109
119
|
/** The refresh failed (a lost rotation race, the pod busy) but the access token still works: keep it, set nothing. */
|
|
110
120
|
| "kept"
|
|
111
|
-
/** The access token is spent and cannot be renewed: connect again. */
|
|
121
|
+
/** The access token is spent and cannot be renewed (lapsed, or the pod moved: `error.code`): connect again. The session cookie is cleared (the marker stays). */
|
|
112
122
|
| "expired"
|
|
113
|
-
/** The pod refused the refresh token (access removed): connect again. */
|
|
123
|
+
/** The pod refused the refresh token (access removed): connect again. The session cookie is cleared (the marker stays). */
|
|
114
124
|
| "revoked"
|
|
115
125
|
/** The pod could not be reached and the access token is spent: try later. */
|
|
116
126
|
| "unavailable";
|
|
@@ -132,6 +142,30 @@ export interface RefreshOutcome {
|
|
|
132
142
|
lapsed?: { podUrl: string };
|
|
133
143
|
}
|
|
134
144
|
|
|
145
|
+
export interface RefreshOptions {
|
|
146
|
+
/**
|
|
147
|
+
* Rotate now, whatever the access token's expiry says: the pod refused it
|
|
148
|
+
* (a Hub call answered `unauthorized` with `refreshable`). Needs a refresh
|
|
149
|
+
* token. If the pod refuses the refresh too, the outcome is `revoked` (or
|
|
150
|
+
* `expired`), never `kept`: the access token is known dead.
|
|
151
|
+
*/
|
|
152
|
+
force?: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface PodAppClientOptions extends PodClientOptions {
|
|
156
|
+
/**
|
|
157
|
+
* Where the client may set cookies (a route handler, a server action): after
|
|
158
|
+
* a 401 it then renews the connection ONCE (rotate, seal, `persist` the
|
|
159
|
+
* Set-Cookie values, retry the request once). The rotation spends the old
|
|
160
|
+
* refresh token, so it is never done where its result cannot be stored: a
|
|
161
|
+
* server component passes no `persist`, and a 401 there answers
|
|
162
|
+
* `unauthorized` with `refreshable` (see `RefreshOptions.force`).
|
|
163
|
+
*/
|
|
164
|
+
persist?: (setCookies: string[]) => void | Promise<void>;
|
|
165
|
+
/** The request (or its Cookie header) the connection came from: chunks a larger old cookie left are cleared. */
|
|
166
|
+
request?: Request | string | null;
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
export interface PodAppAuth {
|
|
136
170
|
clientId: string;
|
|
137
171
|
redirectUri: string;
|
|
@@ -161,15 +195,61 @@ export interface PodAppAuth {
|
|
|
161
195
|
* (or disconnected). Same sources as `readConnection`.
|
|
162
196
|
*/
|
|
163
197
|
readLapsed(source: Request | string | null): Promise<{ podUrl: string } | null>;
|
|
164
|
-
/** For `proxy.ts` / a route handler: rotate near expiry, lost-race safe. */
|
|
165
|
-
refreshIfNeeded(request: Request): Promise<RefreshOutcome>;
|
|
166
|
-
/** A Hub REST client for the connection. */
|
|
167
|
-
client(connection: PodConnection, opts?:
|
|
198
|
+
/** For `proxy.ts` / a route handler: rotate near expiry (or now, `force`), lost-race safe. */
|
|
199
|
+
refreshIfNeeded(request: Request, opts?: RefreshOptions): Promise<RefreshOutcome>;
|
|
200
|
+
/** A Hub REST client for the connection (renewing it after a 401 when it can `persist`). */
|
|
201
|
+
client(connection: PodConnection, opts?: PodAppClientOptions): PodClient;
|
|
168
202
|
}
|
|
169
203
|
|
|
170
|
-
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
171
204
|
const TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
172
205
|
const MAX_METADATA_BYTES = 5 * 1024;
|
|
206
|
+
const MAX_REQUESTS = 32;
|
|
207
|
+
/** How long a refused refresh is not asked again while the access token still works ("kept"). */
|
|
208
|
+
const KEPT_RETRY_MS = 60_000;
|
|
209
|
+
|
|
210
|
+
/** The pod's grant actions: the only verbs a pod app may ask for (client-metadata.ts `assertAppPermission`). */
|
|
211
|
+
const GRANT_ACTIONS = ["read", "create", "update", "delete"];
|
|
212
|
+
/** The review verbs only an official Synap app (`https://<id>.synap.live`) may ask for, spelled exactly. */
|
|
213
|
+
const SYNAP_APP_REVIEW_PERMISSIONS = ["proposal.approve", "proposal.reject"];
|
|
214
|
+
const PERMISSION_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
215
|
+
|
|
216
|
+
function isSynapAppOrigin(origin: string): boolean {
|
|
217
|
+
return /^https:\/\/[a-z0-9]([a-z0-9-]*[a-z0-9])?\.synap\.live$/.test(origin);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Mock mode's origin (`http://localhost`): it signs in to the SDK's mock pod
|
|
222
|
+
* only, since a real pod refuses an http client_id before it reads a single
|
|
223
|
+
* permission (synap-backend `routers/oauth/client-id-url.ts`
|
|
224
|
+
* `assertClientMetadataUrl`: "client_id URL must use https"). So the review
|
|
225
|
+
* verbs are not refused here, and an app that asks for them can run on the mock.
|
|
226
|
+
*/
|
|
227
|
+
function isMockModeOrigin(origin: string): boolean {
|
|
228
|
+
const url = new URL(origin);
|
|
229
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* One `synap_requests[].permission`, refused here exactly when the pod would
|
|
234
|
+
* refuse it: `<subject>[.<kind>].<action>`, lowercase, a kind only on
|
|
235
|
+
* `entity`, an action in the pod's grant actions (the review verbs only for a
|
|
236
|
+
* Synap app, or on mock mode's loopback origin). Stricter on one point: no `*`
|
|
237
|
+
* at all (`can()` matches exactly).
|
|
238
|
+
*/
|
|
239
|
+
function assertAppPermission(permission: string, origin: string): void {
|
|
240
|
+
const segs = permission.split(".");
|
|
241
|
+
const fail = (why: string) => {
|
|
242
|
+
throw new ConnectError("misconfigured", `requests: "${permission}" ${why}`);
|
|
243
|
+
};
|
|
244
|
+
if (segs.length < 2 || segs.length > 3 || segs.some((seg) => !PERMISSION_SEGMENT_RE.test(seg))) {
|
|
245
|
+
fail("is not <subject>[.<kind>].<action> in lowercase (no *)");
|
|
246
|
+
}
|
|
247
|
+
if (segs.length === 3 && segs[0] !== "entity") fail(`names a kind, but "${segs[0]}" has none (only entity does)`);
|
|
248
|
+
if (SYNAP_APP_REVIEW_PERMISSIONS.includes(permission) && (isSynapAppOrigin(origin) || isMockModeOrigin(origin))) return;
|
|
249
|
+
if (!GRANT_ACTIONS.includes(segs[segs.length - 1])) {
|
|
250
|
+
fail(`asks for "${segs[segs.length - 1]}", which an app cannot be granted (read, create, update, delete)`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
173
253
|
|
|
174
254
|
function appOriginOf(appUrl: string): { origin: string; secure: boolean } {
|
|
175
255
|
let url: URL;
|
|
@@ -179,8 +259,11 @@ function appOriginOf(appUrl: string): { origin: string; secure: boolean } {
|
|
|
179
259
|
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
180
260
|
}
|
|
181
261
|
const secure = url.protocol === "https:";
|
|
182
|
-
if (!secure && !(url.protocol === "http:" &&
|
|
183
|
-
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
262
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) {
|
|
263
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost, for the SDK's mock pod)");
|
|
264
|
+
}
|
|
265
|
+
if (secure && isIpLiteral(url.hostname)) {
|
|
266
|
+
throw new ConnectError("misconfigured", "appUrl must use a domain name: the pod refuses an IP address client_id");
|
|
184
267
|
}
|
|
185
268
|
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
186
269
|
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
@@ -194,7 +277,12 @@ function assertPath(name: string, path: string): void {
|
|
|
194
277
|
}
|
|
195
278
|
}
|
|
196
279
|
|
|
197
|
-
/**
|
|
280
|
+
/**
|
|
281
|
+
* The app's client metadata document (its client_id URL serves it). Refuses,
|
|
282
|
+
* at setup, every document the pod would refuse at sign-in: the permissions
|
|
283
|
+
* (`assertAppPermission`), at most 32 requests counted as sent, duplicates
|
|
284
|
+
* included, an IP address origin, the 5 KB cap.
|
|
285
|
+
*/
|
|
198
286
|
export function buildClientMetadata(opts: {
|
|
199
287
|
appUrl: string;
|
|
200
288
|
clientName: string;
|
|
@@ -207,9 +295,13 @@ export function buildClientMetadata(opts: {
|
|
|
207
295
|
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
208
296
|
const clientName = opts.clientName.trim();
|
|
209
297
|
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
298
|
+
// The pod counts the entries as sent, before it drops duplicates.
|
|
299
|
+
if (opts.requests.length === 0 || opts.requests.length > MAX_REQUESTS) {
|
|
300
|
+
throw new ConnectError("misconfigured", `requests must hold 1-${MAX_REQUESTS} entries (duplicates count)`);
|
|
301
|
+
}
|
|
210
302
|
assertPermissions(opts.requests.map((r) => r.permission));
|
|
211
303
|
for (const r of opts.requests) {
|
|
212
|
-
|
|
304
|
+
assertAppPermission(r.permission, origin);
|
|
213
305
|
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
214
306
|
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
215
307
|
}
|
|
@@ -335,7 +427,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
335
427
|
permissions,
|
|
336
428
|
secret: config.secret,
|
|
337
429
|
resource: config.resource,
|
|
338
|
-
spaceHint: opts.space &&
|
|
430
|
+
spaceHint: opts.space && SPACE_HINT_RE.test(opts.space) ? opts.space : null,
|
|
339
431
|
next: opts.next,
|
|
340
432
|
prompt: opts.prompt,
|
|
341
433
|
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
@@ -422,7 +514,7 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
422
514
|
clientId,
|
|
423
515
|
secret: config.secret,
|
|
424
516
|
next,
|
|
425
|
-
spaceHint: space &&
|
|
517
|
+
spaceHint: space && SPACE_HINT_RE.test(space) ? space : null,
|
|
426
518
|
prompt: formField(form, "prompt") === "consent" ? "consent" : null,
|
|
427
519
|
now,
|
|
428
520
|
});
|
|
@@ -459,7 +551,10 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
459
551
|
}
|
|
460
552
|
}
|
|
461
553
|
|
|
462
|
-
|
|
554
|
+
/** Refresh tokens whose refresh failed while their access token still worked ("kept"): not asked again for a minute. */
|
|
555
|
+
const keptFailures = new Map<string, { error: ConnectError; at: number }>();
|
|
556
|
+
|
|
557
|
+
async function refreshIfNeeded(request: Request, opts: RefreshOptions = {}): Promise<RefreshOutcome> {
|
|
463
558
|
const connection = await store.read(request);
|
|
464
559
|
const unchanged = (status: RefreshStatus, conn: PodConnection | null, error?: ConnectError): RefreshOutcome => ({
|
|
465
560
|
status,
|
|
@@ -474,12 +569,16 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
474
569
|
}
|
|
475
570
|
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
476
571
|
const t = now();
|
|
477
|
-
|
|
478
|
-
|
|
572
|
+
// Forced: the pod already refused the access token, whatever its expiry says.
|
|
573
|
+
const accessLives = !opts.force && connection.accessExpiresAt > t;
|
|
574
|
+
if (!opts.force && connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
479
575
|
if (!connection.refreshToken) {
|
|
480
|
-
// The pod issued none
|
|
576
|
+
// The pod issued none: the connection ends with its access token.
|
|
481
577
|
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
482
578
|
}
|
|
579
|
+
for (const [token, f] of keptFailures) if (t - f.at >= KEPT_RETRY_MS) keptFailures.delete(token);
|
|
580
|
+
const keptFailure = accessLives ? keptFailures.get(connection.refreshToken) : undefined;
|
|
581
|
+
if (keptFailure) return unchanged("kept", connection, keptFailure.error);
|
|
483
582
|
let next: PodConnection;
|
|
484
583
|
try {
|
|
485
584
|
next = await refreshConnection(connection, flow);
|
|
@@ -487,13 +586,24 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
487
586
|
const e = fail(err, "refresh");
|
|
488
587
|
// LOST-RACE SAFE: another instance may have redeemed the token at the
|
|
489
588
|
// same moment; the pod refuses the concurrent loser without a family
|
|
490
|
-
// revoke
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
if (
|
|
589
|
+
// revoke, and the access token we hold is still good. Keep it and set
|
|
590
|
+
// NOTHING, so the winner's cookie stands. (Within one process the race
|
|
591
|
+
// never reaches the pod: `refreshConnection` is single-flight.) Never
|
|
592
|
+
// clear the cookie here either: a clear racing the winner's Set-Cookie
|
|
593
|
+
// would sign the person out. The failure is remembered for a minute, so
|
|
594
|
+
// each page view does not ask the pod again.
|
|
595
|
+
if (accessLives) {
|
|
596
|
+
keptFailures.set(connection.refreshToken, { error: e, at: t });
|
|
597
|
+
return unchanged("kept", connection, e);
|
|
598
|
+
}
|
|
599
|
+
// Spent and refused for good: clear the session cookie, so every later
|
|
600
|
+
// page view does not post the dead token to /token again (that budget is
|
|
601
|
+
// per caller IP: the app server, shared by all its visitors). The marker
|
|
602
|
+
// stays: the person still reads "expired, connect again to <pod>".
|
|
603
|
+
if (e.code === "revoked" || e.code === "expired" || e.code === "pod_moved") {
|
|
604
|
+
const cleared = await store.clear(request).catch(() => [] as string[]);
|
|
605
|
+
return { status: e.code === "revoked" ? "revoked" : "expired", connection: null, setCookies: cleared, cookieHeader: applySetCookies(request.headers.get("cookie"), cleared), error: e };
|
|
606
|
+
}
|
|
497
607
|
return unchanged("unavailable", null, e);
|
|
498
608
|
}
|
|
499
609
|
// PERSIST BEFORE USE: the old refresh token is spent now; only a stored
|
|
@@ -517,6 +627,30 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
517
627
|
};
|
|
518
628
|
}
|
|
519
629
|
|
|
630
|
+
/**
|
|
631
|
+
* The client's renewal after a 401: rotate (single-flight per
|
|
632
|
+
* process), PERSIST through `persist`, then hand the new connection over.
|
|
633
|
+
* A rotation that cannot be persisted is not used (persist before use).
|
|
634
|
+
*/
|
|
635
|
+
function renewer(persist: NonNullable<PodAppClientOptions["persist"]>, source: Request | string | null | undefined) {
|
|
636
|
+
return async (connection: PodConnection): Promise<PodConnection> => {
|
|
637
|
+
let next: PodConnection;
|
|
638
|
+
try {
|
|
639
|
+
next = await refreshConnection(connection, flow);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
throw fail(err, "refresh");
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
const cookies = await store.write(next, asRequest(source ?? null));
|
|
645
|
+
await persist([...cookies, marker(next.podUrl)]);
|
|
646
|
+
} catch (err) {
|
|
647
|
+
fail(err, "refresh");
|
|
648
|
+
throw new ConnectError("expired", "The renewed connection could not be stored; connect again");
|
|
649
|
+
}
|
|
650
|
+
return next;
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
520
654
|
return {
|
|
521
655
|
clientId,
|
|
522
656
|
redirectUri,
|
|
@@ -530,6 +664,14 @@ export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
|
530
664
|
return (await store.read(request)) ? null : lapsedIn(request);
|
|
531
665
|
},
|
|
532
666
|
refreshIfNeeded,
|
|
533
|
-
client
|
|
667
|
+
client(connection, opts = {}) {
|
|
668
|
+
const { persist, request, ...clientOpts } = opts;
|
|
669
|
+
return createPodClient(connection, {
|
|
670
|
+
fetch: config.fetch,
|
|
671
|
+
now: config.now,
|
|
672
|
+
...(persist ? { renew: renewer(persist, request) } : {}),
|
|
673
|
+
...clientOpts,
|
|
674
|
+
});
|
|
675
|
+
},
|
|
534
676
|
};
|
|
535
677
|
}
|