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.
- package/README.md +55 -13
- package/dist/_chunks/{credential_io-YeAxg9Xn.js → credential_io-D8AR-jvB.js} +4 -4
- package/dist/_chunks/{env-NbA61JR3.js → env-CZmstYrY.js} +71 -59
- package/dist/_chunks/{env_services-Tz9G4JeT.js → env_services-CAWrZTWa.js} +138 -41
- package/dist/_shared/auth_selection.js +83 -0
- package/dist/_shared/credentials.js +188 -0
- package/dist/_shared/paths.js +151 -0
- package/dist/_shared/profiles.js +226 -0
- package/dist/_shared/secure_file.js +41 -0
- package/dist/analytics.js +2 -2
- package/dist/auth.js +101 -11
- package/dist/auth_context.js +2 -11
- package/dist/claimable/api.js +2 -1
- package/dist/commands/ask.js +2 -1
- package/dist/commands/auth.js +155 -63
- package/dist/commands/checkout.js +28 -9
- package/dist/commands/claim.js +11 -7
- package/dist/commands/config.js +9 -9
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/deploy_help.js +10 -0
- package/dist/commands/dev.js +147 -36
- package/dist/commands/env.js +33 -44
- package/dist/commands/functions.js +63 -2
- package/dist/commands/profile.js +2 -2
- package/dist/config_services.js +36 -1
- package/dist/credential_io.js +1 -1
- package/dist/custom_domains_api.js +37 -0
- package/dist/dev/env.js +1 -1
- package/dist/dev/functions.js +28 -11
- package/dist/dev/websocket.js +1 -1
- package/dist/env_services.js +2 -2
- package/dist/index.js +73 -20
- package/dist/init/build_config.js +4 -0
- package/dist/neon_services.js +2 -2
- package/dist/psql/wire/connection.js +1 -1
- package/dist/refresh_lock.js +44 -0
- package/dist/retire_credential.js +1 -1
- package/dist/test_utils/neon_api_server.js +60 -0
- package/dist/test_utils/rotating_oauth_server.js +94 -0
- package/dist/utils/config_diff.js +2 -13
- package/package.json +7 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NEON_SERVICES } from "../neon_services.js";
|
|
2
|
-
import { ErrorCode, PlatformError, createNeonApiFromOptions, deriveCredentialScopes, resolveConfig } from "@neon/config/v1";
|
|
2
|
+
import { ErrorCode, PlatformError, createNeonApiFromOptions, deriveCredentialScopes, isPlatformError, resolveConfig } from "@neon/config/v1";
|
|
3
3
|
//#region ../../internals/env-core/dist/env.js
|
|
4
4
|
/**
|
|
5
5
|
* The Neon env core — resolving a branch's env from the Neon API, and projecting it into
|
|
@@ -84,26 +84,31 @@ const NEON_ENV_VAR_KEYS = {
|
|
|
84
84
|
baseUrl: "NEON_AI_GATEWAY_BASE_URL"
|
|
85
85
|
}
|
|
86
86
|
};
|
|
87
|
+
const FUNCTION_SLUG = /^[a-z0-9]{1,20}$/;
|
|
88
|
+
const FUNCTION_BASE_URL_KEY = /^NEON_FUNCTION_([A-Z0-9]{1,20})_BASE_URL$/;
|
|
89
|
+
function functionBaseUrlKey(slug) {
|
|
90
|
+
if (!FUNCTION_SLUG.test(slug)) throw new Error(`functionBaseUrlKey: ${JSON.stringify(slug)} is not a function slug ([a-z0-9]{1,20}).`);
|
|
91
|
+
return `NEON_FUNCTION_${slug.toUpperCase()}_BASE_URL`;
|
|
92
|
+
}
|
|
93
|
+
function parseFunctionBaseUrlKey(key) {
|
|
94
|
+
const match = FUNCTION_BASE_URL_KEY.exec(key);
|
|
95
|
+
return match ? match[1].toLowerCase() : null;
|
|
96
|
+
}
|
|
97
|
+
function isFunctionBaseUrlKey(key) {
|
|
98
|
+
return parseFunctionBaseUrlKey(key) !== null;
|
|
99
|
+
}
|
|
87
100
|
/** Fail loudly when selected-key dependency planning and execution disagree. */
|
|
88
101
|
function requiredValue(value, description) {
|
|
89
102
|
if (value === null) throw new Error(`fetchEnv: missing ${description}.`);
|
|
90
103
|
return value;
|
|
91
104
|
}
|
|
92
|
-
|
|
93
|
-
* The {@link fetchEnv} body, with the key selection as a plain argument and no generic
|
|
94
|
-
* narrowing. Exists for callers that compute the selection at runtime — notably
|
|
95
|
-
* {@link fetchEnvReusingSecrets}, which decides which keys it still needs by checking the
|
|
96
|
-
* branch — since the public overload's `keys` is bound to a literal union those callers cannot
|
|
97
|
-
* produce without asserting.
|
|
98
|
-
*
|
|
99
|
-
* `keys === null` selects everything the policy enables.
|
|
100
|
-
*/
|
|
101
|
-
async function fetchEnvKeys(config, options, keys) {
|
|
105
|
+
async function fetchEnvKeysState(config, options, keys) {
|
|
102
106
|
const api = options.api ?? createApiFromOptions(options);
|
|
103
107
|
const projectId = options.projectId;
|
|
104
108
|
const { branch, desired } = await resolveBranchPolicy(config, options, api);
|
|
105
109
|
const selection = keys ? new Set(keys) : null;
|
|
106
|
-
const
|
|
110
|
+
const omitted = new Set(options.omitKeys ?? []);
|
|
111
|
+
const wants = (key) => !omitted.has(key) && (selection === null || selection.has(key));
|
|
107
112
|
const result = {};
|
|
108
113
|
const K = NEON_ENV_VAR_KEYS;
|
|
109
114
|
const wantsPooled = wants(K.postgres.databaseUrl);
|
|
@@ -111,7 +116,17 @@ async function fetchEnvKeys(config, options, keys) {
|
|
|
111
116
|
const wantsAuth = desired.authEnabled && (wants(K.auth.baseUrl) || wants(K.auth.jwksUrl));
|
|
112
117
|
const wantsDataApi = desired.dataApiEnabled && wants(K.dataApi.url);
|
|
113
118
|
const gatewayEnabled = desired.preview?.aiGatewayEnabled ?? false;
|
|
114
|
-
const
|
|
119
|
+
const functionUrlMode = options.functionUrls ?? "policy";
|
|
120
|
+
const declaredSlugs = (desired.preview?.functions ?? []).map((fn) => fn.slug);
|
|
121
|
+
const selectedFunctionKeys = selection === null ? [] : [...selection].filter(isFunctionBaseUrlKey);
|
|
122
|
+
const constructSlugs = functionUrlSlugsToConstruct({
|
|
123
|
+
functionUrlMode,
|
|
124
|
+
selection,
|
|
125
|
+
declaredSlugs,
|
|
126
|
+
selectedFunctionKeys,
|
|
127
|
+
wants
|
|
128
|
+
});
|
|
129
|
+
const needsUnpooled = wantsUnpooled || gatewayEnabled && wants(K.aiGateway.baseUrl) || constructSlugs.length > 0;
|
|
115
130
|
const needsConnectionTarget = wantsPooled || needsUnpooled;
|
|
116
131
|
const needsDatabase = needsConnectionTarget || wantsDataApi;
|
|
117
132
|
const [roles, databases] = await Promise.all([needsConnectionTarget ? api.listBranchRoles(projectId, branch.id) : Promise.resolve([]), needsDatabase ? api.listBranchDatabases(projectId, branch.id) : Promise.resolve([])]);
|
|
@@ -200,7 +215,67 @@ async function fetchEnvKeys(config, options, keys) {
|
|
|
200
215
|
result.aiGateway = gateway;
|
|
201
216
|
}
|
|
202
217
|
}
|
|
203
|
-
|
|
218
|
+
const wantsAnyFunctionUrl = selection === null || selectedFunctionKeys.length > 0;
|
|
219
|
+
const functions = {};
|
|
220
|
+
let functionUrlsUnavailable = false;
|
|
221
|
+
if (functionUrlMode === "all-live" && wantsAnyFunctionUrl) {
|
|
222
|
+
const listed = options.listedFunctions === void 0 ? await listFunctionInvocationUrls(api, projectId, branch.id) : listedFromSnapshots(options.listedFunctions);
|
|
223
|
+
if (listed.status === "unavailable") {
|
|
224
|
+
if (selection === null) functionUrlsUnavailable = true;
|
|
225
|
+
} else for (const fn of listed.functions) {
|
|
226
|
+
if (!wants(functionBaseUrlKey(fn.slug))) continue;
|
|
227
|
+
functions[fn.slug] = { baseUrl: asEnvBaseUrl(fn.invocationUrl) };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const missingConstructSlugs = constructSlugs.filter((slug) => functions[slug] === void 0);
|
|
231
|
+
if (missingConstructSlugs.length > 0) {
|
|
232
|
+
const uri = requiredValue(unpooled, "direct connection URI for function invocation URLs").uri;
|
|
233
|
+
for (const slug of missingConstructSlugs) functions[slug] = { baseUrl: functionInvocationUrl(branch.id, slug, uri) };
|
|
234
|
+
}
|
|
235
|
+
assertSelectedFunctionUrls(selectedFunctionKeys, functions);
|
|
236
|
+
if (Object.keys(functions).length > 0) result.functions = functions;
|
|
237
|
+
return {
|
|
238
|
+
env: result,
|
|
239
|
+
functionUrlsUnavailable
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function functionUrlSlugsToConstruct(args) {
|
|
243
|
+
const slugs = [];
|
|
244
|
+
if (args.functionUrlMode === "policy" && args.selection === null) {
|
|
245
|
+
for (const slug of args.declaredSlugs) if (args.wants(functionBaseUrlKey(slug))) slugs.push(slug);
|
|
246
|
+
return slugs;
|
|
247
|
+
}
|
|
248
|
+
for (const key of args.selectedFunctionKeys) {
|
|
249
|
+
const slug = parseFunctionBaseUrlKey(key);
|
|
250
|
+
if (slug !== null) slugs.push(slug);
|
|
251
|
+
}
|
|
252
|
+
return slugs;
|
|
253
|
+
}
|
|
254
|
+
function listedFromSnapshots(snapshots) {
|
|
255
|
+
return {
|
|
256
|
+
status: "ok",
|
|
257
|
+
functions: snapshots.filter((fn) => fn.invocationUrl !== "").map((fn) => ({
|
|
258
|
+
slug: fn.slug,
|
|
259
|
+
invocationUrl: fn.invocationUrl
|
|
260
|
+
})).sort((left, right) => left.slug.localeCompare(right.slug))
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function assertSelectedFunctionUrls(keys, functions) {
|
|
264
|
+
for (const key of keys) {
|
|
265
|
+
const slug = parseFunctionBaseUrlKey(key);
|
|
266
|
+
if (slug === null || functions[slug] === void 0) throw new Error(`fetchEnv: missing ${key}.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function listFunctionInvocationUrls(api, projectId, branchId) {
|
|
270
|
+
try {
|
|
271
|
+
return listedFromSnapshots(await api.listBranchFunctions(projectId, branchId));
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (isPlatformError(error) && error.code === ErrorCode.FeatureUnavailable) return {
|
|
274
|
+
status: "unavailable",
|
|
275
|
+
error
|
|
276
|
+
};
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
204
279
|
}
|
|
205
280
|
/**
|
|
206
281
|
* Resolve the target branch and evaluate the policy against it — the first thing any
|
|
@@ -302,15 +377,40 @@ async function mintBranchCredential(args) {
|
|
|
302
377
|
* `ep-x.c-3.us-east-2.aws.neon.tech` yields the gateway host
|
|
303
378
|
* `<branchId>-api.ai.c-3.us-east-2.aws.neon.tech`. The cell prefix is **load-bearing** —
|
|
304
379
|
* the gateway is cell-routed, so dropping `c-N.` resolves to the wrong (or no) host.
|
|
380
|
+
*
|
|
381
|
+
* Function invocation URLs use the same suffix: `<branchId>-<slug>.compute.<suffix>`.
|
|
305
382
|
*/
|
|
306
|
-
function
|
|
383
|
+
function connectionHostSuffix(connectionUri) {
|
|
307
384
|
let connectionHost = "";
|
|
308
385
|
try {
|
|
309
386
|
connectionHost = new URL(connectionUri).hostname;
|
|
310
387
|
} catch {
|
|
311
388
|
connectionHost = "";
|
|
312
389
|
}
|
|
313
|
-
return
|
|
390
|
+
return connectionHost.split(".").slice(1).join(".");
|
|
391
|
+
}
|
|
392
|
+
function aiGatewayHost(branchId, connectionUri) {
|
|
393
|
+
return `${branchId}-api.ai.${connectionHostSuffix(connectionUri)}`;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The API's `invocation_url` ends with `/` so paths concatenate onto it. Neon `*_BASE_URL`
|
|
397
|
+
* vars are origin-only (`NEON_AUTH_BASE_URL`, `NEON_AI_GATEWAY_BASE_URL`).
|
|
398
|
+
*/
|
|
399
|
+
function asEnvBaseUrl(url) {
|
|
400
|
+
let parsed;
|
|
401
|
+
try {
|
|
402
|
+
parsed = new URL(url);
|
|
403
|
+
} catch {
|
|
404
|
+
throw new Error(`fetchEnv: function invocation URL is not a URL: ${JSON.stringify(url)}`);
|
|
405
|
+
}
|
|
406
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`fetchEnv: function invocation URL must be http(s): ${JSON.stringify(url)}`);
|
|
407
|
+
return parsed.origin;
|
|
408
|
+
}
|
|
409
|
+
/** Derived from the connection URI so undeployed functions have a cell-routed URL. */
|
|
410
|
+
function functionInvocationUrl(branchId, slug, connectionUri) {
|
|
411
|
+
const suffix = connectionHostSuffix(connectionUri);
|
|
412
|
+
if (suffix === "") throw new Error(`fetchEnv: cannot derive the invocation URL for function "${slug}": the direct connection URI has no host suffix.`);
|
|
413
|
+
return `https://${branchId}-${slug}.compute.${suffix}`;
|
|
314
414
|
}
|
|
315
415
|
/** The AI Gateway's bare base URL (`NEON_AI_GATEWAY_BASE_URL`) on the branch gateway host. */
|
|
316
416
|
function aiGatewayBaseUrl(branchId, connectionUri) {
|
|
@@ -403,19 +503,12 @@ function toEntries(env) {
|
|
|
403
503
|
put(K.storage.region, env.storage?.region);
|
|
404
504
|
put(K.aiGateway.apiKey, env.aiGateway?.apiKey);
|
|
405
505
|
put(K.aiGateway.baseUrl, env.aiGateway?.baseUrl);
|
|
506
|
+
if (env.functions) for (const slug of Object.keys(env.functions).sort()) put(functionBaseUrlKey(slug), env.functions[slug]?.baseUrl);
|
|
406
507
|
return out;
|
|
407
508
|
}
|
|
408
509
|
//#endregion
|
|
409
510
|
//#region src/env_services.ts
|
|
410
|
-
|
|
411
|
-
* The services `env pull --service` can select: every Neon service that produces branch env
|
|
412
|
-
* vars. `functions` is the one left out — a function's env comes from the local `neon.ts`,
|
|
413
|
-
* never from the branch, so there is nothing to pull.
|
|
414
|
-
*/
|
|
415
|
-
const ENV_PULL_SERVICES = NEON_SERVICES.filter((service) => service !== "functions");
|
|
416
|
-
/** Why the services `env pull` leaves out are not selectable, for the refusal message. */
|
|
417
|
-
const ENV_PULL_UNAVAILABLE = { functions: "a function's env comes from your neon.ts, not from the branch, so there is nothing to pull" };
|
|
418
|
-
/** Every OS-level env var `env pull` can write, in stable emit order. */
|
|
511
|
+
const ENV_PULL_SERVICES = NEON_SERVICES;
|
|
419
512
|
const ENV_PULL_KEYS = [
|
|
420
513
|
...Object.values(NEON_ENV_VAR_KEYS.postgres),
|
|
421
514
|
NEON_ENV_VAR_KEYS.branch.name,
|
|
@@ -424,6 +517,7 @@ const ENV_PULL_KEYS = [
|
|
|
424
517
|
...Object.values(NEON_ENV_VAR_KEYS.storage),
|
|
425
518
|
...Object.values(NEON_ENV_VAR_KEYS.aiGateway)
|
|
426
519
|
];
|
|
520
|
+
const ENV_PULL_KEY_HELP = `${ENV_PULL_KEYS.join(", ")}, or NEON_FUNCTION_<SLUG>_BASE_URL`;
|
|
427
521
|
/** The OS-level env vars each service contributes to a pulled `.env`. */
|
|
428
522
|
const SERVICE_ENV_KEYS = {
|
|
429
523
|
postgres: Object.values(NEON_ENV_VAR_KEYS.postgres),
|
|
@@ -437,18 +531,19 @@ const SERVICE_ENV_KEYS = {
|
|
|
437
531
|
* The subset of {@link SERVICE_ENV_KEYS} a pull *owns*, and so may prune from the target file
|
|
438
532
|
* when the branch no longer has it. Object storage is deliberately absent: it is emitted under
|
|
439
533
|
* the third-party `AWS_*` names, which collide with credentials a user may set by hand, so
|
|
440
|
-
* `env pull` only ever writes them.
|
|
534
|
+
* `env pull` only ever writes them. Function URL keys cannot live here because slugs are
|
|
535
|
+
* discovered at runtime.
|
|
441
536
|
*/
|
|
442
537
|
const SERVICE_OWNED_ENV_KEYS = {
|
|
443
538
|
...SERVICE_ENV_KEYS,
|
|
444
|
-
"object-storage": []
|
|
539
|
+
"object-storage": [],
|
|
540
|
+
functions: []
|
|
445
541
|
};
|
|
446
542
|
/**
|
|
447
543
|
* Branch identity. Not a service — every branch has a name — so a scoped pull refreshes it
|
|
448
544
|
* alongside whatever services were selected.
|
|
449
545
|
*/
|
|
450
546
|
const BRANCH_ENV_KEY = NEON_ENV_VAR_KEYS.branch.name;
|
|
451
|
-
/** The service that produces a key, or `null` for branch identity. */
|
|
452
547
|
const ENV_KEY_SERVICE = {
|
|
453
548
|
DATABASE_URL: "postgres",
|
|
454
549
|
DATABASE_URL_UNPOOLED: "postgres",
|
|
@@ -463,9 +558,9 @@ const ENV_KEY_SERVICE = {
|
|
|
463
558
|
NEON_AI_GATEWAY_TOKEN: "ai-gateway",
|
|
464
559
|
NEON_AI_GATEWAY_BASE_URL: "ai-gateway"
|
|
465
560
|
};
|
|
466
|
-
const serviceForEnvKey = (key) => ENV_KEY_SERVICE[key];
|
|
561
|
+
const serviceForEnvKey = (key) => isFunctionBaseUrlKey(key) ? "functions" : ENV_KEY_SERVICE[key];
|
|
467
562
|
/** Services that must be resolved to produce the selected env keys. */
|
|
468
|
-
const servicesForEnvKeys = (keys) => ENV_PULL_SERVICES.filter((service) => keys.some((key) =>
|
|
563
|
+
const servicesForEnvKeys = (keys) => ENV_PULL_SERVICES.filter((service) => keys.some((key) => serviceForEnvKey(key) === service));
|
|
469
564
|
/** Every env var the selected services contribute, plus branch identity. */
|
|
470
565
|
const envServiceKeys = (services) => {
|
|
471
566
|
const keys = /* @__PURE__ */ new Set([BRANCH_ENV_KEY]);
|
|
@@ -476,43 +571,45 @@ const envServiceKeys = (services) => {
|
|
|
476
571
|
* The env vars a pull scoped to `services` may prune. Narrower than the unscoped set on
|
|
477
572
|
* purpose: `env pull -s ai-gateway` says nothing about `DATABASE_URL`, so it must leave it
|
|
478
573
|
* alone rather than treat its absence from this pull as "the branch no longer has it".
|
|
574
|
+
* Function URL keys cannot live here because slugs are discovered at runtime.
|
|
479
575
|
*/
|
|
480
576
|
const ownedEnvServiceKeys = (services) => services.flatMap((service) => SERVICE_OWNED_ENV_KEYS[service]);
|
|
481
|
-
/**
|
|
482
|
-
* The exact env vars an explicit selection writes. Services contribute their complete
|
|
483
|
-
* bundles plus branch identity; `--env` contributes only the named keys. The two selectors
|
|
484
|
-
* compose as a union.
|
|
485
|
-
*/
|
|
486
577
|
const envKeysForSelection = (services, envKeys) => {
|
|
487
578
|
const selected = services.length > 0 ? envServiceKeys(services) : /* @__PURE__ */ new Set();
|
|
488
579
|
for (const key of envKeys) selected.add(key);
|
|
489
580
|
if (selected.has(NEON_ENV_VAR_KEYS.storage.accessKeyId) !== selected.has(NEON_ENV_VAR_KEYS.storage.secretAccessKey)) throw new Error("AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be selected together: they are two halves of one newly issued object-storage credential. Add the missing key to --env, or use --service object-storage.");
|
|
490
|
-
|
|
581
|
+
const staticKeys = ENV_PULL_KEYS.filter((key) => selected.has(key));
|
|
582
|
+
const functionKeys = [...selected].filter(isFunctionBaseUrlKey).sort((left, right) => left.localeCompare(right));
|
|
583
|
+
return [...staticKeys, ...functionKeys];
|
|
491
584
|
};
|
|
492
585
|
/** Parse repeated or comma-separated `--env` values into canonical env-key order. */
|
|
493
586
|
const parseEnvPullKeys = (raw, flag) => {
|
|
494
587
|
const names = raw.flatMap((value) => value.split(",")).map((name) => name.trim()).filter((name) => name !== "");
|
|
495
|
-
const supported = `Supported values: ${
|
|
588
|
+
const supported = `Supported values: ${ENV_PULL_KEY_HELP}.`;
|
|
496
589
|
if (names.length === 0) throw new Error(`${flag} needs at least one env variable. ${supported}`);
|
|
497
|
-
const unknown = names.filter((name) => !ENV_PULL_KEYS.some((key) => key === name));
|
|
590
|
+
const unknown = names.filter((name) => !ENV_PULL_KEYS.some((key) => key === name) && !isFunctionBaseUrlKey(name));
|
|
498
591
|
if (unknown.length > 0) {
|
|
499
592
|
const displayNames = unknown.map(redactUnknownEnvValue);
|
|
500
593
|
const suggestions = [...new Set(unknown.map(suggestEnvPullKey).filter((key) => key !== null))];
|
|
501
594
|
const suggestion = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
|
|
502
595
|
throw new Error(`Unknown env variable${unknown.length === 1 ? "" : "s"} ${displayNames.join(", ")}.${suggestion} ${supported}`);
|
|
503
596
|
}
|
|
504
|
-
|
|
597
|
+
const staticKeys = ENV_PULL_KEYS.filter((key) => names.includes(key));
|
|
598
|
+
const functionKeys = [...new Set(names.filter(isFunctionBaseUrlKey))].sort((left, right) => left.localeCompare(right));
|
|
599
|
+
return [...staticKeys, ...functionKeys];
|
|
505
600
|
};
|
|
601
|
+
const isKnownEnvPullKey = (key) => ENV_PULL_KEYS.some((supportedKey) => supportedKey === key) || isFunctionBaseUrlKey(key);
|
|
506
602
|
const redactUnknownEnvValue = (value) => {
|
|
507
603
|
const separator = value.indexOf("=");
|
|
508
604
|
if (separator !== -1) {
|
|
509
605
|
const key = value.slice(0, separator);
|
|
510
|
-
return
|
|
606
|
+
return isKnownEnvPullKey(key) ? `${key}=<redacted>` : "<redacted invalid value>";
|
|
511
607
|
}
|
|
512
608
|
return "<redacted invalid value>";
|
|
513
609
|
};
|
|
514
610
|
const suggestEnvPullKey = (value) => {
|
|
515
611
|
if (value.includes("=")) return null;
|
|
612
|
+
if (parseFunctionBaseUrlKey(value) !== null) return null;
|
|
516
613
|
const closest = ENV_PULL_KEYS.map((key) => [key, editDistance(value, key)]).sort((a, b) => a[1] - b[1])[0];
|
|
517
614
|
return closest && closest[1] <= 2 ? closest[0] : null;
|
|
518
615
|
};
|
|
@@ -528,4 +625,4 @@ const editDistance = (left, right) => {
|
|
|
528
625
|
/** Narrow yargs' array option value without accepting any other runtime shape. */
|
|
529
626
|
const envPullFlagValue = (value) => Array.isArray(value) ? value.map(String) : void 0;
|
|
530
627
|
//#endregion
|
|
531
|
-
export {
|
|
628
|
+
export { toEntries as S, functionBaseUrlKey as _, envKeysForSelection as a, previewCredentialScopes as b, ownedEnvServiceKeys as c, servicesForEnvKeys as d, NEON_ENV_VAR_KEYS as f, fetchEnvKeysState as g, credentialName as h, ENV_PULL_SERVICES as i, parseEnvPullKeys as l, credentialEnvKeys as m, ENV_PULL_KEYS as n, envPullFlagValue as o, createApiFromOptions as p, ENV_PULL_KEY_HELP as r, envServiceKeys as s, BRANCH_ENV_KEY as t, serviceForEnvKey as u, isFunctionBaseUrlKey as v, resolveBranchPolicy as x, policyEnvKeys as y };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import "./profiles.js";
|
|
2
|
+
//#region src/_shared/auth_selection.ts
|
|
3
|
+
/**
|
|
4
|
+
* # Which credential an invocation authenticates with
|
|
5
|
+
*
|
|
6
|
+
* Four inputs can each answer "who am I": the `--api-key` flag, `NEON_API_KEY`, the
|
|
7
|
+
* `--profile` flag, and `NEON_PROFILE`. This module decides between them, and it is pure so
|
|
8
|
+
* the decision can be tested without a filesystem, a network, or a config directory.
|
|
9
|
+
*
|
|
10
|
+
* ## The rule
|
|
11
|
+
*
|
|
12
|
+
* **An explicit flag beats an ambient environment variable.** That single rule fixes the bug
|
|
13
|
+
* this module exists for: before it, any API key — including one merely exported into the
|
|
14
|
+
* shell — silently voided `--profile`, so `neon --profile work …` would quietly run as
|
|
15
|
+
* whoever `NEON_API_KEY` belonged to and say nothing about it.
|
|
16
|
+
*
|
|
17
|
+
* | Given | What runs |
|
|
18
|
+
* | --- | --- |
|
|
19
|
+
* | `--api-key` and `--profile` | neither: contradictory explicit flags, so this throws |
|
|
20
|
+
* | `--api-key` and `NEON_PROFILE` | the flag's key |
|
|
21
|
+
* | `--profile` and `NEON_API_KEY` | the profile |
|
|
22
|
+
* | `NEON_API_KEY` and `NEON_PROFILE` | the key, and the ignored profile is named in a warning |
|
|
23
|
+
* | `--profile` or `NEON_PROFILE` alone | that profile |
|
|
24
|
+
* | nothing | `DEFAULT` |
|
|
25
|
+
*
|
|
26
|
+
* Two explicit flags throw rather than picking a winner. They express different intents —
|
|
27
|
+
* `--api-key` supplies a credential, `--profile` selects a stored one — so there is no
|
|
28
|
+
* reading of the command that makes both true, and guessing is how the original bug behaved.
|
|
29
|
+
*
|
|
30
|
+
* When both are merely ambient, the key wins. That keeps CI exactly as it was: a pipeline
|
|
31
|
+
* that injects `NEON_API_KEY` must not change behaviour because a `NEON_PROFILE` leaked into
|
|
32
|
+
* the environment. It warns instead of staying silent, because a disregarded account
|
|
33
|
+
* selection is precisely what nobody noticed last time.
|
|
34
|
+
*
|
|
35
|
+
* `auth` and the `profile` subcommands do not use any of this. They read the same flags with
|
|
36
|
+
* different meanings — `neon auth --profile work` names where to *write* a credential, and
|
|
37
|
+
* `neon profile create work --api-key …` names one to *store* — so their callers skip
|
|
38
|
+
* selection entirely rather than passing exemptions down here.
|
|
39
|
+
*/
|
|
40
|
+
let inputs = {
|
|
41
|
+
apiKeyFlag: "",
|
|
42
|
+
apiKeyEnv: "",
|
|
43
|
+
profileEnv: ""
|
|
44
|
+
};
|
|
45
|
+
const recordCredentialInputs = (recorded) => {
|
|
46
|
+
inputs = recorded;
|
|
47
|
+
};
|
|
48
|
+
const credentialInputs = () => inputs;
|
|
49
|
+
const selectCredential = ({ apiKeyFlag, profileFlag, apiKeyEnv, profileEnv }) => {
|
|
50
|
+
const flagKey = nonEmpty(apiKeyFlag);
|
|
51
|
+
const flagProfile = nonEmpty(profileFlag);
|
|
52
|
+
if (flagKey !== void 0 && flagProfile !== void 0) throw new Error("Pass either --api-key or --profile, not both. --api-key supplies a credential directly; --profile selects a stored one.");
|
|
53
|
+
if (flagKey !== void 0) return {
|
|
54
|
+
source: "explicit-api-key",
|
|
55
|
+
apiKey: flagKey
|
|
56
|
+
};
|
|
57
|
+
if (flagProfile !== void 0) return {
|
|
58
|
+
source: "profile",
|
|
59
|
+
profile: flagProfile,
|
|
60
|
+
explicit: true
|
|
61
|
+
};
|
|
62
|
+
const envKey = nonEmpty(apiKeyEnv);
|
|
63
|
+
const envProfile = nonEmpty(profileEnv);
|
|
64
|
+
if (envKey !== void 0) return {
|
|
65
|
+
source: "ambient-api-key",
|
|
66
|
+
apiKey: envKey,
|
|
67
|
+
...envProfile !== void 0 ? { ignoredProfile: envProfile } : {}
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
source: "profile",
|
|
71
|
+
profile: envProfile ?? "DEFAULT",
|
|
72
|
+
explicit: envProfile !== void 0
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
/** The warning for an ambient key that displaced an ambient profile, or `null`. */
|
|
76
|
+
const displacedProfileWarning = (selection) => selection.source === "ambient-api-key" && selection.ignoredProfile !== void 0 ? `NEON_API_KEY is set, so profile "${selection.ignoredProfile}" from NEON_PROFILE was ignored. Pass --profile ${selection.ignoredProfile} to use it instead.` : null;
|
|
77
|
+
function nonEmpty(value) {
|
|
78
|
+
if (typeof value !== "string") return void 0;
|
|
79
|
+
const trimmed = value.trim();
|
|
80
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
export { credentialInputs, displacedProfileWarning, recordCredentialInputs, selectCredential };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { writeSecretFile } from "./secure_file.js";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
//#region src/_shared/credentials.ts
|
|
4
|
+
/**
|
|
5
|
+
* # Stored credentials — one file per account, two kinds
|
|
6
|
+
*
|
|
7
|
+
* A profile points at exactly one credentials file (see `./profiles.ts`), and that file says
|
|
8
|
+
* what kind of credential it holds. Adding API-key support this way rather than adding a
|
|
9
|
+
* second pointer to `profiles.json` keeps a profile what it already was — one name, one path
|
|
10
|
+
* — and means `profiles.json` needs no schema change at all.
|
|
11
|
+
*
|
|
12
|
+
* ```json
|
|
13
|
+
* // oauth: every file written before this existed. An absent `type` means this.
|
|
14
|
+
* { "access_token": "…", "refresh_token": "…", "expires_at": 1786…, "user_id": "…" }
|
|
15
|
+
*
|
|
16
|
+
* // api_key, stored by `neon profile create --api-key`
|
|
17
|
+
* { "type": "api_key", "api_key": "napi_…", "user_id": "…" }
|
|
18
|
+
*
|
|
19
|
+
* // api_key minted by `--mint --org-id`, which records the scope it was issued at
|
|
20
|
+
* { "type": "api_key", "api_key": "napi_…", "key_id": 123, "org_id": "org-…" }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* ## One profile, one kind
|
|
24
|
+
*
|
|
25
|
+
* A credentials file holds an API key or an OAuth session, never both, and `type` states
|
|
26
|
+
* which. An earlier draft let the two coexist — the idea being that a key could keep the
|
|
27
|
+
* session it was minted from and so rotate without a browser. It did not survive review, for
|
|
28
|
+
* two reasons that are worth recording so nobody rebuilds it:
|
|
29
|
+
*
|
|
30
|
+
* 1. **It never worked.** The resolver returned the key without testing it, so a revoked key
|
|
31
|
+
* failed to mint and never fell back to the session sitting beside it.
|
|
32
|
+
* 2. **It could mix accounts.** Nothing compared the identity of the credential being written
|
|
33
|
+
* with the one already there, so a profile could hold one account's session and another's
|
|
34
|
+
* key, told apart only by a single string. Flip or lose `type` and the profile silently
|
|
35
|
+
* becomes a different person.
|
|
36
|
+
*
|
|
37
|
+
* Recovery from a dead key is therefore one browser login — `neon profile create <name>
|
|
38
|
+
* --mint --force` — which is what the retained session was supposed to save and never did.
|
|
39
|
+
*
|
|
40
|
+
* ## Older releases
|
|
41
|
+
*
|
|
42
|
+
* A CLI predating this reads the pointer, finds no `type` it understands, ignores it, and
|
|
43
|
+
* looks for `access_token`. An `api_key` profile has none, so an older release falls through
|
|
44
|
+
* to its browser login rather than crashing. That it does not crash is why `credentials`
|
|
45
|
+
* stays a required pointer: an entry without one makes 2.41 and 2.42 throw
|
|
46
|
+
* `ERR_INVALID_ARG_TYPE` from `resolveEntryPath`.
|
|
47
|
+
*/
|
|
48
|
+
const OAUTH = "oauth";
|
|
49
|
+
const API_KEY = "api_key";
|
|
50
|
+
/**
|
|
51
|
+
* Which credential in this file authenticates, by declaration alone.
|
|
52
|
+
*
|
|
53
|
+
* An unrecognised `type` throws rather than falling back to `oauth`. A file we cannot
|
|
54
|
+
* interpret is a misconfiguration the user has to see: treating it as OAuth would send them
|
|
55
|
+
* to a browser login that silently replaces a credential they meant to keep, and treating it
|
|
56
|
+
* as an API key would authenticate with whatever `api_key` happened to be there.
|
|
57
|
+
*
|
|
58
|
+
* This deliberately does not check that an `api_key` file has a key — `neon profile list`
|
|
59
|
+
* needs the kind of a file it is not about to authenticate with, and must be able to report a
|
|
60
|
+
* broken one rather than throwing halfway through a table.
|
|
61
|
+
*/
|
|
62
|
+
const credentialKind = (credentials, at) => {
|
|
63
|
+
const declared = credentials.type;
|
|
64
|
+
if (declared === void 0 || declared === "oauth") return OAUTH;
|
|
65
|
+
if (declared === "api_key") return API_KEY;
|
|
66
|
+
throw new Error(`${at.path} declares a "type" this version does not understand. Expected "${OAUTH}" or "${API_KEY}". ${repair(at)}`);
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* The way out of a credentials file that cannot be read.
|
|
70
|
+
*
|
|
71
|
+
* One sentence, shared by every such error, because they all have the same two answers: write
|
|
72
|
+
* a new credential over it, or delete it and start again.
|
|
73
|
+
*/
|
|
74
|
+
const repair = (at) => `Replace it deliberately with \`neon profile create ${at.profile} --force\`, or delete the file.`;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve what to authenticate with, validating that the declared kind is actually usable.
|
|
77
|
+
*
|
|
78
|
+
* An `api_key` file with no key is a hard error rather than a fall-through to OAuth: the user
|
|
79
|
+
* asked for a key, and quietly opening a browser instead would replace the credential they
|
|
80
|
+
* were trying to fix.
|
|
81
|
+
*/
|
|
82
|
+
const interpretCredentials = (credentials, at) => {
|
|
83
|
+
if (credentialKind(credentials, at) === "oauth") return { kind: OAUTH };
|
|
84
|
+
const apiKey = nonEmpty(credentials.api_key);
|
|
85
|
+
if (apiKey === void 0) throw new Error(`${at.path} declares "type": "${API_KEY}" but has no "api_key" value. ${repair(at)}`);
|
|
86
|
+
return {
|
|
87
|
+
kind: API_KEY,
|
|
88
|
+
apiKey
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Read and classify a credentials file, without deciding what to do about it.
|
|
93
|
+
*
|
|
94
|
+
* A permission or I/O error still throws: there may be a perfectly good credential here that
|
|
95
|
+
* we cannot see, and treating that as absent would send the user to a browser login that
|
|
96
|
+
* overwrites it.
|
|
97
|
+
*/
|
|
98
|
+
const inspectCredentials = (path) => {
|
|
99
|
+
let contents;
|
|
100
|
+
try {
|
|
101
|
+
contents = readFileSync(path, "utf8");
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.code === "ENOENT") return { kind: "absent" };
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(contents);
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
kind: "unusable",
|
|
112
|
+
reason: `${path} is not valid JSON, so the credential in it cannot be read`
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
116
|
+
kind: "unusable",
|
|
117
|
+
reason: `${path} does not contain a credentials object`
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
kind: "ok",
|
|
121
|
+
credentials: parsed
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* The credential at `path`, or `null` when the file is not there.
|
|
126
|
+
*
|
|
127
|
+
* A damaged file is an error, not an absence. Treating it as absent — which is what this used to
|
|
128
|
+
* do — meant any read-only command could repair it by starting a browser sign-in and overwriting
|
|
129
|
+
* it, **possibly as a different account**, with the user never having asked for a repair and no
|
|
130
|
+
* way back to whatever was in the file. Failing here costs one deliberate command; the message
|
|
131
|
+
* names it.
|
|
132
|
+
*
|
|
133
|
+
* `profile list` and telemetry use {@link inspectCredentials} instead, because describing a
|
|
134
|
+
* broken credential is not the same as using one.
|
|
135
|
+
*/
|
|
136
|
+
const readCredentials = (at) => {
|
|
137
|
+
const read = inspectCredentials(at.path);
|
|
138
|
+
if (read.kind === "unusable") throw new Error(`${read.reason}. ${repair(at)}`);
|
|
139
|
+
return read.kind === "ok" ? read.credentials : null;
|
|
140
|
+
};
|
|
141
|
+
const writeCredentials = (path, credentials) => {
|
|
142
|
+
writeSecretFile(path, JSON.stringify(credentials));
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Build an `api_key` credentials object. Nothing from a previous credential is carried over.
|
|
146
|
+
*
|
|
147
|
+
* The scope is stored because it is not recoverable from the secret: `rotate-key` has to mint
|
|
148
|
+
* the replacement on the same endpoint, and an org or project key minted as an account key
|
|
149
|
+
* would silently widen what the profile reaches.
|
|
150
|
+
*/
|
|
151
|
+
const apiKeyCredentials = ({ apiKey, keyId, userId, scope }) => ({
|
|
152
|
+
type: API_KEY,
|
|
153
|
+
api_key: apiKey,
|
|
154
|
+
...keyId !== void 0 ? { key_id: keyId } : {},
|
|
155
|
+
...userId !== void 0 ? { user_id: userId } : {},
|
|
156
|
+
...scope?.orgId !== void 0 ? { org_id: scope.orgId } : {},
|
|
157
|
+
...scope?.projectId !== void 0 ? { project_id: scope.projectId } : {}
|
|
158
|
+
});
|
|
159
|
+
/** The scope recorded on a stored credential. */
|
|
160
|
+
const scopeOf = (credentials) => ({
|
|
161
|
+
...typeof credentials.org_id === "string" ? { orgId: credentials.org_id } : {},
|
|
162
|
+
...typeof credentials.project_id === "string" ? { projectId: credentials.project_id } : {}
|
|
163
|
+
});
|
|
164
|
+
/** How to describe a scope in output. */
|
|
165
|
+
const describeScope = (scope) => {
|
|
166
|
+
if (scope.projectId !== void 0) return `project ${scope.projectId}`;
|
|
167
|
+
if (scope.orgId !== void 0) return `org ${scope.orgId}`;
|
|
168
|
+
return "account";
|
|
169
|
+
};
|
|
170
|
+
function nonEmpty(value) {
|
|
171
|
+
if (typeof value !== "string") return void 0;
|
|
172
|
+
const trimmed = value.trim();
|
|
173
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Whether a stored credential is the same secret as the one about to replace it.
|
|
177
|
+
*
|
|
178
|
+
* Re-storing the key a profile already holds is a no-op, not a replacement — and retiring it
|
|
179
|
+
* would revoke the credential the command has just committed to. Trimmed on both sides, because
|
|
180
|
+
* a key read from a file or a pipe arrives with a trailing newline.
|
|
181
|
+
*/
|
|
182
|
+
const isSameCredential = (existingKey, replacementKey) => {
|
|
183
|
+
if (existingKey === void 0 || replacementKey === void 0) return false;
|
|
184
|
+
const trimmed = existingKey.trim();
|
|
185
|
+
return trimmed !== "" && trimmed === replacementKey.trim();
|
|
186
|
+
};
|
|
187
|
+
//#endregion
|
|
188
|
+
export { API_KEY, OAUTH, apiKeyCredentials, credentialKind, describeScope, inspectCredentials, interpretCredentials, isSameCredential, readCredentials, scopeOf, writeCredentials };
|