neon 4.13.0 → 4.14.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 (41) hide show
  1. package/README.md +55 -13
  2. package/dist/_chunks/{credential_io-YeAxg9Xn.js → credential_io-D8AR-jvB.js} +4 -4
  3. package/dist/_chunks/{env-NbA61JR3.js → env-CZmstYrY.js} +71 -59
  4. package/dist/_chunks/{env_services-Tz9G4JeT.js → env_services-CAWrZTWa.js} +138 -41
  5. package/dist/_shared/auth_selection.js +83 -0
  6. package/dist/_shared/credentials.js +188 -0
  7. package/dist/_shared/paths.js +151 -0
  8. package/dist/_shared/profiles.js +226 -0
  9. package/dist/_shared/secure_file.js +41 -0
  10. package/dist/analytics.js +2 -2
  11. package/dist/auth.js +101 -11
  12. package/dist/auth_context.js +2 -11
  13. package/dist/claimable/api.js +2 -1
  14. package/dist/commands/ask.js +2 -1
  15. package/dist/commands/auth.js +155 -63
  16. package/dist/commands/checkout.js +28 -9
  17. package/dist/commands/claim.js +11 -7
  18. package/dist/commands/config.js +9 -9
  19. package/dist/commands/deploy.js +2 -1
  20. package/dist/commands/deploy_help.js +10 -0
  21. package/dist/commands/dev.js +147 -36
  22. package/dist/commands/env.js +33 -44
  23. package/dist/commands/functions.js +63 -2
  24. package/dist/commands/profile.js +2 -2
  25. package/dist/config_services.js +36 -1
  26. package/dist/credential_io.js +1 -1
  27. package/dist/custom_domains_api.js +37 -0
  28. package/dist/dev/env.js +1 -1
  29. package/dist/dev/functions.js +28 -11
  30. package/dist/dev/websocket.js +1 -1
  31. package/dist/env_services.js +2 -2
  32. package/dist/index.js +73 -20
  33. package/dist/init/build_config.js +4 -0
  34. package/dist/neon_services.js +2 -2
  35. package/dist/psql/wire/connection.js +1 -1
  36. package/dist/refresh_lock.js +44 -0
  37. package/dist/retire_credential.js +1 -1
  38. package/dist/test_utils/neon_api_server.js +60 -0
  39. package/dist/test_utils/rotating_oauth_server.js +94 -0
  40. package/dist/utils/config_diff.js +2 -13
  41. package/package.json +7 -5
