@elysra/tungsten-runtime 0.0.0-stage → 0.1.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.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@elysra/tungsten-runtime`: the runtime for SDKs generated by tungsten
3
+ * (planning/06). Zero dependencies; runs on Node >= 22, Bun, Deno, Workers
4
+ * and browsers. Node-only adapters live in `@elysra/tungsten-runtime/node`.
5
+ */
6
+ export * from "./types.js";
7
+ export { ClientCore } from "./client.js";
8
+ export { ANY_EVENT, EventStream, type EventHandler, type StreamEvent, type StreamResult } from "./streams.js";
9
+ export { DEFAULT_MAX_EVENT_BYTES, SseParser, type SseEvent } from "./sse.js";
10
+ export { MemoryIdempotencyStore } from "./idempotency.js";
11
+ export { generatePkce, MemoryTokenStore, OAuthFlow, pkceChallenge, type AuthorizationUrlParams, type ExchangeCodeParams, type OAuthResult, type Pkce, type StoredToken, type TokenInfo, type TokenStore, } from "./oauth.js";
12
+ export { TungstenError, unwrap, throwing, type Throwing } from "./errors.js";
13
+ export { defineApi, defineMacro, defineOperation } from "./define.js";
14
+ export { evaluatePredicate } from "./expr.js";
15
+ export { DEFAULT_RETRYABLE } from "./envelope.js";
16
+ export { CONFIRMATION_TTL_MS } from "./confirm.js";
17
+ export { RUNTIME_VERSION } from "./version.js";
18
+ export { isBinaryInput, type BinaryInput, type NamedBinary } from "./util.js";
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * `@elysra/tungsten-runtime`: the runtime for SDKs generated by tungsten
4
+ * (planning/06). Zero dependencies; runs on Node >= 22, Bun, Deno, Workers
5
+ * and browsers. Node-only adapters live in `@elysra/tungsten-runtime/node`.
6
+ */
7
+ export * from "./types.js";
8
+ export { ClientCore } from "./client.js";
9
+ export { ANY_EVENT, EventStream } from "./streams.js";
10
+ export { DEFAULT_MAX_EVENT_BYTES, SseParser } from "./sse.js";
11
+ export { MemoryIdempotencyStore } from "./idempotency.js";
12
+ export { generatePkce, MemoryTokenStore, OAuthFlow, pkceChallenge, } from "./oauth.js";
13
+ export { TungstenError, unwrap, throwing } from "./errors.js";
14
+ export { defineApi, defineMacro, defineOperation } from "./define.js";
15
+ export { evaluatePredicate } from "./expr.js";
16
+ export { DEFAULT_RETRYABLE } from "./envelope.js";
17
+ export { CONFIRMATION_TTL_MS } from "./confirm.js";
18
+ export { RUNTIME_VERSION } from "./version.js";
19
+ export { isBinaryInput } from "./util.js";
package/dist/node.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { IdempotencyStore } from "./types.js";
2
+ /**
3
+ * An {@link IdempotencyStore} persisted to one JSON file, so `auto` keys
4
+ * survive a restart: a call retried after a crash reuses its key. Writes
5
+ * are serialized within the process and atomic (temporary file and
6
+ * rename); the file is created with mode 0600 because keys are never
7
+ * meant to be shown. A file that exists but cannot be parsed makes `get`
8
+ * and `put` reject, and the runtime then refuses the call rather than
9
+ * issue a second key for the same intent.
10
+ */
11
+ export declare class FileIdempotencyStore implements IdempotencyStore {
12
+ #private;
13
+ readonly path: string;
14
+ constructor(path: string);
15
+ get(scope: string, logicalId: string): Promise<string | undefined>;
16
+ put(scope: string, logicalId: string, key: string): Promise<void>;
17
+ }
package/dist/node.js ADDED
@@ -0,0 +1,98 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /// <reference types="node" />
3
+ /**
4
+ * `@elysra/tungsten-runtime/node`: adapters that need Node APIs (also available in
5
+ * Bun and Deno's Node compatibility). The core entry point never imports
6
+ * this module.
7
+ */
8
+ import { randomBytes } from "node:crypto";
9
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
10
+ import { dirname } from "node:path";
11
+ function isStoreFile(value) {
12
+ if (typeof value !== "object" || value === null)
13
+ return false;
14
+ const record = value;
15
+ if (record.version !== 1 || typeof record.keys !== "object" || record.keys === null)
16
+ return false;
17
+ return Object.values(record.keys).every((scope) => typeof scope === "object" && scope !== null && Object.values(scope).every((k) => typeof k === "string"));
18
+ }
19
+ function sorted(keys) {
20
+ const out = {};
21
+ const define = (target, key, value) => {
22
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
23
+ };
24
+ for (const scope of Object.keys(keys).sort()) {
25
+ const inner = {};
26
+ const entries = keys[scope] ?? {};
27
+ for (const id of Object.keys(entries).sort())
28
+ define(inner, id, entries[id]);
29
+ define(out, scope, inner);
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * An {@link IdempotencyStore} persisted to one JSON file, so `auto` keys
35
+ * survive a restart: a call retried after a crash reuses its key. Writes
36
+ * are serialized within the process and atomic (temporary file and
37
+ * rename); the file is created with mode 0600 because keys are never
38
+ * meant to be shown. A file that exists but cannot be parsed makes `get`
39
+ * and `put` reject, and the runtime then refuses the call rather than
40
+ * issue a second key for the same intent.
41
+ */
42
+ export class FileIdempotencyStore {
43
+ path;
44
+ #queue = Promise.resolve();
45
+ constructor(path) {
46
+ this.path = path;
47
+ }
48
+ async #read() {
49
+ let text;
50
+ try {
51
+ text = await readFile(this.path, "utf8");
52
+ }
53
+ catch (error) {
54
+ if (error.code === "ENOENT")
55
+ return { version: 1, keys: {} };
56
+ throw error;
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(text);
61
+ }
62
+ catch {
63
+ throw new Error("the idempotency store file is not valid JSON");
64
+ }
65
+ if (!isStoreFile(parsed))
66
+ throw new Error("the idempotency store file has an unknown format");
67
+ return parsed;
68
+ }
69
+ async get(scope, logicalId) {
70
+ await this.#queue.catch(() => undefined);
71
+ const data = await this.#read();
72
+ const scoped = Object.hasOwn(data.keys, scope) ? data.keys[scope] : undefined;
73
+ return scoped && Object.hasOwn(scoped, logicalId) ? scoped[logicalId] : undefined;
74
+ }
75
+ put(scope, logicalId, key) {
76
+ const write = this.#queue
77
+ .catch(() => undefined)
78
+ .then(async () => {
79
+ const data = await this.#read();
80
+ const scoped = Object.hasOwn(data.keys, scope) ? { ...data.keys[scope] } : {};
81
+ Object.defineProperty(scoped, logicalId, { value: key, enumerable: true, writable: true, configurable: true });
82
+ Object.defineProperty(data.keys, scope, { value: scoped, enumerable: true, writable: true, configurable: true });
83
+ const text = `${JSON.stringify({ version: 1, keys: sorted(data.keys) }, null, 2)}\n`;
84
+ await mkdir(dirname(this.path), { recursive: true });
85
+ const temporary = `${this.path}.${randomBytes(6).toString("hex")}.tmp`;
86
+ try {
87
+ await writeFile(temporary, text, { mode: 0o600 });
88
+ await rename(temporary, this.path);
89
+ }
90
+ catch (error) {
91
+ await rm(temporary, { force: true });
92
+ throw error;
93
+ }
94
+ });
95
+ this.#queue = write;
96
+ return write;
97
+ }
98
+ }
@@ -0,0 +1,124 @@
1
+ import type { ApiDescriptor, AuthorizationCodeDescriptor, ClientOptions, Diagnostic } from "./types.js";
2
+ /** What the store keeps for one scheme. Secrets: never log it. */
3
+ export interface StoredToken {
4
+ accessToken: string;
5
+ refreshToken?: string;
6
+ /** Epoch milliseconds; absent when the server gave no lifetime. */
7
+ expiresAt?: number;
8
+ tokenType?: string;
9
+ scope?: string;
10
+ }
11
+ /** Holds the tokens of the authorization-code schemes, by scheme name.
12
+ * Implement it to persist tokens (keychain, database). */
13
+ export interface TokenStore {
14
+ get(scheme: string): Promise<StoredToken | undefined>;
15
+ set(scheme: string, token: StoredToken): Promise<void>;
16
+ delete(scheme: string): Promise<void>;
17
+ }
18
+ /** The default store: in memory, per client. */
19
+ export declare class MemoryTokenStore implements TokenStore {
20
+ #private;
21
+ get(scheme: string): Promise<StoredToken | undefined>;
22
+ set(scheme: string, token: StoredToken): Promise<void>;
23
+ delete(scheme: string): Promise<void>;
24
+ }
25
+ /** A PKCE pair (RFC 7636). The verifier is a secret until the exchange. */
26
+ export interface Pkce {
27
+ verifier: string;
28
+ challenge: string;
29
+ method: "S256";
30
+ }
31
+ /** What `exchangeCode` and `refresh` report: no secrets. */
32
+ export interface TokenInfo {
33
+ scheme: string;
34
+ /** Epoch milliseconds, or null when the server gave no lifetime. */
35
+ expiresAt: number | null;
36
+ scope: string | null;
37
+ /** Whether a refresh token is stored. */
38
+ refreshable: boolean;
39
+ }
40
+ export type OAuthResult<T> = {
41
+ ok: true;
42
+ value: T;
43
+ } | {
44
+ ok: false;
45
+ error: Diagnostic;
46
+ };
47
+ export interface AuthorizationUrlParams {
48
+ /** Defaults to the `redirectUri` of the scheme's auth config. */
49
+ redirectUri?: string;
50
+ /** Defaults to the scopes of the flow. */
51
+ scopes?: string[];
52
+ state?: string;
53
+ /** The `challenge` of a {@link Pkce}. */
54
+ codeChallenge?: string;
55
+ /** Further query parameters (`prompt`, `login_hint`, `audience`), added in key order. */
56
+ extra?: Record<string, string>;
57
+ }
58
+ export interface ExchangeCodeParams {
59
+ code: string;
60
+ /** Defaults to the `redirectUri` of the scheme's auth config. */
61
+ redirectUri?: string;
62
+ /** The `verifier` of the {@link Pkce} whose challenge started the flow. */
63
+ codeVerifier?: string;
64
+ }
65
+ /** Tokens expiring within this many milliseconds count as expired. */
66
+ export declare const EXPIRY_SKEW_MS = 30000;
67
+ /** The S256 code challenge of `verifier`: base64url(SHA-256(ASCII(verifier))). */
68
+ export declare function pkceChallenge(verifier: string): Promise<string>;
69
+ /** A fresh verifier (43 URL-safe characters from 32 random bytes) and its S256 challenge. */
70
+ export declare function generatePkce(): Promise<Pkce>;
71
+ /** RFC 3986 percent-encoding: everything but `A-Za-z0-9-._~`. */
72
+ export declare function percentEncode(text: string): string;
73
+ export declare function formBody(fields: Array<[string, string]>): string;
74
+ /** The URL that sends the user to the authorization server. Query order:
75
+ * response_type, client_id, redirect_uri, scope, state, code_challenge,
76
+ * code_challenge_method, then `extra` in key order. */
77
+ export declare function buildAuthorizationUrl(flow: AuthorizationCodeDescriptor, clientId: string, p: AuthorizationUrlParams & {
78
+ redirectUri: string;
79
+ }): string;
80
+ export interface OAuthDeps {
81
+ api: ApiDescriptor;
82
+ options: ClientOptions;
83
+ fetch: () => typeof fetch;
84
+ now: () => number;
85
+ timeoutMs: () => number;
86
+ }
87
+ /** Token handling of one client: the store, the code flow's requests and
88
+ * the single flight per scheme. */
89
+ export declare class OAuthSession {
90
+ #private;
91
+ readonly store: TokenStore;
92
+ constructor(deps: OAuthDeps);
93
+ /** The authorization-code flow of `scheme`, or the reason there is none. */
94
+ flowOf(scheme: string): AuthorizationCodeDescriptor | string;
95
+ /** The URL to send the user to. */
96
+ authorizationUrl(scheme: string, params?: AuthorizationUrlParams): OAuthResult<string>;
97
+ /** Trade an authorization code for tokens and store them. */
98
+ exchangeCode(scheme: string, params: ExchangeCodeParams): Promise<OAuthResult<TokenInfo>>;
99
+ /** Refresh the stored token of `scheme` now (single flight). */
100
+ refresh(scheme: string): Promise<OAuthResult<TokenInfo>>;
101
+ /** The access token to send: the stored one, refreshed first when it
102
+ * expires within {@link EXPIRY_SKEW_MS} (or `force`). Throws a
103
+ * {@link TokenError} whose text is shown in the AUTH_FAILED envelope. */
104
+ token(scheme: string, force?: boolean): Promise<string>;
105
+ /** Force a refresh after a 401, when the stored token has a refresh
106
+ * token. Returns the new access token, or null when nothing changed. */
107
+ refreshAfterRejection(scheme: string, rejected: string): Promise<string | null>;
108
+ }
109
+ /** The helpers of one authorization-code scheme, as generated clients expose them. */
110
+ export declare class OAuthFlow {
111
+ #private;
112
+ readonly scheme: string;
113
+ constructor(session: OAuthSession, scheme: string);
114
+ /** The tokens' store. */
115
+ get tokenStore(): TokenStore;
116
+ /** A fresh PKCE pair; pass `challenge` to {@link authorizationUrl} and keep `verifier` for {@link exchangeCode}. */
117
+ pkce(): Promise<Pkce>;
118
+ /** The URL to send the user to. */
119
+ authorizationUrl(params?: AuthorizationUrlParams): OAuthResult<string>;
120
+ /** Trade the code from the redirect for tokens, stored for later calls. */
121
+ exchangeCode(params: ExchangeCodeParams): Promise<OAuthResult<TokenInfo>>;
122
+ /** Refresh the stored token now. Calls refresh on their own when it expires. */
123
+ refresh(): Promise<OAuthResult<TokenInfo>>;
124
+ }
package/dist/oauth.js ADDED
@@ -0,0 +1,367 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * OAuth2 authorization-code helpers (planning/06 "Auth profiles"): PKCE
4
+ * (RFC 7636, S256), the authorization URL, the code exchange, refresh with
5
+ * a single flight per scheme, and the token store behind an
6
+ * `authorizationCode` scheme.
7
+ *
8
+ * Credentials: `ClientOptions.auth[scheme] = { flow: "authorizationCode",
9
+ * clientId, clientSecret?, redirectUri? }`. Tokens live in
10
+ * `ClientOptions.tokenStore` (memory by default), never in the options,
11
+ * envelopes or logs.
12
+ */
13
+ import { TokenError } from "./auth.js";
14
+ import { attempt } from "./transport.js";
15
+ import { base64url, bytesToBase64, getPath, isRecord, utf8 } from "./util.js";
16
+ import { decodeBody } from "./classify.js";
17
+ import { diagnostic } from "./envelope.js";
18
+ /** The default store: in memory, per client. */
19
+ export class MemoryTokenStore {
20
+ #tokens = new Map();
21
+ async get(scheme) {
22
+ const found = this.#tokens.get(scheme);
23
+ return found ? { ...found } : undefined;
24
+ }
25
+ async set(scheme, token) {
26
+ this.#tokens.set(scheme, { ...token });
27
+ }
28
+ async delete(scheme) {
29
+ this.#tokens.delete(scheme);
30
+ }
31
+ }
32
+ /** Tokens expiring within this many milliseconds count as expired. */
33
+ export const EXPIRY_SKEW_MS = 30_000;
34
+ const VERIFIER_BYTES = 32;
35
+ /** The S256 code challenge of `verifier`: base64url(SHA-256(ASCII(verifier))). */
36
+ export async function pkceChallenge(verifier) {
37
+ const digest = await crypto.subtle.digest("SHA-256", utf8(verifier));
38
+ return base64url(new Uint8Array(digest));
39
+ }
40
+ /** A fresh verifier (43 URL-safe characters from 32 random bytes) and its S256 challenge. */
41
+ export async function generatePkce() {
42
+ const verifier = base64url(crypto.getRandomValues(new Uint8Array(VERIFIER_BYTES)));
43
+ return { verifier, challenge: await pkceChallenge(verifier), method: "S256" };
44
+ }
45
+ /** RFC 3986 percent-encoding: everything but `A-Za-z0-9-._~`. */
46
+ export function percentEncode(text) {
47
+ let out = "";
48
+ for (const byte of utf8(text)) {
49
+ const c = String.fromCharCode(byte);
50
+ out += /[A-Za-z0-9\-._~]/.test(c) ? c : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
51
+ }
52
+ return out;
53
+ }
54
+ export function formBody(fields) {
55
+ return fields.map(([k, v]) => `${percentEncode(k)}=${percentEncode(v)}`).join("&");
56
+ }
57
+ /** The URL that sends the user to the authorization server. Query order:
58
+ * response_type, client_id, redirect_uri, scope, state, code_challenge,
59
+ * code_challenge_method, then `extra` in key order. */
60
+ export function buildAuthorizationUrl(flow, clientId, p) {
61
+ const fields = [
62
+ ["response_type", "code"],
63
+ ["client_id", clientId],
64
+ ["redirect_uri", p.redirectUri],
65
+ ];
66
+ const scopes = p.scopes ?? flow.scopes;
67
+ if (scopes.length > 0)
68
+ fields.push(["scope", scopes.join(" ")]);
69
+ if (p.state !== undefined && p.state !== "")
70
+ fields.push(["state", p.state]);
71
+ if (p.codeChallenge !== undefined && p.codeChallenge !== "") {
72
+ fields.push(["code_challenge", p.codeChallenge], ["code_challenge_method", "S256"]);
73
+ }
74
+ const reserved = new Set(fields.map(([k]) => k));
75
+ for (const key of Object.keys(p.extra ?? {}).sort()) {
76
+ const value = p.extra?.[key];
77
+ if (typeof value === "string" && !reserved.has(key))
78
+ fields.push([key, value]);
79
+ }
80
+ const separator = flow.authorizationUrl.includes("?") ? (/[?&]$/.test(flow.authorizationUrl) ? "" : "&") : "?";
81
+ return `${flow.authorizationUrl}${separator}${formBody(fields)}`;
82
+ }
83
+ function str(value) {
84
+ return typeof value === "string" && value !== "" ? value : undefined;
85
+ }
86
+ /** Token handling of one client: the store, the code flow's requests and
87
+ * the single flight per scheme. */
88
+ export class OAuthSession {
89
+ store;
90
+ #deps;
91
+ #inflight = new Map();
92
+ constructor(deps) {
93
+ this.#deps = deps;
94
+ let store;
95
+ try {
96
+ const candidate = deps.options.tokenStore;
97
+ if (isRecord(candidate) && typeof candidate.get === "function" && typeof candidate.set === "function" && typeof candidate.delete === "function") {
98
+ store = candidate;
99
+ }
100
+ }
101
+ catch {
102
+ // Unreadable options: keep the default; calls report the problem.
103
+ }
104
+ this.store = store ?? new MemoryTokenStore();
105
+ }
106
+ /** The authorization-code flow of `scheme`, or the reason there is none. */
107
+ flowOf(scheme) {
108
+ const schemes = Array.isArray(this.#deps.api.auth) ? this.#deps.api.auth : [];
109
+ const found = schemes.find((s) => isRecord(s) && s.name === scheme);
110
+ if (!found)
111
+ return `the API descriptor defines no scheme named ${scheme}`;
112
+ if (found.kind !== "oauth2" || !found.authorizationCode)
113
+ return `the scheme ${scheme} has no authorizationCode flow`;
114
+ return found.authorizationCode;
115
+ }
116
+ #client(scheme) {
117
+ try {
118
+ const config = isRecord(this.#deps.options.auth) ? this.#deps.options.auth[scheme] : undefined;
119
+ if (!isRecord(config) || config.flow !== "authorizationCode") {
120
+ return `auth.${scheme} must be {flow: "authorizationCode", clientId, clientSecret?, redirectUri?}`;
121
+ }
122
+ const clientId = str(config.clientId);
123
+ if (clientId === undefined)
124
+ return `auth.${scheme} needs a clientId`;
125
+ return { clientId, clientSecret: str(config.clientSecret), redirectUri: str(config.redirectUri) };
126
+ }
127
+ catch {
128
+ return `auth.${scheme} could not be read`;
129
+ }
130
+ }
131
+ #failure(operation, remediation) {
132
+ return { ok: false, error: diagnostic(operation, "AUTH_FAILED", { remediation }) };
133
+ }
134
+ /** The URL to send the user to. */
135
+ authorizationUrl(scheme, params = {}) {
136
+ const operation = `oauth.${scheme}.authorizationUrl`;
137
+ const flow = this.flowOf(scheme);
138
+ if (typeof flow === "string")
139
+ return this.#failure(operation, `Cannot build an authorization URL: ${flow}.`);
140
+ const client = this.#client(scheme);
141
+ if (typeof client === "string")
142
+ return this.#failure(operation, `Cannot build an authorization URL: ${client}.`);
143
+ const redirectUri = str(params.redirectUri) ?? client.redirectUri;
144
+ if (redirectUri === undefined) {
145
+ return this.#failure(operation, `Pass redirectUri, or set redirectUri in auth.${scheme}; the authorization URL needs one.`);
146
+ }
147
+ return { ok: true, value: buildAuthorizationUrl(flow, client.clientId, { ...params, redirectUri }) };
148
+ }
149
+ /** Trade an authorization code for tokens and store them. */
150
+ async exchangeCode(scheme, params) {
151
+ const operation = `oauth.${scheme}.exchangeCode`;
152
+ const flow = this.flowOf(scheme);
153
+ if (typeof flow === "string")
154
+ return this.#failure(operation, `Cannot exchange the code: ${flow}.`);
155
+ const client = this.#client(scheme);
156
+ if (typeof client === "string")
157
+ return this.#failure(operation, `Cannot exchange the code: ${client}.`);
158
+ const redirectUri = str(isRecord(params) ? params.redirectUri : undefined) ?? client.redirectUri;
159
+ const code = isRecord(params) ? str(params.code) : undefined;
160
+ if (code === undefined)
161
+ return this.#failure(operation, "Pass the authorization code the server redirected back with as code.");
162
+ if (redirectUri === undefined) {
163
+ return this.#failure(operation, `Pass redirectUri, or set redirectUri in auth.${scheme}; it must equal the one used for the authorization URL.`);
164
+ }
165
+ const fields = [
166
+ ["grant_type", "authorization_code"],
167
+ ["code", code],
168
+ ["redirect_uri", redirectUri],
169
+ ];
170
+ const verifier = str(params.codeVerifier);
171
+ if (verifier !== undefined)
172
+ fields.push(["code_verifier", verifier]);
173
+ try {
174
+ const stored = await this.#tokenRequest(scheme, flow.tokenUrl, client, fields, undefined);
175
+ await this.store.set(scheme, stored);
176
+ return { ok: true, value: info(scheme, stored) };
177
+ }
178
+ catch (error) {
179
+ return this.#failure(operation, error instanceof TokenError ? error.message : `The token store failed while saving the tokens for ${scheme}.`);
180
+ }
181
+ }
182
+ /** Refresh the stored token of `scheme` now (single flight). */
183
+ async refresh(scheme) {
184
+ const operation = `oauth.${scheme}.refresh`;
185
+ try {
186
+ await this.token(scheme, true);
187
+ const stored = await this.store.get(scheme);
188
+ if (!stored)
189
+ return this.#failure(operation, `No token is stored for ${scheme}.`);
190
+ return { ok: true, value: info(scheme, stored) };
191
+ }
192
+ catch (error) {
193
+ return this.#failure(operation, error instanceof TokenError ? error.message : `The token store failed while refreshing ${scheme}.`);
194
+ }
195
+ }
196
+ /** The access token to send: the stored one, refreshed first when it
197
+ * expires within {@link EXPIRY_SKEW_MS} (or `force`). Throws a
198
+ * {@link TokenError} whose text is shown in the AUTH_FAILED envelope. */
199
+ async token(scheme, force = false) {
200
+ const stored = await this.#read(scheme);
201
+ if (!stored) {
202
+ throw new TokenError(`No OAuth2 token is stored for ${scheme}; send the user to authorizationUrl(), then call exchangeCode() with the code, or put a token in ClientOptions.tokenStore.`);
203
+ }
204
+ const fresh = stored.expiresAt === undefined || stored.expiresAt > this.#deps.now() + EXPIRY_SKEW_MS;
205
+ if (fresh && !force)
206
+ return stored.accessToken;
207
+ const running = this.#inflight.get(scheme);
208
+ if (running)
209
+ return running;
210
+ const flight = this.#refreshStored(scheme, stored, force);
211
+ this.#inflight.set(scheme, flight);
212
+ try {
213
+ return await flight;
214
+ }
215
+ finally {
216
+ this.#inflight.delete(scheme);
217
+ }
218
+ }
219
+ /** Force a refresh after a 401, when the stored token has a refresh
220
+ * token. Returns the new access token, or null when nothing changed. */
221
+ async refreshAfterRejection(scheme, rejected) {
222
+ const stored = await this.#read(scheme);
223
+ if (!stored)
224
+ return null;
225
+ // Another call already replaced the rejected token.
226
+ if (stored.accessToken !== rejected)
227
+ return stored.accessToken;
228
+ if (stored.refreshToken === undefined)
229
+ return null;
230
+ try {
231
+ return await this.token(scheme, true);
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ async #read(scheme) {
238
+ try {
239
+ const stored = await this.store.get(scheme);
240
+ return isRecord(stored) && typeof stored.accessToken === "string" && stored.accessToken !== "" ? stored : undefined;
241
+ }
242
+ catch {
243
+ throw new TokenError(`The token store failed while reading the token for ${scheme}.`);
244
+ }
245
+ }
246
+ async #refreshStored(scheme, stored, force) {
247
+ const flow = this.flowOf(scheme);
248
+ const client = this.#client(scheme);
249
+ if (typeof flow === "string" || typeof client === "string") {
250
+ throw new TokenError(`Cannot refresh the OAuth2 token for ${scheme}: ${typeof flow === "string" ? flow : client}.`);
251
+ }
252
+ if (stored.refreshToken === undefined) {
253
+ throw new TokenError(force
254
+ ? `The OAuth2 token for ${scheme} has no refresh token; authorize again with authorizationUrl() and exchangeCode().`
255
+ : `The OAuth2 token for ${scheme} expired and has no refresh token; authorize again with authorizationUrl() and exchangeCode().`);
256
+ }
257
+ const fields = [
258
+ ["grant_type", "refresh_token"],
259
+ ["refresh_token", stored.refreshToken],
260
+ ];
261
+ let next;
262
+ try {
263
+ next = await this.#tokenRequest(scheme, flow.refreshUrl ?? flow.tokenUrl, client, fields, stored.refreshToken);
264
+ }
265
+ catch (error) {
266
+ if (error instanceof TokenError && error.rejected) {
267
+ // The server no longer accepts the refresh token: forget the tokens.
268
+ try {
269
+ await this.store.delete(scheme);
270
+ }
271
+ catch {
272
+ // The failure below is what the caller needs.
273
+ }
274
+ }
275
+ throw error;
276
+ }
277
+ try {
278
+ await this.store.set(scheme, next);
279
+ }
280
+ catch {
281
+ throw new TokenError(`The token store failed while saving the refreshed token for ${scheme}.`);
282
+ }
283
+ return next.accessToken;
284
+ }
285
+ /** One request to the token endpoint. `previous` is the refresh token a
286
+ * response without one keeps. */
287
+ async #tokenRequest(scheme, url, client, fields, previous) {
288
+ const headers = { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" };
289
+ if (client.clientSecret !== undefined) {
290
+ headers.Authorization = `Basic ${bytesToBase64(utf8(`${percentEncode(client.clientId)}:${percentEncode(client.clientSecret)}`))}`;
291
+ }
292
+ else {
293
+ fields.push(["client_id", client.clientId]);
294
+ }
295
+ const outcome = await attempt(this.#deps.fetch(), {
296
+ url,
297
+ method: "POST",
298
+ headers,
299
+ body: formBody(fields),
300
+ redirect: "manual",
301
+ timeoutMs: this.#deps.timeoutMs(),
302
+ signal: undefined,
303
+ });
304
+ if (outcome.kind !== "response" || outcome.body === null) {
305
+ throw new TokenError(`The OAuth2 token endpoint for ${scheme} could not be reached; check network access and the token URL.`);
306
+ }
307
+ const decoded = decodeBody(outcome.body, outcome.headers, "application/json");
308
+ if (outcome.status < 200 || outcome.status > 299) {
309
+ const code = getPath(decoded.value, "error");
310
+ const named = typeof code === "string" && /^[a-z_]{1,40}$/.test(code) ? ` (${code})` : "";
311
+ const error = new TokenError(`The OAuth2 token endpoint for ${scheme} answered HTTP ${outcome.status}${named}; check the code or refresh token, client credentials and redirect URI.`);
312
+ error.rejected = outcome.status >= 400 && outcome.status < 500;
313
+ throw error;
314
+ }
315
+ const access = getPath(decoded.value, "access_token");
316
+ if (typeof access !== "string" || access === "")
317
+ throw new TokenError(`The OAuth2 token endpoint for ${scheme} returned no access_token.`);
318
+ const token = { accessToken: access };
319
+ const refresh = getPath(decoded.value, "refresh_token");
320
+ if (typeof refresh === "string" && refresh !== "")
321
+ token.refreshToken = refresh;
322
+ else if (previous !== undefined)
323
+ token.refreshToken = previous;
324
+ const lifetime = getPath(decoded.value, "expires_in");
325
+ if (typeof lifetime === "number" && Number.isFinite(lifetime))
326
+ token.expiresAt = this.#deps.now() + Math.max(0, lifetime) * 1000;
327
+ const type = getPath(decoded.value, "token_type");
328
+ if (typeof type === "string" && type !== "")
329
+ token.tokenType = type;
330
+ const scope = getPath(decoded.value, "scope");
331
+ if (typeof scope === "string" && scope !== "")
332
+ token.scope = scope;
333
+ return token;
334
+ }
335
+ }
336
+ function info(scheme, token) {
337
+ return { scheme, expiresAt: token.expiresAt ?? null, scope: token.scope ?? null, refreshable: token.refreshToken !== undefined };
338
+ }
339
+ /** The helpers of one authorization-code scheme, as generated clients expose them. */
340
+ export class OAuthFlow {
341
+ scheme;
342
+ #session;
343
+ constructor(session, scheme) {
344
+ this.#session = session;
345
+ this.scheme = scheme;
346
+ }
347
+ /** The tokens' store. */
348
+ get tokenStore() {
349
+ return this.#session.store;
350
+ }
351
+ /** A fresh PKCE pair; pass `challenge` to {@link authorizationUrl} and keep `verifier` for {@link exchangeCode}. */
352
+ pkce() {
353
+ return generatePkce();
354
+ }
355
+ /** The URL to send the user to. */
356
+ authorizationUrl(params = {}) {
357
+ return this.#session.authorizationUrl(this.scheme, params);
358
+ }
359
+ /** Trade the code from the redirect for tokens, stored for later calls. */
360
+ exchangeCode(params) {
361
+ return this.#session.exchangeCode(this.scheme, params);
362
+ }
363
+ /** Refresh the stored token now. Calls refresh on their own when it expires. */
364
+ refresh() {
365
+ return this.#session.refresh(this.scheme);
366
+ }
367
+ }