@neon/env 0.0.0 → 0.9.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/LICENSE.md +178 -0
- package/README.md +96 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +86 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/dist/lib/neon-api.d.ts +375 -0
- package/dist/config/dist/lib/neon-api.d.ts.map +1 -0
- package/dist/config/dist/lib/types.d.ts +445 -0
- package/dist/config/dist/lib/types.d.ts.map +1 -0
- package/dist/config/dist/v1.d.ts +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/cli/commands.d.ts +64 -0
- package/dist/lib/cli/commands.d.ts.map +1 -0
- package/dist/lib/cli/commands.js +219 -0
- package/dist/lib/cli/commands.js.map +1 -0
- package/dist/lib/cli/resolve-context.d.ts +34 -0
- package/dist/lib/cli/resolve-context.d.ts.map +1 -0
- package/dist/lib/cli/resolve-context.js +88 -0
- package/dist/lib/cli/resolve-context.js.map +1 -0
- package/dist/lib/env.d.ts +420 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +569 -0
- package/dist/lib/env.js.map +1 -0
- package/package.json +56 -17
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { ErrorCode, PlatformError, createNeonApiFromOptions, deriveCredentialScopes, resolveConfig } from "@neon/config/v1";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/lib/env.ts
|
|
4
|
+
/**
|
|
5
|
+
* Mapping between the {@link NeonEnv} property paths and the OS-level env-var keys used
|
|
6
|
+
* for cross-process transport (via `.env` files, `env run -- <cmd>`, or anything else
|
|
7
|
+
* that talks to `process.env`).
|
|
8
|
+
*
|
|
9
|
+
* Each top-level key here is a {@link NeonEnv} namespace; the inner record maps the
|
|
10
|
+
* camelCase property names exposed to TypeScript to the UPPER_SNAKE env-var names used
|
|
11
|
+
* by the OS. Keep this in sync with {@link postgresEnvSchema} / {@link authEnvSchema} /
|
|
12
|
+
* {@link dataApiEnvSchema}.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Neon's default branch owner role, created with every project. This is the role a
|
|
16
|
+
* `DATABASE_URL` should connect as.
|
|
17
|
+
*/
|
|
18
|
+
const NEON_DEFAULT_OWNER_ROLE = "neondb_owner";
|
|
19
|
+
/**
|
|
20
|
+
* Roles Neon provisions for the Auth / Data API (PostgREST) stack. They exist to back
|
|
21
|
+
* RLS-scoped Data API requests authenticated by JWT — never to hold a `DATABASE_URL` —
|
|
22
|
+
* so they're skipped when auto-picking the connection role. Enabling Neon Auth or the
|
|
23
|
+
* Data API (`neon config apply`) adds these next to the owner role, which is why a plain
|
|
24
|
+
* branch routinely reports more than one role.
|
|
25
|
+
*/
|
|
26
|
+
const NEON_MANAGED_AUTH_ROLES = /* @__PURE__ */ new Set([
|
|
27
|
+
"authenticator",
|
|
28
|
+
"anonymous",
|
|
29
|
+
"authenticated"
|
|
30
|
+
]);
|
|
31
|
+
const NEON_ENV_VAR_KEYS = {
|
|
32
|
+
/**
|
|
33
|
+
* Branch identity. `NEON_BRANCH` carries the branch **name** and is injected into the
|
|
34
|
+
* Neon Functions runtime on every branch (including the default) by default. `env pull` /
|
|
35
|
+
* `neon dev` / `neon-env run` emit it too so local dev mirrors the deployed runtime.
|
|
36
|
+
*/
|
|
37
|
+
branch: { name: "NEON_BRANCH" },
|
|
38
|
+
postgres: {
|
|
39
|
+
databaseUrl: "DATABASE_URL",
|
|
40
|
+
databaseUrlUnpooled: "DATABASE_URL_UNPOOLED"
|
|
41
|
+
},
|
|
42
|
+
auth: {
|
|
43
|
+
baseUrl: "NEON_AUTH_BASE_URL",
|
|
44
|
+
jwksUrl: "NEON_AUTH_JWKS_URL"
|
|
45
|
+
},
|
|
46
|
+
dataApi: { url: "NEON_DATA_API_URL" },
|
|
47
|
+
/**
|
|
48
|
+
* Object storage (Preview). The S3 SDKs read `AWS_*` from their standard config chain, so
|
|
49
|
+
* a branch credential + `neon dev` / `env pull` makes object storage work from env alone.
|
|
50
|
+
* `region` is injected under the SDK-standard `AWS_REGION`.
|
|
51
|
+
*/
|
|
52
|
+
storage: {
|
|
53
|
+
accessKeyId: "AWS_ACCESS_KEY_ID",
|
|
54
|
+
secretAccessKey: "AWS_SECRET_ACCESS_KEY",
|
|
55
|
+
endpoint: "AWS_ENDPOINT_URL_S3",
|
|
56
|
+
region: "AWS_REGION"
|
|
57
|
+
},
|
|
58
|
+
/**
|
|
59
|
+
* AI Gateway (Preview). Mapped onto the OpenAI SDK's standard env vars so the OpenAI
|
|
60
|
+
* clients work from env alone; `baseUrl` carries the gateway's OpenAI-dialect route prefix
|
|
61
|
+
* (`/ai-gateway/openai/v1`). The `NEON_AI_GATEWAY_*` aliases are also emitted: `neonToken`
|
|
62
|
+
* mirrors the OpenAI key, and `neonBaseUrl` is the bare branch gateway host
|
|
63
|
+
* (`scheme://host`, no path) — the `@ai-sdk/neon` provider appends the
|
|
64
|
+
* `/ai-gateway/<dialect>/…` routes itself (https://github.com/vercel/ai/pull/15997).
|
|
65
|
+
*/
|
|
66
|
+
aiGateway: {
|
|
67
|
+
apiKey: "OPENAI_API_KEY",
|
|
68
|
+
baseUrl: "OPENAI_BASE_URL",
|
|
69
|
+
neonToken: "NEON_AI_GATEWAY_TOKEN",
|
|
70
|
+
neonBaseUrl: "NEON_AI_GATEWAY_BASE_URL"
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/** OpenAI-dialect route prefix on the branch AI Gateway host. */
|
|
74
|
+
const AI_GATEWAY_OPENAI_PATH = "/ai-gateway/openai/v1";
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the project + branch this process should target, then fetch live Neon
|
|
77
|
+
* connection strings for that branch over the network. Async — calls the Neon API.
|
|
78
|
+
*
|
|
79
|
+
* Use this from build scripts and the `neon-env run` command, where top-level await is
|
|
80
|
+
* fine. For application code that needs a synchronous bootstrap (most frameworks: Drizzle
|
|
81
|
+
* config, Next.js, Vite, etc.), inject env vars via `neon-env run -- <cmd>` and use
|
|
82
|
+
* {@link parseEnv} instead — same {@link NeonEnv} shape, but a sync call against
|
|
83
|
+
* `process.env`.
|
|
84
|
+
*
|
|
85
|
+
* Filesystem- and env-agnostic: pass `projectId` and the target `branch` (name or id)
|
|
86
|
+
* explicitly (resolve them in your CLI, e.g. neonctl).
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* import config from "../neon";
|
|
90
|
+
* import { fetchEnv } from "@neon/env";
|
|
91
|
+
*
|
|
92
|
+
* const env = await fetchEnv(config, { projectId: "patient-art-12345", branch: "main" });
|
|
93
|
+
* const db = drizzle(neon(env.postgres.databaseUrl), { schema });
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* The package does **not** mutate `process.env` or the filesystem itself.
|
|
97
|
+
*/
|
|
98
|
+
async function fetchEnv(config, options) {
|
|
99
|
+
const api = options.api ?? createApiFromOptions(options);
|
|
100
|
+
const projectId = options.projectId;
|
|
101
|
+
const branches = await api.listBranches(projectId);
|
|
102
|
+
if (branches.length === 0) throw new PlatformError(ErrorCode.BranchNotFound, [`fetchEnv: project ${projectId} has no branches.`, "Deploy your neon.ts policy (or create a branch) first, or pick a different project id."].join(" "), { details: { projectId } });
|
|
103
|
+
const branchRef = options.branch ?? options.branchId;
|
|
104
|
+
if (!branchRef) throw new PlatformError(ErrorCode.BranchNotFound, ["fetchEnv: no branch provided.", "Pass `branch` with a branch name (e.g. `main`) or id (`br-…`)."].join(" "), { details: { projectId } });
|
|
105
|
+
const branch = resolveBranch(branchRef, branches);
|
|
106
|
+
const desired = resolveConfig(config, {
|
|
107
|
+
name: branch.name,
|
|
108
|
+
id: branch.id,
|
|
109
|
+
exists: true,
|
|
110
|
+
...branch.parentId ? { parentId: branch.parentId } : {},
|
|
111
|
+
isDefault: branch.isDefault,
|
|
112
|
+
isProtected: branch.protected,
|
|
113
|
+
...branch.expiresAt ? { expiresAt: branch.expiresAt } : {}
|
|
114
|
+
});
|
|
115
|
+
const [roles, databases] = await Promise.all([api.listBranchRoles(projectId, branch.id), api.listBranchDatabases(projectId, branch.id)]);
|
|
116
|
+
const roleName = pickRoleName(roles, branch, options.roleName);
|
|
117
|
+
const databaseName = pickDatabaseName(databases, branch, roleName, options.databaseName);
|
|
118
|
+
const wantsAuth = desired.authEnabled;
|
|
119
|
+
const wantsDataApi = desired.dataApiEnabled;
|
|
120
|
+
const [pooled, unpooled, authSnapshot, dataApiSnapshot] = await Promise.all([
|
|
121
|
+
api.getConnectionUri(projectId, {
|
|
122
|
+
branchId: branch.id,
|
|
123
|
+
databaseName,
|
|
124
|
+
roleName,
|
|
125
|
+
pooled: true
|
|
126
|
+
}),
|
|
127
|
+
api.getConnectionUri(projectId, {
|
|
128
|
+
branchId: branch.id,
|
|
129
|
+
databaseName,
|
|
130
|
+
roleName,
|
|
131
|
+
pooled: false
|
|
132
|
+
}),
|
|
133
|
+
wantsAuth ? api.getNeonAuth(projectId, branch.id) : Promise.resolve(null),
|
|
134
|
+
wantsDataApi ? api.getNeonDataApi(projectId, branch.id, databaseName) : Promise.resolve(null)
|
|
135
|
+
]);
|
|
136
|
+
const result = {
|
|
137
|
+
postgres: {
|
|
138
|
+
databaseUrl: pooled.uri,
|
|
139
|
+
databaseUrlUnpooled: unpooled.uri
|
|
140
|
+
},
|
|
141
|
+
branch: { name: branch.name }
|
|
142
|
+
};
|
|
143
|
+
if (wantsAuth) {
|
|
144
|
+
if (!authSnapshot) throw new PlatformError(ErrorCode.NotFound, [`fetchEnv: branch policy enables auth but no Neon Auth integration is enabled on branch ${branch.name} (${branch.id}).`, "Enable it via `apply(config, { projectId, branchId })` (or `npx neonctl …`), in the Neon Console — then re-run fetchEnv. Or return auth.enabled=false."].join(" "), { details: {
|
|
145
|
+
projectId,
|
|
146
|
+
branchId: branch.id
|
|
147
|
+
} });
|
|
148
|
+
const envSource = options.env ?? process.env;
|
|
149
|
+
result.auth = {
|
|
150
|
+
baseUrl: resolveAuthBaseUrl(authSnapshot.baseUrl, envSource),
|
|
151
|
+
jwksUrl: resolveAuthJwksUrl(authSnapshot.jwksUrl, envSource)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (wantsDataApi) {
|
|
155
|
+
if (!dataApiSnapshot) throw new PlatformError(ErrorCode.NotFound, [`fetchEnv: branch policy enables dataApi but no Data API integration is enabled on branch ${branch.name} (${branch.id}) database ${databaseName}.`, "Enable it via `apply(config, { projectId, branchId })` or in the Neon Console — then re-run fetchEnv. Or return dataApi.enabled=false."].join(" "), { details: {
|
|
156
|
+
projectId,
|
|
157
|
+
branchId: branch.id,
|
|
158
|
+
databaseName
|
|
159
|
+
} });
|
|
160
|
+
result.dataApi = { url: dataApiSnapshot.url };
|
|
161
|
+
}
|
|
162
|
+
const wantsStorage = (desired.preview?.buckets.length ?? 0) > 0;
|
|
163
|
+
const wantsAiGateway = desired.preview?.aiGatewayEnabled ?? false;
|
|
164
|
+
if (wantsStorage || wantsAiGateway) {
|
|
165
|
+
const secrets = await resolveCredentialSecrets({
|
|
166
|
+
api,
|
|
167
|
+
projectId,
|
|
168
|
+
branchId: branch.id,
|
|
169
|
+
branchName: branch.name,
|
|
170
|
+
scopes: previewCredentialScopes(desired.preview),
|
|
171
|
+
env: options.env ?? process.env,
|
|
172
|
+
needStorage: wantsStorage,
|
|
173
|
+
needApiToken: wantsAiGateway
|
|
174
|
+
});
|
|
175
|
+
if (wantsStorage) {
|
|
176
|
+
const storage = await api.getProjectBranchStorage(projectId, branch.id);
|
|
177
|
+
if (!storage) throw new PlatformError(ErrorCode.NotFound, [`fetchEnv: branch policy declares object storage (preview.buckets) but storage is not enabled on branch ${branch.name} (${branch.id}).`, "Enable it via `apply(config, { projectId, branchId })` (or in the Neon Console) — then re-run fetchEnv. Or remove preview.buckets."].join(" "), { details: {
|
|
178
|
+
projectId,
|
|
179
|
+
branchId: branch.id
|
|
180
|
+
} });
|
|
181
|
+
result.storage = {
|
|
182
|
+
accessKeyId: secrets.accessKeyId,
|
|
183
|
+
secretAccessKey: secrets.secretAccessKey,
|
|
184
|
+
endpoint: storage.s3Endpoint,
|
|
185
|
+
region: storage.region
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (wantsAiGateway) result.aiGateway = {
|
|
189
|
+
apiKey: secrets.apiToken,
|
|
190
|
+
baseUrl: aiGatewayBaseUrl(branch.id, unpooled.uri)
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Scopes the branch credential should carry for a resolved branch policy. Only object storage
|
|
197
|
+
* and the AI Gateway *require* a credential; functions never force one (they have no credential
|
|
198
|
+
* of their own), but `functions:invoke` is added to the scope set when a credential is already
|
|
199
|
+
* being minted for storage / the AI Gateway, so the one credential can invoke the branch's
|
|
200
|
+
* functions too. Returns `[]` only when nothing credential-bearing is enabled.
|
|
201
|
+
*/
|
|
202
|
+
function previewCredentialScopes(preview) {
|
|
203
|
+
if (!preview) return [];
|
|
204
|
+
const storage = preview.buckets.length > 0;
|
|
205
|
+
const aiGateway = preview.aiGatewayEnabled;
|
|
206
|
+
if (!storage && !aiGateway) return [];
|
|
207
|
+
return deriveCredentialScopes({
|
|
208
|
+
storage,
|
|
209
|
+
aiGateway,
|
|
210
|
+
functions: preview.functions.length > 0
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Resolve the branch credential's secrets, reusing the ones already in the env source when
|
|
215
|
+
* present and minting a fresh `user` credential otherwise. The Neon API returns `api_token` /
|
|
216
|
+
* `s3_secret_access_key` exactly once at mint time, so the persisted copies (e.g. in
|
|
217
|
+
* `.env.local`, surfaced as `OPENAI_API_KEY` / `AWS_SECRET_ACCESS_KEY`) are the only way to
|
|
218
|
+
* recover them — exactly how one-time Auth keys are round-tripped. Reuse is presence-based
|
|
219
|
+
* (no extra bookkeeping vars): if every secret the enabled features need is already present,
|
|
220
|
+
* reuse it; otherwise mint one credential covering all currently-needed scopes.
|
|
221
|
+
*/
|
|
222
|
+
async function resolveCredentialSecrets(args) {
|
|
223
|
+
const sKeys = NEON_ENV_VAR_KEYS.storage;
|
|
224
|
+
const aKeys = NEON_ENV_VAR_KEYS.aiGateway;
|
|
225
|
+
const haveStorage = !args.needStorage || Boolean(args.env[sKeys.accessKeyId] && args.env[sKeys.secretAccessKey]);
|
|
226
|
+
const haveApiToken = !args.needApiToken || Boolean(args.env[aKeys.apiKey]);
|
|
227
|
+
if (haveStorage && haveApiToken) return {
|
|
228
|
+
accessKeyId: args.env[sKeys.accessKeyId] ?? "",
|
|
229
|
+
secretAccessKey: args.env[sKeys.secretAccessKey] ?? "",
|
|
230
|
+
apiToken: args.env[aKeys.apiKey] ?? ""
|
|
231
|
+
};
|
|
232
|
+
const minted = await args.api.createCredential(args.projectId, args.branchId, {
|
|
233
|
+
scopes: args.scopes,
|
|
234
|
+
principalType: "user",
|
|
235
|
+
name: `neon-env ${args.branchName}`
|
|
236
|
+
});
|
|
237
|
+
return {
|
|
238
|
+
accessKeyId: minted.tokenId,
|
|
239
|
+
secretAccessKey: minted.s3SecretAccessKey,
|
|
240
|
+
apiToken: minted.apiToken
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The AI Gateway is a **branch-scoped host** — `<branchId>-api.ai.<host-suffix>` — NOT the
|
|
245
|
+
* control-plane API origin. Derive the suffix from the branch's own Postgres connection host
|
|
246
|
+
* by dropping only the endpoint label (the first segment) and keeping everything after it,
|
|
247
|
+
* including any infra cell prefix (`c-N.`): a connection host of
|
|
248
|
+
* `ep-x.c-3.us-east-2.aws.neon.tech` yields the gateway host
|
|
249
|
+
* `<branchId>-api.ai.c-3.us-east-2.aws.neon.tech`. The cell prefix is **load-bearing** —
|
|
250
|
+
* the gateway is cell-routed, so dropping `c-N.` resolves to the wrong (or no) host.
|
|
251
|
+
*/
|
|
252
|
+
function aiGatewayHost(branchId, connectionUri) {
|
|
253
|
+
let connectionHost = "";
|
|
254
|
+
try {
|
|
255
|
+
connectionHost = new URL(connectionUri).hostname;
|
|
256
|
+
} catch {
|
|
257
|
+
connectionHost = "";
|
|
258
|
+
}
|
|
259
|
+
return `${branchId}-api.ai.${connectionHost.split(".").slice(1).join(".")}`;
|
|
260
|
+
}
|
|
261
|
+
/** The AI Gateway's OpenAI-dialect base URL (`OPENAI_BASE_URL`) on the branch gateway host. */
|
|
262
|
+
function aiGatewayBaseUrl(branchId, connectionUri) {
|
|
263
|
+
return `https://${aiGatewayHost(branchId, connectionUri)}${AI_GATEWAY_OPENAI_PATH}`;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Resolve the Neon Auth base URL to surface in `env.auth`. Prefer the value returned by
|
|
267
|
+
* the integration (`getNeonAuth` includes it); fall back to whatever is already in the
|
|
268
|
+
* caller's env source so older integrations created before `base_url` was returned still
|
|
269
|
+
* round-trip through `env run`.
|
|
270
|
+
*/
|
|
271
|
+
function resolveAuthBaseUrl(snapshotBaseUrl, source) {
|
|
272
|
+
if (snapshotBaseUrl && snapshotBaseUrl !== "") return snapshotBaseUrl;
|
|
273
|
+
return source[NEON_ENV_VAR_KEYS.auth.baseUrl] ?? "";
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Resolve the Neon Auth JWKS URL to surface in `env.auth`. Prefer the value returned by the
|
|
277
|
+
* integration (`getNeonAuth` always includes `jwks_url`); fall back to the caller's env
|
|
278
|
+
* source so the value still round-trips through `env run` if a snapshot ever omits it.
|
|
279
|
+
*/
|
|
280
|
+
function resolveAuthJwksUrl(snapshotJwksUrl, source) {
|
|
281
|
+
if (snapshotJwksUrl && snapshotJwksUrl !== "") return snapshotJwksUrl;
|
|
282
|
+
return source[NEON_ENV_VAR_KEYS.auth.jwksUrl] ?? "";
|
|
283
|
+
}
|
|
284
|
+
function createApiFromOptions(options) {
|
|
285
|
+
return createNeonApiFromOptions("fetchEnv", {
|
|
286
|
+
...options.apiKey ? { apiKey: options.apiKey } : {},
|
|
287
|
+
...options.apiHost ? { apiHost: options.apiHost } : {}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Resolve a branch ref — a name or an id — to a concrete branch. Matches by id first
|
|
292
|
+
* (exact `br-…`), then by name; both are unique within a project, so the lookup is
|
|
293
|
+
* unambiguous. This lets `.neon` files written by `neonctl` (which pin the branch *name*)
|
|
294
|
+
* and explicit `br-…` ids both work.
|
|
295
|
+
*/
|
|
296
|
+
function resolveBranch(branch, branches) {
|
|
297
|
+
const match = branches.find((b) => b.id === branch) ?? branches.find((b) => b.name === branch);
|
|
298
|
+
if (match) return match;
|
|
299
|
+
throw new PlatformError(ErrorCode.BranchNotFound, [`fetchEnv: branch ${JSON.stringify(branch)} not found on project (matched by id or name).`, `Existing branches: ${branches.map((b) => `${b.name} (${b.id})`).join(", ")}.`].join(" "), { details: {
|
|
300
|
+
branch,
|
|
301
|
+
available: branches.map((b) => `${b.name} (${b.id})`)
|
|
302
|
+
} });
|
|
303
|
+
}
|
|
304
|
+
function pickRoleName(roles, branch, requested) {
|
|
305
|
+
if (requested) {
|
|
306
|
+
if (!roles.some((r) => r.name === requested)) throw new PlatformError(ErrorCode.BranchNotFound, [`fetchEnv: role "${requested}" not found on branch ${branch.name} (${branch.id}).`, `Existing roles: ${roles.map((r) => r.name).join(", ") || "(none)"}.`].join(" "), { details: {
|
|
307
|
+
branchId: branch.id,
|
|
308
|
+
roleName: requested,
|
|
309
|
+
availableRoles: roles.map((r) => r.name)
|
|
310
|
+
} });
|
|
311
|
+
return requested;
|
|
312
|
+
}
|
|
313
|
+
if (roles.length === 0) throw new PlatformError(ErrorCode.BranchNotFound, [`fetchEnv: branch ${branch.name} (${branch.id}) has no roles.`, "Create one via the Neon console or pass `roleName` explicitly."].join(" "), { details: { branchId: branch.id } });
|
|
314
|
+
if (roles.length === 1) return roles[0].name;
|
|
315
|
+
const owner = roles.find((r) => r.name === NEON_DEFAULT_OWNER_ROLE);
|
|
316
|
+
if (owner) return owner.name;
|
|
317
|
+
const appRoles = roles.filter((r) => !NEON_MANAGED_AUTH_ROLES.has(r.name));
|
|
318
|
+
if (appRoles.length === 1) return appRoles[0].name;
|
|
319
|
+
throw new PlatformError(ErrorCode.AmbiguousBranchAuth, [`fetchEnv: branch ${branch.name} (${branch.id}) has ${roles.length} roles and none is "${NEON_DEFAULT_OWNER_ROLE}"; cannot auto-pick.`, `Pass \`roleName\` explicitly. Available: ${roles.map((r) => r.name).join(", ")}.`].join(" "), { details: {
|
|
320
|
+
branchId: branch.id,
|
|
321
|
+
availableRoles: roles.map((r) => r.name)
|
|
322
|
+
} });
|
|
323
|
+
}
|
|
324
|
+
function pickDatabaseName(databases, branch, roleName, requested) {
|
|
325
|
+
if (requested) {
|
|
326
|
+
if (!databases.some((d) => d.name === requested)) throw new PlatformError(ErrorCode.BranchNotFound, [`fetchEnv: database "${requested}" not found on branch ${branch.name} (${branch.id}).`, `Existing databases: ${databases.map((d) => d.name).join(", ") || "(none)"}.`].join(" "), { details: {
|
|
327
|
+
branchId: branch.id,
|
|
328
|
+
databaseName: requested,
|
|
329
|
+
availableDatabases: databases.map((d) => d.name)
|
|
330
|
+
} });
|
|
331
|
+
return requested;
|
|
332
|
+
}
|
|
333
|
+
if (databases.length === 0) throw new PlatformError(ErrorCode.BranchNotFound, [`fetchEnv: branch ${branch.name} (${branch.id}) has no databases.`, "Create one via the Neon console or pass `databaseName` explicitly."].join(" "), { details: { branchId: branch.id } });
|
|
334
|
+
if (databases.length === 1) return databases[0].name;
|
|
335
|
+
const owned = databases.filter((d) => d.ownerName === roleName);
|
|
336
|
+
if (owned.length === 1) return owned[0].name;
|
|
337
|
+
throw new PlatformError(ErrorCode.AmbiguousBranchAuth, [`fetchEnv: branch ${branch.name} (${branch.id}) has ${databases.length} databases; cannot auto-pick.`, `Pass \`databaseName\` explicitly. Available: ${databases.map((d) => d.name).join(", ")}.`].join(" "), { details: {
|
|
338
|
+
branchId: branch.id,
|
|
339
|
+
availableDatabases: databases.map((d) => d.name)
|
|
340
|
+
} });
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Per-namespace zod schemas. Each defines exactly the OS-level keys parsed from
|
|
344
|
+
* `process.env` for its namespace. Keep in sync with {@link NEON_ENV_VAR_KEYS}.
|
|
345
|
+
*
|
|
346
|
+
* `z.string().url()` would be tighter than `min(1)` but Postgres URIs that include
|
|
347
|
+
* URL-illegal characters in the password (rare but legal in Neon's connection-string
|
|
348
|
+
* format) fail the WHATWG `URL` parse, so we settle for "non-empty string".
|
|
349
|
+
*/
|
|
350
|
+
const postgresEnvSchema = z.object({
|
|
351
|
+
DATABASE_URL: z.string({ message: "DATABASE_URL is missing" }).min(1, "DATABASE_URL must not be empty"),
|
|
352
|
+
DATABASE_URL_UNPOOLED: z.string({ message: "DATABASE_URL_UNPOOLED is missing" }).min(1, "DATABASE_URL_UNPOOLED must not be empty")
|
|
353
|
+
});
|
|
354
|
+
const authEnvSchema = z.object({
|
|
355
|
+
NEON_AUTH_BASE_URL: z.string({ message: "NEON_AUTH_BASE_URL is missing" }).min(1, "NEON_AUTH_BASE_URL must not be empty"),
|
|
356
|
+
NEON_AUTH_JWKS_URL: z.string({ message: "NEON_AUTH_JWKS_URL is missing" }).min(1, "NEON_AUTH_JWKS_URL must not be empty")
|
|
357
|
+
});
|
|
358
|
+
const dataApiEnvSchema = z.object({ NEON_DATA_API_URL: z.string({ message: "NEON_DATA_API_URL is missing" }).min(1, "NEON_DATA_API_URL must not be empty") });
|
|
359
|
+
const storageEnvSchema = z.object({
|
|
360
|
+
AWS_ACCESS_KEY_ID: z.string({ message: "AWS_ACCESS_KEY_ID is missing" }).min(1, "AWS_ACCESS_KEY_ID must not be empty"),
|
|
361
|
+
AWS_SECRET_ACCESS_KEY: z.string({ message: "AWS_SECRET_ACCESS_KEY is missing" }).min(1, "AWS_SECRET_ACCESS_KEY must not be empty"),
|
|
362
|
+
AWS_ENDPOINT_URL_S3: z.string({ message: "AWS_ENDPOINT_URL_S3 is missing" }).min(1, "AWS_ENDPOINT_URL_S3 must not be empty"),
|
|
363
|
+
AWS_REGION: z.string({ message: "AWS_REGION is missing" }).min(1, "AWS_REGION must not be empty")
|
|
364
|
+
});
|
|
365
|
+
const aiGatewayEnvSchema = z.object({
|
|
366
|
+
OPENAI_API_KEY: z.string({ message: "OPENAI_API_KEY is missing" }).min(1, "OPENAI_API_KEY must not be empty"),
|
|
367
|
+
OPENAI_BASE_URL: z.string({ message: "OPENAI_BASE_URL is missing" }).min(1, "OPENAI_BASE_URL must not be empty")
|
|
368
|
+
});
|
|
369
|
+
/** Whether a **static** policy declares object storage (`preview.buckets`). No network. */
|
|
370
|
+
function configWantsStorage(config) {
|
|
371
|
+
return Object.keys(config.preview?.buckets ?? {}).length > 0;
|
|
372
|
+
}
|
|
373
|
+
/** Whether a **static** policy enables the AI Gateway (`preview.aiGateway`). No network. */
|
|
374
|
+
function configWantsAiGateway(config) {
|
|
375
|
+
return isServiceEnabledInput(config.preview?.aiGateway);
|
|
376
|
+
}
|
|
377
|
+
/** Static-toggle helper mirroring `config`'s `isServiceEnabled` for the env reader. */
|
|
378
|
+
function isServiceEnabledInput(toggle) {
|
|
379
|
+
if (toggle === void 0) return false;
|
|
380
|
+
if (typeof toggle === "boolean") return toggle;
|
|
381
|
+
return toggle.enabled !== false;
|
|
382
|
+
}
|
|
383
|
+
function parseEnv(config, scopeOrKeys) {
|
|
384
|
+
const source = process.env;
|
|
385
|
+
if (Array.isArray(scopeOrKeys)) return parseFilteredEnv(source, scopeOrKeys);
|
|
386
|
+
const scope = typeof scopeOrKeys === "string" ? scopeOrKeys : void 0;
|
|
387
|
+
const issues = [];
|
|
388
|
+
const result = {};
|
|
389
|
+
const pg = postgresEnvSchema.safeParse({
|
|
390
|
+
DATABASE_URL: source.DATABASE_URL,
|
|
391
|
+
DATABASE_URL_UNPOOLED: source.DATABASE_URL_UNPOOLED
|
|
392
|
+
});
|
|
393
|
+
if (pg.success) result.postgres = {
|
|
394
|
+
databaseUrl: pg.data.DATABASE_URL,
|
|
395
|
+
databaseUrlUnpooled: pg.data.DATABASE_URL_UNPOOLED
|
|
396
|
+
};
|
|
397
|
+
else for (const issue of pg.error.issues) issues.push(issue.message);
|
|
398
|
+
const branchName = source[NEON_ENV_VAR_KEYS.branch.name];
|
|
399
|
+
if (branchName !== void 0 && branchName !== "") result.branch = { name: branchName };
|
|
400
|
+
if (isServiceEnabledInput(config.auth)) {
|
|
401
|
+
const auth = authEnvSchema.safeParse({
|
|
402
|
+
NEON_AUTH_BASE_URL: source.NEON_AUTH_BASE_URL,
|
|
403
|
+
NEON_AUTH_JWKS_URL: source.NEON_AUTH_JWKS_URL
|
|
404
|
+
});
|
|
405
|
+
if (auth.success) result.auth = {
|
|
406
|
+
baseUrl: auth.data.NEON_AUTH_BASE_URL,
|
|
407
|
+
jwksUrl: auth.data.NEON_AUTH_JWKS_URL
|
|
408
|
+
};
|
|
409
|
+
else for (const issue of auth.error.issues) issues.push(issue.message);
|
|
410
|
+
}
|
|
411
|
+
if (isServiceEnabledInput(config.dataApi)) {
|
|
412
|
+
const dataApi = dataApiEnvSchema.safeParse({ NEON_DATA_API_URL: source.NEON_DATA_API_URL });
|
|
413
|
+
if (dataApi.success) result.dataApi = { url: dataApi.data.NEON_DATA_API_URL };
|
|
414
|
+
else for (const issue of dataApi.error.issues) issues.push(issue.message);
|
|
415
|
+
}
|
|
416
|
+
if (configWantsStorage(config)) {
|
|
417
|
+
const storage = storageEnvSchema.safeParse({
|
|
418
|
+
AWS_ACCESS_KEY_ID: source.AWS_ACCESS_KEY_ID,
|
|
419
|
+
AWS_SECRET_ACCESS_KEY: source.AWS_SECRET_ACCESS_KEY,
|
|
420
|
+
AWS_ENDPOINT_URL_S3: source.AWS_ENDPOINT_URL_S3,
|
|
421
|
+
AWS_REGION: source.AWS_REGION
|
|
422
|
+
});
|
|
423
|
+
if (storage.success) result.storage = {
|
|
424
|
+
accessKeyId: storage.data.AWS_ACCESS_KEY_ID,
|
|
425
|
+
secretAccessKey: storage.data.AWS_SECRET_ACCESS_KEY,
|
|
426
|
+
endpoint: storage.data.AWS_ENDPOINT_URL_S3,
|
|
427
|
+
region: storage.data.AWS_REGION
|
|
428
|
+
};
|
|
429
|
+
else for (const issue of storage.error.issues) issues.push(issue.message);
|
|
430
|
+
}
|
|
431
|
+
if (configWantsAiGateway(config)) {
|
|
432
|
+
const aiGateway = aiGatewayEnvSchema.safeParse({
|
|
433
|
+
OPENAI_API_KEY: source.OPENAI_API_KEY,
|
|
434
|
+
OPENAI_BASE_URL: source.OPENAI_BASE_URL
|
|
435
|
+
});
|
|
436
|
+
if (aiGateway.success) result.aiGateway = {
|
|
437
|
+
apiKey: aiGateway.data.OPENAI_API_KEY,
|
|
438
|
+
baseUrl: aiGateway.data.OPENAI_BASE_URL
|
|
439
|
+
};
|
|
440
|
+
else for (const issue of aiGateway.error.issues) issues.push(issue.message);
|
|
441
|
+
}
|
|
442
|
+
if (scope !== void 0) {
|
|
443
|
+
const fn = config.preview?.functions?.[scope];
|
|
444
|
+
if (!fn) throw new PlatformError(ErrorCode.EnvNotInjected, [`parseEnv: no function "${scope}" is declared in this policy's preview.functions.`, "Pass a declared function slug (or omit the scope to read external env)."].join("\n"), { details: { scope } });
|
|
445
|
+
const envOut = {};
|
|
446
|
+
for (const key of Object.keys(fn.env ?? {})) {
|
|
447
|
+
const value = source[key];
|
|
448
|
+
if (value === void 0) issues.push(`${key} is missing (function "${scope}")`);
|
|
449
|
+
else envOut[key] = value;
|
|
450
|
+
}
|
|
451
|
+
result.function = envOut;
|
|
452
|
+
}
|
|
453
|
+
if (issues.length > 0) throw new PlatformError(ErrorCode.EnvNotInjected, [
|
|
454
|
+
"parseEnv: the required Neon env variables are not present in process.env.",
|
|
455
|
+
...issues.map((i) => ` - ${i}`),
|
|
456
|
+
"Inject them via one of:",
|
|
457
|
+
" - `neon dev` / `neon-env run -- <your dev command>` (wraps the command with the vars injected)",
|
|
458
|
+
" - your hosting platform's Neon integration (Vercel, Fly, Railway, …)",
|
|
459
|
+
" - for the `function` namespace: deploy the function (`neon deploy` / `config apply`) so its env is uploaded.",
|
|
460
|
+
"Or switch the call to `await fetchEnv(config, …)` if you're in a context that can do async I/O."
|
|
461
|
+
].join("\n"), { details: { missing: issues } });
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Runtime reverse map for filtered `parseEnv`: OS-level env-var key → `[namespace, property]`
|
|
466
|
+
* in the {@link NeonEnv} shape. The compile-time mirror is {@link EnvKeysByNamespace} /
|
|
467
|
+
* {@link EnvKeyToProp}; keep all three in sync. Only input vars appear (no output-only
|
|
468
|
+
* aliases).
|
|
469
|
+
*/
|
|
470
|
+
const FILTERABLE_ENV_KEYS = {
|
|
471
|
+
DATABASE_URL: ["postgres", "databaseUrl"],
|
|
472
|
+
DATABASE_URL_UNPOOLED: ["postgres", "databaseUrlUnpooled"],
|
|
473
|
+
NEON_AUTH_BASE_URL: ["auth", "baseUrl"],
|
|
474
|
+
NEON_AUTH_JWKS_URL: ["auth", "jwksUrl"],
|
|
475
|
+
NEON_DATA_API_URL: ["dataApi", "url"],
|
|
476
|
+
AWS_ACCESS_KEY_ID: ["storage", "accessKeyId"],
|
|
477
|
+
AWS_SECRET_ACCESS_KEY: ["storage", "secretAccessKey"],
|
|
478
|
+
AWS_ENDPOINT_URL_S3: ["storage", "endpoint"],
|
|
479
|
+
AWS_REGION: ["storage", "region"],
|
|
480
|
+
OPENAI_API_KEY: ["aiGateway", "apiKey"],
|
|
481
|
+
OPENAI_BASE_URL: ["aiGateway", "baseUrl"]
|
|
482
|
+
};
|
|
483
|
+
/**
|
|
484
|
+
* Filtered counterpart to the {@link parseEnv} body: validate and return only the explicitly
|
|
485
|
+
* selected OS-level env-var keys, projected back into the narrowed namespaced shape. Unlike
|
|
486
|
+
* the full reader it never consults the policy — the selection alone decides what's required —
|
|
487
|
+
* so vars the caller didn't ask for (e.g. `DATABASE_URL_UNPOOLED`) can be absent without
|
|
488
|
+
* throwing. Mirrors the same non-empty constraint and {@link PlatformError} aggregation.
|
|
489
|
+
*/
|
|
490
|
+
function parseFilteredEnv(source, keys) {
|
|
491
|
+
const issues = [];
|
|
492
|
+
const result = {};
|
|
493
|
+
for (const key of keys) {
|
|
494
|
+
if (!Object.hasOwn(FILTERABLE_ENV_KEYS, key)) {
|
|
495
|
+
issues.push(`${key} is not a selectable Neon env variable`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const value = source[key];
|
|
499
|
+
if (value === void 0) {
|
|
500
|
+
issues.push(`${key} is missing`);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (value === "") {
|
|
504
|
+
issues.push(`${key} must not be empty`);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const [namespace, property] = FILTERABLE_ENV_KEYS[key];
|
|
508
|
+
const bucket = result[namespace] ?? {};
|
|
509
|
+
bucket[property] = value;
|
|
510
|
+
result[namespace] = bucket;
|
|
511
|
+
}
|
|
512
|
+
if (issues.length > 0) throw new PlatformError(ErrorCode.EnvNotInjected, [
|
|
513
|
+
"parseEnv: the required Neon env variables are not present in process.env.",
|
|
514
|
+
...issues.map((i) => ` - ${i}`),
|
|
515
|
+
"Inject them via one of:",
|
|
516
|
+
" - `neon dev` / `neon-env run -- <your dev command>` (wraps the command with the vars injected)",
|
|
517
|
+
" - your hosting platform's Neon integration (Vercel, Fly, Railway, …)",
|
|
518
|
+
"Or switch the call to `await fetchEnv(config, …)` if you're in a context that can do async I/O."
|
|
519
|
+
].join("\n"), { details: { missing: issues } });
|
|
520
|
+
return result;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Project a fully-resolved {@link NeonEnv} into the OS-level `{ KEY: value }` pairs used
|
|
524
|
+
* for cross-process transport. Named after the web-platform `.entries()` convention
|
|
525
|
+
* (`URLSearchParams` / `Headers` / `FormData`); returns a `Record` rather than an
|
|
526
|
+
* iterator of tuples since that's the shape env injection needs (wrap with
|
|
527
|
+
* `Object.entries(...)` if you want literal `[key, value]` pairs). Used by `neon-env run`
|
|
528
|
+
* to inject the vars into a subprocess's `process.env`.
|
|
529
|
+
*
|
|
530
|
+
* Walks the value at runtime so it works for any `NeonEnv<C>` regardless of which
|
|
531
|
+
* conditional namespaces are present.
|
|
532
|
+
*/
|
|
533
|
+
function toEntries(env) {
|
|
534
|
+
const out = {
|
|
535
|
+
[NEON_ENV_VAR_KEYS.postgres.databaseUrl]: env.postgres.databaseUrl,
|
|
536
|
+
[NEON_ENV_VAR_KEYS.postgres.databaseUrlUnpooled]: env.postgres.databaseUrlUnpooled
|
|
537
|
+
};
|
|
538
|
+
if (env.branch) out[NEON_ENV_VAR_KEYS.branch.name] = env.branch.name;
|
|
539
|
+
const withAuth = env;
|
|
540
|
+
if (withAuth.auth) {
|
|
541
|
+
out[NEON_ENV_VAR_KEYS.auth.baseUrl] = withAuth.auth.baseUrl;
|
|
542
|
+
out[NEON_ENV_VAR_KEYS.auth.jwksUrl] = withAuth.auth.jwksUrl;
|
|
543
|
+
}
|
|
544
|
+
const withDataApi = env;
|
|
545
|
+
if (withDataApi.dataApi) out[NEON_ENV_VAR_KEYS.dataApi.url] = withDataApi.dataApi.url;
|
|
546
|
+
const withStorage = env;
|
|
547
|
+
if (withStorage.storage) {
|
|
548
|
+
const s = withStorage.storage;
|
|
549
|
+
const keys = NEON_ENV_VAR_KEYS.storage;
|
|
550
|
+
out[keys.accessKeyId] = s.accessKeyId;
|
|
551
|
+
out[keys.secretAccessKey] = s.secretAccessKey;
|
|
552
|
+
out[keys.endpoint] = s.endpoint;
|
|
553
|
+
out[keys.region] = s.region;
|
|
554
|
+
}
|
|
555
|
+
const withAiGateway = env;
|
|
556
|
+
if (withAiGateway.aiGateway) {
|
|
557
|
+
const keys = NEON_ENV_VAR_KEYS.aiGateway;
|
|
558
|
+
const ai = withAiGateway.aiGateway;
|
|
559
|
+
out[keys.apiKey] = ai.apiKey;
|
|
560
|
+
out[keys.baseUrl] = ai.baseUrl;
|
|
561
|
+
out[keys.neonToken] = ai.apiKey;
|
|
562
|
+
out[keys.neonBaseUrl] = new URL(ai.baseUrl).origin;
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
//#endregion
|
|
567
|
+
export { NEON_ENV_VAR_KEYS, fetchEnv, parseEnv, toEntries };
|
|
568
|
+
|
|
569
|
+
//# sourceMappingURL=env.js.map
|