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
package/dist/dev/functions.js
CHANGED
|
@@ -21,21 +21,38 @@ const resolveFunctionsFromConfig = async (cwd, branchName) => {
|
|
|
21
21
|
name: branchName ?? "local",
|
|
22
22
|
exists: branchName !== void 0
|
|
23
23
|
}).preview?.functions ?? []).map((fn) => {
|
|
24
|
-
const source =
|
|
24
|
+
const source = resolveFunctionSource(fn.source, configDir);
|
|
25
25
|
if (!existsSync(source)) throw new Error(`Function "${fn.slug}" points at a source that does not exist: ${source} (from neon.ts "${fn.source}"). Fix the source path and re-run.`);
|
|
26
|
-
return
|
|
27
|
-
slug: fn.slug,
|
|
28
|
-
name: fn.name,
|
|
29
|
-
source,
|
|
30
|
-
...devPort(fn.dev) !== void 0 ? { port: devPort(fn.dev) } : {},
|
|
31
|
-
env: { ...fn.env },
|
|
32
|
-
...fn.externalPackages ? { externalPackages: fn.externalPackages.map((pkg) => pkg.name) } : {},
|
|
33
|
-
...fn.bundler !== "esbuild" ? { bundler: fn.bundler } : {}
|
|
34
|
-
};
|
|
26
|
+
return toPlannedFunction(fn, source);
|
|
35
27
|
})
|
|
36
28
|
};
|
|
37
29
|
};
|
|
38
30
|
/**
|
|
31
|
+
* The neon.ts function whose `source` is this file, if any. Sibling sources are not
|
|
32
|
+
* required to exist: `neon dev --source` is serving one file, not the whole set.
|
|
33
|
+
*/
|
|
34
|
+
const findConfigFunctionBySource = async (cwd, source) => {
|
|
35
|
+
const loaded = await loadNeonConfig(cwd);
|
|
36
|
+
if (!loaded) return void 0;
|
|
37
|
+
return (resolveConfig(loaded.config, {
|
|
38
|
+
name: "local",
|
|
39
|
+
exists: false
|
|
40
|
+
}).preview?.functions ?? []).map((fn) => toPlannedFunction(fn, resolveFunctionSource(fn.source, loaded.configDir))).find((fn) => fn.source === source);
|
|
41
|
+
};
|
|
42
|
+
const resolveFunctionSource = (source, configDir) => isAbsolute(source) ? source : resolve(configDir, source);
|
|
43
|
+
const toPlannedFunction = (fn, source) => {
|
|
44
|
+
const port = devPort(fn.dev);
|
|
45
|
+
return {
|
|
46
|
+
slug: fn.slug,
|
|
47
|
+
name: fn.name,
|
|
48
|
+
source,
|
|
49
|
+
...port !== void 0 ? { port } : {},
|
|
50
|
+
env: { ...fn.env },
|
|
51
|
+
...fn.externalPackages ? { externalPackages: fn.externalPackages.map((pkg) => pkg.name) } : {},
|
|
52
|
+
...fn.bundler !== "esbuild" ? { bundler: fn.bundler } : {}
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
39
56
|
* Read the `port` off a {@link FunctionDevConfig}. `undefined` when no `dev.port` is set
|
|
40
57
|
* (the supervisor then searches for a free port).
|
|
41
58
|
*/
|
|
@@ -61,4 +78,4 @@ const loadNeonConfig = async (cwd) => {
|
|
|
61
78
|
}
|
|
62
79
|
};
|
|
63
80
|
//#endregion
|
|
64
|
-
export { resolveFunctionsFromConfig };
|
|
81
|
+
export { findConfigFunctionBySource, resolveFunctionsFromConfig };
|
package/dist/dev/websocket.js
CHANGED
|
@@ -357,7 +357,7 @@ const handleUpgrade = async (req, socket, head, { fetch: fetchHandler, upgrade,
|
|
|
357
357
|
return;
|
|
358
358
|
}
|
|
359
359
|
if (record.claimed) {
|
|
360
|
-
log("websocket_upgrade_response_lost: upgradeWebSocket() was called for this request but the response returned from the handler is not the one it produced. `Response.clone()` and rebuilding a response (`new Response(res.body, res)`, as response-rewriting middleware does) both discard the upgrade. Return the `response` from upgradeWebSocket() unchanged.");
|
|
360
|
+
log("websocket_upgrade_response_lost: upgradeWebSocket() was called for this request but the response returned from the handler is not the one it produced. `Response.clone()` and rebuilding a response (`new Response(res.body, res)`, as response-rewriting middleware does) both discard the upgrade. Return the `response` from upgradeWebSocket() unchanged.\nWith @neon/functions/hono the helper returns the response for you, so this is a middleware on the upgrade route rebuilding it: `cors()` always does, and so does any middleware that reads `c.res` before `await next()` or calls `c.header()` after it. Take it off this route. It usually surfaces first as `RangeError: init[\"status\"] must be in the range of 200 to 599` from inside Hono, which names none of this.");
|
|
361
361
|
abortClaimedUpgrade(record);
|
|
362
362
|
writeUpgradeRejection(socket, 500, "websocket_upgrade_response_lost");
|
|
363
363
|
return;
|
package/dist/env_services.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as envKeysForSelection, c as ownedEnvServiceKeys, d as servicesForEnvKeys, i as
|
|
2
|
-
export { BRANCH_ENV_KEY, ENV_PULL_KEYS,
|
|
1
|
+
import { a as envKeysForSelection, c as ownedEnvServiceKeys, d as servicesForEnvKeys, i as ENV_PULL_SERVICES, l as parseEnvPullKeys, n as ENV_PULL_KEYS, o as envPullFlagValue, r as ENV_PULL_KEY_HELP, s as envServiceKeys, t as BRANCH_ENV_KEY, u as serviceForEnvKey } from "./_chunks/env_services-CAWrZTWa.js";
|
|
2
|
+
export { BRANCH_ENV_KEY, ENV_PULL_KEYS, ENV_PULL_KEY_HELP, ENV_PULL_SERVICES, envKeysForSelection, envPullFlagValue, envServiceKeys, ownedEnvServiceKeys, parseEnvPullKeys, serviceForEnvKey, servicesForEnvKeys };
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
import { t as storeFor } from "./_chunks/credential_io-D8AR-jvB.js";
|
|
1
2
|
import { log } from "./log.js";
|
|
2
3
|
import pkg_default from "./pkg.js";
|
|
3
4
|
import { isNeonApiError, messageFromBody } from "./api.js";
|
|
5
|
+
import { authFailureMessage, getAuthContext, locationFromContext } from "./auth_context.js";
|
|
4
6
|
import { r as defaultDir } from "./_chunks/paths-DMq0Lt7a.js";
|
|
5
7
|
import { ensureConfigDir } from "./config.js";
|
|
6
|
-
import { authFailureMessage, credentialsToClearOn401, getAuthContext } from "./auth_context.js";
|
|
7
8
|
import { currentContextFile, enrichFromContext } from "./context.js";
|
|
8
9
|
import { analyticsMiddleware, closeAnalytics, getAnalyticsEventProperties, initAnalyticsClientMiddleware, sendError, trackEvent } from "./analytics.js";
|
|
9
10
|
import { NETWORK_ERROR_MESSAGE, isNetworkError, matchErrorCode } from "./errors.js";
|
|
10
|
-
import { defaultClientID } from "./auth.js";
|
|
11
|
+
import { AuthRefreshError, defaultClientID } from "./auth.js";
|
|
11
12
|
import { getCliName } from "./utils/cli_name.js";
|
|
12
13
|
import { showHelp } from "./help.js";
|
|
13
|
-
import {
|
|
14
|
+
import { ensureAuth, isAccessTokenUsable, refreshStoredCredentials } from "./commands/auth.js";
|
|
14
15
|
import { rewriteUnknownAgentArg } from "./init/plan.js";
|
|
15
16
|
import commands_default from "./commands/index.js";
|
|
16
17
|
import { fillInArgs, resolveApiKeyFromEnv } from "./utils/middlewares.js";
|
|
@@ -123,7 +124,64 @@ builder = builder.scriptName(pkg_default.name).locale("en").usage("$0 <command>
|
|
|
123
124
|
}).alias("help", "h").middleware(async (args) => {
|
|
124
125
|
if (args.help || args._.length === 1 && !NO_SUBCOMMANDS_VERBS.includes(args._[0])) await showHelp(builder);
|
|
125
126
|
}).middleware(ensureAuth).middleware(enrichFromContext).middleware(analyticsMiddleware).command(commands_default).strictCommands().version(pkg_default.version).group("version", "Global options:").alias("version", "v").completion().scriptName(getCliName()).epilog("For more information, visit https://neon.com/docs/reference/neon-cli").wrap(null).fail(false);
|
|
126
|
-
|
|
127
|
+
function supersededOnDisk(rejectedToken) {
|
|
128
|
+
if (rejectedToken === void 0) return false;
|
|
129
|
+
const context = getAuthContext();
|
|
130
|
+
if (context === null) return false;
|
|
131
|
+
const at = locationFromContext(context);
|
|
132
|
+
if (at === null) return false;
|
|
133
|
+
try {
|
|
134
|
+
const loaded = storeFor(context.configDir).read(at);
|
|
135
|
+
return loaded !== null && loaded.credentials.access_token !== rejectedToken && isAccessTokenUsable(loaded.credentials, Date.now());
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function recoverFrom401(canRetry) {
|
|
141
|
+
const context = getAuthContext();
|
|
142
|
+
if (context === null || context.source !== "stored-credentials") {
|
|
143
|
+
log.error(authFailureMessage(context));
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (context.refreshed === true) {
|
|
147
|
+
log.error(authFailureMessage(context));
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (!canRetry) {
|
|
151
|
+
log.error(authFailureMessage(context));
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (supersededOnDisk(context.accessToken)) {
|
|
155
|
+
log.debug("The rejected token has already been replaced on disk; retrying with the current one");
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
const at = locationFromContext(context);
|
|
159
|
+
if (at === null || context.oauthHost === void 0 || context.clientId === void 0) {
|
|
160
|
+
log.error(authFailureMessage(context));
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
if (await refreshStoredCredentials(at, {
|
|
165
|
+
apiHost: "",
|
|
166
|
+
oauthHost: context.oauthHost,
|
|
167
|
+
clientId: context.clientId,
|
|
168
|
+
configDir: context.configDir
|
|
169
|
+
})) {
|
|
170
|
+
log.debug("Refreshed the stored session after a 401; retrying");
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err instanceof AuthRefreshError) {
|
|
175
|
+
log.error(err.message);
|
|
176
|
+
if (err.terminal) log.error(`Run \`neon auth --profile ${context.profile ?? "DEFAULT"}\` to sign in again.`);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
log.debug("Refresh after 401 failed: %s", err instanceof Error ? err.message : "unknown error");
|
|
180
|
+
}
|
|
181
|
+
log.error(authFailureMessage(context));
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
async function handleError(msg, err, canRetry) {
|
|
127
185
|
if (process.argv.some((arg) => arg === "--help" || arg === "-h")) {
|
|
128
186
|
await showHelp(builder);
|
|
129
187
|
process.exit(0);
|
|
@@ -142,6 +200,15 @@ async function handleError(msg, err) {
|
|
|
142
200
|
return false;
|
|
143
201
|
}
|
|
144
202
|
}
|
|
203
|
+
if (err instanceof AuthRefreshError) {
|
|
204
|
+
log.error(err.message);
|
|
205
|
+
if (err.terminal) {
|
|
206
|
+
const context = getAuthContext();
|
|
207
|
+
log.error(`Run \`neon auth --profile ${context?.profile ?? "DEFAULT"}\` to sign in again.`);
|
|
208
|
+
}
|
|
209
|
+
sendError(err, "AUTH_FAILED");
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
145
212
|
if (isNetworkError(err)) {
|
|
146
213
|
log.error(NETWORK_ERROR_MESSAGE);
|
|
147
214
|
const error = err instanceof Error ? err : new Error(NETWORK_ERROR_MESSAGE);
|
|
@@ -155,21 +222,7 @@ async function handleError(msg, err) {
|
|
|
155
222
|
return false;
|
|
156
223
|
} else if (err.status === 401) {
|
|
157
224
|
sendError(err, "AUTH_FAILED");
|
|
158
|
-
|
|
159
|
-
const staleCredentials = credentialsToClearOn401(context);
|
|
160
|
-
if (staleCredentials === null) {
|
|
161
|
-
log.error(authFailureMessage(context));
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
log.info("Authentication failed, deleting credentials...");
|
|
165
|
-
try {
|
|
166
|
-
if (context === null) return false;
|
|
167
|
-
deleteCredentialsAt(staleCredentials, context.configDir);
|
|
168
|
-
return true;
|
|
169
|
-
} catch (deleteErr) {
|
|
170
|
-
log.debug("Failed to delete credentials: %s", deleteErr instanceof Error ? deleteErr.message : "unknown error");
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
225
|
+
return await recoverFrom401(canRetry);
|
|
173
226
|
} else {
|
|
174
227
|
const serverMessage = messageFromBody(err.data);
|
|
175
228
|
if (serverMessage) log.error(serverMessage);
|
|
@@ -206,7 +259,7 @@ async function handleError(msg, err) {
|
|
|
206
259
|
break;
|
|
207
260
|
} catch (err) {
|
|
208
261
|
attempts++;
|
|
209
|
-
if (!await handleError("", err) || attempts >= MAX_ATTEMPTS) {
|
|
262
|
+
if (!await handleError("", err, attempts < MAX_ATTEMPTS) || attempts >= MAX_ATTEMPTS) {
|
|
210
263
|
await closeAnalytics();
|
|
211
264
|
process.exit(1);
|
|
212
265
|
}
|
package/dist/neon_services.js
CHANGED
|
@@ -48,8 +48,8 @@ const deprecatedServiceMessage = (used, canonical) => `"${used}" is the old name
|
|
|
48
48
|
*
|
|
49
49
|
* An unrecognized name is rejected rather than dropped: a typo would otherwise act on
|
|
50
50
|
* everything *except* the service that was asked for, and report success. A name that is a
|
|
51
|
-
* real service but not one this command supports says so specifically — "
|
|
52
|
-
*
|
|
51
|
+
* real service but not one this command supports says so specifically — "postgres is on
|
|
52
|
+
* every branch" is a different problem from a typo, and has a different fix.
|
|
53
53
|
*/
|
|
54
54
|
const parseServices = (raw, options) => {
|
|
55
55
|
const { allowed, flag, noneMeans, whyUnavailable = {}, onDeprecated } = options;
|
|
@@ -4,9 +4,9 @@ import { PipelineSession } from "./pipeline.js";
|
|
|
4
4
|
import { createScramClient } from "./sasl.js";
|
|
5
5
|
import { negotiateTls } from "./tls.js";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
|
+
import * as net from "node:net";
|
|
7
8
|
import { Buffer } from "node:buffer";
|
|
8
9
|
import * as dns from "node:dns/promises";
|
|
9
|
-
import * as net from "node:net";
|
|
10
10
|
import * as tls from "node:tls";
|
|
11
11
|
//#region src/psql/wire/connection.ts
|
|
12
12
|
/**
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { closeSync, openSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
//#region src/refresh_lock.ts
|
|
3
|
+
const WAIT_MS = 2e4;
|
|
4
|
+
const RETRY_MS = 50;
|
|
5
|
+
const STALE_MS = 6e4;
|
|
6
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
const isEexist = (err) => err instanceof Error && err.code === "EEXIST";
|
|
8
|
+
const isStale = (path) => {
|
|
9
|
+
try {
|
|
10
|
+
return Date.now() - statSync(path).mtimeMs > STALE_MS;
|
|
11
|
+
} catch {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Serialize exchanges because replaying a one-time refresh token can revoke the
|
|
17
|
+
* newly issued token family.
|
|
18
|
+
*/
|
|
19
|
+
const withExclusiveLock = async (lockPath, fn) => {
|
|
20
|
+
const deadline = Date.now() + WAIT_MS;
|
|
21
|
+
for (;;) try {
|
|
22
|
+
const fd = openSync(lockPath, "wx", 384);
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
} finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
try {
|
|
28
|
+
unlinkSync(lockPath);
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (!isEexist(err)) throw err;
|
|
33
|
+
if (isStale(lockPath)) {
|
|
34
|
+
try {
|
|
35
|
+
unlinkSync(lockPath);
|
|
36
|
+
} catch {}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (Date.now() >= deadline) throw new Error(`Could not lock ${lockPath} to refresh the stored session. Retry the command.`);
|
|
40
|
+
await delay(RETRY_MS);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { withExclusiveLock };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as OAUTH, d as scopeOf, i as API_KEY, l as interpretCredentials, t as storeFor, u as isSameCredential } from "./_chunks/credential_io-
|
|
1
|
+
import { a as OAUTH, d as scopeOf, i as API_KEY, l as interpretCredentials, t as storeFor, u as isSameCredential } from "./_chunks/credential_io-D8AR-jvB.js";
|
|
2
2
|
import { log } from "./log.js";
|
|
3
3
|
import { getApiClient } from "./api.js";
|
|
4
4
|
import { revokeToken } from "./auth.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
//#region src/test_utils/neon_api_server.ts
|
|
3
|
+
const startNeonApiServer = async (user = {
|
|
4
|
+
id: "user-1",
|
|
5
|
+
email: "user@example.com"
|
|
6
|
+
}) => {
|
|
7
|
+
const rejected = /* @__PURE__ */ new Set();
|
|
8
|
+
const seen = [];
|
|
9
|
+
let rejectAll = false;
|
|
10
|
+
let failUserLookup = false;
|
|
11
|
+
const server = createServer((req, res) => {
|
|
12
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
13
|
+
const token = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
|
|
14
|
+
if (url.pathname !== "/users/me") return json(res, 404, { message: "Not Found" });
|
|
15
|
+
seen.push(token);
|
|
16
|
+
if (rejectAll || token === "" || rejected.has(token)) return json(res, 401, { message: "Unauthorized" });
|
|
17
|
+
if (failUserLookup) return json(res, 500, { message: "Internal Server Error" });
|
|
18
|
+
return json(res, 200, {
|
|
19
|
+
id: user.id,
|
|
20
|
+
email: user.email,
|
|
21
|
+
login: "tester",
|
|
22
|
+
name: "Tester",
|
|
23
|
+
projects_limit: 10,
|
|
24
|
+
branches_limit: 10,
|
|
25
|
+
max_autoscaling_limit: 1,
|
|
26
|
+
plan: "free",
|
|
27
|
+
auth_accounts: []
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
await new Promise((resolve) => {
|
|
31
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
url: `http://127.0.0.1:${server.address().port}`,
|
|
35
|
+
reject: (accessToken) => {
|
|
36
|
+
rejected.add(accessToken);
|
|
37
|
+
},
|
|
38
|
+
rejectAll: (value) => {
|
|
39
|
+
rejectAll = value;
|
|
40
|
+
},
|
|
41
|
+
failUserLookup: (value) => {
|
|
42
|
+
failUserLookup = value;
|
|
43
|
+
},
|
|
44
|
+
seenTokens: () => [...seen],
|
|
45
|
+
stop: () => closeServer(server)
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
const json = (res, status, body) => {
|
|
49
|
+
const payload = JSON.stringify(body);
|
|
50
|
+
res.writeHead(status, {
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
"content-length": Buffer.byteLength(payload)
|
|
53
|
+
});
|
|
54
|
+
res.end(payload);
|
|
55
|
+
};
|
|
56
|
+
const closeServer = (server) => new Promise((resolve, reject) => {
|
|
57
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
58
|
+
});
|
|
59
|
+
//#endregion
|
|
60
|
+
export { startNeonApiServer };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
//#region src/test_utils/rotating_oauth_server.ts
|
|
4
|
+
const startRotatingOauthServer = async (options = {}) => {
|
|
5
|
+
const lifetime = options.accessTokenLifetimeSeconds ?? 3600;
|
|
6
|
+
const liveRefreshTokens = /* @__PURE__ */ new Set();
|
|
7
|
+
let refreshAttempts = 0;
|
|
8
|
+
let rotations = 0;
|
|
9
|
+
let unreachable = false;
|
|
10
|
+
const issue = ({ expiresIn = lifetime } = {}) => {
|
|
11
|
+
const refresh = `refresh-${randomUUID()}`;
|
|
12
|
+
liveRefreshTokens.add(refresh);
|
|
13
|
+
return {
|
|
14
|
+
access_token: `access-${randomUUID()}`,
|
|
15
|
+
refresh_token: refresh,
|
|
16
|
+
expires_in: expiresIn,
|
|
17
|
+
token_type: "bearer"
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
const server = createServer((req, res) => {
|
|
21
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
22
|
+
if (url.pathname === "/.well-known/openid-configuration") return json(res, 200, {
|
|
23
|
+
issuer: baseUrl(),
|
|
24
|
+
token_endpoint: `${baseUrl()}/oauth2/token`,
|
|
25
|
+
authorization_endpoint: `${baseUrl()}/oauth2/authorize`,
|
|
26
|
+
revocation_endpoint: `${baseUrl()}/oauth2/revoke`,
|
|
27
|
+
response_types_supported: ["code"],
|
|
28
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
29
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
30
|
+
scopes_supported: [
|
|
31
|
+
"openid",
|
|
32
|
+
"offline",
|
|
33
|
+
"offline_access"
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
if (url.pathname === "/oauth2/token" && req.method === "POST") return readBody(req, (body) => {
|
|
37
|
+
const form = new URLSearchParams(body);
|
|
38
|
+
if (form.get("grant_type") !== "refresh_token") return json(res, 400, { error: "unsupported_grant_type" });
|
|
39
|
+
refreshAttempts += 1;
|
|
40
|
+
if (unreachable) {
|
|
41
|
+
res.destroy();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const presented = form.get("refresh_token") ?? "";
|
|
45
|
+
if (!liveRefreshTokens.delete(presented)) return json(res, 400, {
|
|
46
|
+
error: "invalid_grant",
|
|
47
|
+
error_description: "The provided authorization grant or refresh token is invalid, expired, or revoked."
|
|
48
|
+
});
|
|
49
|
+
rotations += 1;
|
|
50
|
+
return json(res, 200, {
|
|
51
|
+
...issue(),
|
|
52
|
+
scope: "openid offline offline_access"
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return json(res, 404, { error: "not_found" });
|
|
56
|
+
});
|
|
57
|
+
await new Promise((resolve) => {
|
|
58
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
59
|
+
});
|
|
60
|
+
const baseUrl = () => `http://127.0.0.1:${server.address().port}`;
|
|
61
|
+
return {
|
|
62
|
+
url: baseUrl(),
|
|
63
|
+
refreshAttempts: () => refreshAttempts,
|
|
64
|
+
rotations: () => rotations,
|
|
65
|
+
issue,
|
|
66
|
+
revoke: (refreshToken) => {
|
|
67
|
+
liveRefreshTokens.delete(refreshToken);
|
|
68
|
+
},
|
|
69
|
+
setUnreachable: (value) => {
|
|
70
|
+
unreachable = value;
|
|
71
|
+
},
|
|
72
|
+
stop: () => closeServer(server)
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
const json = (res, status, body) => {
|
|
76
|
+
const payload = JSON.stringify(body);
|
|
77
|
+
res.writeHead(status, {
|
|
78
|
+
"content-type": "application/json;charset=UTF-8",
|
|
79
|
+
"content-length": Buffer.byteLength(payload)
|
|
80
|
+
});
|
|
81
|
+
res.end(payload);
|
|
82
|
+
};
|
|
83
|
+
const readBody = (req, next) => {
|
|
84
|
+
let body = "";
|
|
85
|
+
req.on("data", (chunk) => {
|
|
86
|
+
body += chunk.toString();
|
|
87
|
+
});
|
|
88
|
+
req.on("end", () => next(body));
|
|
89
|
+
};
|
|
90
|
+
const closeServer = (server) => new Promise((resolve, reject) => {
|
|
91
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
92
|
+
});
|
|
93
|
+
//#endregion
|
|
94
|
+
export { startRotatingOauthServer };
|
|
@@ -104,18 +104,6 @@ const appliedBranchFields = (change) => {
|
|
|
104
104
|
default: return expandField(field, void 0, details.settings);
|
|
105
105
|
}
|
|
106
106
|
};
|
|
107
|
-
/**
|
|
108
|
-
* Render the applied (or planned) changes as a `git diff`-style list:
|
|
109
|
-
*
|
|
110
|
-
* - **Service changes** (auth / Data API / buckets / functions) are additions
|
|
111
|
-
* with no meaningful "before", so they list as green `+ <label>` lines
|
|
112
|
-
* (`~ <label>` for the lone Data API settings *update*).
|
|
113
|
-
* - **Branch setting changes** group per branch and render as sorted
|
|
114
|
-
* `field → value` lines under a `~ <branch>` header.
|
|
115
|
-
*
|
|
116
|
-
* Returns "" when there is nothing to show. Pure — callers own the heading
|
|
117
|
-
* spacing and any surrounding summary.
|
|
118
|
-
*/
|
|
119
107
|
const renderAppliedChanges = (changes, title, opts) => {
|
|
120
108
|
if (changes.length === 0) return "";
|
|
121
109
|
const paint = palette(opts.color);
|
|
@@ -123,7 +111,8 @@ const renderAppliedChanges = (changes, title, opts) => {
|
|
|
123
111
|
const services = changes.filter((c) => c.kind === "service").sort((a, b) => a.identifier.localeCompare(b.identifier));
|
|
124
112
|
for (const service of services) {
|
|
125
113
|
const label = serviceLabel(service.identifier);
|
|
126
|
-
|
|
114
|
+
const line = service.action === "create" ? `+ ${label}` : service.action === "delete" ? `- ${label}` : `~ ${label}`;
|
|
115
|
+
lines.push(` ${service.action === "create" ? paint.added(line) : service.action === "delete" ? paint.removed(line) : paint.arrow(line)}`);
|
|
127
116
|
}
|
|
128
117
|
const byBranch = /* @__PURE__ */ new Map();
|
|
129
118
|
for (const change of changes.filter((c) => c.kind === "branch")) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neon",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.0",
|
|
4
4
|
"description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"neon",
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
"yaml": "^2.9.0",
|
|
64
64
|
"yargs": "17.7.2",
|
|
65
65
|
"yoctocolors": "^2.1.2",
|
|
66
|
-
"@neon/config": "1.
|
|
67
|
-
"@neon/sdk": "3.
|
|
68
|
-
"@neon/config-runtime": "1.
|
|
66
|
+
"@neon/config": "1.2.0",
|
|
67
|
+
"@neon/sdk": "3.1.0",
|
|
68
|
+
"@neon/config-runtime": "1.2.0"
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
71
|
"@napi-rs/keyring": "1.3.0",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@apidevtools/swagger-parser": "12.1.0",
|
|
76
|
+
"hono": "^4.12.25",
|
|
76
77
|
"@rollup/plugin-commonjs": "25.0.8",
|
|
77
78
|
"@rollup/plugin-json": "6.1.0",
|
|
78
79
|
"@rollup/plugin-node-resolve": "15.2.3",
|
|
@@ -97,8 +98,9 @@
|
|
|
97
98
|
"typescript": "^5.9.0",
|
|
98
99
|
"vitest": "^3.0.9",
|
|
99
100
|
"@neon-internals/cli-core": "0.0.0",
|
|
101
|
+
"@neon-internals/env-core": "0.0.7",
|
|
100
102
|
"@neon/e2e-harness": "0.0.0",
|
|
101
|
-
"@neon
|
|
103
|
+
"@neon/functions": "0.9.0"
|
|
102
104
|
},
|
|
103
105
|
"publishConfig": {
|
|
104
106
|
"access": "public",
|