@@ -0,0 +1,151 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ //#region src/_shared/paths.ts
4
+ /**
5
+ * # Where the Neon CLIs keep their files on disk
6
+ *
7
+ **Deliberately impure.** It reads environment variables and touches the filesystem, which
8
+ * `@neon/config` — the package this used to be a subpath of — must never do from its root
9
+ * export. It lives here instead of there precisely so that a policy-facing package does not
10
+ * carry implementor-only code.
11
+ *
12
+ * It exists because three separate readers each grew their own answer to "where is the
13
+ * config directory", and all three disagreed: `packages/cli` honoured `XDG_CONFIG_HOME` but
14
+ * not `NEONCTL_CONFIG_DIR`, `packages/env` honoured the env var but not XDG, and the init
15
+ * flow hardcoded `~/.config/neonctl`. With `XDG_CONFIG_HOME` set, the CLI wrote
16
+ * credentials somewhere the other two never looked.
17
+ *
18
+ * ## The directory
19
+ *
20
+ * `neon` is the current name; `neonctl` is the legacy one, kept readable forever. Resolution,
21
+ * each entry winning over the next:
22
+ *
23
+ * 1. An explicit directory (a `--config-dir` flag) — **exact**, no legacy fallback.
24
+ * 2. `NEON_CONFIG_DIR` — exact.
25
+ * 3. `NEONCTL_CONFIG_DIR` (legacy name) — exact.
26
+ * 4. `$XDG_CONFIG_HOME/neon`, else `<home>/.config/neon`.
27
+ *
28
+ * An explicitly chosen directory is never paired with a fallback: `--config-dir /tmp/ci` that
29
+ * quietly read `~/.config/neonctl` would defeat the point of passing it.
30
+ *
31
+ * ## The files
32
+ *
33
+ * {@link resolveConfigFile} answers "which path should I use for this file", and it is the
34
+ * same answer for reading and writing:
35
+ *
36
+ * - Present in `neon/` → use it.
37
+ * - Present only in `neonctl/` → **use it there, in place.** An existing credentials file is
38
+ * never copied or moved, so nothing is left behind to go stale and no other tool starts
39
+ * reading an abandoned token.
40
+ * - Present in neither → the new location. New files only ever appear under `neon/`.
41
+ */
42
+ /** Current directory name. New files are created here. */
43
+ const CONFIG_DIR_NAME = "neon";
44
+ /** Legacy directory name, read forever so existing installs keep working untouched. */
45
+ const LEGACY_CONFIG_DIR_NAME = "neonctl";
46
+ /** Where files are created. See the module docs for the precedence. */
47
+ function configDir(options = {}) {
48
+ const explicit = explicitDir(options);
49
+ if (explicit) return explicit;
50
+ return join(configHome(options.env ?? process.env), CONFIG_DIR_NAME);
51
+ }
52
+ /**
53
+ * The legacy directory, or `undefined` when the location was chosen explicitly (in which
54
+ * case there is no legacy counterpart to fall back to).
55
+ */
56
+ function legacyConfigDir(options = {}) {
57
+ if (explicitDir(options)) return void 0;
58
+ return join(configHome(options.env ?? process.env), LEGACY_CONFIG_DIR_NAME);
59
+ }
60
+ /**
61
+ * Resolve one file inside the config directory. Prefers the current location, falls back to
62
+ * an existing legacy file **in place**, and otherwise points at the current location so new
63
+ * files are created there.
64
+ */
65
+ function resolveConfigFile(fileName, options = {}) {
66
+ const dir = configDir(options);
67
+ const current = resolve(dir, fileName);
68
+ if (existsSync(current)) return {
69
+ path: current,
70
+ dir,
71
+ isLegacy: false,
72
+ exists: true
73
+ };
74
+ const legacyDir = legacyConfigDir(options);
75
+ if (legacyDir) {
76
+ const legacy = resolve(legacyDir, fileName);
77
+ if (existsSync(legacy)) return {
78
+ path: legacy,
79
+ dir: legacyDir,
80
+ isLegacy: true,
81
+ exists: true
82
+ };
83
+ }
84
+ return {
85
+ path: current,
86
+ dir,
87
+ isLegacy: false,
88
+ exists: false
89
+ };
90
+ }
91
+ /** `$XDG_CONFIG_HOME`, else `<home>/.config`. Falls back to a relative `.config` with no home. */
92
+ function configHome(env) {
93
+ const xdg = nonEmpty(env.XDG_CONFIG_HOME);
94
+ if (xdg) return xdg;
95
+ const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
96
+ return home ? join(home, ".config") : ".config";
97
+ }
98
+ function explicitDir(options) {
99
+ const env = options.env ?? process.env;
100
+ return nonEmpty(options.dir) ?? nonEmpty(env.NEON_CONFIG_DIR) ?? nonEmpty(env.NEONCTL_CONFIG_DIR);
101
+ }
102
+ function nonEmpty(value) {
103
+ if (typeof value !== "string") return void 0;
104
+ const trimmed = value.trim();
105
+ return trimmed === "" ? void 0 : trimmed;
106
+ }
107
+ const CREDENTIALS_FILE = "credentials.json";
108
+ /**
109
+ * Default for `--config-dir`: `$XDG_CONFIG_HOME/neon`, else `~/.config/neon`.
110
+ *
111
+ * The directory was called `neonctl` until the CLI was renamed. An existing one is still read —
112
+ * see {@link credentialsPath} — but it is never written to, moved, or deleted.
113
+ */
114
+ const defaultDir = configDir();
115
+ /**
116
+ * Where this invocation's `credentials.json` lives.
117
+ *
118
+ * When `--config-dir` was left at its default, an existing file in the legacy `neonctl`
119
+ * directory is used **in place**: an install that predates the rename keeps working, and its
120
+ * credentials are never duplicated into a second location where one copy could go stale while
121
+ * another tool still reads it.
122
+ *
123
+ * A `--config-dir` the user actually passed is used exactly as given. Falling back out of an
124
+ * explicitly chosen directory would defeat the reason for choosing it — a CI run pointed at a
125
+ * scratch directory must never pick up a developer's real credentials.
126
+ */
127
+ const credentialsPath = (dir) => resolveConfigFile(CREDENTIALS_FILE, dir === defaultDir ? {} : { dir }).path;
128
+ /**
129
+ * Whether a credentials file is one the CLI created, rather than a path a profile adopted.
130
+ *
131
+ * Anything that deletes a credential has to ask this first. A profile entry may point anywhere —
132
+ * that is what makes adopting an existing directory a one-line edit — and a file we did not
133
+ * create is not ours to remove.
134
+ */
135
+ const isInsideConfigDir = (configDirectory, file) => `${resolve(file)}/`.startsWith(`${resolve(configDirectory)}/`);
136
+ /**
137
+ * Whether a credentials file is one the CLI owns, counting the legacy `neonctl` directory.
138
+ *
139
+ * {@link credentialsPath} deliberately reads an existing legacy file in place rather than
140
+ * migrating it, so for a default config directory that file is ours even though it sits outside
141
+ * `neon/`. Judging ownership on the current directory alone would call an install that predates
142
+ * the rename "adopted".
143
+ */
144
+ const isOwnedCredentialPath = (configDirectory, file) => {
145
+ if (isInsideConfigDir(configDirectory, file)) return true;
146
+ if (configDirectory !== defaultDir) return false;
147
+ const legacy = legacyConfigDir();
148
+ return legacy !== void 0 && isInsideConfigDir(legacy, file);
149
+ };
150
+ //#endregion
151
+ export { CONFIG_DIR_NAME, CREDENTIALS_FILE, LEGACY_CONFIG_DIR_NAME, configDir, credentialsPath, defaultDir, isInsideConfigDir, isOwnedCredentialPath, legacyConfigDir, resolveConfigFile };
@@ -0,0 +1,226 @@
1
+ import { credentialsPath, defaultDir, resolveConfigFile } from "./paths.js";
2
+ import { writeSecretFile } from "./secure_file.js";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { isAbsolute, relative, resolve } from "node:path";
5
+ //#region src/_shared/profiles.ts
6
+ /**
7
+ * # Profiles — several Neon accounts in one config directory
8
+ *
9
+ * A profile is **a pointer to a credentials file**. Nothing more. That constraint is what
10
+ * keeps the feature small: there is no mirror, no per-profile directory tree, no persistent
11
+ * "active profile" state to fall out of sync, and no migration.
12
+ *
13
+ * ```
14
+ * ~/.config/neon/
15
+ * ├── credentials.json # this IS the DEFAULT profile, not a copy of it
16
+ * ├── credentials.work.json # created by `neon auth --profile work`
17
+ * └── profiles.json # created only once a second profile exists
18
+ * ```
19
+ *
20
+ * `profiles.json` maps a name to a path, and the path may point anywhere — which is what
21
+ * makes adopting an existing directory a one-line edit rather than an import command:
22
+ *
23
+ * ```json
24
+ * {
25
+ * "version": 1,
26
+ * "profiles": {
27
+ * "DEFAULT": { "credentials": "credentials.json" },
28
+ * "work": {
29
+ * "credentials": "../neonctl-databricks/credentials.json",
30
+ * "label": "someone@example.com"
31
+ * }
32
+ * }
33
+ * }
34
+ * ```
35
+ *
36
+ * ## Selection
37
+ *
38
+ * `--profile` → `NEON_PROFILE` → `DEFAULT`. Per invocation, like `AWS_PROFILE`; there is no
39
+ * `profile use` command, so nothing persists that could disagree with what you typed.
40
+ *
41
+ * ## Compatibility
42
+ *
43
+ * An install with no `profiles.json` is already a valid `DEFAULT`-only state: `DEFAULT`
44
+ * resolves to `credentials.json` in the config directory (including an existing one in the
45
+ * legacy `neonctl` directory — see `./paths.ts`). Nothing is created until a second
46
+ * profile is, and nothing is ever moved.
47
+ */
48
+ const PROFILES_FILE = "profiles.json";
49
+ /** The implicit profile. Backed by plain `credentials.json`, with or without a profiles file. */
50
+ const DEFAULT_PROFILE = "DEFAULT";
51
+ /** Profile names become part of a filename, so keep them boring. */
52
+ const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
53
+ /** Which profile this invocation should use: `--profile` → `NEON_PROFILE` → `DEFAULT`. */
54
+ const selectProfileName = (flag, env = process.env) => nonEmpty(flag) ?? nonEmpty(env.NEON_PROFILE) ?? "DEFAULT";
55
+ const assertValidProfileName = (name) => {
56
+ if (!NAME_PATTERN.test(name)) throw new Error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore, starting with a letter or digit.`);
57
+ };
58
+ /** Where `profiles.json` lives for this config directory (whether or not it exists yet). */
59
+ const profilesFilePath = (dir) => resolveConfigFile(PROFILES_FILE, dir === defaultDir ? {} : { dir }).path;
60
+ /**
61
+ * Read and classify `profiles.json` without deciding what to do about it.
62
+ *
63
+ * Entry keys and shapes are validated here rather than at each use. A key is a profile name,
64
+ * and a name that `assertValidProfileName` would reject cannot have been written by this CLI —
65
+ * it would travel into error messages as a recovery command nobody can run, and into a
66
+ * `credentials.<name>.json` filename.
67
+ */
68
+ const inspectProfiles = (dir) => {
69
+ const path = profilesFilePath(dir);
70
+ if (!existsSync(path)) return { kind: "absent" };
71
+ const broken = (why) => ({
72
+ kind: "unusable",
73
+ reason: `${path} could not be read as a profiles file: ${why}`
74
+ });
75
+ let contents;
76
+ try {
77
+ contents = readFileSync(path, "utf8");
78
+ } catch (err) {
79
+ const code = err.code;
80
+ return broken(code ? `reading it failed with ${code}` : "reading it failed");
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(contents);
85
+ } catch {
86
+ return broken("it is not valid JSON");
87
+ }
88
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return broken("it does not contain an object");
89
+ const profiles = parsed.profiles;
90
+ if (profiles === null || typeof profiles !== "object" || Array.isArray(profiles)) return broken("it has no `profiles` object");
91
+ for (const [name, entry] of Object.entries(profiles)) {
92
+ if (!NAME_PATTERN.test(name)) return broken(`"${name}" is not a valid profile name`);
93
+ if (entry === null || typeof entry !== "object" || typeof entry.credentials !== "string" || entry.credentials.trim() === "") return broken(`profile "${name}" has no \`credentials\` path`);
94
+ }
95
+ return {
96
+ kind: "ok",
97
+ file: {
98
+ version: 1,
99
+ profiles
100
+ }
101
+ };
102
+ };
103
+ /**
104
+ * Read `profiles.json`, or `null` when there is nothing usable there.
105
+ *
106
+ * A malformed file is reported through `onWarn` and treated as absent, because for a *read* the
107
+ * worst case is a named profile turning up missing, which is recoverable — whereas throwing
108
+ * would lock the user out of `neon auth` itself. Writing is the opposite: see
109
+ * {@link upsertProfile}, which refuses rather than rebuilding a file it cannot read.
110
+ */
111
+ const readProfiles = (dir, onWarn = () => {}) => {
112
+ const read = inspectProfiles(dir);
113
+ if (read.kind === "ok") return read.file;
114
+ if (read.kind === "unusable") onWarn(read.reason);
115
+ return null;
116
+ };
117
+ /**
118
+ * Refuse to act on a named profile when the file that defines it cannot be read.
119
+ *
120
+ * Call this **before** anything that writes a credential, opens a browser, or spends an API
121
+ * call. {@link upsertProfile} refuses too, but it runs last: by then `create` has already
122
+ * overwritten `credentials.<name>.json` and revoked the key it replaced, and `neon auth
123
+ * --profile` has already signed in over it — a refusal that arrives after the destruction it
124
+ * exists to prevent. The path resolution itself is the unsound part, since with the metadata
125
+ * unreadable the conventional filename is a guess about which account that file belongs to.
126
+ *
127
+ * `DEFAULT` is exempt: it is defined by the absence of metadata rather than by an entry, so
128
+ * signing in normally must keep working while a broken `profiles.json` is repaired.
129
+ */
130
+ const assertProfilesUsable = (dir, name) => {
131
+ if (name === "DEFAULT") return;
132
+ const read = inspectProfiles(dir);
133
+ if (read.kind === "unusable") 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.`);
134
+ };
135
+ /** Resolve a profile to an absolute credentials path. Throws when a named profile is unknown. */
136
+ const resolveProfile = (dir, name) => {
137
+ const read = inspectProfiles(dir);
138
+ if (read.kind === "unusable" && name !== "DEFAULT") throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
139
+ const file = read.kind === "ok" ? read.file : null;
140
+ const entry = file?.profiles[name];
141
+ if (entry) return {
142
+ name,
143
+ credentialsPath: resolveEntryPath(dir, entry.credentials),
144
+ ...entry.label ? { label: entry.label } : {},
145
+ ...entry.userId ? { userId: entry.userId } : {},
146
+ declared: true
147
+ };
148
+ if (name === "DEFAULT") return {
149
+ name,
150
+ credentialsPath: credentialsPath(dir),
151
+ declared: false
152
+ };
153
+ const known = file ? Object.keys(file.profiles).join(", ") : DEFAULT_PROFILE;
154
+ throw new Error(`Unknown profile "${name}". Known profiles: ${known}. Create it with \`neon profile create ${name}\`.`);
155
+ };
156
+ /** Default location for a new named profile's credentials file. */
157
+ const newProfileCredentialsPath = (dir, name) => resolve(dir, `credentials.${name}.json`);
158
+ /**
159
+ * Record a profile, creating `profiles.json` if this is the first named one.
160
+ *
161
+ * When the file is created, `DEFAULT` is written explicitly and pointed at wherever
162
+ * `credentials.json` actually is. That matters for an install predating the directory
163
+ * rename: `profiles.json` is created in `neon/` while the credentials are still in
164
+ * `neonctl/`, so `DEFAULT` is recorded as `../neonctl/credentials.json` rather than a
165
+ * relative name that would resolve to a file that isn't there.
166
+ */
167
+ const upsertProfile = (dir, name, entry) => {
168
+ assertValidProfileName(name);
169
+ const path = profilesFilePath(dir);
170
+ const read = inspectProfiles(dir);
171
+ if (read.kind === "unusable") 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.`);
172
+ const file = read.kind === "ok" ? read.file : {
173
+ version: 1,
174
+ profiles: { [DEFAULT_PROFILE]: { credentials: relativeToProfiles(path, credentialsPath(dir)) } }
175
+ };
176
+ file.profiles[name] = {
177
+ credentials: relativeToProfiles(path, entry.credentials),
178
+ ...entry.label ? { label: entry.label } : {},
179
+ ...entry.userId ? { userId: entry.userId } : {}
180
+ };
181
+ writeProfiles(path, file);
182
+ };
183
+ /** Remove an entry. Returns false when it wasn't there. */
184
+ const removeProfileEntry = (dir, name) => {
185
+ const path = profilesFilePath(dir);
186
+ const file = readProfiles(dir);
187
+ if (!file?.profiles[name]) return false;
188
+ delete file.profiles[name];
189
+ writeProfiles(path, file);
190
+ return true;
191
+ };
192
+ /**
193
+ * True when only `DEFAULT` is left, so `profiles.json` no longer earns its place. Mirrors
194
+ * lazy creation: a single-account install has no profiles file, before or after.
195
+ */
196
+ const onlyDefaultRemains = (file) => {
197
+ const names = Object.keys(file.profiles);
198
+ return names.length === 0 || names.length === 1 && names[0] === "DEFAULT";
199
+ };
200
+ const listProfiles = (dir) => {
201
+ const read = inspectProfiles(dir);
202
+ if (read.kind === "unusable") throw new Error(`${read.reason}. Fix or delete the file — every named profile is defined in it.`);
203
+ const file = read.kind === "ok" ? read.file : null;
204
+ if (!file) return [resolveProfile(dir, DEFAULT_PROFILE)];
205
+ const names = Object.keys(file.profiles);
206
+ if (!names.includes("DEFAULT")) names.unshift(DEFAULT_PROFILE);
207
+ return names.map((name) => resolveProfile(dir, name));
208
+ };
209
+ const writeProfiles = (path, file) => {
210
+ writeSecretFile(path, `${JSON.stringify(file, null, 2)}\n`);
211
+ };
212
+ const resolveEntryPath = (dir, entry) => isAbsolute(entry) ? entry : resolve(profilesDir(dir), entry);
213
+ /** `profiles.json` may sit in the legacy directory, so entries resolve against its own dir. */
214
+ const profilesDir = (dir) => resolve(profilesFilePath(dir), "..");
215
+ /** Keep entries relative when they sit near `profiles.json`; absolute paths stay absolute. */
216
+ const relativeToProfiles = (profilesPath, target) => {
217
+ const rel = relative(resolve(profilesPath, ".."), target);
218
+ return rel && !isAbsolute(rel) ? rel : target;
219
+ };
220
+ function nonEmpty(value) {
221
+ if (typeof value !== "string") return void 0;
222
+ const trimmed = value.trim();
223
+ return trimmed === "" ? void 0 : trimmed;
224
+ }
225
+ //#endregion
226
+ export { DEFAULT_PROFILE, PROFILES_FILE, assertProfilesUsable, assertValidProfileName, inspectProfiles, listProfiles, newProfileCredentialsPath, onlyDefaultRemains, profilesFilePath, readProfiles, removeProfileEntry, resolveProfile, selectProfileName, upsertProfile };
@@ -0,0 +1,41 @@
1
+ import { renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ //#region src/_shared/secure_file.ts
4
+ /** Owner read/write. A credential needs those two and nothing else. */
5
+ const SECRET_FILE_MODE = 384;
6
+ /**
7
+ * Write a secret to disk owner-only, by creating a temporary file in the same directory and
8
+ * renaming it over the target.
9
+ *
10
+ * The rename is what makes this correct rather than merely tidy. `writeFileSync`'s `mode`
11
+ * applies only when it *creates* the file, so writing over an existing credentials file
12
+ * leaves whatever permissions it already had — a file created `0700` by an older release
13
+ * stays `0700` forever, and one created before a umask change stays world-readable. Renaming
14
+ * a fresh inode into place means every write lands at {@link SECRET_FILE_MODE}, so the
15
+ * permissions repair themselves instead of being inherited.
16
+ *
17
+ * It also closes the window where a reader could see the file at default permissions: the
18
+ * temporary file is created `0600` *before* it holds the secret's final name, and `rename`
19
+ * is atomic within a directory, so there is no moment at which the target is readable by
20
+ * anyone else and no moment at which it is half-written.
21
+ *
22
+ * The temporary name carries the pid so two processes writing at once cannot collide on it.
23
+ */
24
+ const writeSecretFile = (path, contents) => {
25
+ const directory = dirname(path);
26
+ const temporary = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
27
+ try {
28
+ writeFileSync(temporary, contents, {
29
+ encoding: "utf8",
30
+ mode: 384
31
+ });
32
+ renameSync(temporary, path);
33
+ } catch (err) {
34
+ try {
35
+ unlinkSync(temporary);
36
+ } catch {}
37
+ throw err;
38
+ }
39
+ };
40
+ //#endregion
41
+ export { SECRET_FILE_MODE, writeSecretFile };
package/dist/analytics.js CHANGED
@@ -1,11 +1,11 @@
1
- import { a as OAUTH, t as storeFor } from "./_chunks/credential_io-YeAxg9Xn.js";
1
+ import { a as OAUTH, t as storeFor } from "./_chunks/credential_io-D8AR-jvB.js";
2
2
  import { getCliAgent, getGithubEnvVars, isCi } from "./env.js";
3
3
  import { log } from "./log.js";
4
4
  import pkg_default from "./pkg.js";
5
5
  import { getApiClient, isNeonApiError } from "./api.js";
6
+ import { getAuthContext } from "./auth_context.js";
6
7
  import { n as credentialsPath } from "./_chunks/paths-DMq0Lt7a.js";
7
8
  import "./config.js";
8
- import { getAuthContext } from "./auth_context.js";
9
9
  import { isCurrentBranchProbe } from "./context.js";
10
10
  import { Analytics } from "@segment/analytics-node";
11
11
  //#region src/analytics.ts
package/dist/auth.js CHANGED
@@ -30,13 +30,101 @@ const NEONCTL_SCOPES = [
30
30
  ];
31
31
  const AUTH_TIMEOUT_SECONDS = 60;
32
32
  const defaultClientID = "neonctl";
33
- const refreshToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) => {
34
- log.debug("Discovering oauth server");
33
+ /** `terminal` is a dead grant. A network failure is not. */
34
+ var AuthRefreshError = class extends Error {
35
+ constructor(message, options) {
36
+ super(message);
37
+ this.name = "AuthRefreshError";
38
+ this.terminal = options.terminal;
39
+ this.oauthError = options.oauthError;
40
+ this.cause = options.cause;
41
+ }
42
+ };
43
+ const DEAD_GRANT_ERRORS = /* @__PURE__ */ new Set([
44
+ "invalid_grant",
45
+ "token_inactive",
46
+ "invalid_token"
47
+ ]);
48
+ const classifyRefreshFailure = (err) => {
49
+ const rejection = oauthRejection(err);
50
+ if (rejection) return new AuthRefreshError(`The Neon authorization server rejected the stored session: ${rejection.error}${rejection.description ? `: ${rejection.description}` : ""}`, {
51
+ terminal: DEAD_GRANT_ERRORS.has(rejection.error),
52
+ oauthError: rejection.error,
53
+ cause: err
54
+ });
55
+ return new AuthRefreshError(`Could not reach the Neon authorization server to refresh the stored session: ${err instanceof Error ? err.message : String(err)}`, {
56
+ terminal: false,
57
+ cause: err
58
+ });
59
+ };
60
+ const oauthRejection = (err) => {
61
+ if (err instanceof client.ResponseBodyError) return {
62
+ error: err.error,
63
+ ...err.error_description ? { description: err.error_description } : {}
64
+ };
65
+ if (err instanceof client.WWWAuthenticateChallengeError) {
66
+ const challenge = err.cause.find(({ parameters }) => parameters.error);
67
+ const error = challenge?.parameters.error;
68
+ if (typeof error !== "string") return null;
69
+ const description = challenge?.parameters.error_description;
70
+ return {
71
+ error,
72
+ ...typeof description === "string" ? { description } : {}
73
+ };
74
+ }
75
+ return null;
76
+ };
77
+ const isLoopbackHostname = (hostname) => hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
78
+ /** Remote HTTP would send the refresh token in the clear. Loopback is for local test servers. */
79
+ const oauthExecute = (oauthHost, allowUnsafeTls) => {
80
+ if (allowUnsafeTls === true) return [client.allowInsecureRequests];
81
+ try {
82
+ const url = new URL(oauthHost);
83
+ if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) return [client.allowInsecureRequests];
84
+ } catch {
85
+ return;
86
+ }
87
+ };
88
+ const isSafeOAuthUrl = (value) => {
89
+ try {
90
+ const url = new URL(value);
91
+ if (url.protocol === "https:") return true;
92
+ return url.protocol === "http:" && isLoopbackHostname(url.hostname);
93
+ } catch {
94
+ return false;
95
+ }
96
+ };
97
+ const assertSafeOAuthEndpoints = (configuration, allowUnsafeTls) => {
98
+ if (allowUnsafeTls === true) return;
99
+ const metadata = configuration.serverMetadata();
100
+ for (const endpoint of [
101
+ metadata.token_endpoint,
102
+ metadata.revocation_endpoint,
103
+ metadata.authorization_endpoint,
104
+ metadata.jwks_uri
105
+ ]) {
106
+ if (typeof endpoint !== "string" || endpoint === "") continue;
107
+ if (!isSafeOAuthUrl(endpoint)) throw new AuthRefreshError(`The authorization server advertised ${endpoint}, which is not HTTPS or loopback HTTP.`, { terminal: false });
108
+ }
109
+ };
110
+ const discover = async ({ oauthHost, clientId, allowUnsafeTls }) => {
35
111
  const configuration = await client.discovery(new URL(oauthHost), clientId, { token_endpoint_auth_method: "none" }, client.None(), {
36
112
  timeout: SERVER_TIMEOUT,
37
- execute: allowUnsafeTls ? [client.allowInsecureRequests] : void 0
113
+ execute: oauthExecute(oauthHost, allowUnsafeTls)
38
114
  });
39
- return await client.refreshTokenGrant(configuration, tokenSet.refresh_token);
115
+ assertSafeOAuthEndpoints(configuration, allowUnsafeTls);
116
+ return configuration;
117
+ };
118
+ const refreshToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) => {
119
+ const refresh = tokenSet.refresh_token;
120
+ if (typeof refresh !== "string" || refresh === "") throw new AuthRefreshError("The stored credentials hold no refresh token.", { terminal: true });
121
+ log.debug("Discovering oauth server");
122
+ const configuration = await discover({
123
+ oauthHost,
124
+ clientId,
125
+ allowUnsafeTls
126
+ });
127
+ return await client.refreshTokenGrant(configuration, refresh);
40
128
  };
41
129
  /**
42
130
  * Invalidate a refresh token at the authorization server (RFC 7009).
@@ -50,9 +138,10 @@ const revokeToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) =>
50
138
  const token = tokenSet.refresh_token;
51
139
  if (typeof token !== "string" || token === "") return false;
52
140
  try {
53
- const configuration = await client.discovery(new URL(oauthHost), clientId, { token_endpoint_auth_method: "none" }, client.None(), {
54
- timeout: SERVER_TIMEOUT,
55
- execute: allowUnsafeTls ? [client.allowInsecureRequests] : void 0
141
+ const configuration = await discover({
142
+ oauthHost,
143
+ clientId,
144
+ allowUnsafeTls
56
145
  });
57
146
  await client.tokenRevocation(configuration, token, { token_type_hint: "refresh_token" });
58
147
  return true;
@@ -63,9 +152,10 @@ const revokeToken = async ({ oauthHost, clientId, allowUnsafeTls }, tokenSet) =>
63
152
  };
64
153
  const auth = async ({ oauthHost, clientId, allowUnsafeTls }) => {
65
154
  log.debug("Discovering oauth server");
66
- const configuration = await client.discovery(new URL(oauthHost), clientId, { token_endpoint_auth_method: "none" }, client.None(), {
67
- timeout: SERVER_TIMEOUT,
68
- execute: allowUnsafeTls ? [client.allowInsecureRequests] : void 0
155
+ const configuration = await discover({
156
+ oauthHost,
157
+ clientId,
158
+ allowUnsafeTls
69
159
  });
70
160
  log.debug("Starting HTTP Server for callback");
71
161
  const server = createServer();
@@ -131,4 +221,4 @@ const auth = async ({ oauthHost, clientId, allowUnsafeTls }) => {
131
221
  });
132
222
  };
133
223
  //#endregion
134
- export { auth, defaultClientID, refreshToken, revokeToken };
224
+ export { AuthRefreshError, auth, classifyRefreshFailure, defaultClientID, refreshToken, revokeToken };
@@ -1,5 +1,3 @@
1
- import { a as isOwnedCredentialPath } from "./_chunks/paths-DMq0Lt7a.js";
2
- import "./config.js";
3
1
  //#region src/auth_context.ts
4
2
  const locationFromContext = (context) => {
5
3
  if (context.profile === void 0) return null;
@@ -23,13 +21,6 @@ const getAuthContext = () => current;
23
21
  const clearAuthContext = () => {
24
22
  current = null;
25
23
  };
26
- const credentialsToClearOn401 = (context) => {
27
- if (context?.source !== "stored-credentials") return null;
28
- const at = locationFromContext(context);
29
- if (at === null) return null;
30
- if (at.storage === "keyring") return null;
31
- return isOwnedCredentialPath(context.configDir, at.path) ? at : null;
32
- };
33
24
  /**
34
25
  * What to tell the user when the API rejects their credential, naming the profile and file
35
26
  * when one is involved so they know which of several accounts failed and what to re-run.
@@ -41,9 +32,9 @@ const authFailureMessage = (context) => {
41
32
  if (context?.source === "claimable") return `Authentication failed: Claimable Neon rejected the linked project's short-lived access token${where}. Retry the command to exchange the saved identity assertion again; if it still fails, run \`neon claim status\`.`;
42
33
  if (context?.source === "stored-credentials") {
43
34
  if (context.storage === "keyring") return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session (OS keyring). Sign in again with \`neon auth --profile ${profile}\`.`;
44
- return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session${where}. That file was not created by neon, so it was left alone — sign in again with \`neon auth --profile ${profile}\`.`;
35
+ return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session${where}. Sign in again with \`neon auth --profile ${profile}\`.`;
45
36
  }
46
37
  return "Authentication failed: the Neon API rejected the API key. Check --api-key or NEON_API_KEY.";
47
38
  };
48
39
  //#endregion
49
- export { authFailureMessage, clearAuthContext, credentialsToClearOn401, getAuthContext, locationFromContext, setAuthContext };
40
+ export { authFailureMessage, clearAuthContext, getAuthContext, locationFromContext, setAuthContext };
@@ -179,7 +179,8 @@ var ClaimableClient = class {
179
179
  json: {
180
180
  type: "anonymous",
181
181
  capabilities: input.capabilities,
182
- source: input.source
182
+ source: input.source,
183
+ ...input.dataApi !== void 0 ? { data_api: input.dataApi } : {}
183
184
  }
184
185
  }));
185
186
  }
@@ -128,7 +128,8 @@ async function askAssistant(opts) {
128
128
  method: "POST",
129
129
  headers: {
130
130
  "Content-Type": "application/json",
131
- Accept: opts.acceptEventStream ? "text/event-stream" : "application/json"
131
+ Accept: opts.acceptEventStream ? "text/event-stream" : "application/json",
132
+ "x-neon-source": "cli"
132
133
  },
133
134
  body: JSON.stringify({ prompt: opts.prompt }),
134
135
  signal: AbortSignal.timeout(ASK_TIMEOUT_MS)