@neondatabase/env 1.2.3 → 1.3.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
@@ -153,13 +153,15 @@ functions.hello.baseUrl; // string
153
153
 
154
154
  ### The branch credential
155
155
 
156
- Object storage and the AI Gateway are backed by one branch credential, and the Neon API returns its secrets (`s3_secret_access_key`, `api_token`) **once**, at mint time they aren't stored server-side, and the list endpoint returns metadata only. So there is nothing to *fetch*: `fetchEnv` mints. Call it on every `neon dev` start and you leave a live credential behind each time.
156
+ Object storage and the AI Gateway are backed by platform default credentials on every branch in a region that has those products (`Default object storage credential`, `Default AI gateway credential`). `fetchEnv` reveals those by exact name. It mints a combined `neon-env ${branch}` credential only when a needed default is missing.
157
157
 
158
- Handling that is the caller's problem, and it is not just "cache the secret": a persisted secret is only reusable if it still names a live credential on that branchunrevoked, unexpired, and carrying every scope the policy needs. A presence check cannot tell a real secret from a `.env.example` placeholder.
158
+ Secrets (`s3_secret_access_key`, `api_token`) are returned from reveal as well as at mint time. Call `fetchEnv` on every `neon dev` start without reusing what you already hold and you re-reveal the same defaults or, in a region with no defaults, you leave a live minted credential behind each time.
159
+
160
+ A persisted secret is only reusable if it still names the platform default (when one exists), or a still-live minted credential (when none does). A presence check cannot tell a real secret from a `.env.example` placeholder.
159
161
 
160
162
  No local bookkeeping is needed to do it, because the secrets carry their own credential id: `AWS_ACCESS_KEY_ID` **is** the credential's token id, and the AI Gateway token is minted as `nt_live_<tokenIdShort>_<secret>`. So the `.env` you are about to rewrite already records which credential issued it.
161
163
 
162
- The [`neon` CLI](../cli) does all of this `neon env pull` and `neon dev` reuse a branch credential rather than issuing one per run. If you are calling `fetchEnv` on a loop yourself, `credentialScopesSatisfied` and `deriveCredentialScopes` from `@neon/config/v1`, plus `listCredentials` / `createCredential` / `revokeCredential` on a `NeonApi`, are the pieces you need.
164
+ The [`neon` CLI](../cli) additionally reuses persisted secrets (`neon env pull`, `neon dev`) so a loop does not re-reveal every start. `fetchEnv` itself reveals or mints on every call.
163
165
 
164
166
  ### Fetching a subset
165
167
 
@@ -187,7 +189,7 @@ selected.branch?.name; // string | undefined
187
189
 
