neon 2.42.0 → 2.44.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,149 @@
1
+ /**
2
+ * # Where the Neon CLIs keep their files on disk
3
+ *
4
+ **Deliberately impure.** It reads environment variables and touches the filesystem, which
5
+ * `@neon/config` — the package this used to be a subpath of — must never do from its root
6
+ * export. It lives here instead of there precisely so that a policy-facing package does not
7
+ * carry implementor-only code, and so `neon-init`, which has no workspace dependencies, can use
8
+ * the same resolution as everything else.
9
+ *
10
+ * It exists because three separate readers each grew their own answer to "where is the
11
+ * config directory", and all three disagreed: `packages/cli` honoured `XDG_CONFIG_HOME` but
12
+ * not `NEONCTL_CONFIG_DIR`, `packages/env` honoured the env var but not XDG, and
13
+ * `packages/init` hardcoded `~/.config/neonctl`. With `XDG_CONFIG_HOME` set, the CLI wrote
14
+ * credentials somewhere the other two never looked.
15
+ *
16
+ * ## The directory
17
+ *
18
+ * `neon` is the current name; `neonctl` is the legacy one, kept readable forever. Resolution,
19
+ * each entry winning over the next:
20
+ *
21
+ * 1. An explicit directory (a `--config-dir` flag) — **exact**, no legacy fallback.
22
+ * 2. `NEON_CONFIG_DIR` — exact.
23
+ * 3. `NEONCTL_CONFIG_DIR` (legacy name) — exact.
24
+ * 4. `$XDG_CONFIG_HOME/neon`, else `<home>/.config/neon`.
25
+ *
26
+ * An explicitly chosen directory is never paired with a fallback: `--config-dir /tmp/ci` that
27
+ * quietly read `~/.config/neonctl` would defeat the point of passing it.
28
+ *
29
+ * ## The files
30
+ *
31
+ * {@link resolveConfigFile} answers "which path should I use for this file", and it is the
32
+ * same answer for reading and writing:
33
+ *
34
+ * - Present in `neon/` → use it.
35
+ * - Present only in `neonctl/` → **use it there, in place.** An existing credentials file is
36
+ * never copied or moved, so nothing is left behind to go stale and no other tool starts
37
+ * reading an abandoned token.
38
+ * - Present in neither → the new location. New files only ever appear under `neon/`.
39
+ */
40
+ import { existsSync } from "node:fs";
41
+ import { join, resolve } from "node:path";
42
+ /** Current directory name. New files are created here. */
43
+ export const CONFIG_DIR_NAME = "neon";
44
+ /** Legacy directory name, read forever so existing installs keep working untouched. */
45
+ export const LEGACY_CONFIG_DIR_NAME = "neonctl";
46
+ /** Where files are created. See the module docs for the precedence. */
47
+ export function configDir(options = {}) {
48
+ const explicit = explicitDir(options);
49
+ if (explicit)
50
+ return explicit;
51
+ return join(configHome(options.env ?? process.env), CONFIG_DIR_NAME);
52
+ }
53
+ /**
54
+ * The legacy directory, or `undefined` when the location was chosen explicitly (in which
55
+ * case there is no legacy counterpart to fall back to).
56
+ */
57
+ export function legacyConfigDir(options = {}) {
58
+ if (explicitDir(options))
59
+ return undefined;
60
+ return join(configHome(options.env ?? process.env), LEGACY_CONFIG_DIR_NAME);
61
+ }
62
+ /**
63
+ * Resolve one file inside the config directory. Prefers the current location, falls back to
64
+ * an existing legacy file **in place**, and otherwise points at the current location so new
65
+ * files are created there.
66
+ */
67
+ export function resolveConfigFile(fileName, options = {}) {
68
+ const dir = configDir(options);
69
+ const current = resolve(dir, fileName);
70
+ if (existsSync(current))
71
+ return { path: current, dir, isLegacy: false, exists: true };
72
+ const legacyDir = legacyConfigDir(options);
73
+ if (legacyDir) {
74
+ const legacy = resolve(legacyDir, fileName);
75
+ if (existsSync(legacy))
76
+ return {
77
+ path: legacy,
78
+ dir: legacyDir,
79
+ isLegacy: true,
80
+ exists: true,
81
+ };
82
+ }
83
+ return { path: current, dir, isLegacy: false, exists: false };
84
+ }
85
+ /** `$XDG_CONFIG_HOME`, else `<home>/.config`. Falls back to a relative `.config` with no home. */
86
+ function configHome(env) {
87
+ const xdg = nonEmpty(env.XDG_CONFIG_HOME);
88
+ if (xdg)
89
+ return xdg;
90
+ const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
91
+ return home ? join(home, ".config") : ".config";
92
+ }
93
+ function explicitDir(options) {
94
+ const env = options.env ?? process.env;
95
+ return (nonEmpty(options.dir) ??
96
+ nonEmpty(env.NEON_CONFIG_DIR) ??
97
+ nonEmpty(env.NEONCTL_CONFIG_DIR));
98
+ }
99
+ function nonEmpty(value) {
100
+ if (typeof value !== "string")
101
+ return undefined;
102
+ const trimmed = value.trim();
103
+ return trimmed === "" ? undefined : trimmed;
104
+ }
105
+ export const CREDENTIALS_FILE = "credentials.json";
106
+ /**
107
+ * Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
108
+ *
109
+ * The directory was called `neonctl` until the CLI was renamed. An existing one is still read —
110
+ * see {@link credentialsPath} — but it is never written to, moved, or deleted.
111
+ */
112
+ export const defaultDir = configDir();
113
+ /**
114
+ * Where this invocation's `credentials.json` lives.
115
+ *
116
+ * When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
117
+ * directory is used **in place**: an install that predates the rename keeps working, and its
118
+ * credentials are never duplicated into a second location where one copy could go stale while
119
+ * another tool still reads it.
120
+ *
121
+ * A `--config-dir` the user actually passed is used exactly as given. Falling back out of an
122
+ * explicitly chosen directory would defeat the reason for choosing it — a CI run pointed at a
123
+ * scratch directory must never pick up a developer's real credentials.
124
+ */
125
+ export const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
126
+ /**
127
+ * Whether a credentials file is one the CLI created, rather than a path a profile adopted.
128
+ *
129
+ * Anything that deletes a credential has to ask this first. A profile entry may point anywhere —
130
+ * that is what makes adopting an existing directory a one-line edit — and a file we did not
131
+ * create is not ours to remove.
132
+ */
133
+ export const isInsideConfigDir = (configDirectory, file) => `${resolve(file)}/`.startsWith(`${resolve(configDirectory)}/`);
134
+ /**
135
+ * Whether a credentials file is one the CLI owns, counting the legacy `neonctl` directory.
136
+ *
137
+ * {@link credentialsPath} deliberately reads an existing legacy file in place rather than
138
+ * migrating it, so for a default config directory that file is ours even though it sits outside
139
+ * `neon/`. Judging ownership on the current directory alone would call an install that predates
140
+ * the rename "adopted".
141
+ */
142
+ export const isOwnedCredentialPath = (configDirectory, file) => {
143
+ if (isInsideConfigDir(configDirectory, file))
144
+ return true;
145
+ if (configDirectory !== defaultDir)
146
+ return false;
147
+ const legacy = legacyConfigDir();
148
+ return legacy !== undefined && isInsideConfigDir(legacy, file);
149
+ };
@@ -37,14 +37,13 @@
37
37
  *
