neon 2.42.0 → 2.43.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.
package/README.md CHANGED
@@ -650,12 +650,12 @@ The CLI holds one Neon account by default. A profile adds another, and is nothin
650
650
 
651
651
  ```bash
652
652
  neon auth --profile work # create it, or sign in again
653
- neon profile list
654
- neon profile remove work
653
+ neon profiles list
654
+ neon profiles remove work
655
655
  ```
656
656
 
657
657
  ```console
658
- $ neon profile list
658
+ $ neon profiles list
659
659
  Profiles
660
660
  ┌────────┬─────────┬──────────────────────┬──────────┬──────────────────────────────────────┐
661
661
  │ Active │ Name │ Account │ SignedIn │ Credentials │
@@ -680,14 +680,87 @@ Entries in `profiles.json` are paths, and a path may point anywhere — which is
680
680
  }
681
681
  ```
682
682
 
683
- `neon profile remove` revokes the refresh token at the authorization server, not just locally. It deletes the credentials file only when the CLI created it: an adopted path like the one above is unlinked and left on disk, and the command says so. Removing the last named profile deletes `profiles.json`, returning you to the single-account layout. `neon profile remove DEFAULT` signs you out.
683
+ `neon profiles remove` revokes the refresh token at the authorization server, not just locally. It deletes the credentials file only when the CLI created it: an adopted path like the one above is unlinked and left on disk, and the command says so. Removing the last named profile deletes `profiles.json`, returning you to the single-account layout. `neon profiles remove DEFAULT` signs you out.
684
+
685
+ ## API keys (`api-keys`)
686
+
687
+ ```bash
688
+ neon api-keys list # your account's keys
689
+ neon api-keys list --org-id org-… # an organization's, with scope shown
690
+
691
+ neon api-keys create --name ci # account key
692
+ neon api-keys create --name ci --org-id org-… # organization key
693
+ neon api-keys create --name agent --project-id frosty-… # can access only that project
694
+
695
+ neon api-keys revoke <id> [--org-id org-…]
696
+ ```
697
+
698
+ The key is returned once, on create, and cannot be retrieved again. It prints on its own line below the table, so it can be selected in one gesture regardless of terminal width — and `… | tail -1` on stdout yields exactly the key, since both notices go to stderr.
699
+
700
+ ```console
701
+ $ neon api-keys create --name agent --project-id proj-in-org
702
+ API key
703
+ ┌─────┬───────┬─────────────┐
704
+ │ Id │ Name │ Project │
705
+ ├─────┼───────┼─────────────┤
706
+ │ 303 │ agent │ proj-in-org │
707
+ └─────┴───────┴─────────────┘
708
+
709
+ napi_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
710
+ WARNING: Store this key now: it is not shown again.
711
+ INFO: Limited to proj-in-org: it cannot create projects, mint API keys, or read any other project. It can still change and delete everything inside that project.
712
+ ```
713
+
714
+ `--org-id` and `--project-id` are mutually exclusive. A project-scoped key *is* an organization key, and its organization is looked up from the project rather than chosen separately. With neither flag you get an account key.
715
+
716
+ ### Project-scoped keys
717
+
718
+ A key created with `--project-id` bounds what it can reach to one project. Verified against a real scoped key:
719
+
720
+ | Attempt | Result |
721
+ | --- | --- |
722
+ | Read and write its own project | works |
723
+ | Read any other project | `project not found` — not even an existence oracle |
724
+ | `neon projects create` | `project-scoped keys are not allowed to create projects` |
725
+ | `neon projects list` | refused |
726
+ | `neon api-keys create` / `list` | refused (true of any organization key, not only scoped ones) |
727
+ | `neon orgs list` | **works** — it can see the id, name and handle of the organization it belongs to |
728
+ | Anything else about that organization (`GET /organizations/{id}`, members) | refused |
729
+
730
+ It is **not** read-only. Inside its one project it can do everything the API allows, including deleting branches and the project itself — `neon deploy` working at all is proof of that. What it bounds is *reach*, which is what lets you hand it to an agent or a CI job without handing over your account:
731
+
732
+ ```bash
733
+ neon link --project-id frosty-… # once, as yourself — writes .neon
734
+ NEON_API_KEY=napi_… neon deploy # then the agent, reaching only that project
735
+ ```
736
+
737
+ `neon link` needs `--project-id` explicitly when using a scoped key: the interactive picker lists your projects, which a scoped key cannot do.
738
+
739
+ `api-keys` deliberately ignores the `.neon` context file, unlike every other project command. Otherwise `neon api-keys create --name ci` inside a linked directory would silently mint a key scoped to that project instead of the account key you asked for. How far a credential reaches comes only from a flag you typed.
740
+
741
+ ### Seeing what is scoped
742
+
743
+ ```console
744
+ $ neon api-keys list --org-id org-7
745
+ API keys in org-7
746
+ ┌─────┬──────────┬────────────────┬──────────────────────┬──────────────────────┬─────────────────────┐
747
+ │ Id │ Name │ Project │ Created At │ Last Used At │ Last Used From Addr │
748
+ ├─────┼──────────┼────────────────┼──────────────────────┼──────────────────────┼─────────────────────┤
749
+ │ 301 │ scoped │ proj-in-org │ 2026-01-02T00:00:00Z │ │ │
750
+ ├─────┼──────────┼────────────────┼──────────────────────┼──────────────────────┼─────────────────────┤
751
+ │ 302 │ org-wide │ (all projects) │ 2026-01-03T00:00:00Z │ 2026-02-03T00:00:00Z │ 203.0.113.9 │
752
+ └─────┴──────────┴────────────────┴──────────────────────┴──────────────────────┴─────────────────────┘
753
+ ```
754
+
755
+ `last_used_at` and `last_used_from_addr` are how you spot a key worth revoking.
684
756
 
