neon 2.47.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ import { credentialScopesSatisfied, } from "@neon/config/v1";
2
+ import { createApiFromOptions, credentialEnvKeys, credentialName, fetchEnvKeys, NEON_ENV_VAR_KEYS, policyEnvKeys, previewCredentialScopes, resolveBranchPolicy, toEntries, } from "./env.js";
3
+ /**
4
+ * Resolve a branch's env while keeping one-time secrets the caller already holds.
5
+ *
6
+ * {@link fetchEnvKeys} — and the public `fetchEnv` — only ever *fetch*. The Neon API returns a
7
+ * credential's `api_token` / `s3_secret_access_key` exactly once, at mint time, so "fetching"
8
+ * them means minting a new credential; a plain `fetchEnv` on every `neon dev` start or `env
9
+ * pull` would leave a live credential behind each time. This is the wrapper that avoids that:
10
+ * it looks at what the caller already has, decides what is still usable, and asks `fetchEnv`
11
+ * for only the rest.
12
+ *
13
+ * The check is a real verification, not a presence test. A persisted secret is kept only when
14
+ * it names a credential that still exists on this branch, is not revoked or expired, and
15
+ * carries every scope the policy needs. A `.env.example` placeholder, a credential revoked in
16
+ * the console, one copied in from another branch, or one predating a newly-enabled feature all
17
+ * fail that check and get replaced.
18
+ *
19
+ * None of this needs local bookkeeping, because the secrets carry their own credential id:
20
+ * `AWS_ACCESS_KEY_ID` **is** the credential's `tokenId` (the storage gateway authenticates
21
+ * against the full id), and the AI Gateway token is minted as `nt_live_<tokenIdShort>_<secret>`,
22
+ * where `tokenIdShort` is what the credentials list reports. The env source being replaced is
23
+ * the record of what the last call issued.
24
+ *
25
+ * ```ts
26
+ * import { fetchEnvReusingSecrets } from "../_shared/env-core/reuse-secrets.js";
27
+ *
28
+ * const { vars, credential } = await fetchEnvReusingSecrets(config, {
29
+ * projectId,
30
+ * branch: "main",
31
+ * env: { ...process.env, ...readEnvFile(".env") },
32
+ * });
33
+ * if (credential.issued) console.log(`new values for ${credential.keys.join(", ")}`);
34
+ * ```
35
+ */
36
+ export async function fetchEnvReusingSecrets(config, options) {
37
+ const { env: source = process.env, revokeSuperseded = true, ...fetchOptions } = options;
38
+ const api = options.api ?? createApiFromOptions(options);
39
+ const { branch, desired } = await resolveBranchPolicy(config, options, api);
40
+ const storageEnabled = (desired.preview?.buckets.length ?? 0) > 0;
41
+ const gatewayEnabled = desired.preview?.aiGatewayEnabled ?? false;
42
+ const secretKeys = credentialEnvKeys({
43
+ storage: storageEnabled,
44
+ aiGateway: gatewayEnabled,
45
+ });
46
+ // Nothing credential-backed on this branch, so there is nothing to preserve and no
47
+ // credential to spend: fetch everything and skip the credentials endpoint entirely.
48
+ if (secretKeys.length === 0) {
49
+ const fetched = await fetchEnvKeys(config, fetchOptions, null);
50
+ return {
51
+ vars: preferPersisted(toEntries(fetched), source),
52
+ credential: {
53
+ issued: false,
54
+ keys: [],
55
+ revoked: [],
56
+ superseded: [],
57
+ },
58
+ };
59
+ }
60
+ const persisted = readPersistedSecrets(source);
61
+ const complete = (!storageEnabled ||
62
+ Boolean(persisted.accessKeyId && persisted.secretAccessKey)) &&
63
+ (!gatewayEnabled || Boolean(persisted.apiToken));
64
+ // Look the persisted secrets up whenever there are any — not only when they're complete.
65
+ // An incomplete set still names the credential a newly-enabled feature is about to
66
+ // supersede (a storage-only credential on a branch that just gained the AI Gateway), and
67
+ // that one should be revoked rather than left live.
68
+ const named = persisted.accessKeyId !== "" || persisted.apiToken !== ""
69
+ ? namedCredentials(await api.listCredentials(options.projectId, branch.id), persisted)
70
+ : { storage: null, gateway: null };
71
+ const reusable = complete
72
+ ? reusableCredential(named, { storageEnabled, gatewayEnabled })
73
+ : null;
74
+ const scopes = previewCredentialScopes(desired.preview);
75
+ const keep = reusable !== null && credentialScopesSatisfied(reusable.scopes, scopes);
76
+ // Ask for everything the policy produces, minus the secrets we're keeping — which is what
77
+ // stops `fetchEnv` from minting a credential it doesn't need.
78
+ const allKeys = policyEnvKeys(desired);
79
+ const fetchKeys = keep
80
+ ? allKeys.filter((key) => !secretKeys.includes(key))
81
+ : allKeys;
82
+ const fetched = await fetchEnvKeys(config,
83
+ // Pass the resolved id so `fetchEnv` targets the same branch this call verified against,
84
+ // even if `options.branch` was a name that has since been reused.
85
+ { ...fetchOptions, branchId: branch.id, api }, fetchKeys);
86
+ const vars = preferPersisted(toEntries(fetched), source);
87
+ if (keep) {
88
+ for (const key of secretKeys) {
89
+ const value = source[key];
90
+ if (value !== undefined)
91
+ vars[key] = value;
92
+ }
93
+ return {
94
+ vars,
95
+ credential: {
96
+ issued: false,
97
+ keys: secretKeys,
98
+ revoked: [],
99
+ superseded: [],
100
+ },
101
+ };
102
+ }
103
+ // A replacement was minted, so revoke what it supersedes: the credentials the old secrets
104
+ // named, minus any this tool did not issue. Their secrets lived nowhere but the env source
105
+ // this call replaces, so revoking them strands nothing — and it keeps a branch from
106
+ // accumulating a live credential per call. Everything else on the branch is left alone: it
107
+ // may belong to a teammate, another checkout, or a deployed function, and nothing
108
+ // observable distinguishes those from an orphan of our own.
109
+ //
110
+ // Revoked *after* the fetch, so a failed fetch leaves the caller's existing secrets working.
111
+ const ours = new Set();
112
+ for (const meta of [named.storage, named.gateway]) {
113
+ if (meta !== null &&
114
+ meta.principalType === "user" &&
115
+ meta.name === credentialName(branch.name)) {
116
+ ours.add(meta.tokenId);
117
+ }
118
+ }
119
+ if (revokeSuperseded) {
120
+ for (const tokenId of ours) {
121
+ await api.revokeCredential(options.projectId, branch.id, tokenId);
122
+ }
123
+ }
124
+ return {
125
+ vars,
126
+ credential: {
127
+ issued: true,
128
+ keys: secretKeys,
129
+ revoked: revokeSuperseded ? [...ours] : [],
130
+ superseded: revokeSuperseded ? [] : [...ours],
131
+ },
132
+ };
133
+ }
134
+ /** Read the branch credential's secrets out of an env source. */
135
+ function readPersistedSecrets(source) {
136
+ const storage = NEON_ENV_VAR_KEYS.storage;
137
+ const gateway = NEON_ENV_VAR_KEYS.aiGateway;
138
+ return {
139
+ accessKeyId: source[storage.accessKeyId] ?? "",
140
+ secretAccessKey: source[storage.secretAccessKey] ?? "",
141
+ apiToken: source[gateway.apiKey] ?? "",
142
+ };
143
+ }
144
+ /**
145
+ * Keep a persisted value rather than overwriting it with an empty fetched one.
146
+ *
147
+ * Neon Auth's `base_url` is the case that needs this: integrations created before the API
148
+ * returned it answer with an empty string, and the persisted copy is the only one left. An
149
+ * empty fetched value never carries more information than a non-empty persisted one, so
150
+ * preferring the latter is safe for every var — and it keeps a pull from blanking a working
151
+ * line in someone's `.env`.
152
+ */
153
+ function preferPersisted(vars, source) {
154
+ const out = { ...vars };
155
+ for (const [key, value] of Object.entries(out)) {
156
+ if (value !== "")
157
+ continue;
158
+ const persisted = source[key];
159
+ if (persisted !== undefined && persisted !== "")
160
+ out[key] = persisted;
161
+ }
162
+ return out;
163
+ }
164
+ /**
165
+ * The credential id embedded in an AI Gateway token. The API mints them as
166
+ * `nt_live_<tokenIdShort>_<secret>`, and `tokenIdShort` is the public identifier the credentials
167
+ * list reports — so a persisted token names the credential that issued it. Returns `null` for
168
+ * anything not in that shape (a `.env.example` placeholder, a hand-typed value), which callers
169
+ * treat as unverifiable.
170
+ */
171
+ function gatewayTokenIdShort(apiToken) {
172
+ return /^nt_live_([^_]+)_.+$/.exec(apiToken)?.[1] ?? null;
173
+ }
174
+ /** Whether an issued credential can still be used: not revoked, not past its expiry. */
175
+ function isLiveCredential(meta, now) {
176
+ if (meta.revokedAt !== undefined)
177
+ return false;
178
+ if (meta.expiresAt === undefined)
179
+ return true;
180
+ const expiresAt = Date.parse(meta.expiresAt);
181
+ return Number.isNaN(expiresAt) || expiresAt > now;
182
+ }
183
+ /**
184
+ * The live credentials the persisted secrets name — at most one per half. A half that names
185
+ * nothing contributes nothing, which is what a placeholder, a credential revoked in the
186
+ * console, and one copied in from another branch all look like from here.
187
+ */
188
+ function namedCredentials(live, persisted) {
189
+ const usable = live.filter((meta) => isLiveCredential(meta, Date.now()));
190
+ const shortId = persisted.apiToken
191
+ ? gatewayTokenIdShort(persisted.apiToken)
192
+ : null;
193
+ return {
194
+ storage: persisted.accessKeyId
195
+ ? (usable.find((meta) => meta.tokenId === persisted.accessKeyId) ??
196
+ null)
197
+ : null,
198
+ gateway: shortId
199
+ ? (usable.find((meta) => meta.tokenIdShort === shortId) ?? null)
200
+ : null,
201
+ };
202
+ }
203
+ /**
204
+ * The credential the persisted secrets can be *reused* as, or `null`.
205
+ *
206
+ * Strict on purpose: every half the policy enables has to name a live credential, and when both
207
+ * features are enabled they must name the *same* one — they share a single credential, so
208
+ * halves that disagree came from two different calls and neither can be trusted.
209
+ */
210
+ function reusableCredential(named, enabled) {
211
+ if (enabled.storageEnabled && enabled.gatewayEnabled) {
212
+ return named.storage &&
213
+ named.gateway &&
214
+ named.storage.tokenId === named.gateway.tokenId
215
+ ? named.storage
216
+ : null;
217
+ }
218
+ if (enabled.storageEnabled)
219
+ return named.storage;
220
+ if (enabled.gatewayEnabled)
221
+ return named.gateway;
222
+ return null;
223
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { resolveConfig } from "@neon/config";
4
- import { apply, createBranch as createBranchFromPolicy, inspect, isPartialBranchCreateError, loadConfigFromFile, PushConflictError, plan, } from "@neon/config-runtime";
2
+ import { dirname, join } from "node:path";
3
+ import { packagesToStage, resolveConfig } from "@neon/config";
4
+ import { apply, assertZipWithinLimits, createBranch as createBranchFromPolicy, describeNativeFinding, enforceLimits, findUndeclaredNativePackages, inspect, isPartialBranchCreateError, loadConfigFromFile, PushConflictError, plan, traceNativePackages, } from "@neon/config-runtime";
5
5
  import chalk from "chalk";
6
6
  import { getApiClient } from "../api.js";
7
7
  import { toNeonConfigView } from "../config_format.js";
@@ -28,11 +28,45 @@ import { autoPullEnvAfterPin } from "./env.js";
28
28
  * out of config-runtime's static module graph — and therefore out of the packaged
29
29
  * neonctl snapshot, which resolves esbuild dynamically at deploy time.
30
30
  */
31
- const neonctlBundler = async (fn) => zipBundle(await bundleEntry(fn.source, {
32
- ...(fn.externalPackages
33
- ? { externalPackages: fn.externalPackages }
34
- : {}),
35
- }));
31
+ const neonctlBundler = async (fn) => {
32
+ const externalPackages = fn.externalPackages ?? [];
33
+ const { files, metafile, warnings } = await bundleEntry(fn.source, {
34
+ externalPackages: externalPackages.map((pkg) => pkg.name),
35
+ });
36
+ for (const warning of warnings)
37
+ log.warning(warning);
38
+ // Advisory only — the evidence cannot prove the code path is reached, so a package with a
39
+ // working JavaScript fallback must not have its deploy blocked. See native-detect.
40
+ for (const finding of findUndeclaredNativePackages({
41
+ metafile,
42
+ declared: externalPackages.map((pkg) => pkg.name),
43
+ projectDir: dirname(fn.source),
44
+ })) {
45
+ log.warning(describeNativeFinding(fn.slug, finding));
46
+ }
47
+ const staged = packagesToStage(externalPackages);
48
+ // Nothing to stage is the pre-existing path: zip the esbuild output and nothing else,
49
+ // producing the archive it always did.
50
+ if (staged.length === 0)
51
+ return zipBundle(files);
52
+ // Staged packages are installed for the runtime target, traced, and merged in under their
53
+ // `node_modules/...` paths. The tree layout is load-bearing — a `.node` addon finds its
54
+ // sibling shared libraries relative to its own directory.
55
+ const traced = await traceNativePackages({
56
+ slug: fn.slug,
57
+ packages: staged,
58
+ projectDir: dirname(fn.source),
59
+ });
60
+ for (const warning of traced.warnings)
61
+ log.warning(warning);
62
+ const entries = { ...files, ...traced.entries };
63
+ // Re-checked against the final archive: the staged files were measured without the
64
+ // bundle, so the entry count and uncompressed total are only complete now.
65
+ enforceLimits(fn.slug, entries);
66
+ const zip = zipBundle(entries);
67
+ assertZipWithinLimits(fn.slug, zip, entries);
68
+ return zip;
69
+ };
36
70
  const INSPECT_FIELDS = ["project", "branch", "config"];
37
71
  /**
38
72
  * Shared `--env` flag for `config plan|apply` and `deploy`. Loads a `.env` into
@@ -1,8 +1,9 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { once } from "node:events";
3
3
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { describeNativeFinding, findUndeclaredNativePackages, } from "@neon/config-runtime";
6
7
  import chalk from "chalk";
7
8
  import { resolveDevEnv } from "../dev/env.js";
8
9
  import { resolveFunctionsFromConfig, } from "../dev/functions.js";
@@ -119,7 +120,7 @@ const runSingleSource = async (props) => {
119
120
  const unit = {
120
121
  slug: null,
121
122
  source,
122
- bundleDir: join(process.cwd(), "node_modules", ".neon-dev"),
123
+ bundleDir: devBundleDir(process.cwd()),
123
124
  childEnv: buildChildEnv(neonEnv, portFromProps(props.port)),
124
125
  label: null,
125
126
  envSummary: { neon: Object.keys(neonEnv), fn: [] },
@@ -213,6 +214,19 @@ const portFromProps = (port) => {
213
214
  }
214
215
  return { mode: "search", from: DEFAULT_PORT_BASE };
215
216
  };
217
+ /**
218
+ * Where a locally-served function's bundle is written.
219
+ *
220
+ * The location inside the project's own `node_modules` is load-bearing, not a tidiness
221
+ * choice. A function's `externalPackages` are left unbundled, and Node resolves an unbundled
222
+ * import by walking up from the importing file — so from here it reaches the project's real
223
+ * `node_modules` and finds them, at the host architecture that can actually run locally.
224
+ * Moving this directory anywhere outside `node_modules` breaks `neon dev` for those
225
+ * functions with a `Cannot find module`, so the path is pinned by a test.
226
+ */
227
+ export const devBundleDir = (cwd, slug) => slug === undefined
228
+ ? join(cwd, "node_modules", ".neon-dev")
229
+ : join(cwd, "node_modules", ".neon-dev", slug);
216
230
  /**
217
231
  * Translate a {@link PlannedFunction} into a {@link ServedUnit}. Port rules:
218
232
  * - explicit `dev.port`: bind exactly, fail if taken.
@@ -227,7 +241,7 @@ const plannedToUnit = (fn, branchEnv, searchBase) => {
227
241
  return {
228
242
  slug: fn.slug,
229
243
  source: fn.source,
230
- bundleDir: join(process.cwd(), "node_modules", ".neon-dev", fn.slug),
244
+ bundleDir: devBundleDir(process.cwd(), fn.slug),
231
245
  childEnv,
232
246
  label: fn.slug,
233
247
  envSummary: { neon: Object.keys(branchEnv), fn: Object.keys(fn.env) },
@@ -286,7 +300,9 @@ const runSupervisor = async (units, options = {}) => {
286
300
  const bundleAndStart = async (r) => {
287
301
  let bundlePath;
288
302
  try {
289
- bundlePath = await writeBundle(r.unit.source, r.unit.bundleDir, r.unit.externalPackages);
303
+ bundlePath = await writeBundle(r.unit.source, r.unit.bundleDir, r.unit.externalPackages,
304
+ // The `neon.ts` key, so a function is named the same way here as at deploy.
305
+ r.unit.slug ?? undefined);
290
306
  }
291
307
  catch (err) {
292
308
  r.status = "error";
@@ -534,10 +550,35 @@ const spawnChild = (unit, runtimePath, bundlePath) => {
534
550
  detached: true,
535
551
  });
536
552
  };
537
- const writeBundle = async (source, bundleDir, externalPackages) => {
538
- const files = await bundleEntry(source, {
553
+ /**
554
+ * Findings already reported for a served unit, so an advisory that cannot change between
555
+ * saves is not reprinted on every one. A dev session on a project with a standing false
556
+ * positive would otherwise repeat the whole block for its lifetime.
557
+ */
558
+ const reportedFindings = new Map();
559
+ const writeBundle = async (source, bundleDir, externalPackages, label) => {
560
+ // Left unbundled only. `bundleDir` sits inside the project's node_modules, so an
561
+ // externalized package resolves from the real tree at the host architecture — no install
562
+ // or copy is needed or wanted locally, whatever `includeFiles` says for a deploy.
563
+ const { files, metafile, warnings } = await bundleEntry(source, {
539
564
  ...(externalPackages ? { externalPackages } : {}),
540
565
  });
566
+ for (const warning of warnings)
567
+ log.warning(warning);
568
+ // Local runs resolve native packages from the real tree, so a missing declaration is
569
+ // invisible until deploy. Reporting it here is the only signal before then.
570
+ const findings = findUndeclaredNativePackages({
571
+ metafile,
572
+ declared: externalPackages ?? [],
573
+ projectDir: dirname(source),
574
+ });
575
+ const signature = findings.map((f) => f.name).join(",");
576
+ if (reportedFindings.get(source) !== signature) {
577
+ reportedFindings.set(source, signature);
578
+ for (const finding of findings) {
579
+ log.warning(describeNativeFinding(label ?? basename(source), finding));
580
+ }
581
+ }
541
582
  mkdirSync(bundleDir, { recursive: true });
542
583
  // bundleEntry emits a single `index.mjs` (no source map). The `.mjs` extension makes Node
543
584
  // load it as ESM directly, so no `package.json` `"type": "module"` marker is needed.
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
- import { NEON_ENV_VAR_KEYS } from "@neon/env";
3
2
  import chalk from "chalk";
3
+ import { NEON_ENV_VAR_KEYS } from "../_shared/env-core/env.js";
4
4
  import { ensureGitignored } from "../context.js";
5
5
  import { resolveNeonEnvVars } from "../dev/env.js";
6
6
  import { mergeEnvFile, readEnvFile, resolveEnvFilePath } from "../env_file.js";
@@ -1,5 +1,6 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
+ import { describeNativeFinding, findUndeclaredNativePackages, } from "@neon/config-runtime";
3
4
  import { isNeonApiError, retryOnLock } from "../api.js";
4
5
  import { createDeployment, deleteFunction, getFunction, listFunctions, } from "../functions_api.js";
5
6
  import { log } from "../log.js";
@@ -194,7 +195,20 @@ const deploy = async (props) => {
194
195
  throw new Error(`No entry file found in ${src}. Expected one of: ${ENTRY_CANDIDATES.join(", ")}.`);
195
196
  }
196
197
  // Bundle before any network round-trip so a bundling failure fails fast.
197
- const zip = zipBundle(await bundleEntry(source));
198
+ const bundled = await bundleEntry(source);
199
+ for (const warning of bundled.warnings)
200
+ log.warning(warning);
201
+ // `--src` bypasses `neon.ts`, so there is no policy to declare anything in and no way to
202
+ // stage files here. The advisory still runs: this is the shortest path to a function that
203
+ // deploys clean and then fails at invoke, so it is the last place to stay silent.
204
+ for (const finding of findUndeclaredNativePackages({
205
+ metafile: bundled.metafile,
206
+ declared: [],
207
+ projectDir: dirname(source),
208
+ })) {
209
+ log.warning(describeNativeFinding(props.slug, finding));
210
+ }
211
+ const zip = zipBundle(bundled.files);
198
212
  const branchId = await branchIdFromProps(props);
199
213
  // Snapshot the current version before deploy so we can detect the new one
200
214
  // afterward. A missing function (404) or no deployment yet → undefined.
package/dist/dev/env.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createNeonApiFromOptions, loadConfigFromFile, } from "@neon/config";
2
2
  import { plan, pullConfig } from "@neon/config-runtime";
3
- import { NEON_ENV_VAR_KEYS } from "@neon/env";
4
- import { fetchEnvReusingSecrets, } from "@neon/env/runtime";
3
+ import { NEON_ENV_VAR_KEYS } from "../_shared/env-core/env.js";
4
+ import { fetchEnvReusingSecrets, } from "../_shared/env-core/reuse-secrets.js";
5
5
  import { log } from "../log.js";
6
6
  import { getCliName } from "../utils/cli_name.js";
7
7
  /** The API-targeting options every runtime call forwards from the context. */
@@ -36,8 +36,12 @@ export const resolveFunctionsFromConfig = async (cwd, branchName) => {
36
36
  ? { port: devPort(fn.dev) }
37
37
  : {}),
38
38
  env: { ...fn.env },
39
+ // Names only: locally every entry is simply left unbundled, and `includeFiles`
40
+ // governs the deployed archive, which `neon dev` does not build.
39
41
  ...(fn.externalPackages
40
- ? { externalPackages: [...fn.externalPackages] }
42
+ ? {
43
+ externalPackages: fn.externalPackages.map((pkg) => pkg.name),
44
+ }
41
45
  : {}),
42
46
  };
43
47
  });
@@ -1,4 +1,4 @@
1
- import { NEON_ENV_VAR_KEYS } from "@neon/env";
1
+ import { NEON_ENV_VAR_KEYS } from "./_shared/env-core/env.js";
2
2
  import { NEON_SERVICES } from "./neon_services.js";
3
3
  /**
4
4
  * The services `env pull --service` can select: every Neon service that produces branch env