38
38
  * An install with no `profiles.json` is already a valid `DEFAULT`-only state: `DEFAULT`
39
39
  * resolves to `credentials.json` in the config directory (including an existing one in the
40
- * legacy `neonctl` directory — see `@neon/config/paths`). Nothing is created until a second
40
+ * legacy `neonctl` directory — see `./paths.ts`). Nothing is created until a second
41
41
  * profile is, and nothing is ever moved.
42
42
  */
43
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
43
+ import { existsSync, readFileSync } from "node:fs";
44
44
  import { isAbsolute, relative, resolve } from "node:path";
45
- import { resolveConfigFile } from "@neon/config/paths";
46
- import { credentialsPath, defaultDir } from "./config.js";
47
- import { log } from "./log.js";
45
+ import { credentialsPath, defaultDir, resolveConfigFile } from "./paths.js";
46
+ import { writeSecretFile } from "./secure_file.js";
48
47
  export const PROFILES_FILE = "profiles.json";
49
48
  /** The implicit profile. Backed by plain `credentials.json`, with or without a profiles file. */
50
49
  export const DEFAULT_PROFILE = "DEFAULT";
@@ -60,36 +59,106 @@ export const assertValidProfileName = (name) => {
60
59
  /** Where `profiles.json` lives for this config directory (whether or not it exists yet). */
61
60
  export const profilesFilePath = (dir) => resolveConfigFile(PROFILES_FILE, dir === defaultDir ? {} : { dir }).path;
62
61
  /**
63
- * Read `profiles.json`, or `null` when there isn't one — the normal single-account state.
64
- * A malformed file is reported and treated as absent rather than breaking every command;
65
- * the worst case is that a named profile is "not found", which is recoverable, whereas
66
- * throwing here would lock the user out of `neon auth` itself.
62
+ * Read and classify `profiles.json` without deciding what to do about it.
63
+ *
64
+ * Entry keys and shapes are validated here rather than at each use. A key is a profile name,
65
+ * and a name that `assertValidProfileName` would reject cannot have been written by this CLI —
66
+ * it would travel into error messages as a recovery command nobody can run, and into a
67
+ * `credentials.<name>.json` filename.
67
68
  */
68
- export const readProfiles = (dir) => {
69
+ export const inspectProfiles = (dir) => {
69
70
  const path = profilesFilePath(dir);
70
71
  if (!existsSync(path))
71
- return null;
72
+ return { kind: "absent" };
73
+ const broken = (why) => ({
74
+ kind: "unusable",
75
+ reason: `${path} could not be read as a profiles file: ${why}`,
76
+ });
77
+ // Reading and parsing are separate failures with separate answers. Sharing one catch
78
+ // reported `EACCES` as "not valid JSON", which sends the user to edit a file that is
79
+ // perfectly valid and that they cannot open.
80
+ let contents;
72
81
  try {
73
- const parsed = JSON.parse(readFileSync(path, "utf8"));
74
- if (parsed === null ||
75
- typeof parsed !== "object" ||
76
- Array.isArray(parsed))
77
- throw new Error("not an object");
78
- const profiles = parsed.profiles;
79
- if (profiles === null ||
80
- typeof profiles !== "object" ||
81
- Array.isArray(profiles))
82
- throw new Error("missing `profiles`");
83
- return { version: 1, profiles };
82
+ contents = readFileSync(path, "utf8");
84
83
  }
85
84
  catch (err) {
86
- log.warning("Ignoring malformed %s: %s", path, err instanceof Error ? err.message : String(err));
87
- return null;
85
+ const code = err.code;
86
+ return broken(code ? `reading it failed with ${code}` : "reading it failed");
87
+ }
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(contents);
91
+ }
92
+ catch {
93
+ return broken("it is not valid JSON");
94
+ }
95
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
96
+ return broken("it does not contain an object");
97
+ const profiles = parsed.profiles;
98
+ if (profiles === null ||
99
+ typeof profiles !== "object" ||
100
+ Array.isArray(profiles))
101
+ return broken("it has no `profiles` object");
102
+ for (const [name, entry] of Object.entries(profiles)) {
103
+ if (!NAME_PATTERN.test(name))
104
+ return broken(`"${name}" is not a valid profile name`);
105
+ if (entry === null ||
106
+ typeof entry !== "object" ||
107
+ typeof entry.credentials !== "string" ||
108
+ entry.credentials.trim() === "") {
109
+ return broken(`profile "${name}" has no \`credentials\` path`);
110
+ }
111
+ }
112
+ return { kind: "ok", file: { version: 1, profiles } };
113
+ };
114
+ /**
115
+ * Read `profiles.json`, or `null` when there is nothing usable there.
116
+ *
117
+ * A malformed file is reported through `onWarn` and treated as absent, because for a *read* the
118
+ * worst case is a named profile turning up missing, which is recoverable — whereas throwing
119
+ * would lock the user out of `neon auth` itself. Writing is the opposite: see
120
+ * {@link upsertProfile}, which refuses rather than rebuilding a file it cannot read.
121
+ */
122
+ export const readProfiles = (dir,
123
+ /** Called with the reason a profiles file was ignored. The consumer owns how it reports. */
124
+ onWarn = () => { }) => {
125
+ const read = inspectProfiles(dir);
126
+ if (read.kind === "ok")
127
+ return read.file;
128
+ if (read.kind === "unusable")
129
+ onWarn(read.reason);
130
+ return null;
131
+ };
132
+ /**
133
+ * Refuse to act on a named profile when the file that defines it cannot be read.
134
+ *
135
+ * Call this **before** anything that writes a credential, opens a browser, or spends an API
136
+ * call. {@link upsertProfile} refuses too, but it runs last: by then `create` has already
137
+ * overwritten `credentials.<name>.json` and revoked the key it replaced, and `neon auth
138
+ * --profile` has already signed in over it — a refusal that arrives after the destruction it
139
+ * exists to prevent. The path resolution itself is the unsound part, since with the metadata
140
+ * unreadable the conventional filename is a guess about which account that file belongs to.
141
+ *
142
+ * `DEFAULT` is exempt: it is defined by the absence of metadata rather than by an entry, so
143
+ * signing in normally must keep working while a broken `profiles.json` is repaired.
144
+ */
145
+ export const assertProfilesUsable = (dir, name) => {
146
+ if (name === DEFAULT_PROFILE)
147
+ return;
148
+ const read = inspectProfiles(dir);
149
+ if (read.kind === "unusable") {
150
+ throw new Error(`${read.reason}. Fix or delete the file before working with profile "${name}" — it is the only record of where each account's credentials live.`);
88
151
  }
89
152
  };
90
153
  /** Resolve a profile to an absolute credentials path. Throws when a named profile is unknown. */
91
154
  export const resolveProfile = (dir, name) => {
92
- const file = readProfiles(dir);
155
+ const read = inspectProfiles(dir);
156
+ // A broken file must not be reported as `Unknown profile "work"`. That names the wrong
157
+ // problem, and the user goes looking for a profile they can see in the file in front of them.
158
+ if (read.kind === "unusable" && name !== DEFAULT_PROFILE) {
159
+ throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
160
+ }
161
+ const file = read.kind === "ok" ? read.file : null;
93
162
  const entry = file?.profiles[name];
94
163
  if (entry) {
95
164
  return {
@@ -112,7 +181,7 @@ export const resolveProfile = (dir, name) => {
112
181
  const known = file
113
182
  ? Object.keys(file.profiles).join(", ")
114
183
  : DEFAULT_PROFILE;
115
- throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon auth --profile ${name}\`.`);
184
+ throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon profile create ${name}\`.`);
116
185
  };
117
186
  /** Default location for a new named profile's credentials file. */
118
187
  export const newProfileCredentialsPath = (dir, name) => resolve(dir, `credentials.${name}.json`);
@@ -128,14 +197,24 @@ export const newProfileCredentialsPath = (dir, name) => resolve(dir, `credential
128
197
  export const upsertProfile = (dir, name, entry) => {
129
198
  assertValidProfileName(name);
130
199
  const path = profilesFilePath(dir);
131
- const file = readProfiles(dir) ?? {
132
- version: 1,
133
- profiles: {
134
- [DEFAULT_PROFILE]: {
135
- credentials: relativeToProfiles(path, credentialsPath(dir)),
200
+ const read = inspectProfiles(dir);
201
+ // Refusing is the point. Treating a broken file as absent here rebuilt it from a single
202
+ // `DEFAULT` entry and dropped every named profile in it — silent data loss, in the file
203
+ // that is the only record of where each account's credentials live. The credentials
204
+ // themselves survive, so fixing the file by hand recovers everything.
205
+ if (read.kind === "unusable") {
206
+ throw new Error(`${read.reason}. Refusing to rewrite it, because doing so would discard the profiles it defines. Fix or delete the file, then re-run.`);
207
+ }
208
+ const file = read.kind === "ok"
209
+ ? read.file
210
+ : {
211
+ version: 1,
212
+ profiles: {
213
+ [DEFAULT_PROFILE]: {
214
+ credentials: relativeToProfiles(path, credentialsPath(dir)),
215
+ },
136
216
  },
137
- },
138
- };
217
+ };
139
218
  file.profiles[name] = {
140
219
  credentials: relativeToProfiles(path, entry.credentials),
141
220
  ...(entry.label ? { label: entry.label } : {}),
@@ -163,7 +242,14 @@ export const onlyDefaultRemains = (file) => {
163
242
  (names.length === 1 && names[0] === DEFAULT_PROFILE));
164
243
  };
165
244
  export const listProfiles = (dir) => {
166
- const file = readProfiles(dir);
245
+ const read = inspectProfiles(dir);
246
+ // Listing is the command run to find out what is there, so a broken file is the answer
247
+ // rather than an obstacle. Showing only `DEFAULT` would state, as fact, that the profiles
248
+ // in that file do not exist.
249
+ if (read.kind === "unusable") {
250
+ throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
251
+ }
252
+ const file = read.kind === "ok" ? read.file : null;
167
253
  if (!file)
168
254
  return [resolveProfile(dir, DEFAULT_PROFILE)];
169
255
  const names = Object.keys(file.profiles);
@@ -172,7 +258,7 @@ export const listProfiles = (dir) => {
172
258
  return names.map((name) => resolveProfile(dir, name));
173
259
  };
174
260
  const writeProfiles = (path, file) => {
175
- writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
261
+ writeSecretFile(path, `${JSON.stringify(file, null, 2)}\n`);
176
262
  };
177
263
  const resolveEntryPath = (dir, entry) => isAbsolute(entry) ? entry : resolve(profilesDir(dir), entry);
178
264
  /** `profiles.json` may sit in the legacy directory, so entries resolve against its own dir. */
@@ -0,0 +1,43 @@
1
+ import { renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ /** Owner read/write. A credential needs those two and nothing else. */
4
+ export const SECRET_FILE_MODE = 0o600;
5
+ /**
6
+ * Write a secret to disk owner-only, by creating a temporary file in the same directory and
7
+ * renaming it over the target.
8
+ *
9
+ * The rename is what makes this correct rather than merely tidy. `writeFileSync`'s `mode`
10
+ * applies only when it *creates* the file, so writing over an existing credentials file
11
+ * leaves whatever permissions it already had — a file created `0700` by an older release
12
+ * stays `0700` forever, and one created before a umask change stays world-readable. Renaming
13
+ * a fresh inode into place means every write lands at {@link SECRET_FILE_MODE}, so the
14
+ * permissions repair themselves instead of being inherited.
15
+ *
16
+ * It also closes the window where a reader could see the file at default permissions: the
17
+ * temporary file is created `0600` *before* it holds the secret's final name, and `rename`
18
+ * is atomic within a directory, so there is no moment at which the target is readable by
19
+ * anyone else and no moment at which it is half-written.
20
+ *
21
+ * The temporary name carries the pid so two processes writing at once cannot collide on it.
22
+ */
23
+ export const writeSecretFile = (path, contents) => {
24
+ const directory = dirname(path);
25
+ const temporary = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
26
+ try {
27
+ writeFileSync(temporary, contents, {
28
+ encoding: "utf8",
29
+ mode: SECRET_FILE_MODE,
30
+ });
31
+ renameSync(temporary, path);
32
+ }
33
+ catch (err) {
34
+ // Never leave the secret behind under a temporary name the caller doesn't know about.
35
+ try {
36
+ unlinkSync(temporary);
37
+ }
38
+ catch {
39
+ // The temp file was never created, or is already gone. Report the original error.
40
+ }
41
+ throw err;
42
+ }
43
+ };
package/dist/analytics.js CHANGED
@@ -1,6 +1,7 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { Analytics } from "@segment/analytics-node";
2
+ import { inspectCredentials } from "./_shared/credentials.js";
3
3
  import { getApiClient, isNeonApiError } from "./api.js";
4
+ import { getAuthContext } from "./auth_context.js";
4
5
  import { credentialsPath } from "./config.js";
5
6
  import { isCurrentBranchProbe } from "./context.js";
6
7
  import { getGithubEnvVars, isCi } from "./env.js";
@@ -55,14 +56,23 @@ export const analyticsMiddleware = async (args) => {
55
56
  if (isCurrentBranchProbe(args)) {
56
57
  return;
57
58
  }
59
+ // Read the credentials this invocation actually authenticated with, which `ensureAuth`
60
+ // recorded. Reading `DEFAULT`'s unconditionally attributed every `--profile`-selected
61
+ // command to whichever account happened to be the default one.
62
+ const authenticatedAs = getAuthContext()?.credentialsPath ?? credentialsPath(args.configDir);
63
+ // Telemetry must never turn a damaged or unreadable credentials file into a failed command.
58
64
  try {
59
- const credentials = readFileSync(credentialsPath(args.configDir), {
60
- encoding: "utf-8",
61
- });
62
- userId = JSON.parse(credentials).user_id;
65
+ const read = inspectCredentials(authenticatedAs);
66
+ if (read.kind === "ok" &&
67
+ typeof read.credentials.user_id === "string") {
68
+ userId = read.credentials.user_id;
69
+ }
70
+ else if (read.kind !== "ok") {
71
+ log.debug("No usable credentials at %s", authenticatedAs);
72
+ }
63
73
  }
64
74
  catch (err) {
65
- log.debug("Failed to read credentials file", err);
75
+ log.debug("Could not read %s: %s", authenticatedAs, err);
66
76
  }
67
77
  try {
68
78
  if (args.apiKey) {
package/dist/api.js CHANGED
@@ -111,9 +111,44 @@ function headersToObject(headers) {
111
111
  });
112
112
  return out;
113
113
  }
114
+ /**
115
+ * Raised by {@link makeTimedFetch} when the CLI's own request timeout fires. Owning the
116
+ * type is what makes the timeout recognisable further up: `@neon/sdk` reports every
117
+ * transport failure as a `NeonNetworkError`, so matching on names or codes cannot tell a
118
+ * timeout from a reset connection.
119
+ */
120
+ class RequestTimeoutError extends Error {
121
+ constructor(timeoutMs, reason) {
122
+ super(`Request timed out after ${timeoutMs}ms`);
123
+ this.name = "RequestTimeoutError";
124
+ this.reason = reason;
125
+ }
126
+ }
127
+ /**
128
+ * Whether a failure was a request timeout rather than a connectivity problem.
129
+ *
130
+ * Walks the `cause` chain, because the SDK wraps whatever `fetch` threw. Before this, the
131
+ * check only looked at the top-level error's `name` — which is `NeonNetworkError` on every
132
+ * SDK path — so a timeout fell through to the connectivity branch and the user was told to
133
+ * check an internet connection that was working.
134
+ */
114
135
  function isAbortError(err) {
115
- return (err instanceof Error &&
116
- (err.name === "AbortError" || err.name === "TimeoutError"));
136
+ let current = err;
137
+ for (let depth = 0; depth < 6 && current != null; depth++) {
138
+ if (current instanceof RequestTimeoutError)
139
+ return true;
140
+ if (current instanceof Error &&
141
+ (current.name === "AbortError" || current.name === "TimeoutError")) {
142
+ return true;
143
+ }
144
+ // `@neon/sdk` classifies its own deadlines and cancellations by kind; the CLI
145
+ // does not set them today, but reading them keeps this correct if it ever does.
146
+ const kind = current.kind;
147
+ if (kind === "timeout" || kind === "aborted")
148
+ return true;
149
+ current = current.cause;
150
+ }
151
+ return false;
117
152
  }
118
153
  /**
119
154
  * Walk an error's `cause` chain to find the underlying socket/DNS `code` (e.g.
@@ -182,17 +217,53 @@ function networkError(err) {
182
217
  * and lightweight debug logging of the request line + response status — the
183
218
  * fetch-native replacement for the old `axios-debug-log` wiring.
184
219
  */
185
- const timedFetch = async (input, init) => {
186
- const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
220
+ /**
221
+ * The largest delay a timer can represent. Above it Node warns
222
+ * (`TimeoutOverflowWarning`) and fires after 1ms instead.
223
+ */
224
+ const MAX_TIMER_MS = 2 ** 31 - 1;
225
+ /**
226
+ * Reject a timeout `AbortSignal.timeout` would refuse or silently mistreat.
227
+ *
228
+ * Without this the bad value surfaces as the very failure this classification exists to
229
+ * prevent: `-1`, `NaN`, `Infinity`, fractions and anything above `4294967295` throw
230
+ * `ERR_OUT_OF_RANGE` from inside the fetch wrapper, which is then wrapped as a
231
+ * `NeonNetworkError` and reported as a broken internet connection.
232
+ *
233
+ * `0`, and the band from {@link MAX_TIMER_MS} + 1 up to `4294967295`, are worse still:
234
+ * both are accepted, and both make every request time out immediately — `0` by asking for
235
+ * it, the band because a timer above the signed 32-bit ceiling collapses to 1ms.
236
+ */
237
+ function validateRequestTimeout(ms) {
238
+ if (!Number.isInteger(ms) || ms < 1 || ms > MAX_TIMER_MS) {
239
+ throw new Error(`requestTimeoutMs must be a whole number of milliseconds between 1 and ${MAX_TIMER_MS}; received ${ms}.`);
240
+ }
241
+ return ms;
242
+ }
243
+ const makeTimedFetch = (requestTimeoutMs) => async (input, init) => {
244
+ const timeout = AbortSignal.timeout(requestTimeoutMs);
187
245
  const signal = init?.signal
188
246
  ? AbortSignal.any([init.signal, timeout])
189
247
  : timeout;
190
248
  const method = init?.method ?? (input instanceof Request ? input.method : "GET");
191
249
  const url = input instanceof Request ? input.url : String(input);
192
250
  log.debug("%s %s", method.toUpperCase(), url);
193
- const response = await fetch(input, { ...init, signal });
194
- log.debug("%d %s", response.status, response.statusText);
195
- return response;
251
+ try {
252
+ const response = await fetch(input, { ...init, signal });
253
+ log.debug("%d %s", response.status, response.statusText);
254
+ return response;
255
+ }
256
+ catch (err) {
257
+ // Our own timeout fired, and we are the only code that knows that: by the
258
+ // time this reaches `networkError` the SDK has wrapped it as a
259
+ // `NeonNetworkError`, and neither its name nor its `cause` chain carries a
260
+ // string code to recognise. Raise something we own instead of leaving the
261
+ // classification to guess.
262
+ if (timeout.aborted && !init?.signal?.aborted) {
263
+ throw new RequestTimeoutError(requestTimeoutMs, err);
264
+ }
265
+ throw err;
266
+ }
196
267
  };
197
268
  const RETRY_COUNT = 5;
198
269
  const RETRY_DELAY = 3000;
@@ -246,12 +317,15 @@ async function readJsonBody(response) {
246
317
  return text;
247
318
  }
248
319
  }
249
- export const getApiClient = ({ apiKey, apiHost }) => {
320
+ export const getApiClient = ({ apiKey, apiHost, requestTimeoutMs = REQUEST_TIMEOUT_MS, }) => {
250
321
  const baseUrl = apiHost ?? DEFAULT_API_HOST;
322
+ // Shared by the generated client and the low-level `request()` escape hatch, so both
323
+ // paths get the same timeout and the same timeout classification.
324
+ const fetchWithTimeout = makeTimedFetch(validateRequestTimeout(requestTimeoutMs));
251
325
  const client = createClient(createConfig({
252
326
  auth: () => apiKey,
253
327
  baseUrl,
254
- fetch: timedFetch,
328
+ fetch: fetchWithTimeout,
255
329
  headers: { "User-Agent": USER_AGENT },
256
330
  }));
257
331
  /** Await a raw call, unwrap to a `{ data, status, headers }` envelope, or throw {@link NeonApiError}. */
@@ -306,7 +380,7 @@ export const getApiClient = ({ apiKey, apiHost }) => {
306
380
  }
307
381
  let response;
308
382
  try {
309
- response = await timedFetch(url, {
383
+ response = await fetchWithTimeout(url, {
310
384
  method: params.method,
311
385
  headers,
312
386
  ...(payload !== undefined ? { body: payload } : {}),
@@ -346,6 +420,20 @@ export const getApiClient = ({ apiKey, apiHost }) => {
346
420
  getCurrentUserOrganizations: () => call(() => raw.getCurrentUserOrganizations({ client })),
347
421
  getAuthDetails: () => call(() => raw.getAuthDetails({ client })),
348
422
  getActiveRegions: () => call(() => raw.getActiveRegions({ client })),
423
+ // ─── API keys ────────────────────────────────────────────────────────
424
+ // Account keys reach everything the account can. Org keys can additionally be
425
+ // narrowed to a single project via `project_id`, which is the only way to mint a
426
+ // least-privilege credential — so the org endpoints are not merely the org
427
+ // equivalent of the account ones.
428
+ listApiKeys: () => call(() => raw.listApiKeys({ client })),
429
+ createApiKey: (body) => call(() => raw.createApiKey({ client, body })),
430
+ revokeApiKey: (keyId) => call(() => raw.revokeApiKey({ client, path: { key_id: keyId } })),
431
+ listOrgApiKeys: (orgId) => call(() => raw.listOrgApiKeys({ client, path: { org_id: orgId } })),
432
+ createOrgApiKey: (orgId, body) => call(() => raw.createOrgApiKey({ client, path: { org_id: orgId }, body })),
433
+ revokeOrgApiKey: (orgId, keyId) => call(() => raw.revokeOrgApiKey({
434
+ client,
435
+ path: { org_id: orgId, key_id: keyId },
436
+ })),
349
437
  // ─── Projects ────────────────────────────────────────────────────────
350
438
  listProjects: (query = {}) => call(() => raw.listProjects({ client, query })),
351
439
  listSharedProjects: (query = {}) => call(() => raw.listSharedProjects({