@synap-core/sdk 1.1.0 → 1.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.
Files changed (63) hide show
  1. package/README.md +138 -6
  2. package/dist/chunk-J7XHAAFJ.js +79 -0
  3. package/dist/chunk-J7XHAAFJ.js.map +1 -0
  4. package/dist/client-CVUT1Mcf.d.cts +253 -0
  5. package/dist/client-CVUT1Mcf.d.ts +253 -0
  6. package/dist/connect.browser.cjs +22 -0
  7. package/dist/connect.browser.cjs.map +1 -0
  8. package/dist/connect.browser.d.cts +2 -0
  9. package/dist/connect.browser.d.ts +2 -0
  10. package/dist/connect.browser.js +5 -0
  11. package/dist/connect.browser.js.map +1 -0
  12. package/dist/connect.cjs +1206 -0
  13. package/dist/connect.cjs.map +1 -0
  14. package/dist/connect.d.cts +442 -0
  15. package/dist/connect.d.ts +442 -0
  16. package/dist/connect.js +1092 -0
  17. package/dist/connect.js.map +1 -0
  18. package/dist/index.cjs +48 -6
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +118 -7
  21. package/dist/index.d.ts +118 -7
  22. package/dist/index.js +41 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/public.cjs +627 -0
  25. package/dist/public.cjs.map +1 -0
  26. package/dist/public.d.cts +228 -0
  27. package/dist/public.d.ts +228 -0
  28. package/dist/public.js +578 -0
  29. package/dist/public.js.map +1 -0
  30. package/dist/testing.cjs +671 -0
  31. package/dist/testing.cjs.map +1 -0
  32. package/dist/testing.d.cts +181 -0
  33. package/dist/testing.d.ts +181 -0
  34. package/dist/testing.js +614 -0
  35. package/dist/testing.js.map +1 -0
  36. package/dist/types-DpqPNaaa.d.cts +76 -0
  37. package/dist/types-DpqPNaaa.d.ts +76 -0
  38. package/package.json +16 -3
  39. package/src/client.ts +24 -7
  40. package/src/connect/auth.ts +405 -0
  41. package/src/connect/browser.ts +10 -0
  42. package/src/connect/client.ts +193 -0
  43. package/src/connect/discovery.ts +262 -0
  44. package/src/connect/encoding.ts +93 -0
  45. package/src/connect/errors.ts +115 -0
  46. package/src/connect/flow.ts +449 -0
  47. package/src/connect/guard.ts +25 -0
  48. package/src/connect/index.ts +59 -0
  49. package/src/connect/seal.ts +119 -0
  50. package/src/connect/store.ts +151 -0
  51. package/src/connect/types.ts +80 -0
  52. package/src/index.ts +13 -0
  53. package/src/public/client.ts +579 -0
  54. package/src/public/form-model.ts +210 -0
  55. package/src/public/index.ts +12 -0
  56. package/src/public/safe-markdown.ts +114 -0
  57. package/src/public/share-model.ts +47 -0
  58. package/src/public/site-model.ts +217 -0
  59. package/src/templates.ts +122 -0
  60. package/src/testing/auth-server.ts +380 -0
  61. package/src/testing/fixtures.ts +132 -0
  62. package/src/testing/index.ts +359 -0
  63. package/src/public.ts +0 -262
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Pod discovery (spec 17 §6.3): a typed address or a launch `iss` → the pod's
3
+ * authorization server metadata (RFC 8414), validated before any redirect.
4
+ *
5
+ * - the metadata's `issuer` must equal the address it was fetched for, byte
6
+ * for byte (RFC 8414 §3.3): a document naming another issuer is not used;
7
+ * - it must advertise CIMD clients, `iss` on every authorization response
8
+ * (RFC 9207) and PKCE S256, else the pod is too old for app sign-in;
9
+ * - every endpoint must live on the issuer's own origin.
10
+ *
11
+ * The fetch runs on the app's server with an address a visitor typed, so it is
12
+ * fenced: https only (http only on loopback when `allowInsecureLoopback`), no
13
+ * IP literal outside public space, no single-label or `.local`/`.internal`
14
+ * host, no redirect followed, 5 s, 64 KB. Residual: a public DNS name that
15
+ * resolves to a private address cannot be refused without a resolver; a
16
+ * deployment that cares adds egress rules.
17
+ *
18
+ * Connecting through Synap Cloud (the CP pod chooser, wave C1) is not
19
+ * available yet: `cloudConnectAvailable()` is false and `startCloudConnect`
20
+ * throws `cp_unavailable`.
21
+ */
22
+ import { ConnectError, retryAfterSeconds } from "./errors";
23
+ import type { PodIssuer } from "./types";
24
+
25
+ const METADATA_PATH = "/.well-known/oauth-authorization-server";
26
+ const TIMEOUT_MS = 5_000;
27
+ const MAX_METADATA_BYTES = 64 * 1024;
28
+
29
+ export interface DiscoverOptions {
30
+ fetch?: typeof fetch;
31
+ /** Allow `http://localhost` (and 127.0.0.1, [::1]) pods: development only. */
32
+ allowInsecureLoopback?: boolean;
33
+ now?: () => number;
34
+ }
35
+
36
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
37
+
38
+ function isPrivateIpv4(host: string): boolean {
39
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
40
+ if (!m) return false;
41
+ const [a, b] = [Number(m[1]), Number(m[2])];
42
+ return (
43
+ a === 0 ||
44
+ a === 10 ||
45
+ a === 127 ||
46
+ (a === 100 && b >= 64 && b <= 127) ||
47
+ (a === 169 && b === 254) ||
48
+ (a === 172 && b >= 16 && b <= 31) ||
49
+ (a === 192 && b === 168) ||
50
+ a >= 224
51
+ );
52
+ }
53
+
54
+ /**
55
+ * The canonical issuer for a typed address, or a ConnectError. Adds `https://`
56
+ * when no scheme is given; drops a trailing slash.
57
+ */
58
+ export function canonicalPodUrl(input: string, opts: { allowInsecureLoopback?: boolean } = {}): string {
59
+ const raw = input.trim();
60
+ if (!raw || raw.length > 512) throw new ConnectError("invalid_pod_address", "Empty or overlong pod address");
61
+ let url: URL;
62
+ try {
63
+ url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
64
+ } catch {
65
+ throw new ConnectError("invalid_pod_address", "Not a URL");
66
+ }
67
+ const host = url.hostname.toLowerCase();
68
+ const loopback = LOOPBACK_HOSTS.has(host); // WHATWG keeps the brackets: "[::1]"
69
+ if (url.protocol === "http:" && !(loopback && opts.allowInsecureLoopback)) {
70
+ throw new ConnectError("invalid_pod_address", "A pod address must use https");
71
+ }
72
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
73
+ throw new ConnectError("invalid_pod_address", "A pod address must use https");
74
+ }
75
+ if (url.username || url.password || url.search || url.hash) {
76
+ throw new ConnectError("invalid_pod_address", "A pod address has no credentials, query or fragment");
77
+ }
78
+ if (!(loopback && opts.allowInsecureLoopback)) {
79
+ if (loopback || host.startsWith("[") || isPrivateIpv4(host) || /^\d+$/.test(host)) {
80
+ throw new ConnectError("invalid_pod_address", "A pod address must be a public host name");
81
+ }
82
+ if (!host.includes(".") || /\.(local|internal|localhost|lan|home\.arpa)$/.test(host)) {
83
+ throw new ConnectError("invalid_pod_address", "A pod address must be a public host name");
84
+ }
85
+ }
86
+ const path = url.pathname.replace(/\/+$/, "");
87
+ return `${url.origin}${path}`;
88
+ }
89
+
90
+ function metadataUrl(issuer: string): string {
91
+ const url = new URL(issuer);
92
+ const path = url.pathname === "/" ? "" : url.pathname;
93
+ return `${url.origin}${METADATA_PATH}${path}`;
94
+ }
95
+
96
+ function sameOriginEndpoint(value: unknown, issuer: string): string | null {
97
+ if (typeof value !== "string") return null;
98
+ try {
99
+ const url = new URL(value);
100
+ return url.origin === new URL(issuer).origin && !url.username && !url.hash ? url.href : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ async function readJsonCapped(res: Response): Promise<unknown> {
107
+ const text = await res.text();
108
+ if (text.length > MAX_METADATA_BYTES) throw new ConnectError("not_a_pod", "Metadata document too large");
109
+ try {
110
+ return JSON.parse(text);
111
+ } catch {
112
+ throw new ConnectError("not_a_pod", "Metadata is not JSON");
113
+ }
114
+ }
115
+
116
+ /** Fetch and validate one candidate issuer's metadata. */
117
+ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<PodIssuer> {
118
+ const doFetch = opts.fetch ?? fetch;
119
+ let res: Response;
120
+ try {
121
+ res = await doFetch(metadataUrl(issuer), {
122
+ method: "GET",
123
+ headers: { Accept: "application/json" },
124
+ redirect: "manual",
125
+ signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
126
+ });
127
+ } catch {
128
+ throw new ConnectError("pod_unreachable", "The pod did not answer");
129
+ }
130
+ if (res.status === 429) {
131
+ throw new ConnectError("rate_limited", "The pod asked to slow down", {
132
+ retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
133
+ });
134
+ }
135
+ if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
136
+ if (res.status !== 200) throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
137
+ const doc = await readJsonCapped(res);
138
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
139
+ const m = doc as Record<string, unknown>;
140
+
141
+ // RFC 8414 §3.3 — the mix-up defence at discovery time.
142
+ if (m.issuer !== issuer) {
143
+ throw new ConnectError("not_a_pod", "The metadata names a different issuer than the address it was fetched from");
144
+ }
145
+ const list = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : null);
146
+ const pkce = list(m.code_challenge_methods_supported);
147
+ const responseTypes = list(m.response_types_supported);
148
+ if (
149
+ m.authorization_response_iss_parameter_supported !== true ||
150
+ m.client_id_metadata_document_supported !== true ||
151
+ !pkce?.includes("S256") ||
152
+ (responseTypes !== null && !responseTypes.includes("code"))
153
+ ) {
154
+ throw new ConnectError("pod_unsupported", "This pod does not support app sign-in (CIMD, iss, S256)");
155
+ }
156
+ const authorizationEndpoint = sameOriginEndpoint(m.authorization_endpoint, issuer);
157
+ const tokenEndpoint = sameOriginEndpoint(m.token_endpoint, issuer);
158
+ if (!authorizationEndpoint || !tokenEndpoint) {
159
+ throw new ConnectError("pod_unsupported", "The pod's endpoints are missing or not on its own origin");
160
+ }
161
+ return {
162
+ issuer,
163
+ authorizationEndpoint,
164
+ tokenEndpoint,
165
+ revocationEndpoint: sameOriginEndpoint(m.revocation_endpoint, issuer),
166
+ refreshSupported: list(m.grant_types_supported)?.includes("refresh_token") ?? false,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * A typed pod address (or a launch `iss`) → its validated authorization server.
172
+ * Tries the address, then `pod.<host>` (so "antoine.synap.live" finds
173
+ * "pod.antoine.synap.live").
174
+ */
175
+ export async function discoverPod(input: string, opts: DiscoverOptions = {}): Promise<PodIssuer> {
176
+ const first = canonicalPodUrl(input, opts);
177
+ try {
178
+ return await discoverIssuer(first, opts);
179
+ } catch (err) {
180
+ const url = new URL(first);
181
+ const retry =
182
+ err instanceof ConnectError &&
183
+ (err.code === "not_a_pod" || err.code === "pod_unreachable") &&
184
+ !url.hostname.startsWith("pod.") &&
185
+ url.pathname === "/" &&
186
+ url.hostname.includes(".");
187
+ if (!retry) throw err;
188
+ const second = `${url.protocol}//pod.${url.host}`;
189
+ try {
190
+ return await discoverIssuer(second, opts);
191
+ } catch (err2) {
192
+ // A pod that is there but too old says more than "nothing answered".
193
+ throw err2 instanceof ConnectError && err2.code === "pod_unsupported" ? err2 : err;
194
+ }
195
+ }
196
+ }
197
+
198
+ /** Whether "Continue with Synap Cloud" can be offered. False until the CP pod chooser (C1) ships. */
199
+ export function cloudConnectAvailable(): boolean {
200
+ return false;
201
+ }
202
+
203
+ /** The Synap Cloud pod chooser (C1). Not available yet: always throws `cp_unavailable`. */
204
+ export function startCloudConnect(_opts: { clientId: string; state?: string }): never {
205
+ throw new ConnectError("cp_unavailable", "cp: not available — connecting through Synap Cloud is not built yet; use a pod address");
206
+ }
207
+
208
+ /**
209
+ * A same-origin path to return to after connecting, or null. Accepts a
210
+ * relative path (never `//host` or `/\host`) or an absolute URL on the app's
211
+ * own origin; anything else is dropped (no open redirect).
212
+ */
213
+ export function safeNextPath(value: string | null | undefined, appOrigin: string): string | null {
214
+ if (!value || value.length > 2048) return null;
215
+ if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) {
216
+ try {
217
+ const url = new URL(value, appOrigin);
218
+ return url.origin === appOrigin ? `${url.pathname}${url.search}${url.hash}` : null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+ try {
224
+ const url = new URL(value);
225
+ return url.origin === appOrigin ? `${url.pathname}${url.search}${url.hash}` : null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ export interface LaunchHint {
232
+ /** The pod issuer the launcher named (`iss`), to pre-fill and CONFIRM (T6), never to auto-connect. */
233
+ pod: string | null;
234
+ /** Where to land after connecting (`target_link_uri`), same origin only. */
235
+ next: string | null;
236
+ /** A space to preselect on consent (`synap_space_hint`). */
237
+ spaceHint: string | null;
238
+ }
239
+
240
+ /**
241
+ * Read a third-party-initiated login (OIDC Core §4, spec 17 §7.2): the browser
242
+ * opens the app's `initiate_login_uri?iss=…&target_link_uri=…`. One parser for
243
+ * the browser launch and, later, the Synap Cloud chooser's return.
244
+ */
245
+ export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allowInsecureLoopback?: boolean } = {}): LaunchHint {
246
+ const params = new URL(url).searchParams;
247
+ let pod: string | null = null;
248
+ const iss = params.get("iss");
249
+ if (iss) {
250
+ try {
251
+ pod = canonicalPodUrl(iss, opts);
252
+ } catch {
253
+ pod = null;
254
+ }
255
+ }
256
+ const hint = params.get("synap_space_hint");
257
+ return {
258
+ pod,
259
+ next: safeNextPath(params.get("target_link_uri"), appOrigin),
260
+ spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
261
+ };
262
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Web-standard primitives (WebCrypto, TextEncoder, btoa/atob): the same code
3
+ * runs on Node 18+, edge runtimes and workers. No Node import.
4
+ */
5
+
6
+ const encoder = new TextEncoder();
7
+ const decoder = new TextDecoder();
8
+
9
+ /** `Uint8Array<ArrayBuffer>`: what WebCrypto's `BufferSource` takes under TS 5.7+ lib types. */
10
+ export type Bytes = Uint8Array<ArrayBuffer>;
11
+
12
+ export function utf8(text: string): Bytes {
13
+ return encoder.encode(text);
14
+ }
15
+
16
+ export function fromUtf8(bytes: Uint8Array): string {
17
+ return decoder.decode(bytes);
18
+ }
19
+
20
+ export function base64url(bytes: Uint8Array): string {
21
+ let binary = "";
22
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
23
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
24
+ }
25
+
26
+ /** Null when `text` is not base64url. */
27
+ export function fromBase64url(text: string): Bytes | null {
28
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
29
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4);
30
+ let binary: string;
31
+ try {
32
+ binary = atob(padded);
33
+ } catch {
34
+ return null;
35
+ }
36
+ const out = new Uint8Array(binary.length);
37
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
38
+ return out;
39
+ }
40
+
41
+ export function randomBytes(length: number): Bytes {
42
+ const out = new Uint8Array(length);
43
+ crypto.getRandomValues(out);
44
+ return out;
45
+ }
46
+
47
+ /** 32 random bytes, base64url (43 chars): a state value or a PKCE verifier. */
48
+ export function randomToken(): string {
49
+ return base64url(randomBytes(32));
50
+ }
51
+
52
+ /** RFC 7636 §4.2: `BASE64URL(SHA256(ASCII(code_verifier)))`. */
53
+ export async function pkceChallenge(verifier: string): Promise<string> {
54
+ const digest = await crypto.subtle.digest("SHA-256", utf8(verifier));
55
+ return base64url(new Uint8Array(digest));
56
+ }
57
+
58
+ /** Compare two strings without an early exit on the first differing byte. */
59
+ export function constantTimeEqual(a: string, b: string): boolean {
60
+ const x = utf8(a);
61
+ const y = utf8(b);
62
+ let diff = x.length ^ y.length;
63
+ for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
64
+ return diff === 0;
65
+ }
66
+
67
+ /** The request's cookies by name (the first value wins, as browsers order the most specific first). */
68
+ export function parseCookies(header: string | null): Map<string, string> {
69
+ const out = new Map<string, string>();
70
+ if (!header) return out;
71
+ for (const part of header.split(";")) {
72
+ const eq = part.indexOf("=");
73
+ if (eq < 0) continue;
74
+ const name = part.slice(0, eq).trim();
75
+ if (!name || out.has(name)) continue;
76
+ let value = part.slice(eq + 1).trim();
77
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
78
+ out.set(name, value);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export interface CookieAttributes {
84
+ maxAgeSec: number;
85
+ secure: boolean;
86
+ }
87
+
88
+ /** `HttpOnly; SameSite=Lax; Path=/` always; `Secure` on https. Never a `Domain`. */
89
+ export function serializeCookie(name: string, value: string, attrs: CookieAttributes): string {
90
+ const parts = [`${name}=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${Math.max(0, Math.floor(attrs.maxAgeSec))}`];
91
+ if (attrs.secure) parts.push("Secure");
92
+ return parts.join("; ");
93
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The one error type of `@synap-core/sdk/connect`. `code` is the contract a UI
3
+ * branches on; `message` and `detail` are for the developer's logs, never for
4
+ * the person (a sign-in failure page says one sentence per code, never why).
5
+ */
6
+
7
+ export type ConnectErrorCode =
8
+ /** The typed pod address is not a usable URL. */
9
+ | "invalid_pod_address"
10
+ /** Something answered, but it is not a Synap pod (no AS metadata). */
11
+ | "not_a_pod"
12
+ /** The pod did not answer (network, timeout, 5xx). */
13
+ | "pod_unreachable"
14
+ /** A Synap pod, but too old for app sign-in (no CIMD, no `iss`, no S256). */
15
+ | "pod_unsupported"
16
+ /** Connecting through Synap Cloud is not available yet. */
17
+ | "cp_unavailable"
18
+ /** The person said no on the consent screen. */
19
+ | "denied"
20
+ /** No sign-in in progress here, or it is older than 10 minutes. */
21
+ | "expired_transaction"
22
+ /** The answer does not belong to the sign-in this browser started (`state` or `iss`). */
23
+ | "mismatch"
24
+ /** The app's own setup is wrong (client metadata, scopes, secret, redirect). */
25
+ | "misconfigured"
26
+ /** The pod asked to slow down; see `retryAfterSec`. */
27
+ | "rate_limited"
28
+ /** The connection lapsed (refresh token idle past its life, or none was issued). */
29
+ | "expired"
30
+ /** The pod refused the connection's refresh token while it should still live. */
31
+ | "revoked"
32
+ /** The pod answered something the SDK cannot use. */
33
+ | "server_error";
34
+
35
+ export class ConnectError extends Error {
36
+ readonly code: ConnectErrorCode;
37
+ /** Seconds to wait, from `Retry-After`, when `code` is `rate_limited`. */
38
+ readonly retryAfterSec?: number;
39
+ /** The OAuth error code the pod sent, when there was one (logs only). */
40
+ readonly oauthError?: string;
41
+
42
+ constructor(
43
+ code: ConnectErrorCode,
44
+ message: string,
45
+ extra: { retryAfterSec?: number; oauthError?: string } = {},
46
+ ) {
47
+ super(message);
48
+ this.name = "ConnectError";
49
+ this.code = code;
50
+ if (extra.retryAfterSec !== undefined) this.retryAfterSec = extra.retryAfterSec;
51
+ if (extra.oauthError !== undefined) this.oauthError = extra.oauthError;
52
+ }
53
+ }
54
+
55
+ export function isConnectError(err: unknown): err is ConnectError {
56
+ return err instanceof ConnectError;
57
+ }
58
+
59
+ export const CONNECT_ERROR_CODES: readonly ConnectErrorCode[] = [
60
+ "invalid_pod_address",
61
+ "not_a_pod",
62
+ "pod_unreachable",
63
+ "pod_unsupported",
64
+ "cp_unavailable",
65
+ "denied",
66
+ "expired_transaction",
67
+ "mismatch",
68
+ "misconfigured",
69
+ "rate_limited",
70
+ "expired",
71
+ "revoked",
72
+ "server_error",
73
+ ];
74
+
75
+ /** Parse an untrusted `?error=` value back into a code (null when unknown). */
76
+ export function parseConnectErrorCode(value: string | null | undefined): ConnectErrorCode | null {
77
+ return (CONNECT_ERROR_CODES as readonly string[]).includes(value ?? "") ? (value as ConnectErrorCode) : null;
78
+ }
79
+
80
+ /**
81
+ * RFC 6749 §4.1.2.1 / §5.2 error codes → ours. `invalid_grant` at the token
82
+ * endpoint during a callback means the code was used, expired or does not
83
+ * match: for the person that is "the link expired".
84
+ */
85
+ export function fromOAuthError(oauthError: string, during: "authorize" | "token" | "refresh"): ConnectErrorCode {
86
+ switch (oauthError) {
87
+ case "access_denied":
88
+ return "denied";
89
+ case "temporarily_unavailable":
90
+ case "server_error":
91
+ return "pod_unreachable";
92
+ case "invalid_grant":
93
+ return during === "refresh" ? "revoked" : "expired_transaction";
94
+ case "invalid_request":
95
+ case "invalid_client":
96
+ case "invalid_scope":
97
+ case "invalid_target":
98
+ case "unauthorized_client":
99
+ case "unsupported_grant_type":
100
+ case "unsupported_response_type":
101
+ return "misconfigured";
102
+ default:
103
+ return "server_error";
104
+ }
105
+ }
106
+
107
+ /** `Retry-After` as whole seconds (delta-seconds or an HTTP date); undefined when unusable. */
108
+ export function retryAfterSeconds(header: string | null, now: number): number | undefined {
109
+ if (!header) return undefined;
110
+ const trimmed = header.trim();
111
+ if (/^\d{1,9}$/.test(trimmed)) return Number(trimmed);
112
+ const at = Date.parse(trimmed);
113
+ if (Number.isNaN(at)) return undefined;
114
+ return Math.max(0, Math.ceil((at - now) / 1000));
115
+ }