188
190
  `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are one credential and must be selected together. Literal lists that contain only one half are a type error; a runtime-built list that resolves to one half throws before any API request or credential issuance.
189
191
 
190
- Work is skipped, not just the result narrowed. Leave out `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `NEON_AI_GATEWAY_TOKEN` and **no credential is minted at all** — which is exactly how `fetchEnvReusingSecrets` refreshes everything else while keeping secrets you already have. The non-secret vars of those features (`AWS_ENDPOINT_URL_S3`, `AWS_REGION`, `NEON_AI_GATEWAY_BASE_URL`) are branch metadata and stay available on their own.
192
+ Work is skipped, not just the result narrowed. Leave out `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `NEON_AI_GATEWAY_TOKEN` and **no credential is revealed or minted at all** — which is exactly how `fetchEnvReusingSecrets` refreshes everything else while keeping secrets you already have. The non-secret vars of those features (`AWS_ENDPOINT_URL_S3`, `AWS_REGION`, `NEON_AI_GATEWAY_BASE_URL`) are branch metadata and stay available on their own.
191
193
 
192
194
  ## Connection role & database selection
193
195
 
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { c as isFunctionBaseUrlKey, d as previewCredentialScopes, f as resolveBranchPolicy, i as credentialName, n as createApiFromOptions, o as fetchEnvKeysState, p as toEntries, r as credentialEnvKeys, t as NEON_ENV_VAR_KEYS, u as policyEnvKeys } from "./env.js";
2
+ import { a as defaultAiGatewayCredential, c as fetchEnvKeysState, d as isLiveCredential, h as toEntries, i as credentialName, m as resolveBranchPolicy, n as createApiFromOptions, o as defaultStorageCredential, p as policyEnvKeys, r as credentialEnvKeys, t as NEON_ENV_VAR_KEYS, u as isFunctionBaseUrlKey } from "./env.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
@@ -12,20 +12,19 @@ import { createHash } from "node:crypto";
12
12
  import { homedir } from "node:os";
13
13
  //#region ../../internals/env-core/dist/reuse-secrets.js
14
14
  /**
15
- * Resolve a branch's env while keeping one-time secrets the caller already holds.
15
+ * Resolve a branch's env while keeping secrets the caller already holds.
16
16
  *
17
- * {@link fetchEnvKeys} and the public `fetchEnv` only ever *fetch*. The Neon API returns a
18
- * credential's `api_token` / `s3_secret_access_key` exactly once, at mint time, so "fetching"
19
- * them means minting a new credential; a plain `fetchEnv` on every `neon dev` start or `env
20
- * pull` would leave a live credential behind each time. This is the wrapper that avoids that:
21
- * it looks at what the caller already has, decides what is still usable, and asks `fetchEnv`
22
- * for only the rest.
17
+ * {@link fetchEnvKeys} reveals the platform default credentials (or mints a fallback when a
18
+ * region has none). Calling it on every `neon dev` start would rewrite `.env` with freshly
19
+ * revealed secrets each time. This wrapper looks at what the caller already has, keeps a
20
+ * half when it already *is* that default (storage and gateway are independent credentials),
21
+ * and asks `fetchEnv` for only the rest.
23
22
  *
24
23
  * The check is a real verification, not a presence test. A persisted secret is kept only when
25
- * it names a credential that still exists on this branch, is not revoked or expired, and
26
- * carries every scope the policy needs. A `.env.example` placeholder, a credential revoked in
27
- * the console, one copied in from another branch, or one predating a newly-enabled feature all
28
- * fail that check and get replaced.
24
+ * it names a credential that still exists on this branch, is not revoked or expired, and
25
+ * when defaults exist *is* that default. A leftover `neon-env ${branch}` credential this
26
+ * tool minted is replaced by the defaults and revoked. A `.env.example` placeholder, a
27
+ * credential revoked in the console, or one copied in from another branch fails that check.
29
28
  *
30
29
  * None of this needs local bookkeeping, because the secrets carry their own credential id:
31
30
  * `AWS_ACCESS_KEY_ID` **is** the credential's `tokenId` (the storage gateway authenticates
@@ -75,47 +74,47 @@ async function fetchEnvReusingSecrets(config, options) {
75
74
  const persisted = readPersistedSecrets(source);
76
75
  const storageCredentialManaged = requested === null || storageCredentialSelected;
77
76
  const gatewayCredentialManaged = requested === null || gatewayCredentialSelected;
78
- const complete = (!storageCredentialSelected || Boolean(persisted.accessKeyId && persisted.secretAccessKey)) && (!gatewayCredentialSelected || Boolean(persisted.apiToken));
79
- const named = storageCredentialManaged && persisted.accessKeyId !== "" || gatewayCredentialManaged && persisted.apiToken !== "" ? namedCredentials(await api.listCredentials(options.projectId, branch.id), persisted) : {
77
+ const storageComplete = Boolean(persisted.accessKeyId && persisted.secretAccessKey);
78
+ const gatewayComplete = Boolean(persisted.apiToken);
79
+ const listed = storageCredentialManaged && persisted.accessKeyId !== "" || gatewayCredentialManaged && persisted.apiToken !== "" ? await api.listCredentials(options.projectId, branch.id) : [];
80
+ const named = listed.length > 0 ? namedCredentials(listed, persisted) : {
80
81
  storage: null,
81
82
  gateway: null
82
83
  };
83
- const reusable = complete ? reusableCredential(named, {
84
- storageEnabled: storageCredentialSelected,
85
- gatewayEnabled: gatewayCredentialSelected
86
- }) : null;
87
- const scopes = previewCredentialScopes(desired.preview, {
88
- storage: storageCredentialSelected,
89
- aiGateway: gatewayCredentialSelected
90
- });
91
- const keep = reusable !== null && credentialScopesSatisfied(reusable.scopes, scopes);
92
- const fetchKeys = requested === null ? null : keep ? selectedPolicyKeys.filter((key) => !secretKeys.includes(key)) : selectedPolicyKeys;
84
+ const now = Date.now();
85
+ const storageDefault = defaultStorageCredential(listed, now);
86
+ const gatewayDefault = defaultAiGatewayCredential(listed, now);
87
+ const keepStorage = !storageCredentialSelected || storageComplete && halfReusable(named.storage, storageDefault, ["storage:read", "storage:write"]);
88
+ const keepGateway = !gatewayCredentialSelected || gatewayComplete && halfReusable(named.gateway, gatewayDefault, ["ai_gateway:invoke"]);
89
+ const keptSecretKeys = credentialEnvKeys({
90
+ storage: storageCredentialSelected && keepStorage,
91
+ aiGateway: gatewayCredentialSelected && keepGateway
92
+ }).filter((key) => selected.has(key));
93
+ const fetchKeys = requested === null ? null : selectedPolicyKeys.filter((key) => !keptSecretKeys.includes(key));
93
94
  const fetched = await fetchEnvKeysState(config, {
94
95
  ...fetchOptions,
95
96
  branchId: branch.id,
96
97
  api,
97
- ...keep && requested === null ? { omitKeys: secretKeys } : {}
98
+ ...keptSecretKeys.length > 0 && requested === null ? { omitKeys: keptSecretKeys } : {}
98
99
  }, fetchKeys);
99
100
  const vars = preferPersisted(toEntries(fetched.env), source);
100
101
  const unavailable = fetched.functionUrlsUnavailable ? { functionUrlsUnavailable: true } : {};
101
- if (keep) {
102
- for (const key of secretKeys) {
103
- const value = source[key];
104
- if (value !== void 0) vars[key] = value;
105
- }
106
- return {
107
- vars,
108
- credential: {
109
- issued: false,
110
- keys: secretKeys,
111
- revoked: [],
112
- superseded: []
113
- },
114
- ...unavailable
115
- };
102
+ for (const key of keptSecretKeys) {
103
+ const value = source[key];
104
+ if (value !== void 0) vars[key] = value;
116
105
  }
106
+ if (!(storageCredentialSelected && !keepStorage || gatewayCredentialSelected && !keepGateway)) return {
107
+ vars,
108
+ credential: {
109
+ issued: false,
110
+ keys: secretKeys,
111
+ revoked: [],
112
+ superseded: []
113
+ },
114
+ ...unavailable
115
+ };
117
116
  const ours = /* @__PURE__ */ new Set();
118
- for (const meta of [storageCredentialManaged ? named.storage : null, gatewayCredentialManaged ? named.gateway : null]) if (meta !== null && meta.principalType === "user" && meta.name === credentialName(branch.name)) ours.add(meta.tokenId);
117
+ for (const meta of [storageCredentialManaged && !keepStorage ? named.storage : null, gatewayCredentialManaged && !keepGateway ? named.gateway : null]) if (meta !== null && meta.principalType === "user" && meta.name === credentialName(branch.name)) ours.add(meta.tokenId);
119
118
  if (revokeSuperseded) for (const tokenId of ours) await api.revokeCredential(options.projectId, branch.id, tokenId);
120
119
  return {
121
120
  vars,
@@ -166,13 +165,6 @@ function preferPersisted(vars, source) {
166
165
  function gatewayTokenIdShort(apiToken) {
167
166
  return /^nt_live_([^_]+)_.+$/.exec(apiToken)?.[1] ?? null;
168
167
  }
169
- /** Whether an issued credential can still be used: not revoked, not past its expiry. */
170
- function isLiveCredential(meta, now) {
171
- if (meta.revokedAt !== void 0) return false;
172
- if (meta.expiresAt === void 0) return true;
173
- const expiresAt = Date.parse(meta.expiresAt);
174
- return Number.isNaN(expiresAt) || expiresAt > now;
175
- }
176
168
  /**
177
169
  * The live credentials the persisted secrets name — at most one per half. A half that names
178
170
  * nothing contributes nothing, which is what a placeholder, a credential revoked in the
@@ -187,17 +179,17 @@ function namedCredentials(live, persisted) {
187
179
  };
188
180
  }
189
181
  /**
190
- * The credential the persisted secrets can be *reused* as, or `null`.
182
+ * Whether a persisted half can be reused.
191
183
  *
192
- * Strict on purpose: every half the policy enables has to name a live credential, and when both
193
- * features are enabled they must name the *same* one they share a single credential, so
194
- * halves that disagree came from two different calls and neither can be trusted.
184
+ * When a platform default exists, only that default is reusable a leftover
185
+ * `neon-env ${branch}` mint is replaced. When no default exists (mint fallback),
186
+ * any live credential the secrets name is reusable if it still carries the
187
+ * scopes that half needs.
195
188
  */
196
- function reusableCredential(named, enabled) {
197
- if (enabled.storageEnabled && enabled.gatewayEnabled) return named.storage && named.gateway && named.storage.tokenId === named.gateway.tokenId ? named.storage : null;
198
- if (enabled.storageEnabled) return named.storage;
199
- if (enabled.gatewayEnabled) return named.gateway;
200
- return null;
189
+ function halfReusable(named, defaultMeta, requiredScopes) {
190
+ if (named === null) return false;
191
+ if (defaultMeta !== null) return named.tokenId === defaultMeta.tokenId;
192
+ return credentialScopesSatisfied(named.scopes, requiredScopes);
201
193
  }
202
194
  //#endregion
203
195
  //#region ../../internals/cli-core/dist/cli_config.js