@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/src/connect/discovery.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pod discovery
|
|
3
|
-
*
|
|
2
|
+
* Pod discovery: a typed address or a launch `iss` → the pod's authorization
|
|
3
|
+
* server metadata (RFC 8414), validated before any redirect.
|
|
4
4
|
*
|
|
5
5
|
* - the metadata's `issuer` must equal the address it was fetched for, byte
|
|
6
6
|
* for byte (RFC 8414 §3.3): a document naming another issuer is not used;
|
|
@@ -11,13 +11,17 @@
|
|
|
11
11
|
* The fetch runs on the app's server with an address a visitor typed, so it is
|
|
12
12
|
* fenced: https only (http only on loopback when `allowInsecureLoopback`), no
|
|
13
13
|
* IP literal outside public space, no single-label or `.local`/`.internal`
|
|
14
|
-
* host, no redirect followed, 5 s, 64 KB
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* host, no redirect followed, 5 s, 64 KB counted on the stream. At most three
|
|
15
|
+
* addresses are tried per call (`discoverPod`). Residual: a public DNS name
|
|
16
|
+
* that resolves to a private address cannot be refused without a resolver; a
|
|
17
|
+
* deployment that cares adds egress rules, and an app rate-limits the route
|
|
18
|
+
* that calls this (it fetches an address a visitor chose).
|
|
17
19
|
*
|
|
18
|
-
* Connecting through Synap Cloud (the
|
|
20
|
+
* Connecting through Synap Cloud (the Control Plane's pod chooser) is ./cloud.ts.
|
|
19
21
|
*/
|
|
20
22
|
import { ConnectError, retryAfterSeconds } from "./errors";
|
|
23
|
+
import { readCapped, timeoutSignal } from "./http";
|
|
24
|
+
import { CLOUD_STATE_RE, LOOPBACK_HOSTS, SPACE_HINT_RE } from "./rules";
|
|
21
25
|
import type { PodIssuer } from "./types";
|
|
22
26
|
|
|
23
27
|
const METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
@@ -31,8 +35,6 @@ export interface DiscoverOptions {
|
|
|
31
35
|
now?: () => number;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
35
|
-
|
|
36
38
|
function isPrivateIpv4(host: string): boolean {
|
|
37
39
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
38
40
|
if (!m) return false;
|
|
@@ -102,10 +104,14 @@ function sameOriginEndpoint(value: unknown, issuer: string): string | null {
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
async function readJsonCapped(res: Response): Promise<unknown> {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
+
const read = await readCapped(res, MAX_METADATA_BYTES);
|
|
108
|
+
if (!read.ok) {
|
|
109
|
+
throw read.reason === "too_large"
|
|
110
|
+
? new ConnectError("not_a_pod", "Metadata document too large")
|
|
111
|
+
: new ConnectError("pod_unreachable", "The metadata answer broke off");
|
|
112
|
+
}
|
|
107
113
|
try {
|
|
108
|
-
return JSON.parse(text);
|
|
114
|
+
return JSON.parse(read.text);
|
|
109
115
|
} catch {
|
|
110
116
|
throw new ConnectError("not_a_pod", "Metadata is not JSON");
|
|
111
117
|
}
|
|
@@ -120,18 +126,25 @@ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<Po
|
|
|
120
126
|
method: "GET",
|
|
121
127
|
headers: { Accept: "application/json" },
|
|
122
128
|
redirect: "manual",
|
|
123
|
-
signal:
|
|
129
|
+
signal: timeoutSignal(TIMEOUT_MS),
|
|
124
130
|
});
|
|
125
131
|
} catch {
|
|
126
132
|
throw new ConnectError("pod_unreachable", "The pod did not answer");
|
|
127
133
|
}
|
|
134
|
+
if (res.status >= 300 && res.status < 400) {
|
|
135
|
+
await res.body?.cancel().catch(() => {});
|
|
136
|
+
throw new ConnectError("pod_moved", `The address answered ${res.status}: the pod moved (its new address is not followed)`);
|
|
137
|
+
}
|
|
128
138
|
if (res.status === 429) {
|
|
129
139
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
130
140
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
|
|
131
141
|
});
|
|
132
142
|
}
|
|
133
143
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
|
|
134
|
-
if (res.status !== 200)
|
|
144
|
+
if (res.status !== 200) {
|
|
145
|
+
await res.body?.cancel().catch(() => {});
|
|
146
|
+
throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
|
|
147
|
+
}
|
|
135
148
|
const doc = await readJsonCapped(res);
|
|
136
149
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
|
|
137
150
|
const m = doc as Record<string, unknown>;
|
|
@@ -165,32 +178,53 @@ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<Po
|
|
|
165
178
|
};
|
|
166
179
|
}
|
|
167
180
|
|
|
181
|
+
/** Synap Cloud's pod domain: pods are `<name>.pod.synap.live`; `<name>.synap.live` and `pod.<name>.synap.live` are older names. */
|
|
182
|
+
const CLOUD_DOMAIN = "synap.live";
|
|
183
|
+
const CLOUD_POD_ZONE = `pod.${CLOUD_DOMAIN}`;
|
|
184
|
+
const POD_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
185
|
+
|
|
186
|
+
/** The addresses to try after the typed one fails, in order (at most two). */
|
|
187
|
+
function fallbackIssuers(first: string): string[] {
|
|
188
|
+
const url = new URL(first);
|
|
189
|
+
const host = url.hostname;
|
|
190
|
+
if (url.pathname !== "/" || !host.includes(".") || host.endsWith(`.${CLOUD_POD_ZONE}`)) return [];
|
|
191
|
+
const label = host.endsWith(`.${CLOUD_DOMAIN}`) ? host.slice(0, -CLOUD_DOMAIN.length - 1) : null;
|
|
192
|
+
if (label !== null && POD_NAME_RE.test(label)) {
|
|
193
|
+
// A Synap Cloud name typed the old way: the pod's current name, then the old nested one.
|
|
194
|
+
return [`${url.protocol}//${label}.${CLOUD_POD_ZONE}`, `${url.protocol}//pod.${url.host}`];
|
|
195
|
+
}
|
|
196
|
+
return host.startsWith("pod.") ? [] : [`${url.protocol}//pod.${url.host}`];
|
|
197
|
+
}
|
|
198
|
+
|
|
168
199
|
/**
|
|
169
200
|
* A typed pod address (or a launch `iss`) → its validated authorization server.
|
|
170
|
-
* Tries the address, then
|
|
171
|
-
* "pod.
|
|
201
|
+
* Tries the address, then the pod names it may stand for: on Synap Cloud,
|
|
202
|
+
* "you.synap.live" finds "you.pod.synap.live" (and the older
|
|
203
|
+
* "pod.you.synap.live"); elsewhere, "example.com" finds "pod.example.com".
|
|
204
|
+
* A redirect is never followed: an old name that answers 308 counts as a
|
|
205
|
+
* miss (`pod_moved` when nothing else answers).
|
|
172
206
|
*/
|
|
173
207
|
export async function discoverPod(input: string, opts: DiscoverOptions = {}): Promise<PodIssuer> {
|
|
174
208
|
const first = canonicalPodUrl(input, opts);
|
|
209
|
+
let firstError: unknown;
|
|
175
210
|
try {
|
|
176
211
|
return await discoverIssuer(first, opts);
|
|
177
212
|
} catch (err) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (!retry) throw err;
|
|
186
|
-
const second = `${url.protocol}//pod.${url.host}`;
|
|
213
|
+
firstError = err;
|
|
214
|
+
}
|
|
215
|
+
const retryable = (err: unknown) =>
|
|
216
|
+
err instanceof ConnectError && (err.code === "not_a_pod" || err.code === "pod_unreachable" || err.code === "pod_moved");
|
|
217
|
+
if (!retryable(firstError)) throw firstError;
|
|
218
|
+
let tooOld: ConnectError | null = null;
|
|
219
|
+
for (const candidate of fallbackIssuers(first)) {
|
|
187
220
|
try {
|
|
188
|
-
return await discoverIssuer(
|
|
189
|
-
} catch (
|
|
221
|
+
return await discoverIssuer(candidate, opts);
|
|
222
|
+
} catch (err) {
|
|
190
223
|
// A pod that is there but too old says more than "nothing answered".
|
|
191
|
-
|
|
224
|
+
if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
|
|
192
225
|
}
|
|
193
226
|
}
|
|
227
|
+
throw tooOld ?? firstError;
|
|
194
228
|
}
|
|
195
229
|
|
|
196
230
|
/**
|
|
@@ -217,11 +251,11 @@ export function safeNextPath(value: string | null | undefined, appOrigin: string
|
|
|
217
251
|
}
|
|
218
252
|
|
|
219
253
|
export interface LaunchHint {
|
|
220
|
-
/** The pod issuer the launcher named (`iss`), to pre-fill and CONFIRM
|
|
254
|
+
/** The pod issuer the launcher named (`iss`), to pre-fill and CONFIRM, never to auto-connect. */
|
|
221
255
|
pod: string | null;
|
|
222
256
|
/** Where to land after connecting (`target_link_uri`), same origin only. */
|
|
223
257
|
next: string | null;
|
|
224
|
-
/** A space
|
|
258
|
+
/** A space hint for the pod's consent (`synap_space_hint`); a pod may ignore it. */
|
|
225
259
|
spaceHint: string | null;
|
|
226
260
|
/**
|
|
227
261
|
* The `state` of a Synap Cloud return (the CP pod chooser answers
|
|
@@ -235,7 +269,7 @@ export interface LaunchHint {
|
|
|
235
269
|
}
|
|
236
270
|
|
|
237
271
|
/**
|
|
238
|
-
* Read a third-party-initiated login (OIDC Core §4
|
|
272
|
+
* Read a third-party-initiated login (OIDC Core §4): the browser
|
|
239
273
|
* opens the app's `initiate_login_uri?iss=…&target_link_uri=…`. One parser for
|
|
240
274
|
* the browser launch and the Synap Cloud chooser's return (`cloudState`).
|
|
241
275
|
*/
|
|
@@ -255,9 +289,9 @@ export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allow
|
|
|
255
289
|
return {
|
|
256
290
|
pod,
|
|
257
291
|
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
258
|
-
spaceHint: hint &&
|
|
292
|
+
spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
|
|
259
293
|
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
260
294
|
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
261
|
-
cloudState: state &&
|
|
295
|
+
cloudState: state && CLOUD_STATE_RE.test(state) ? state : null,
|
|
262
296
|
};
|
|
263
297
|
}
|
package/src/connect/encoding.ts
CHANGED
package/src/connect/errors.ts
CHANGED
|
@@ -29,6 +29,12 @@ export type ConnectErrorCode =
|
|
|
29
29
|
| "expired"
|
|
30
30
|
/** The pod refused the connection's refresh token while it should still live. */
|
|
31
31
|
| "revoked"
|
|
32
|
+
/**
|
|
33
|
+
* The pod answered a redirect where it must answer itself (a moved pod
|
|
34
|
+
* answers 308 from its old name for a while): connect again with its new
|
|
35
|
+
* address. The new address is never taken from the redirect.
|
|
36
|
+
*/
|
|
37
|
+
| "pod_moved"
|
|
32
38
|
/** The pod answered something the SDK cannot use. */
|
|
33
39
|
| "server_error";
|
|
34
40
|
|
|
@@ -69,6 +75,7 @@ export const CONNECT_ERROR_CODES: readonly ConnectErrorCode[] = [
|
|
|
69
75
|
"rate_limited",
|
|
70
76
|
"expired",
|
|
71
77
|
"revoked",
|
|
78
|
+
"pod_moved",
|
|
72
79
|
"server_error",
|
|
73
80
|
];
|
|
74
81
|
|
package/src/connect/flow.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The OAuth 2.1 authorization-code flow of a pod app
|
|
2
|
+
* The OAuth 2.1 authorization-code flow of a pod app, as
|
|
3
3
|
* plain functions over a SEALED transaction string. `createPodAppAuth` wires
|
|
4
4
|
* them to cookies and route handlers; use these directly for another store.
|
|
5
5
|
*
|
|
@@ -18,15 +18,19 @@
|
|
|
18
18
|
import { ConnectError, fromOAuthError, retryAfterSeconds } from "./errors";
|
|
19
19
|
import { constantTimeEqual, pkceChallenge, randomToken } from "./encoding";
|
|
20
20
|
import { discoverPod, type DiscoverOptions } from "./discovery";
|
|
21
|
+
import { readCapped, timeoutSignal } from "./http";
|
|
22
|
+
import { LOOPBACK_HOSTS } from "./rules";
|
|
21
23
|
import { seal, unseal, type AppSecret } from "./seal";
|
|
22
|
-
import type { PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
|
|
24
|
+
import type { PodActsAs, PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
|
|
23
25
|
|
|
24
26
|
/** How long a started sign-in stays valid. */
|
|
25
27
|
export const TRANSACTION_TTL_MS = 10 * 60 * 1000;
|
|
26
|
-
/** The refresh token's idle life when the pod does not say (
|
|
28
|
+
/** The refresh token's idle life when the pod does not say (the pod's: 30 days). */
|
|
27
29
|
export const DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
30
|
const DEFAULT_ACCESS_TTL_SEC = 3600;
|
|
29
31
|
const TIMEOUT_MS = 10_000;
|
|
32
|
+
/** A token response is a few KB; more is not a token response. */
|
|
33
|
+
const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
30
34
|
|
|
31
35
|
/** A sealed `ConnectTransaction`: opaque, safe to put in an HttpOnly cookie. */
|
|
32
36
|
export type SealedTransaction = string & { readonly __sealedTransaction: true };
|
|
@@ -240,20 +244,30 @@ async function tokenRequest(
|
|
|
240
244
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
241
245
|
body: body.toString(),
|
|
242
246
|
redirect: "manual",
|
|
243
|
-
signal:
|
|
247
|
+
signal: timeoutSignal(TIMEOUT_MS),
|
|
244
248
|
});
|
|
245
249
|
} catch {
|
|
246
250
|
throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
|
|
247
251
|
}
|
|
252
|
+
if (res.status >= 300 && res.status < 400) {
|
|
253
|
+
await res.body?.cancel().catch(() => {});
|
|
254
|
+
throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
|
|
255
|
+
}
|
|
248
256
|
if (res.status === 429) {
|
|
249
257
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
250
258
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
|
|
251
259
|
});
|
|
252
260
|
}
|
|
253
261
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
|
|
262
|
+
const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
263
|
+
if (!read.ok) {
|
|
264
|
+
throw read.reason === "too_large"
|
|
265
|
+
? new ConnectError("server_error", "The token endpoint's answer is too large")
|
|
266
|
+
: new ConnectError("pod_unreachable", "The token endpoint's answer broke off");
|
|
267
|
+
}
|
|
254
268
|
let json: unknown;
|
|
255
269
|
try {
|
|
256
|
-
json =
|
|
270
|
+
json = JSON.parse(read.text);
|
|
257
271
|
} catch {
|
|
258
272
|
throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
|
|
259
273
|
}
|
|
@@ -270,15 +284,26 @@ function parseGrant(value: unknown): PodGrant[] | null {
|
|
|
270
284
|
const out: PodGrant[] = [];
|
|
271
285
|
for (const g of value) {
|
|
272
286
|
if (!g || typeof g !== "object") return null;
|
|
273
|
-
const { permission, workspaceId, workspaceName } = g as Record<string, unknown>;
|
|
287
|
+
const { permission, workspaceId, workspaceName, templateSlug } = g as Record<string, unknown>;
|
|
274
288
|
if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
|
|
275
|
-
|
|
289
|
+
const row: PodGrant = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
|
|
290
|
+
// The space's template, omitted when it came from none.
|
|
291
|
+
if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
|
|
292
|
+
out.push(row);
|
|
276
293
|
}
|
|
277
294
|
return out;
|
|
278
295
|
}
|
|
279
296
|
|
|
297
|
+
/**
|
|
298
|
+
* `synap_acts_as`: read from EVERY token response, never carried
|
|
299
|
+
* over from the previous one, so a pod that stops saying "person" (or never
|
|
300
|
+
* did) gives an agent connection.
|
|
301
|
+
*/
|
|
302
|
+
function parseActsAs(value: unknown): PodActsAs | undefined {
|
|
303
|
+
return value === "person" || value === "agent" ? value : undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
280
306
|
const APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
281
|
-
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
282
307
|
|
|
283
308
|
/**
|
|
284
309
|
* `synap_app` (Synap extension): the pod's id for this app connection and its
|
|
@@ -311,8 +336,8 @@ function parseUser(value: unknown): PodUser | null {
|
|
|
311
336
|
|
|
312
337
|
/**
|
|
313
338
|
* A token response → a connection. On a refresh, `previous` supplies what the
|
|
314
|
-
* response may omit (grant, user, app). `expires_in` is read as sent: a refresh
|
|
315
|
-
* answered within the pod's reuse grace
|
|
339
|
+
* response may omit (grant, user, app; never `actsAs`). `expires_in` is read as sent: a refresh
|
|
340
|
+
* answered within the pod's reuse grace hands back the live key the
|
|
316
341
|
* first redeem got, with less than an hour left. A refresh response WITHOUT a new
|
|
317
342
|
* refresh_token drops the old one: with single-use rotation, resending a
|
|
318
343
|
* consumed token reads as theft and revokes the whole family.
|
|
@@ -362,6 +387,8 @@ export function parseTokenResponse(
|
|
|
362
387
|
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
363
388
|
if (appId !== undefined) connection.appId = appId;
|
|
364
389
|
if (manageUrl !== undefined) connection.manageUrl = manageUrl;
|
|
390
|
+
const actsAs = parseActsAs(body.synap_acts_as);
|
|
391
|
+
if (actsAs !== undefined) connection.actsAs = actsAs;
|
|
365
392
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
366
393
|
connection.refreshToken = refresh_token;
|
|
367
394
|
const idle =
|
|
@@ -375,7 +402,7 @@ export function parseTokenResponse(
|
|
|
375
402
|
|
|
376
403
|
/**
|
|
377
404
|
* How long a rotation's result is remembered for its SPENT refresh token.
|
|
378
|
-
* The pod
|
|
405
|
+
* The pod refuses a concurrent second redeem softly, but a token
|
|
379
406
|
* presented AGAIN after its use is a reuse: the whole family and the live
|
|
380
407
|
* access key are revoked. A browser easily sends that replay: parallel
|
|
381
408
|
* requests, a second tab, a response it had not stored yet, all still
|
|
@@ -401,7 +428,7 @@ function pruneRotations(now: number): void {
|
|
|
401
428
|
|
|
402
429
|
/**
|
|
403
430
|
* Exchange the refresh token for a new access token and the rotated refresh
|
|
404
|
-
* token (
|
|
431
|
+
* token (single-use, 30 days from issue, its reach re-derived by the pod).
|
|
405
432
|
*
|
|
406
433
|
* SINGLE-FLIGHT per connection within this process: concurrent callers share
|
|
407
434
|
* one request, and for `ROTATION_MEMORY_MS` after it a caller still holding
|
|
@@ -415,7 +442,7 @@ function pruneRotations(now: number): void {
|
|
|
415
442
|
* (invalid_grant while it should live: spent, revoked, the app removed or
|
|
416
443
|
* narrowed to nothing, or a concurrent redeem lost), `misconfigured`
|
|
417
444
|
* (invalid_client, unauthorized_client), `rate_limited`, `pod_unreachable`,
|
|
418
|
-
* `server_error`.
|
|
445
|
+
* `pod_moved` (the token endpoint answered a redirect), `server_error`.
|
|
419
446
|
*/
|
|
420
447
|
export function refreshConnection(connection: PodConnection, opts: FlowOptions = {}): Promise<PodConnection> {
|
|
421
448
|
const clock = opts.now ?? Date.now;
|
|
@@ -478,8 +505,9 @@ export async function revokeConnection(connection: PodConnection, opts: FlowOpti
|
|
|
478
505
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
479
506
|
body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
|
|
480
507
|
redirect: "manual",
|
|
481
|
-
signal:
|
|
508
|
+
signal: timeoutSignal(TIMEOUT_MS),
|
|
482
509
|
});
|
|
510
|
+
await res.body?.cancel().catch(() => {});
|
|
483
511
|
if (res.status !== 200) revoked = false;
|
|
484
512
|
} catch {
|
|
485
513
|
revoked = false;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading answers from hosts the SDK does not control: a pod a visitor typed,
|
|
3
|
+
* its token endpoint, its Hub API. Bounded in time (`timeoutSignal`) and in
|
|
4
|
+
* bytes (`readCapped`): the bytes are counted on the stream, so an answer past
|
|
5
|
+
* the cap is cancelled there, never buffered whole first.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** An abort signal after `ms`, where the runtime has `AbortSignal.timeout` (workers and edge runtimes may not). */
|
|
9
|
+
export function timeoutSignal(ms: number): AbortSignal | undefined {
|
|
10
|
+
return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type CappedRead = { ok: true; text: string } | { ok: false; reason: "too_large" | "unreadable" };
|
|
14
|
+
|
|
15
|
+
/** The body as UTF-8 text, at most `maxBytes` bytes. */
|
|
16
|
+
export async function readCapped(res: Response, maxBytes: number): Promise<CappedRead> {
|
|
17
|
+
const declared = Number(res.headers.get("content-length"));
|
|
18
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
19
|
+
await res.body?.cancel().catch(() => {});
|
|
20
|
+
return { ok: false, reason: "too_large" };
|
|
21
|
+
}
|
|
22
|
+
if (!res.body) return { ok: true, text: "" };
|
|
23
|
+
const reader = res.body.getReader();
|
|
24
|
+
const decoder = new TextDecoder();
|
|
25
|
+
let total = 0;
|
|
26
|
+
let text = "";
|
|
27
|
+
try {
|
|
28
|
+
for (;;) {
|
|
29
|
+
const { done, value } = await reader.read();
|
|
30
|
+
if (done) break;
|
|
31
|
+
total += value.byteLength;
|
|
32
|
+
if (total > maxBytes) {
|
|
33
|
+
await reader.cancel().catch(() => {});
|
|
34
|
+
return { ok: false, reason: "too_large" };
|
|
35
|
+
}
|
|
36
|
+
text += decoder.decode(value, { stream: true });
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
return { ok: false, reason: "unreadable" };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, text: text + decoder.decode() };
|
|
42
|
+
}
|