@revenexx/integrations-node-sdk 1.9.0 → 1.10.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,16 @@
1
+ import type { IShowIfCondition } from './types.js';
2
+ /**
3
+ * Whether a setting applies, given what the author has filled in so far.
4
+ *
5
+ * A setting with no condition always applies — which is what makes `showIf`
6
+ * additive: a node that says nothing gets the behaviour it has today, and an
7
+ * editor that does not know the key draws every field as before.
8
+ *
9
+ * The value the condition reads is taken from the config as it stands mid-edit,
10
+ * so it is routinely `undefined`: the moment a node is dropped on the canvas
11
+ * nothing is filled in. That is why `equals` against a missing value is `false`
12
+ * rather than an error — the setting does not apply *yet*.
13
+ */
14
+ export declare function settingApplies(field: {
15
+ showIf?: IShowIfCondition;
16
+ }, config: Record<string, unknown>): boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Reduce an `IConfigField.credentialType` (a single slug or an array of slugs)
3
+ * to a normalized, deduplicated `string[]`.
4
+ *
5
+ * The union `string | string[]` exists so node authors can write the common
6
+ * single-type case as a bare string, but every consumer (the editor that
7
+ * lists tenant credential instances, the Laravel side) needs a list to
8
+ * iterate. This centralizes the
9
+ * "is it a string or an array?" branch — analogous to {@link normalizeLocalized}
10
+ * for `LocalizedString` — so the rules are defined once:
11
+ *
12
+ * - a single non-empty slug becomes a one-element array;
13
+ * - an array is trimmed of blank entries and deduplicated, preserving order;
14
+ * - `undefined`, an empty string, or an empty/all-blank array yields `[]`, so
15
+ * the caller can treat "accepts nothing" uniformly.
16
+ */
17
+ export declare function normalizeCredentialType(value: string | string[] | undefined): string[];
@@ -0,0 +1,162 @@
1
+ import type { ICredential, ICredentialContext, ICredentialDescription, ICredentialOAuthAuthorize, ICredentialResolveResult, ICredentialTestResult } from './types.js';
2
+ /**
3
+ * Reusable credential base classes. Concrete credential types (in node
4
+ * packages) extend one of these and only supply field mappings / endpoints —
5
+ * the auth strategy itself lives here so it is implemented once.
6
+ *
7
+ * All of this runs in the credentials broker (a Node side-container), never in
8
+ * workflow code, so wall-clock time and network I/O are fine.
9
+ */
10
+ type Config = Record<string, unknown>;
11
+ type DurableCreds = Record<string, unknown> | null;
12
+ export interface OAuthTokenResponse {
13
+ access_token?: string;
14
+ refresh_token?: string;
15
+ expires_in?: number;
16
+ token_type?: string;
17
+ /**
18
+ * Provider-specific base URL the access token must target (e.g. Pipedrive
19
+ * returns the company-scoped `api_domain` on both the initial exchange and
20
+ * every refresh). Absent for providers that use a single global API host.
21
+ */
22
+ api_domain?: string;
23
+ [key: string]: unknown;
24
+ }
25
+ /**
26
+ * The base for every credential type. Stores the published description and
27
+ * provides a small HTTP helper. `resolve` is abstract; `test` defaults to a
28
+ * presence check and should be overridden where a real connection test exists.
29
+ */
30
+ export declare abstract class BaseCredential implements ICredential {
31
+ abstract readonly description: ICredentialDescription;
32
+ abstract resolve(ctx: ICredentialContext, config: Config, durableCreds: DurableCreds): Promise<ICredentialResolveResult>;
33
+ test(_ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
34
+ /**
35
+ * POST `application/x-www-form-urlencoded` and parse a JSON token response.
36
+ *
37
+ * Goes through {@link safeFetch}, not a raw `fetch` (PO-185). The token
38
+ * endpoint is not a constant: `tokenUrl(config)` derives it from credential
39
+ * config, i.e. from a value someone types into a credential form. A raw fetch
40
+ * would happily POST the client secret at `http://169.254.169.254/…` or any
41
+ * other internal address, which is precisely what `assertPublicUrl` exists to
42
+ * prevent. Every OAuth token exchange in this file — client-credentials,
43
+ * auth-code, refresh — funnels through here, so this is the one call site that
44
+ * has to hold.
45
+ *
46
+ * Two behaviours safeFetch adds, both wanted here: a request budget
47
+ * (`DEFAULT_TIMEOUT_MS`) on top of `ctx.signal`, and manual redirect handling
48
+ * that re-checks each hop and drops `Authorization` across an origin boundary.
49
+ * Note that a 301/302 answer to this POST is downgraded to a bodiless GET —
50
+ * per the redirect rules, not a quirk of ours. Real token endpoints do not
51
+ * redirect; one that does was never going to complete the exchange anyway.
52
+ *
53
+ * The third protection is the caller's own: safeFetch hands back a `Response`
54
+ * and reading it is not its job, so the answer goes through {@link readText}
55
+ * under {@link MAX_TOKEN_RESPONSE_BYTES} rather than a bare `res.text()`.
56
+ * Both branches below read that same string, so the cap covers the refusal as
57
+ * well as the token — an error body is the less predictable of the two.
58
+ */
59
+ protected postForm(ctx: ICredentialContext, url: string, form: Record<string, string | undefined>, headers?: Record<string, string>): Promise<OAuthTokenResponse>;
60
+ }
61
+ /**
62
+ * "Simple value retrieval": the validated config fields ARE the access data
63
+ * handed to the node (e.g. SMTP host/port/user/password, SFTP host/key). No
64
+ * token, no expiry. Override `test` to add a real connection check.
65
+ */
66
+ export declare abstract class SimpleValueCredential extends BaseCredential {
67
+ resolve(_ctx: ICredentialContext, config: Config, _durableCreds: DurableCreds): Promise<ICredentialResolveResult>;
68
+ }
69
+ /**
70
+ * API-key credential. Reads the key from the configured field and hands it to
71
+ * the node under that same name; override {@link credentialShape} where the
72
+ * node needs something else, or more than the key alone.
73
+ */
74
+ export declare abstract class ApiKeyCredential extends BaseCredential {
75
+ /**
76
+ * Which field of the credential form the key is typed into. Override it to
77
+ * name the form field this provider calls its key — `botToken`, `authKey`,
78
+ * `webhookUrl`.
79
+ *
80
+ * **What this does not do** (PO-497): it does not decide what a node looks
81
+ * the key up under at run time. It used to do only half of that — it changed
82
+ * which field was *read* while {@link credentialShape} went on emitting
83
+ * `apiKey` — so a credential that renamed its form field alone handed the
84
+ * node a key under a name it was never told to expect, and the node refused
85
+ * a perfectly good key as a missing one. The default shape now follows this
86
+ * name, so overriding this alone is correct on both sides. Overriding
87
+ * {@link credentialShape} as well is still how a credential emits a
88
+ * different name, or more than the key.
89
+ *
90
+ * Note that no node test can see a mismatch between the two: a mocked node
91
+ * context is seeded with the resolved blob directly and never calls
92
+ * `resolve`. Only a credential test asserting the whole resolved shape
93
+ * catches it, and asserting it loosely still misses — a partial match is
94
+ * happy with the wrong key present beside the right one.
95
+ */
96
+ protected apiKeyField(): string;
97
+ /**
98
+ * What the node receives. Defaults to the key under the name
99
+ * {@link apiKeyField} returns, so the two cannot drift apart by default.
100
+ *
101
+ * Override it where the node needs a different name than the form uses, or
102
+ * needs more than the key — a base URL derived from it, say.
103
+ */
104
+ protected credentialShape(apiKey: string): Record<string, unknown>;
105
+ resolve(_ctx: ICredentialContext, config: Config, _durableCreds: DurableCreds): Promise<ICredentialResolveResult>;
106
+ }
107
+ /**
108
+ * HTTP Basic auth credential. Exposes `{ username, password }` by default.
109
+ */
110
+ export declare abstract class BasicAuthCredential extends BaseCredential {
111
+ protected usernameField(): string;
112
+ protected passwordField(): string;
113
+ resolve(_ctx: ICredentialContext, config: Config, _durableCreds: DurableCreds): Promise<ICredentialResolveResult>;
114
+ }
115
+ /**
116
+ * OAuth2 client-credentials grant (service-to-service, no user, no
117
+ * refresh_token). Concrete types supply the token endpoint + client creds via
118
+ * the config field map. Robust to downtime: a fresh access token is minted on
119
+ * every resolve when the broker cache misses.
120
+ */
121
+ export declare abstract class OAuth2ClientCredentialsCredential extends BaseCredential {
122
+ protected abstract tokenUrl(config: Config): string;
123
+ protected abstract clientId(config: Config): string;
124
+ protected abstract clientSecret(config: Config): string;
125
+ protected scope(_config: Config): string | undefined;
126
+ /** Map the raw token response to the node-facing credentials. */
127
+ protected credentialShape(token: OAuthTokenResponse): Record<string, unknown>;
128
+ resolve(ctx: ICredentialContext, config: Config, _durableCreds: DurableCreds): Promise<ICredentialResolveResult>;
129
+ test(ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
130
+ }
131
+ /**
132
+ * OAuth2 authorization-code grant (3-legged, interactive consent). Implements
133
+ * the consent dance (with PKCE by default), code exchange, and refresh-token
134
+ * based resolution. The long-lived `refresh_token` is stored in durableCreds;
135
+ * if the provider rotates it, the new one is persisted via the context.
136
+ */
137
+ export declare abstract class OAuth2AuthCodeCredential extends BaseCredential implements ICredentialOAuthAuthorize {
138
+ protected abstract authorizeUrl(config: Config): string;
139
+ protected abstract tokenUrl(config: Config): string;
140
+ protected abstract clientId(config: Config): string;
141
+ protected abstract clientSecret(config: Config): string;
142
+ protected scope(_config: Config): string | undefined;
143
+ protected usePkce(): boolean;
144
+ protected credentialShape(token: OAuthTokenResponse): Record<string, unknown>;
145
+ buildAuthorizeUrl(_ctx: ICredentialContext, config: Config, params: {
146
+ redirectUri: string;
147
+ state: string;
148
+ }): Promise<{
149
+ authorizeUrl: string;
150
+ codeVerifier?: string;
151
+ }>;
152
+ exchangeCode(ctx: ICredentialContext, config: Config, params: {
153
+ code: string;
154
+ redirectUri: string;
155
+ codeVerifier?: string;
156
+ }): Promise<{
157
+ durableCreds: Record<string, unknown>;
158
+ }>;
159
+ resolve(ctx: ICredentialContext, config: Config, durableCreds: DurableCreds): Promise<ICredentialResolveResult>;
160
+ test(_ctx: ICredentialContext, config: Config): Promise<ICredentialTestResult>;
161
+ }
162
+ export {};
@@ -0,0 +1,5 @@
1
+ export declare class NodeError extends Error {
2
+ readonly code: string;
3
+ readonly meta?: Record<string, unknown>;
4
+ constructor(code: string, message: string, meta?: Record<string, unknown>);
5
+ }
@@ -0,0 +1,5 @@
1
+ import type { ICredential, ICredentialDescription, INode, INodeDescription } from './types.js';
2
+ export declare function extractManifest(node: INode): INodeDescription;
3
+ export declare function extractManifests(nodes: INode[]): INodeDescription[];
4
+ export declare function extractCredentialManifest(credential: ICredential): ICredentialDescription;
5
+ export declare function extractCredentialManifests(credentials: ICredential[]): ICredentialDescription[];
@@ -0,0 +1,133 @@
1
+ import type { IConfigField } from './types.js';
2
+ export declare const DEFAULT_TIMEOUT_MS = 30000;
3
+ export declare const MAX_TIMEOUT_MS = 120000;
4
+ /** Maximum number of redirect hops `safeFetch` follows before giving up. */
5
+ export declare const MAX_REDIRECTS = 5;
6
+ /**
7
+ * Whether a request header with this name goes on to the next hop when
8
+ * {@link safeFetch} follows a redirect to another origin — `true` for `Accept`,
9
+ * `Accept-Encoding`, `Accept-Language`, `Content-Encoding`, `Content-Language`,
10
+ * `Content-Type`, `Idempotency-Key`, `If-Match`, `If-Modified-Since`,
11
+ * `If-None-Match`, `If-Range`, `If-Unmodified-Since`, `Range`, `SOAPAction` and
12
+ * `User-Agent`, in any case, and `false` for every other name. The keep-list is by
13
+ * name, so it can only keep a secret out of the headers it drops: a credential
14
+ * whose header name the author chooses must refuse a name this answers `true`
15
+ * for, or its key would cross with the harmless headers.
16
+ */
17
+ export declare function headerCrossesOrigin(name: string): boolean;
18
+ export declare const DEFAULT_RETRY_ATTEMPTS = 0;
19
+ export declare const MAX_RETRY_ATTEMPTS = 5;
20
+ export declare const DEFAULT_RETRY_DELAY_MS = 1000;
21
+ /**
22
+ * Default cap for response bodies read via {@link readArrayBuffer} /
23
+ * {@link readText} / {@link readJsonOrText}. Guards the (shared) worker process
24
+ * against a single oversized response exhausting its memory.
25
+ */
26
+ export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
27
+ /**
28
+ * Hard upper ceiling for any per-node `maxBytes`. Even a permissive node author
29
+ * (who calls {@link maxBytesConfigField} without an explicit `max`, or passes a
30
+ * large `maxBytes` to the `read*` helpers) can never lift the cap above this, so
31
+ * the shared worker's memory stays bounded. Analogous to {@link MAX_TIMEOUT_MS}.
32
+ */
33
+ export declare const MAX_RESPONSE_BYTES: number;
34
+ /** Clamp a requested `maxBytes` into `[1, MAX_RESPONSE_BYTES]`. */
35
+ export declare function clampResponseBytes(maxBytes: number): number;
36
+ export interface SafeFetchRetry {
37
+ attempts: number;
38
+ delayMs?: number;
39
+ }
40
+ /**
41
+ * Binds a request to the one origin its credential belongs to (PO-688).
42
+ *
43
+ * Setting it declares the whole request secret-bearing — headers, query, path
44
+ * and body alike — so nothing has to name which part carries the secret: a
45
+ * request to any other origin is refused with `CREDENTIAL_ORIGIN`, on the first
46
+ * hop and on every redirect, before that origin is looked up or sent anything.
47
+ */
48
+ export interface SafeFetchCredential {
49
+ /** The only origin this request may reach, e.g. `https://api.pipedrive.com`. */
50
+ origin: string | URL;
51
+ /**
52
+ * What a redirect off that origin does. `'refuse'` (the default) fails the
53
+ * call. `'strip'` follows it the way an unbound request would — every header
54
+ * not known to be harmless dropped, a body refused — for a download that is
55
+ * redirected to a CDN or a signed storage link. A secret in the query or the
56
+ * path is not protected on such a hop.
57
+ */
58
+ crossOriginRedirect?: 'refuse' | 'strip';
59
+ }
60
+ export interface SafeFetchOptions extends RequestInit {
61
+ timeoutMs?: number;
62
+ /** Pass `ctx.signal` so the workflow engine can cancel the request. */
63
+ signal?: AbortSignal;
64
+ retry?: SafeFetchRetry;
65
+ /** Bind the request to the origin of the credential it carries. */
66
+ credential?: SafeFetchCredential;
67
+ }
68
+ export declare function safeFetch(url: string | URL, options?: SafeFetchOptions): Promise<Response>;
69
+ /**
70
+ * Read a response body into an ArrayBuffer while enforcing a hard byte cap.
71
+ *
72
+ * The `Content-Length` header is used as a fast-reject (bail before downloading
73
+ * anything), but the limit is *also* enforced while streaming, since the header
74
+ * can be absent or lie. On overrun the stream is cancelled and a
75
+ * `NodeError('RESPONSE_TOO_LARGE', …, { status })` is thrown. Both refusal paths
76
+ * cancel the body first, so neither leaves the connection held.
77
+ */
78
+ export declare function readArrayBuffer(res: Response, maxBytes?: number): Promise<ArrayBuffer>;
79
+ /** Read a response body as UTF-8 text, capped at `maxBytes` (see {@link readArrayBuffer}). */
80
+ export declare function readText(res: Response, maxBytes?: number): Promise<string>;
81
+ /**
82
+ * Read a response body as JSON when the `Content-Type` denotes JSON (see
83
+ * {@link isJsonContentType}), otherwise as text — the content-type sniff
84
+ * previously duplicated across the HTTP/Upload/DeepL node sinks. Capped at
85
+ * `maxBytes` (see {@link readArrayBuffer}).
86
+ *
87
+ * A malformed JSON body surfaces as `NodeError('RESPONSE_PARSE_ERROR', …, { status })`
88
+ * rather than a raw `SyntaxError`, keeping to the SDK error contract.
89
+ */
90
+ export declare function readJsonOrText(res: Response, maxBytes?: number): Promise<unknown>;
91
+ /**
92
+ * The setting a node offers its workflow author for the response size cap,
93
+ * ready to spread into `description.config`.
94
+ *
95
+ * **What the node still has to do** (PO-497): declaring the setting does not
96
+ * wire it up. Read the configured value in `execute` and pass it on — the
97
+ * helpers here only produce the declaration, and a node that spreads this and
98
+ * never reads `config.maxBytes` offers a setting that does nothing.
99
+ *
100
+ * Everything an author reads is supplied in both languages the node packages
101
+ * write, so spreading this unchanged already satisfies the rule they hold.
102
+ * Nothing is left to the caller on that side: a node that wants different
103
+ * words replaces the field rather than completing it.
104
+ */
105
+ export declare function maxBytesConfigField(opts?: {
106
+ default?: number;
107
+ max?: number;
108
+ }): IConfigField;
109
+ /**
110
+ * The setting a node offers its workflow author for the request budget, ready
111
+ * to spread into `description.config`.
112
+ *
113
+ * **What the node still has to do** (PO-497): pass the configured value to
114
+ * `safeFetch` as `timeoutMs`. See {@link maxBytesConfigField} for the same note
115
+ * and for why the label and description are not the caller's to complete.
116
+ */
117
+ export declare function timeoutConfigField(opts?: {
118
+ default?: number;
119
+ max?: number;
120
+ }): IConfigField;
121
+ /**
122
+ * The two settings a node offers its workflow author for the retry budget,
123
+ * ready to spread into `description.config`.
124
+ *
125
+ * **What the node still has to do** (PO-497): pass both configured values to
126
+ * `safeFetch` as `retry.attempts` and `retry.delayMs`. See
127
+ * {@link maxBytesConfigField} for the same note and for why the labels and
128
+ * descriptions are not the caller's to complete.
129
+ */
130
+ export declare function retryConfigFields(opts?: {
131
+ defaultAttempts?: number;
132
+ defaultDelayMs?: number;
133
+ }): IConfigField[];
@@ -0,0 +1,28 @@
1
+ import type { IImage } from './types.js';
2
+ /** Something that may carry an `images` array (a node/credential/template manifest). */
3
+ interface WithImages {
4
+ images?: IImage[];
5
+ }
6
+ /**
7
+ * Collect the unique `src` of every image declared across all nodes,
8
+ * credential types, and templates in a manifest.
9
+ */
10
+ export declare function collectImageSources(manifest: {
11
+ nodes?: WithImages[];
12
+ credentials?: WithImages[];
13
+ templates?: WithImages[];
14
+ }): string[];
15
+ /**
16
+ * Copy declared image files into `dist/`, preserving their sub-path
17
+ * (`images/x.png` → `dist/images/x.png`) so `npm pack` picks them up via the
18
+ * package's `files: ["dist"]`. Sources are resolved relative to `rootDir`
19
+ * (the package root, `process.cwd()`).
20
+ *
21
+ * Every problematic declaration warns and is skipped rather than throwing, so a
22
+ * stale manifest never breaks the build: this covers a missing file, a `src`
23
+ * that is a directory rather than a file, and — defensively — an unsafe `src`
24
+ * that is absolute or escapes the package root via `..` traversal (which would
25
+ * otherwise read/write outside `dist/`).
26
+ */
27
+ export declare function copyImages(sources: string[], outDir: string, rootDir: string): void;
28
+ export {};
package/dist/index.cjs CHANGED
@@ -542,6 +542,26 @@ function clampResponseBytes(maxBytes) {
542
542
  if (!Number.isFinite(maxBytes) || maxBytes < 1) return MAX_RESPONSE_BYTES;
543
543
  return Math.min(maxBytes, MAX_RESPONSE_BYTES);
544
544
  }
545
+ function boundOrigin(credential) {
546
+ let parsed;
547
+ try {
548
+ parsed = new URL(credential.origin);
549
+ } catch {
550
+ parsed = void 0;
551
+ }
552
+ if (!parsed || parsed.protocol !== "http:" && parsed.protocol !== "https:") {
553
+ throw new NodeError("CREDENTIAL_ORIGIN", "The origin a credential is bound to is not an http(s) address", { status: 0 });
554
+ }
555
+ return parsed.origin;
556
+ }
557
+ function credentialOriginRefusal(target, bound, hop) {
558
+ const what = hop === void 0 ? "a request" : `a ${hop} redirect`;
559
+ return new NodeError(
560
+ "CREDENTIAL_ORIGIN",
561
+ `Refused ${what} to ${target}: the credential it carries is bound to ${bound}`,
562
+ { status: 0 }
563
+ );
564
+ }
545
565
  async function timedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
546
566
  const ac = new AbortController();
547
567
  const timer = setTimeout(() => ac.abort(), effectiveMs);
@@ -581,7 +601,16 @@ function hasContent(body) {
581
601
  if (body instanceof URLSearchParams) return body.toString().length > 0;
582
602
  return true;
583
603
  }
584
- async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
604
+ function refuseUserinfo(url, status = 0) {
605
+ if (url.username !== "" || url.password !== "") {
606
+ throw new NodeError(
607
+ "INVALID_URL",
608
+ status === 0 ? "Refused a URL with a user name or password in it: pass them in a credential, not in the address" : `Refused a ${status} redirect to a URL with a user name or password in it`,
609
+ { status: 0 }
610
+ );
611
+ }
612
+ }
613
+ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding) {
585
614
  let currentUrl = url;
586
615
  let currentInit = { ...fetchOptions, redirect: "manual" };
587
616
  await guardUrl(currentUrl, ctxSignal, effectiveMs);
@@ -610,6 +639,10 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
610
639
  } catch {
611
640
  throw new NodeError("BLOCKED_ADDRESS", "Blocked redirect with an invalid Location header", { status: 0 });
612
641
  }
642
+ refuseUserinfo(nextUrl, redirectStatus);
643
+ if (binding && binding.crossOriginRedirect === "refuse" && nextUrl.origin !== binding.origin) {
644
+ throw credentialOriginRefusal(nextUrl.origin, binding.origin, redirectStatus);
645
+ }
613
646
  if (base.protocol === "https:" && nextUrl.protocol !== "https:") {
614
647
  throw new NodeError("BLOCKED_ADDRESS", "Blocked https\u2192http downgrade on redirect", { status: 0 });
615
648
  }
@@ -630,7 +663,9 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
630
663
  );