685
757
  ## Commands
686
758
 
687
759
  | Command | Subcommands | Description |
688
760
  | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
689
761
  | [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
690
- | profile | `list`, `remove` | Manage named sets of credentials |
762
+ | profiles | `list`, `remove` | Manage named sets of credentials |
763
+ | api-keys | `list`, `create`, `revoke` | Manage API keys |
691
764
  | [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
692
765
  | [ip-allow](https://neon.com/docs/reference/cli-ip-allow) | `list`, `add`, `remove`, `reset` | Manage IP Allow |
693
766
  | [me](https://neon.com/docs/reference/cli-me) | | Show current user |
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({
@@ -0,0 +1,397 @@
1
+ import { isNeonApiError } from "../api.js";
2
+ import { log } from "../log.js";
3
+ import { writer } from "../writer.js";
4
+ const ACCOUNT_FIELDS = [
5
+ "id",
6
+ "name",
7
+ "created_at",
8
+ "last_used_at",
9
+ "last_used_from_addr",
10
+ ];
11
+ /**
12
+ * Table view of an org listing. `project` is the rendered column; the raw `project_id` is
13
+ * what structured output keeps. See {@link list} for why the two differ.
14
+ */
15
+ const ORG_TABLE_FIELDS = [
16
+ "id",
17
+ "name",
18
+ "project",
19
+ "created_at",
20
+ "last_used_at",
21
+ "last_used_from_addr",
22
+ ];
23
+ const ORG_FIELDS = [
24
+ "id",
25
+ "name",
26
+ "project_id",
27
+ "created_at",
28
+ "last_used_at",
29
+ "last_used_from_addr",
30
+ ];
31
+ const CREATE_FIELDS = ["id", "name", "key"];
32
+ /** Metadata only: the secret is printed separately so it can be copied cleanly. */
33
+ const CREATE_TABLE_FIELDS = ["id", "name"];
34
+ const CREATE_TABLE_FIELDS_SCOPED = ["id", "name", "project"];
35
+ const CREATE_FIELDS_SCOPED = ["id", "name", "project_id", "key"];
36
+ /** Rendered for an org key that was not narrowed to a project. Table output only. */
37
+ const ALL_PROJECTS = "(all projects)";
38
+ /**
39
+ * "This key should carry no project scope."
40
+ *
41
+ * A symbol rather than a string, because project ids are `^[a-z0-9-]{1,60}$` — any sentinel
42
+ * spelled as a string is a legal project id, and a project genuinely called that would have
43
+ * its correctly-scoped key rejected and revoked.
44
+ */
45
+ const NO_PROJECT = Symbol("no-project");
46
+ /**
47
+ * A flag is either absent, or exactly one non-empty string. Anything else is an error.
48
+ *
49
+ * Every rejected shape otherwise ends the same way: the flag reads as falsy, the scope check
50
+ * falls through, and an **account** key is minted instead of the narrow one asked for — the
51
+ * worst thing this command can do, so none of them get a lenient reading.
52
+ *
53
+ * - `--project-id ""` — an unset shell variable; empty string, which is falsy.
54
+ * - `--no-project-id` — yargs boolean negation; `false`, which is falsy.
55
+ * - `--project-id a --project-id b` — an array, which would reach the API as `a,b`.
56
+ *
57
+ * A misspelled flag never binds and cannot be seen here; `.strict()` rejects it. Anything
58
+ * after a `--` terminator is handled by {@link noPassthrough}.
59
+ */
60
+ const single = (name, { required = false } = {}) => (value) => {
61
+ if (value === undefined)
62
+ return undefined;
63
+ if (Array.isArray(value)) {
64
+ throw new Error(`--${name} was given more than once. Pass it at most once.`);
65
+ }
66
+ // `--no-x` is the negation form and yields `false`, so name it rather than telling
67
+ // the user their value was empty when they never gave one.
68
+ if (value === false) {
69
+ throw new Error(required
70
+ ? `--no-${name} is not valid: --${name} is required.`
71
+ : `--no-${name} is not a valid way to skip --${name}. Omit the flag entirely.`);
72
+ }
73
+ if (typeof value !== "string" || value.trim() === "") {
74
+ throw new Error(required
75
+ ? `--${name} needs a value.`
76
+ : `--${name} needs a value. Pass one, or omit the flag entirely.`);
77
+ }
78
+ return value;
79
+ };
80
+ /**
81
+ * Refuse arguments after a `--` terminator.
82
+ *
83
+ * The CLI sets `populate--`, so everything past `--` lands in `argv["--"]` where `.strict()`
84
+ * never looks — `create --name x -- --project-id p` would parse cleanly and mint an account
85
+ * key from a line that names the scope flag. No `api-keys` subcommand takes passthrough
86
+ * arguments, so their presence is always a mistake.
87
+ */
88
+ const noPassthrough = (argv) => {
89
+ const rest = argv["--"];
90
+ if (Array.isArray(rest) && rest.length > 0) {
91
+ throw new Error(`api-keys takes no arguments after \`--\`, and options placed there are ignored rather than applied. Remove the \`--\`.`);
92
+ }
93
+ return true;
94
+ };
95
+ export const command = "api-keys";
96
+ export const aliases = ["api-key"];
97
+ export const describe = "Manage API keys";
98
+ export const builder = (argv) => argv
99
+ .usage("$0 api-keys <sub-command> [options]")
100
+ .command("list", "List API keys for your account, or for an organization", (yargs) => yargs
101
+ .options({
102
+ "org-id": {
103
+ describe: "List the organization's keys instead of your account's",
104
+ type: "string",
105
+ coerce: single("org-id"),
106
+ },
107
+ })
108
+ .strict()
109
+ .check(noPassthrough), async (args) => await list(args))
110
+ .command("create", "Create an API key. The key is shown once and cannot be retrieved again", (yargs) => yargs
111
+ .options({
112
+ name: {
113
+ describe: "A name to identify the key later",
114
+ type: "string",
115
+ demandOption: true,
116
+ coerce: single("name", { required: true }),
117
+ },
118
+ "org-id": {
119
+ describe: "Create a key for this organization instead of your account",
120
+ type: "string",
121
+ coerce: single("org-id"),
122
+ },
123
+ "project-id": {
124
+ describe: "Create a key that can access only this project. Its organization is looked up from the project",
125
+ type: "string",
126
+ coerce: single("project-id"),
127
+ },
128
+ })
129
+ // A key scoped to a project is already an organization key, so naming
130
+ // both is contradictory rather than redundant — the org is derived from
131
+ // the project and cannot be chosen independently.
132
+ .conflicts("org-id", "project-id")
133
+ .strict()
134
+ .check(noPassthrough), async (args) => await create(args))
135
+ .command("revoke <id>", "Revoke an API key. Anything using it stops working immediately", (yargs) => yargs
136
+ .positional("id", {
137
+ describe: "The API key id, from `api-keys list`",
138
+ // Deliberately not `type: "number"`. That coerces before `coerce`
139
+ // runs, so an unparseable id arrives as NaN and the error can only
140
+ // echo `NaN` — a JavaScript artifact the user never typed. Taking
141
+ // the raw string lets the message name what they actually passed,
142
+ // and stops NaN reaching the API, which answers "not found" and
143
+ // blames the key rather than the input.
144
+ type: "string",
145
+ demandOption: true,
146
+ coerce: (value) => {
147
+ // Digits only, checked before Number(): `Number("0x65")` is 101
148
+ // and `Number("1e3")` is 1000, so either would revoke a key the
149
+ // user never named while the message promises "a numeric id".
150
+ const id = Number(value);
151
+ if (typeof value !== "string" ||
152
+ !/^\d+$/.test(value.trim()) ||
153
+ !Number.isSafeInteger(id) ||
154
+ id <= 0) {
155
+ throw new Error(`api-keys revoke needs a numeric key id, from \`neon api-keys list\`. Got \`${String(value)}\`.`);
156
+ }
157
+ return id;
158
+ },
159
+ })
160
+ .options({
161
+ "org-id": {
162
+ describe: "Revoke an organization key instead of an account key",
163
+ type: "string",
164
+ coerce: single("org-id"),
165
+ },
166
+ })
167
+ .strict()
168
+ .check(noPassthrough), async (args) => await revoke(args))
169
+ .demandCommand(1, "Run `neon api-keys --help` to see the subcommands.");
170
+ export const handler = (args) => args;
171
+ const list = async (props) => {
172
+ const out = writer(props);
173
+ if (props.orgId) {
174
+ const { data } = await props.apiClient.listOrgApiKeys(props.orgId);
175
+ // `writeTable` drops any column empty in every row, so an all-absent `project_id`
176
+ // would take the whole column with it — hiding the answer exactly when it is "none
177
+ // of them are scoped". Fill it in for the table, but leave structured output alone:
178
+ // json/yaml serialize the whole object, so a synthetic field there would change the
179
+ // machine-readable shape and duplicate `project_id` under a second name.
180
+ if (props.output === "table") {
181
+ out.write(data.map((key) => ({
182
+ ...key,
183
+ project: key.project_id ?? ALL_PROJECTS,
184
+ })), {
185
+ fields: ORG_TABLE_FIELDS,
186
+ title: `API keys in ${props.orgId}`,
187
+ emptyMessage: `No API keys in ${props.orgId}.`,
188
+ });
189
+ }
190
+ else {
191
+ out.write(data, {
192
+ fields: ORG_FIELDS,
193
+ title: `API keys in ${props.orgId}`,
194
+ emptyMessage: `No API keys in ${props.orgId}.`,
195
+ });
196
+ }
197
+ out.end();
198
+ return;
199
+ }
200
+ const { data } = await props.apiClient.listApiKeys();
201
+ out.write(data, {
202
+ fields: ACCOUNT_FIELDS,
203
+ title: "Account API keys",
204
+ emptyMessage: "You have no account API keys.",
205
+ });
206
+ out.end();
207
+ // Organization keys live on a different endpoint, so a heading of plain "API keys"
208
+ // would claim to be everything while showing only half.
209
+ if (props.output === "table") {
210
+ log.info("Organization keys are listed separately: neon api-keys list --org-id <org> (see `neon orgs list`).");
211
+ }
212
+ };
213
+ const create = async (props) => {
214
+ const { name, projectId, orgId } = props;
215
+ // Neither flag: an account key, matching `POST /api_keys`. `api-keys` is exempt from
216
+ // `.neon` enrichment (see `isApiKeysCommand`), so reaching this branch means the user
217
+ // really did ask for account scope rather than inheriting a checked-out project.
218
+ if (!projectId && !orgId) {
219
+ const { data } = await props.apiClient.createApiKey({ key_name: name });
220
+ await assertUsable(props, data, { orgId: null, expect: NO_PROJECT });
221
+ report(props, data, CREATE_FIELDS, CREATE_TABLE_FIELDS);
222
+ // The only key here that reaches everything, and the only one that used to say
223
+ // nothing about its reach.
224
+ log.warning("This key reaches everything your account can, in every organization. Pass --org-id or --project-id to narrow it.");
225
+ return;
226
+ }
227
+ if (!projectId && orgId) {
228
+ const { data } = await props.apiClient.createOrgApiKey(orgId, {
229
+ key_name: name,
230
+ });
231
+ await assertUsable(props, data, { orgId, expect: NO_PROJECT });
232
+ report(props, data, CREATE_FIELDS, CREATE_TABLE_FIELDS);
233
+ log.warning("This key reaches every project in %s, including ones created later. Pass --project-id instead to restrict it to one.", orgId);
234
+ return;
235
+ }
236
+ const scopeTo = projectId;
237
+ // Project-scoped keys exist only on the organization endpoint, so an org is required.
238
+ // Resolve it from the project rather than asking for both: `--project-id` alone would
239
+ // otherwise fail for a reason that isn't visible from the command line.
240
+ const resolvedOrgId = await orgIdForProject(props, scopeTo);
241
+ const { data } = await props.apiClient.createOrgApiKey(resolvedOrgId, {
242
+ key_name: name,
243
+ project_id: scopeTo,
244
+ });
245
+ // `project_id` is optional on the response. Printing a key and calling it scoped
246
+ // without checking would hand over a credential whose reach we never confirmed — the
247
+ // one thing this command must not get wrong.
248
+ await assertUsable(props, data, {
249
+ orgId: resolvedOrgId,
250
+ expect: scopeTo,
251
+ });
252
+ report(props, data, CREATE_FIELDS_SCOPED, CREATE_TABLE_FIELDS_SCOPED);
253
+ log.info("Limited to %s: it cannot create projects, mint API keys, or read any other project. It can still change and delete everything inside that project.", scopeTo);
254
+ };
255
+ /**
256
+ * Print the issued key.
257
+ *
258
+ * In a terminal the secret goes on its own line rather than into a table cell: `cli-table`
259
+ * neither wraps nor truncates, so a 50-odd character key makes the row wider than most
260
+ * terminals and the wrapped remainder ends up beside box-drawing characters. Since this is
261
+ * the only time the key is ever shown, it has to be selectable in one gesture. Structured
262
+ * output keeps the key in the object, where a script expects it.
263
+ */
264
+ const report = (props, data, fields, tableFields) => {
265
+ const out = writer(props);
266
+ if (props.output === "table") {
267
+ // `project` mirrors the column name `list` uses. Added here rather than by the
268
+ // caller so structured output keeps the API's own `project_id` and gains no
269
+ // duplicate under a second name.
270
+ const row = typeof data.project_id === "string"
271
+ ? { ...data, project: data.project_id }
272
+ : data;
273
+ out.write(row, {
274
+ fields: tableFields,
275
+ title: "API key",
276
+ });
277
+ out.end();
278
+ // Blank line so the key is visually detached from the table border, and so
279
+ // `| tail -1` on stdout yields exactly the key (both notices go to stderr).
280
+ out.text(`\n${data.key}\n`);
281
+ }
282
+ else {
283
+ out.write(data, { fields: fields, title: "API key" });
284
+ out.end();
285
+ }
286
+ log.warning("Store this key now: it is not shown again.");
287
+ };
288
+ /**
289
+ * Refuse to report a key that isn't what we asked for, and take it back.
290
+ *
291
+ * Two ways a 2xx can still be wrong: no `key` in the body, which leaves a live credential
292
+ * the user can never see or use; and a `project_id` that doesn't match the requested
293
+ * project, which would mean announcing a scope the key does not have. Both withdraw the key
294
+ * before throwing.
295
+ *
296
+ * The thrown message states whether the withdrawal actually succeeded. Saying "the key has
297
+ * been revoked" when the revoke itself failed would be worse than saying nothing — it would
298
+ * leave an unverified credential live while telling the user it is gone.
299
+ */
300
+ const assertUsable = async (props, data, scope) => {
301
+ // `expect` is always stated, never inferred from a missing field: "no project" has to be
302
+ // checked as deliberately as an exact project, or a key that came back narrower than
303
+ // requested would be reported as reaching the whole organization.
304
+ const wanted = scope.expect === NO_PROJECT ? undefined : scope.expect;
305
+ const problem = typeof data.key !== "string" || data.key.trim() === ""
306
+ ? "Neon returned no key."
307
+ : data.project_id !== wanted
308
+ ? `Neon returned a key scoped to ${data.project_id ?? "nothing"} rather than ${wanted ?? "the whole organization"}.`
309
+ : null;
310
+ if (!problem)
311
+ return;
312
+ const withdrawn = await withdraw(props, scope.orgId, data.id);
313
+ throw new Error(`${problem} ${withdrawn
314
+ ? "The key has been revoked; nothing was issued."
315
+ : `The key could NOT be revoked and may still be live${data.id === undefined
316
+ ? ""
317
+ : `. Remove it with \`neon api-keys revoke ${data.id}${scope.orgId
318
+ ? ` --org-id ${scope.orgId}`
319
+ : ""}\``}.`}`);
320
+ };
321
+ /**
322
+ * Revoke, turning the most likely mistake into a usable message.
323
+ *
324
+ * Account and organization keys live on different endpoints, and `api-keys list --org-id X`
325
+ * shows ids that the account endpoint cannot see. Copying one and forgetting the flag — or
326
+ * leaving it on for an account key — is the easy error, and a bare "API key not found" sends
327
+ * the user looking for a deleted key rather than a misplaced flag.
328
+ *
329
+ * Only the key id is worth second-guessing here: an org id that is wrong or not yours does
330
+ * not reach this path at all, because the API answers "not an organization member" (verified
331
+ * against production) rather than a 404.
332
+ */
333
+ const revokeOrExplain = async (props) => {
334
+ try {
335
+ return props.orgId
336
+ ? await props.apiClient.revokeOrgApiKey(props.orgId, props.id)
337
+ : await props.apiClient.revokeApiKey(props.id);
338
+ }
339
+ catch (err) {
340
+ if (isNeonApiError(err) && err.status === 404) {
341
+ throw new Error(props.orgId
342
+ ? `No API key with id ${props.id} in ${props.orgId}. If it is one of your account's own keys, drop --org-id.`
343
+ : `No account API key with id ${props.id}. If it belongs to an organization, pass --org-id. Organization keys are not visible to your account.`);
344
+ }
345
+ throw err;
346
+ }
347
+ };
348
+ /** Best-effort withdrawal of a key we are refusing to report. Never throws. */
349
+ const withdraw = async (props, orgId, keyId) => {
350
+ if (!Number.isSafeInteger(keyId) || keyId <= 0)
351
+ return false;
352
+ try {
353
+ const { data } = orgId
354
+ ? await props.apiClient.revokeOrgApiKey(orgId, keyId)
355
+ : await props.apiClient.revokeApiKey(keyId);
356
+ // Check which key the response names: a `revoked: true` for some other id is not
357
+ // evidence that the one we issued is gone.
358
+ return data.revoked === true && data.id === keyId;
359
+ }
360
+ catch (err) {
361
+ log.error("Failed to revoke API key %d: %s", keyId, err instanceof Error ? err.message : String(err));
362
+ return false;
363
+ }
364
+ };
365
+ const revoke = async (props) => {
366
+ const out = writer(props);
367
+ const { data } = await revokeOrExplain(props);
368
+ out.write(data, {
369
+ fields: ["id", "name", "revoked", "last_used_at"],
370
+ title: "API key",
371
+ });
372
+ out.end();
373
+ };
374
+ /**
375
+ * The organization a project belongs to. A project outside any organization cannot have a
376
+ * scoped key — the endpoint that accepts `project_id` is org-only — so that case fails here
377
+ * with the reason, rather than as a 404 from a URL the user never typed.
378
+ */
379
+ const orgIdForProject = async (props, projectId) => {
380
+ let orgId;
381
+ try {
382
+ const { data: { project }, } = await props.apiClient.getProject(projectId);
383
+ orgId = project.org_id;
384
+ }
385
+ catch (err) {
386
+ if (isNeonApiError(err) && err.status === 404) {
387
+ throw new Error(projectId.startsWith("org-")
388
+ ? `Project ${projectId} not found. That looks like an organization id. Pass it as --org-id instead.`
389
+ : `Project ${projectId} not found. Check the id with \`neon projects list\`.`);
390
+ }
391
+ throw err;
392
+ }
393
+ if (!orgId) {
394
+ throw new Error(`Project ${projectId} does not belong to an organization, so it cannot have a project-scoped API key. Create an account key by omitting --project-id.`);
395
+ }
396
+ return orgId;
397
+ };
@@ -1,4 +1,5 @@
1
1
  import * as api from "./api.js";
2
+ import * as apiKeys from "./api_keys.js";
2
3
  import * as auth from "./auth.js";
3
4
  import * as bootstrap from "./bootstrap.js";
4
5
  import * as branches from "./branches.js";
@@ -32,6 +33,7 @@ import * as vpcEndpoints from "./vpc_endpoints.js";
32
33
  export default [
33
34
  auth,
34
35
  profile,
36
+ apiKeys,
35
37
  api,
36
38
  users,
37
39
  orgs,
@@ -6,11 +6,11 @@ import { isCi } from "../env.js";
6
6
  import { log } from "../log.js";
7
7
  import { DEFAULT_PROFILE, listProfiles, onlyDefaultRemains, profilesFilePath, readProfiles, resolveProfile, selectProfileName, } from "../profiles.js";
8
8
  import { writer } from "../writer.js";
9
- export const command = "profile";
10
- export const aliases = ["profiles"];
9
+ export const command = "profiles";
10
+ export const aliases = ["profile"];
11
11
  export const describe = "Manage named sets of Neon credentials";
12
12
  export const builder = (argv) => argv
13
- .usage("$0 profile <sub-command> [options]")
13
+ .usage("$0 profiles <sub-command> [options]")
14
14
  .command("list", "List profiles, the account each holds, and where its credentials live", (y) => y, async (args) => await list(args))
15
15
  .command("remove <name>", "Revoke a profile's token and remove it", (y) => y
16
16
  .positional("name", {
@@ -24,7 +24,7 @@ export const builder = (argv) => argv
24
24
  type: "boolean",
25
25
  default: false,
26
26
  }), async (args) => await remove(args))
27
- .demandCommand(1, "Run `neon profile --help` to see the subcommands.");
27
+ .demandCommand(1, "Run `neon profiles --help` to see the subcommands.");
28
28
  export const handler = (_args) => {
29
29
  /* subcommands only */
30
30
  };
package/dist/context.js CHANGED
@@ -49,6 +49,11 @@ export const isConfigInit = (args) => args._[0] === "config" &&
49
49
  * access has already lapsed is the main reason to remove one.
50
50
  */
51
51
  export const isProfileCommand = (args) => args._[0] === "profile" || args._[0] === "profiles";
52
+ /**
53
+ * `neon api-keys …`, under either spelling. Exempts the group from context enrichment: how
54
+ * far a credential reaches must come from an explicit flag, never from `.neon`.
55
+ */
56
+ export const isApiKeysCommand = (args) => args._[0] === "api-keys" || args._[0] === "api-key";
52
57
  const CONTEXT_FILE = ".neon";
53
58
  const GITIGNORE_FILE = ".gitignore";
54
59
  const canAccessFile = (file) => {
@@ -116,6 +121,13 @@ export const enrichFromContext = (args) => {
116
121
  if (args._[0] === "link" || args._[0] === "set-context") {
117
122
  return;
118
123
  }
124
+ // `api-keys` mints credentials, and how far a credential reaches must be something the
125
+ // user typed — never something inherited from whichever project happens to be checked
126
+ // out. Enriched here, `api-keys create --name ci` in a linked directory would quietly
127
+ // produce a key scoped to that project instead of the account key it asked for.
128
+ if (isApiKeysCommand(args)) {
129
+ return;
130
+ }
119
131
  const context = readContextFile(args.contextFile);
120
132
  if (!args.orgId) {
121
133
  args.orgId = context.orgId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neon",
3
- "version": "2.42.0",
3
+ "version": "2.43.0",
4
4
  "description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
5
5
  "keywords": [
6
6
  "neon",
@@ -55,10 +55,10 @@
55
55
  "which": "3.0.1",
56
56
  "yaml": "^2.9.0",
57
57
  "yargs": "17.7.2",
58
- "@neon/sdk": "1.4.1",
59
- "@neon/config": "0.13.1",
60
- "@neon/config-runtime": "0.12.2",
61
- "@neon/env": "0.13.2",
58
+ "@neon/sdk": "1.5.0",
59
+ "@neon/config": "0.13.2",
60
+ "@neon/config-runtime": "0.12.3",
61
+ "@neon/env": "0.13.3",
62
62
  "neon-init": "0.20.6"
63
63
  },
64
64
  "optionalDependencies": {