631
664
  }
632
665
  await guardUrl(nextUrl, ctxSignal, effectiveMs);
633
- if (nextUrl.origin !== base.origin) {
666
+ if (binding && nextUrl.origin !== binding.origin) {
667
+ for (const name of [...headers.keys()]) headers.delete(name);
668
+ } else if (nextUrl.origin !== base.origin) {
634
669
  for (const name of [...headers.keys()]) {
635
670
  if (!headerCrossesOrigin(name)) headers.delete(name);
636
671
  }
@@ -640,7 +675,7 @@ async function guardedFetch(url, fetchOptions, ctxSignal, effectiveMs) {
640
675
  }
641
676
  }
642
677
  async function safeFetch(url, options = {}) {
643
- const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, ...fetchOptions } = options;
678
+ const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: ctxSignal, retry, credential, ...fetchOptions } = options;
644
679
  const effectiveMs = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
645
680
  const rawAttempts = retry?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
646
681
  const safeAttempts = Number.isFinite(rawAttempts) ? Math.min(Math.max(0, rawAttempts), MAX_RETRY_ATTEMPTS) : DEFAULT_RETRY_ATTEMPTS;
@@ -648,14 +683,26 @@ async function safeFetch(url, options = {}) {
648
683
  const retryDelayMs = retry?.delayMs ?? DEFAULT_RETRY_DELAY_MS;
649
684
  if (ctxSignal?.aborted) throw ctxSignal.reason;
650
685
  if (fetchOptions.headers !== void 0) fetchOptions.headers = sendableHeaders(fetchOptions.headers);
686
+ let parsed;
687
+ try {
688
+ parsed = url instanceof URL ? url : new URL(url);
689
+ } catch {
690
+ throw new NodeError("INVALID_URL", "Refused an address that cannot be read as a URL", { status: 0 });
691
+ }
692
+ refuseUserinfo(parsed);
693
+ let binding;
694
+ if (credential) {
695
+ binding = { origin: boundOrigin(credential), crossOriginRedirect: credential.crossOriginRedirect ?? "refuse" };
696
+ if (parsed.origin !== binding.origin) throw credentialOriginRefusal(parsed.origin, binding.origin);
697
+ }
651
698
  let lastError;
652
699
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
653
700
  if (ctxSignal?.aborted) throw ctxSignal.reason;
654
701
  try {
655
- return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs);
702
+ return await guardedFetch(url, fetchOptions, ctxSignal, effectiveMs, binding);
656
703
  } catch (err) {
657
704
  if (ctxSignal?.aborted) throw ctxSignal.reason;
658
- if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS")) {
705
+ if (err instanceof NodeError && (err.code === "BLOCKED_ADDRESS" || err.code === "TOO_MANY_REDIRECTS" || err.code === "CREDENTIAL_ORIGIN" || err.code === "INVALID_URL")) {
659
706
  throw err;
660
707
  }
661
708
  lastError = err;
@@ -971,15 +1018,23 @@ var BaseCredential = class {
971
1018
  });
972
1019
  const text = await readText(res, MAX_TOKEN_RESPONSE_BYTES);
973
1020
  if (!res.ok) {
974
- throw new Error(`token endpoint ${url} -> ${res.status}${formatOAuthError(text)}`);
1021
+ throw new Error(`token endpoint ${endpointForMessage(url)} -> ${res.status}${formatOAuthError(text)}`);
975
1022
  }
976
1023
  try {
977
1024
  return JSON.parse(text);
978
1025
  } catch {
979
- throw new Error(`token endpoint ${url} returned non-JSON body`);
1026
+ throw new Error(`token endpoint ${endpointForMessage(url)} returned non-JSON body`);
980
1027
  }
981
1028
  }
982
1029
  };
1030
+ function endpointForMessage(url) {
1031
+ try {
1032
+ const u = new URL(url);
1033
+ return `${u.origin}${u.pathname}`;
1034
+ } catch {
1035
+ return "(unreadable address)";
1036
+ }
1037
+ }
983
1038
  function formatOAuthError(body) {
984
1039
  try {
985
1040
  const parsed = JSON.parse(body);