neon 4.4.0 → 4.5.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 +52 -0
- package/dist/_chunks/{auth_selection-Bx2NAgkk.js → auth_selection-ktL6uoEe.js} +1 -1
- package/dist/_chunks/{credential_io-D2vw1UOw.js → credential_io-B1JxWELN.js} +2 -1
- package/dist/_chunks/{profiles-CVXNvU2r.js → profiles-D95AeZ5v.js} +4 -39
- package/dist/_chunks/secure_file-BucZj4yQ.js +39 -0
- package/dist/analytics.js +1 -1
- package/dist/api.js +14 -0
- package/dist/auth_context.js +1 -0
- package/dist/claimable/api.js +242 -0
- package/dist/claimable/state.js +92 -0
- package/dist/commands/auth.js +42 -5
- package/dist/commands/bootstrap.js +1 -1
- package/dist/commands/claim.js +466 -0
- package/dist/commands/config.js +10 -18
- package/dist/commands/index.js +2 -0
- package/dist/commands/profile.js +4 -3
- package/dist/config_services.js +18 -0
- package/dist/context.js +3 -1
- package/dist/credential_io.js +1 -1
- package/dist/init/auth.js +2 -2
- package/dist/init/profile_cli.js +2 -2
- package/dist/retire_credential.js +1 -1
- package/dist/utils/middlewares.js +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -64,6 +64,57 @@ neon projects list --api-key <neon_api_key>
|
|
|
64
64
|
|
|
65
65
|
For information about obtaining an Neon API key, see [Authentication](https://neon.com/docs/reference/api/get-started), in the _Neon API Reference_.
|
|
66
66
|
|
|
67
|
+
## Create a project without an account
|
|
68
|
+
|
|
69
|
+
`neon claim create` provisions a temporary Claimable Neon project for an agent without
|
|
70
|
+
requiring a Neon account or opening a browser:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Lakebase Postgres is always included
|
|
74
|
+
neon claim create
|
|
75
|
+
|
|
76
|
+
# Request Managed Better Auth and the Data API too
|
|
77
|
+
neon claim create --service auth --service data-api
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
When the current directory has a `neon.ts`, `claim create` also requests every service
|
|
81
|
+
declared there. Explicit `--service` values are added to that set. Object Storage, Functions,
|
|
82
|
+
and the AI Gateway are sent to the service so demand is recorded, but are reported as
|
|
83
|
+
unavailable until the project is claimed; the CLI does not silently remove them.
|
|
84
|
+
|
|
85
|
+
The command writes:
|
|
86
|
+
|
|
87
|
+
- a `.neon` context that identifies the project and Claimable Neon service;
|
|
88
|
+
- an owner-only identity assertion under the CLI config directory;
|
|
89
|
+
- `DATABASE_URL` and any granted Auth or Data API variables to `.env` or `.env.local`
|
|
90
|
+
(disable this with `--no-env-pull`).
|
|
91
|
+
|
|
92
|
+
Subsequent project commands automatically exchange the assertion for a short-lived agent
|
|
93
|
+
token and send API calls to Claimable Neon. The service decides which operations are
|
|
94
|
+
allowed before claim.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
neon claim status # lifecycle and transfer status
|
|
98
|
+
neon projects get <project-id> # regular CLI command, same agent token
|
|
99
|
+
neon psql --role-name neondb_owner -- -c "select now()"
|
|
100
|
+
neon config plan
|
|
101
|
+
neon env pull --service postgres --service auth --service data-api
|
|
102
|
+
|
|
103
|
+
neon claim accept # create a claim code and open the transfer URL
|
|
104
|
+
neon claim delete --yes # permanently delete an unclaimed project
|
|
105
|
+
neon claim list # local records, including expired
|
|
106
|
+
neon claim delete <project-id> --yes
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`status`, `accept`, and `delete` take an optional project id from `claim list`, so a
|
|
110
|
+
project stays manageable after its original directory is gone. `list` prints `state`
|
|
111
|
+
(`unclaimed` or `expired`) from the identity assertion clock and the project
|
|
112
|
+
expiry, plus `project_expires_at`. `delete` also drops a
|
|
113
|
+
local record whose identity assertion has expired or been revoked.
|
|
114
|
+
|
|
115
|
+
`neon claimable` is an alias for `neon claim`. For local service development, set
|
|
116
|
+
`CLAIMABLE_NEON_HOST=http://localhost:8787`; non-local origins must use HTTPS.
|
|
117
|
+
|
|
67
118
|
## Project and branch creation
|
|
68
119
|
|
|
69
120
|
Choose the PostgreSQL version when creating a project:
|
|
@@ -1173,6 +1224,7 @@ Id Name Project Created At Last Used At Last
|
|
|
1173
1224
|
| Command | Subcommands | Description |
|
|
1174
1225
|
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
|
|
1175
1226
|
| [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate |
|
|
1227
|
+
| claim (`claimable`) | `create`, `status`, `accept`, `list`, `delete` | Manage claimable projects |
|
|
1176
1228
|
| profile | `list`, `create`, `rotate-key`, `remove` | Manage named sets of credentials |
|
|
1177
1229
|
| api-keys | `list`, `create`, `revoke` | Manage API keys |
|
|
1178
1230
|
| [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { t as writeSecretFile } from "./secure_file-BucZj4yQ.js";
|
|
2
2
|
import { o as isOwnedCredentialPath } from "./paths-g3DRqJiD.js";
|
|
3
|
+
import { f as profilesFilePath, v as CRED_STORAGE_FILE, y as CRED_STORAGE_KEYRING } from "./profiles-D95AeZ5v.js";
|
|
3
4
|
import { tryLoadKeyring } from "../keyring.js";
|
|
4
5
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
5
6
|
import { dirname } from "node:path";
|
|
@@ -1,42 +1,7 @@
|
|
|
1
|
+
import { t as writeSecretFile } from "./secure_file-BucZj4yQ.js";
|
|
1
2
|
import { i as defaultDir, r as credentialsPath, s as resolveConfigFile } from "./paths-g3DRqJiD.js";
|
|
2
|
-
import { existsSync, readFileSync
|
|
3
|
-
import {
|
|
4
|
-
//#region ../../internals/cli-core/dist/secure_file.js
|
|
5
|
-
/**
|
|
6
|
-
* Write a secret to disk owner-only, by creating a temporary file in the same directory and
|
|
7
|
-
* renaming it over the target.
|
|
8
|
-
*
|
|
9
|
-
* The rename is what makes this correct rather than merely tidy. `writeFileSync`'s `mode`
|
|
10
|
-
* applies only when it *creates* the file, so writing over an existing credentials file
|
|
11
|
-
* leaves whatever permissions it already had — a file created `0700` by an older release
|
|
12
|
-
* stays `0700` forever, and one created before a umask change stays world-readable. Renaming
|
|
13
|
-
* a fresh inode into place means every write lands at {@link SECRET_FILE_MODE}, so the
|
|
14
|
-
* permissions repair themselves instead of being inherited.
|
|
15
|
-
*
|
|
16
|
-
* It also closes the window where a reader could see the file at default permissions: the
|
|
17
|
-
* temporary file is created `0600` *before* it holds the secret's final name, and `rename`
|
|
18
|
-
* is atomic within a directory, so there is no moment at which the target is readable by
|
|
19
|
-
* anyone else and no moment at which it is half-written.
|
|
20
|
-
*
|
|
21
|
-
* The temporary name carries the pid so two processes writing at once cannot collide on it.
|
|
22
|
-
*/
|
|
23
|
-
const writeSecretFile = (path, contents) => {
|
|
24
|
-
const directory = dirname(path);
|
|
25
|
-
const temporary = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
|
|
26
|
-
try {
|
|
27
|
-
writeFileSync(temporary, contents, {
|
|
28
|
-
encoding: "utf8",
|
|
29
|
-
mode: 384
|
|
30
|
-
});
|
|
31
|
-
renameSync(temporary, path);
|
|
32
|
-
} catch (err) {
|
|
33
|
-
try {
|
|
34
|
-
unlinkSync(temporary);
|
|
35
|
-
} catch {}
|
|
36
|
-
throw err;
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
//#endregion
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
40
5
|
//#region ../../internals/cli-core/dist/cli_config.js
|
|
41
6
|
const CRED_STORAGE_FILE = "file";
|
|
42
7
|
const CRED_STORAGE_KEYRING = "keyring";
|
|
@@ -254,4 +219,4 @@ function nonEmpty(value) {
|
|
|
254
219
|
return trimmed === "" ? void 0 : trimmed;
|
|
255
220
|
}
|
|
256
221
|
//#endregion
|
|
257
|
-
export { upsertProfile as _, canDropProfilesFile as a,
|
|
222
|
+
export { upsertProfile as _, canDropProfilesFile as a, listProfiles as c, newProfileLocation as d, profilesFilePath as f, selectProfileName as g, resolveProfile as h, assertValidProfileName as i, locationForName as l, readProfiles as m, KEYRING_CREDENTIALS as n, credentialsDisplay as o, profilesUsingPath as p, assertProfilesUsable as r, isKeyringPointer as s, DEFAULT_PROFILE as t, locationOf as u, CRED_STORAGE_FILE as v, CRED_STORAGE_KEYRING as y };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
//#region ../../internals/cli-core/dist/secure_file.js
|
|
4
|
+
/**
|
|
5
|
+
* Write a secret to disk owner-only, by creating a temporary file in the same directory and
|
|
6
|
+
* renaming it over the target.
|
|
7
|
+
*
|
|
8
|
+
* The rename is what makes this correct rather than merely tidy. `writeFileSync`'s `mode`
|
|
9
|
+
* applies only when it *creates* the file, so writing over an existing credentials file
|
|
10
|
+
* leaves whatever permissions it already had — a file created `0700` by an older release
|
|
11
|
+
* stays `0700` forever, and one created before a umask change stays world-readable. Renaming
|
|
12
|
+
* a fresh inode into place means every write lands at {@link SECRET_FILE_MODE}, so the
|
|
13
|
+
* permissions repair themselves instead of being inherited.
|
|
14
|
+
*
|
|
15
|
+
* It also closes the window where a reader could see the file at default permissions: the
|
|
16
|
+
* temporary file is created `0600` *before* it holds the secret's final name, and `rename`
|
|
17
|
+
* is atomic within a directory, so there is no moment at which the target is readable by
|
|
18
|
+
* anyone else and no moment at which it is half-written.
|
|
19
|
+
*
|
|
20
|
+
* The temporary name carries the pid so two processes writing at once cannot collide on it.
|
|
21
|
+
*/
|
|
22
|
+
const writeSecretFile = (path, contents) => {
|
|
23
|
+
const directory = dirname(path);
|
|
24
|
+
const temporary = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(temporary, contents, {
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
mode: 384
|
|
29
|
+
});
|
|
30
|
+
renameSync(temporary, path);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
try {
|
|
33
|
+
unlinkSync(temporary);
|
|
34
|
+
} catch {}
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { writeSecretFile as t };
|
package/dist/analytics.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as OAUTH, t as storeFor } from "./_chunks/credential_io-
|
|
1
|
+
import { a as OAUTH, t as storeFor } from "./_chunks/credential_io-B1JxWELN.js";
|
|
2
2
|
import { getCliAgent, getGithubEnvVars, isCi } from "./env.js";
|
|
3
3
|
import { log } from "./log.js";
|
|
4
4
|
import pkg_default from "./pkg.js";
|
package/dist/api.js
CHANGED
|
@@ -52,6 +52,13 @@ function messageFromBody(body) {
|
|
|
52
52
|
const message = body.message;
|
|
53
53
|
if (typeof message === "string") return message;
|
|
54
54
|
}
|
|
55
|
+
if (body && typeof body === "object" && "error" in body) {
|
|
56
|
+
const error = body.error;
|
|
57
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
58
|
+
const message = error.message;
|
|
59
|
+
if (typeof message === "string") return message;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
55
62
|
}
|
|
56
63
|
/** Extract a machine-readable `code` string from a parsed error body, if present. */
|
|
57
64
|
function codeFromBody(body) {
|
|
@@ -59,6 +66,13 @@ function codeFromBody(body) {
|
|
|
59
66
|
const code = body.code;
|
|
60
67
|
if (typeof code === "string") return code;
|
|
61
68
|
}
|
|
69
|
+
if (body && typeof body === "object" && "error" in body) {
|
|
70
|
+
const error = body.error;
|
|
71
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
72
|
+
const code = error.code;
|
|
73
|
+
if (typeof code === "string") return code;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
62
76
|
}
|
|
63
77
|
/**
|
|
64
78
|
* Adapt a WHATWG `ReadableStream` (what `fetch` gives us as `response.body`) to
|
package/dist/auth_context.js
CHANGED
|
@@ -38,6 +38,7 @@ const authFailureMessage = (context) => {
|
|
|
38
38
|
const profile = context?.profile ?? "the selected profile";
|
|
39
39
|
const where = context?.storage === "keyring" ? " (OS keyring)" : context?.credentialsPath !== void 0 ? ` (${context.credentialsPath})` : "";
|
|
40
40
|
if (context?.source === "profile-api-key") return `Authentication failed: the Neon API rejected profile "${profile}"'s API key${where}. Replace it with \`neon profile create ${profile} --mint\`, or store another with \`neon profile create ${profile} --api-key -\`.`;
|
|
41
|
+
if (context?.source === "claimable") return `Authentication failed: Claimable Neon rejected the linked project's short-lived access token${where}. Retry the command to exchange the saved identity assertion again; if it still fails, run \`neon claim status\`.`;
|
|
41
42
|
if (context?.source === "stored-credentials") {
|
|
42
43
|
if (context.storage === "keyring") return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session (OS keyring). Sign in again with \`neon auth --profile ${profile}\`.`;
|
|
43
44
|
return `Authentication failed: the Neon API rejected profile "${profile}"'s stored session${where}. That file was not created by neon, so it was left alone — sign in again with \`neon auth --profile ${profile}\`.`;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
//#region src/claimable/api.ts
|
|
2
|
+
const REQUEST_TIMEOUT_MS = 6e4;
|
|
3
|
+
const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
4
|
+
const DEFAULT_CLAIMABLE_ORIGIN = "https://claimable.neon.tech";
|
|
5
|
+
var ClaimableServiceError = class extends Error {
|
|
6
|
+
constructor(status, metadata, details) {
|
|
7
|
+
super(metadata.message);
|
|
8
|
+
this.name = "ClaimableServiceError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = metadata.code;
|
|
11
|
+
this.retryable = metadata.retryable;
|
|
12
|
+
this.requestId = metadata.requestId;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
const record = (value, action) => {
|
|
18
|
+
if (!isRecord(value)) throw invalidResponse(action);
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
const stringField = (value, key, action) => {
|
|
22
|
+
const field = value[key];
|
|
23
|
+
if (typeof field !== "string" || field.length === 0) throw invalidResponse(action);
|
|
24
|
+
return field;
|
|
25
|
+
};
|
|
26
|
+
const stringFieldAllowEmpty = (value, key, action) => {
|
|
27
|
+
const field = value[key];
|
|
28
|
+
if (typeof field !== "string") throw invalidResponse(action);
|
|
29
|
+
return field;
|
|
30
|
+
};
|
|
31
|
+
const numberField = (value, key, action) => {
|
|
32
|
+
const field = value[key];
|
|
33
|
+
if (typeof field !== "number" || !Number.isFinite(field) || field <= 0) throw invalidResponse(action);
|
|
34
|
+
return field;
|
|
35
|
+
};
|
|
36
|
+
const booleanField = (value, key, action) => {
|
|
37
|
+
const field = value[key];
|
|
38
|
+
if (typeof field !== "boolean") throw invalidResponse(action);
|
|
39
|
+
return field;
|
|
40
|
+
};
|
|
41
|
+
const stringArrayField = (value, key, action) => {
|
|
42
|
+
const field = value[key];
|
|
43
|
+
if (!Array.isArray(field) || !field.every((item) => typeof item === "string" && item.length > 0)) throw invalidResponse(action);
|
|
44
|
+
return field;
|
|
45
|
+
};
|
|
46
|
+
const invalidResponse = (action) => /* @__PURE__ */ new Error(`Claimable Neon returned an invalid response while ${action}. The response was not used.`);
|
|
47
|
+
const parseCapabilityDecision = (value, action) => {
|
|
48
|
+
const decision = record(value, action);
|
|
49
|
+
const capability = stringField(decision, "capability", action);
|
|
50
|
+
if (booleanField(decision, "granted", action)) return {
|
|
51
|
+
capability,
|
|
52
|
+
granted: true
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
capability,
|
|
56
|
+
granted: false,
|
|
57
|
+
reason: stringField(decision, "reason", action),
|
|
58
|
+
message: stringField(decision, "message", action)
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
const parseRegistrationResponse = (value) => {
|
|
62
|
+
const action = "registering an anonymous identity";
|
|
63
|
+
const response = record(value, action);
|
|
64
|
+
const project = record(response.project, action);
|
|
65
|
+
const capabilities = response.capabilities;
|
|
66
|
+
if (!Array.isArray(capabilities)) throw invalidResponse(action);
|
|
67
|
+
return {
|
|
68
|
+
registrationId: stringField(response, "registration_id", action),
|
|
69
|
+
identityAssertion: stringField(response, "identity_assertion", action),
|
|
70
|
+
assertionExpires: numberField(response, "assertion_expires", action),
|
|
71
|
+
scopes: stringArrayField(response, "scopes", action),
|
|
72
|
+
project: {
|
|
73
|
+
id: stringField(project, "id", action),
|
|
74
|
+
branchId: stringField(project, "branch_id", action),
|
|
75
|
+
expiresAt: stringField(project, "expires_at", action)
|
|
76
|
+
},
|
|
77
|
+
capabilities: capabilities.map((item) => parseCapabilityDecision(item, action))
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
const parseTokenResponse = (value) => {
|
|
81
|
+
const action = "exchanging the identity assertion";
|
|
82
|
+
const response = record(value, action);
|
|
83
|
+
if (response.token_type !== "Bearer") throw invalidResponse(action);
|
|
84
|
+
return {
|
|
85
|
+
accessToken: stringField(response, "access_token", action),
|
|
86
|
+
expiresIn: numberField(response, "expires_in", action),
|
|
87
|
+
scope: stringFieldAllowEmpty(response, "scope", action)
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
const parseCredentialsResponse = (value) => {
|
|
91
|
+
const action = "fetching project credentials";
|
|
92
|
+
const response = record(value, action);
|
|
93
|
+
const services = record(response.services, action);
|
|
94
|
+
const authValue = services.auth;
|
|
95
|
+
const dataApiValue = services.data_api;
|
|
96
|
+
const auth = authValue === void 0 ? void 0 : (() => {
|
|
97
|
+
const parsed = record(authValue, action);
|
|
98
|
+
return {
|
|
99
|
+
baseUrl: stringField(parsed, "base_url", action),
|
|
100
|
+
jwksUrl: stringField(parsed, "jwks_url", action)
|
|
101
|
+
};
|
|
102
|
+
})();
|
|
103
|
+
const dataApi = dataApiValue === void 0 ? void 0 : (() => {
|
|
104
|
+
const parsed = record(dataApiValue, action);
|
|
105
|
+
return { url: stringField(parsed, "url", action) };
|
|
106
|
+
})();
|
|
107
|
+
return {
|
|
108
|
+
projectId: stringField(response, "project_id", action),
|
|
109
|
+
branchId: stringField(response, "branch_id", action),
|
|
110
|
+
databaseUrl: stringField(response, "database_url", action),
|
|
111
|
+
expiresAt: stringField(response, "expires_at", action),
|
|
112
|
+
services: {
|
|
113
|
+
...auth === void 0 ? {} : { auth },
|
|
114
|
+
...dataApi === void 0 ? {} : { dataApi }
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
const parseClaimCodeResponse = (value) => {
|
|
119
|
+
const action = "creating a claim code";
|
|
120
|
+
const response = record(value, action);
|
|
121
|
+
return {
|
|
122
|
+
userCode: stringField(response, "user_code", action),
|
|
123
|
+
verificationUri: stringField(response, "verification_uri", action),
|
|
124
|
+
verificationUriComplete: stringField(response, "verification_uri_complete", action),
|
|
125
|
+
expiresIn: numberField(response, "expires_in", action),
|
|
126
|
+
interval: numberField(response, "interval", action)
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
const parseClaimStatusResponse = (value) => {
|
|
130
|
+
const action = "fetching claim status";
|
|
131
|
+
const response = record(value, action);
|
|
132
|
+
return {
|
|
133
|
+
state: stringField(response, "state", action),
|
|
134
|
+
expiresAt: stringField(response, "expires_at", action),
|
|
135
|
+
reconciled: booleanField(response, "reconciled", action)
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
const normalizeOrigin = (origin) => {
|
|
139
|
+
let url;
|
|
140
|
+
try {
|
|
141
|
+
url = new URL(origin);
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error(`Invalid Claimable Neon origin "${origin}".`);
|
|
144
|
+
}
|
|
145
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/") throw new Error(`Claimable Neon origin must contain only a scheme and host, got "${origin}".`);
|
|
146
|
+
if (url.protocol !== "https:" && url.hostname !== "localhost") throw new Error("Claimable Neon requires HTTPS except when testing against localhost.");
|
|
147
|
+
return url.origin;
|
|
148
|
+
};
|
|
149
|
+
const parseErrorMetadata = (status, statusText, payload) => {
|
|
150
|
+
if (!isRecord(payload) || !isRecord(payload.error)) return {
|
|
151
|
+
code: "unknown_error",
|
|
152
|
+
message: `Claimable Neon returned HTTP ${status} ${statusText}.`,
|
|
153
|
+
retryable: status === 429 || status >= 500
|
|
154
|
+
};
|
|
155
|
+
const error = payload.error;
|
|
156
|
+
return {
|
|
157
|
+
code: typeof error.code === "string" && error.code.length > 0 ? error.code : "unknown_error",
|
|
158
|
+
message: typeof error.message === "string" && error.message.length > 0 ? error.message : `Claimable Neon returned HTTP ${status} ${statusText}.`,
|
|
159
|
+
retryable: typeof error.retryable === "boolean" ? error.retryable : status === 429 || status >= 500,
|
|
160
|
+
requestId: typeof error.request_id === "string" ? error.request_id : void 0
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
const readJson = async (response) => {
|
|
164
|
+
const text = await response.text();
|
|
165
|
+
if (text.trim() === "") return void 0;
|
|
166
|
+
try {
|
|
167
|
+
return JSON.parse(text);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new Error(`Claimable Neon returned non-JSON content with HTTP ${response.status}.`);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var ClaimableClient = class {
|
|
173
|
+
constructor(origin = DEFAULT_CLAIMABLE_ORIGIN) {
|
|
174
|
+
this.origin = normalizeOrigin(origin);
|
|
175
|
+
}
|
|
176
|
+
async register(input) {
|
|
177
|
+
return parseRegistrationResponse(await this.request("/v1/agent/identity", {
|
|
178
|
+
method: "POST",
|
|
179
|
+
json: {
|
|
180
|
+
type: "anonymous",
|
|
181
|
+
capabilities: input.capabilities,
|
|
182
|
+
source: input.source
|
|
183
|
+
}
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
async exchange(identityAssertion) {
|
|
187
|
+
return parseTokenResponse(await this.request("/v1/oauth2/token", {
|
|
188
|
+
method: "POST",
|
|
189
|
+
form: new URLSearchParams({
|
|
190
|
+
grant_type: JWT_BEARER_GRANT,
|
|
191
|
+
assertion: identityAssertion,
|
|
192
|
+
resource: `${this.origin}/`
|
|
193
|
+
})
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
async credentials(projectId, accessToken) {
|
|
197
|
+
return parseCredentialsResponse(await this.request(`/v1/projects/${encodeURIComponent(projectId)}/credentials`, { accessToken }));
|
|
198
|
+
}
|
|
199
|
+
async createClaim(projectId, accessToken) {
|
|
200
|
+
return parseClaimCodeResponse(await this.request(`/v1/projects/${encodeURIComponent(projectId)}/claim`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
accessToken
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
async claimStatus(projectId, accessToken) {
|
|
206
|
+
return parseClaimStatusResponse(await this.request(`/v1/projects/${encodeURIComponent(projectId)}/claim`, { accessToken }));
|
|
207
|
+
}
|
|
208
|
+
async deleteProject(projectId, accessToken) {
|
|
209
|
+
await this.request(`/v1/projects/${encodeURIComponent(projectId)}`, {
|
|
210
|
+
method: "DELETE",
|
|
211
|
+
accessToken
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async request(path, options = {}) {
|
|
215
|
+
const headers = new Headers({ accept: "application/json" });
|
|
216
|
+
if (options.accessToken) headers.set("authorization", `Bearer ${options.accessToken}`);
|
|
217
|
+
let body;
|
|
218
|
+
if (options.json !== void 0) {
|
|
219
|
+
headers.set("content-type", "application/json");
|
|
220
|
+
body = JSON.stringify(options.json);
|
|
221
|
+
} else if (options.form !== void 0) {
|
|
222
|
+
headers.set("content-type", "application/x-www-form-urlencoded");
|
|
223
|
+
body = options.form.toString();
|
|
224
|
+
}
|
|
225
|
+
let response;
|
|
226
|
+
try {
|
|
227
|
+
response = await fetch(new URL(path, `${this.origin}/`), {
|
|
228
|
+
method: options.method ?? "GET",
|
|
229
|
+
headers,
|
|
230
|
+
body,
|
|
231
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
232
|
+
});
|
|
233
|
+
} catch {
|
|
234
|
+
throw new Error(`Could not reach Claimable Neon at ${this.origin}. Check the connection and retry.`);
|
|
235
|
+
}
|
|
236
|
+
const payload = await readJson(response);
|
|
237
|
+
if (!response.ok) throw new ClaimableServiceError(response.status, parseErrorMetadata(response.status, response.statusText, payload), payload);
|
|
238
|
+
return payload;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
//#endregion
|
|
242
|
+
export { ClaimableClient, ClaimableServiceError, DEFAULT_CLAIMABLE_ORIGIN, parseClaimCodeResponse, parseClaimStatusResponse, parseCredentialsResponse, parseRegistrationResponse, parseTokenResponse };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { t as writeSecretFile } from "../_chunks/secure_file-BucZj4yQ.js";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/claimable/state.ts
|
|
5
|
+
const FILE_PREFIX = "claimable-credential.";
|
|
6
|
+
const FILE_SUFFIX = ".json";
|
|
7
|
+
const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
|
|
8
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
|
|
10
|
+
const assertProjectId = (projectId) => {
|
|
11
|
+
if (!PROJECT_ID.test(projectId)) throw new Error(`Invalid Claimable Neon project ID "${projectId}".`);
|
|
12
|
+
};
|
|
13
|
+
const claimableCredentialsPath = (configDir, projectId) => {
|
|
14
|
+
assertProjectId(projectId);
|
|
15
|
+
return join(configDir, `${FILE_PREFIX}${projectId}${FILE_SUFFIX}`);
|
|
16
|
+
};
|
|
17
|
+
const parseStoredCredentials = (value, path, expectedProjectId) => {
|
|
18
|
+
if (!isRecord(value) || value.version !== 1 || !nonEmptyString(value.origin) || !nonEmptyString(value.registrationId) || !nonEmptyString(value.projectId) || !nonEmptyString(value.branchId) || !nonEmptyString(value.identityAssertion) || !nonEmptyString(value.expiresAt)) throw new Error(`${path} does not contain a valid Claimable Neon credential. Delete it and run \`neon claim create\` again.`);
|
|
19
|
+
assertProjectId(value.projectId);
|
|
20
|
+
if (expectedProjectId !== void 0 && value.projectId !== expectedProjectId) throw new Error(`${path} belongs to a different Claimable Neon project. Delete it and run \`neon claim create\` again.`);
|
|
21
|
+
const assertionExpires = optionalUnixSeconds(value.assertionExpires, path);
|
|
22
|
+
return {
|
|
23
|
+
version: 1,
|
|
24
|
+
origin: value.origin,
|
|
25
|
+
registrationId: value.registrationId,
|
|
26
|
+
projectId: value.projectId,
|
|
27
|
+
branchId: value.branchId,
|
|
28
|
+
identityAssertion: value.identityAssertion,
|
|
29
|
+
expiresAt: value.expiresAt,
|
|
30
|
+
...assertionExpires === void 0 ? {} : { assertionExpires }
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const optionalUnixSeconds = (value, path) => {
|
|
34
|
+
if (value === void 0) return void 0;
|
|
35
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${path} does not contain a valid Claimable Neon credential. Delete it and run \`neon claim create\` again.`);
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
const assertionHasExpired = (credentials, now = Date.now()) => credentials.assertionExpires !== void 0 && credentials.assertionExpires * 1e3 <= now;
|
|
39
|
+
const writeClaimableCredentials = (configDir, credentials) => {
|
|
40
|
+
mkdirSync(configDir, { recursive: true });
|
|
41
|
+
const path = claimableCredentialsPath(configDir, credentials.projectId);
|
|
42
|
+
writeSecretFile(path, JSON.stringify(credentials));
|
|
43
|
+
};
|
|
44
|
+
const readClaimableCredentials = (configDir, projectId) => {
|
|
45
|
+
const path = claimableCredentialsPath(configDir, projectId);
|
|
46
|
+
if (!existsSync(path)) return null;
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
50
|
+
} catch {
|
|
51
|
+
throw new Error(`${path} is not valid JSON, so the Claimable Neon credential cannot be read. Delete it and run \`neon claim create\` again.`);
|
|
52
|
+
}
|
|
53
|
+
return parseStoredCredentials(parsed, path, projectId);
|
|
54
|
+
};
|
|
55
|
+
const listClaimableCredentials = (configDir) => {
|
|
56
|
+
if (!existsSync(configDir)) return [];
|
|
57
|
+
return readdirSync(configDir).filter((name) => name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX)).sort().map((name) => {
|
|
58
|
+
const path = join(configDir, name);
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`${path} is not valid JSON, so the Claimable Neon credential cannot be listed. Delete it and run \`neon claim create\` again.`);
|
|
64
|
+
}
|
|
65
|
+
return parseStoredCredentials(parsed, path);
|
|
66
|
+
}).sort((left, right) => left.projectId.localeCompare(right.projectId));
|
|
67
|
+
};
|
|
68
|
+
const removeClaimableCredentials = (configDir, projectId) => {
|
|
69
|
+
const path = claimableCredentialsPath(configDir, projectId);
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(path);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (isRecord(error) && typeof error.code === "string" && error.code === "ENOENT") return;
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const resolveClaimableContext = (context) => {
|
|
78
|
+
const marker = context.claimable;
|
|
79
|
+
if (marker === void 0) return null;
|
|
80
|
+
if (!isRecord(marker)) throw new Error("The linked .neon file has an invalid \"claimable\" marker. Delete the claimable field from .neon, or delete .neon.");
|
|
81
|
+
if (marker.version !== 1) throw new Error(`Unsupported Claimable Neon context version in .neon. Update the Neon CLI before using this project.`);
|
|
82
|
+
if (!nonEmptyString(marker.origin) || !nonEmptyString(context.projectId)) throw new Error("The linked .neon file has an incomplete \"claimable\" marker. Delete the claimable field from .neon, or delete .neon.");
|
|
83
|
+
assertProjectId(context.projectId);
|
|
84
|
+
return {
|
|
85
|
+
origin: marker.origin,
|
|
86
|
+
projectId: context.projectId,
|
|
87
|
+
...nonEmptyString(context.branch) ? { branch: context.branch } : {}
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
const shouldUseClaimableCredentials = (inputs, profileFlag, context) => inputs.apiKeyFlag.trim() === "" && inputs.apiKeyEnv.trim() === "" && inputs.profileEnv.trim() === "" && (profileFlag === void 0 || profileFlag.trim() === "") && resolveClaimableContext(context) !== null;
|
|
91
|
+
//#endregion
|
|
92
|
+
export { assertionHasExpired, claimableCredentialsPath, listClaimableCredentials, readClaimableCredentials, removeClaimableCredentials, resolveClaimableContext, shouldUseClaimableCredentials, writeClaimableCredentials };
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
|
|
2
|
-
import {
|
|
3
|
-
import { a as OAUTH, i as API_KEY, l as interpretCredentials, s as credentialLabel, t as storeFor } from "../_chunks/credential_io-D2vw1UOw.js";
|
|
2
|
+
import { a as OAUTH, i as API_KEY, l as interpretCredentials, s as credentialLabel, t as storeFor } from "../_chunks/credential_io-B1JxWELN.js";
|
|
4
3
|
import { isCi } from "../env.js";
|
|
5
4
|
import { log } from "../log.js";
|
|
6
5
|
import { getApiClient } from "../api.js";
|
|
7
6
|
import { o as isOwnedCredentialPath } from "../_chunks/paths-g3DRqJiD.js";
|
|
8
7
|
import { setAuthContext } from "../auth_context.js";
|
|
9
|
-
import { isConfigInit, isCurrentBranchProbe, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand } from "../context.js";
|
|
8
|
+
import { currentContextFile, isClaimCommand, isConfigInit, isCurrentBranchProbe, isMcpCommand, isMcpOauth, isPluginsCommand, isProfileCommand, isSkillsCommand, readContextFile } from "../context.js";
|
|
9
|
+
import { _ as upsertProfile, d as newProfileLocation, g as selectProfileName, h as resolveProfile, i as assertValidProfileName, l as locationForName, m as readProfiles, n as KEYRING_CREDENTIALS, p as profilesUsingPath, r as assertProfilesUsable, s as isKeyringPointer, t as DEFAULT_PROFILE, u as locationOf } from "../_chunks/profiles-D95AeZ5v.js";
|
|
10
10
|
import { extendTokenSet } from "../utils/auth.js";
|
|
11
11
|
import { auth, refreshToken } from "../auth.js";
|
|
12
|
-
import { i as selectCredential, n as displacedProfileWarning, t as credentialInputs } from "../_chunks/auth_selection-
|
|
12
|
+
import { i as selectCredential, n as displacedProfileWarning, t as credentialInputs } from "../_chunks/auth_selection-ktL6uoEe.js";
|
|
13
|
+
import { ClaimableClient, ClaimableServiceError } from "../claimable/api.js";
|
|
14
|
+
import { assertionHasExpired, claimableCredentialsPath, readClaimableCredentials, resolveClaimableContext, shouldUseClaimableCredentials } from "../claimable/state.js";
|
|
13
15
|
import { readOutgoingCredential, retirePreviousCredential } from "../retire_credential.js";
|
|
14
16
|
import { existsSync } from "node:fs";
|
|
15
17
|
import { createHash } from "node:crypto";
|
|
@@ -218,6 +220,7 @@ const ensureAuth = async (props) => {
|
|
|
218
220
|
if (isMcpOauth(props)) return;
|
|
219
221
|
if (props._[0] === "open") return;
|
|
220
222
|
if (isSkillsCommand(props) || isPluginsCommand(props)) return;
|
|
223
|
+
if (isClaimCommand(props)) return;
|
|
221
224
|
const isLocalDev = props._[0] === "dev";
|
|
222
225
|
const isBootstrap = props._[0] === "bootstrap";
|
|
223
226
|
const isInit = props._[0] === "init";
|
|
@@ -229,8 +232,42 @@ const ensureAuth = async (props) => {
|
|
|
229
232
|
});
|
|
230
233
|
return;
|
|
231
234
|
}
|
|
235
|
+
const inputs = credentialInputs();
|
|
236
|
+
const contextFile = typeof props.contextFile === "function" ? props.contextFile() : props.contextFile ?? currentContextFile();
|
|
237
|
+
const localContext = readContextFile(contextFile);
|
|
238
|
+
if (shouldUseClaimableCredentials(inputs, props.profile, localContext)) {
|
|
239
|
+
const linked = resolveClaimableContext(localContext);
|
|
240
|
+
if (linked === null) throw new Error("The linked Claimable Neon context could not be resolved.");
|
|
241
|
+
const stored = readClaimableCredentials(props.configDir, linked.projectId);
|
|
242
|
+
const path = claimableCredentialsPath(props.configDir, linked.projectId);
|
|
243
|
+
if (stored === null) throw new Error(`The linked project is claimable, but its identity assertion is missing from ${path}. Run \`neon claim create\` in a new directory, or \`neon link\` after claiming the project.`);
|
|
244
|
+
if (assertionHasExpired(stored)) throw new Error(`The identity assertion for ${linked.projectId} has expired. Run \`neon claim delete ${linked.projectId} --yes\` to drop the local record.`);
|
|
245
|
+
const client = new ClaimableClient(stored.origin);
|
|
246
|
+
if (client.origin !== new ClaimableClient(linked.origin).origin) throw new Error(`The linked .neon file and ${path} name different Claimable Neon services. Delete .neon or the assertion file and run \`neon claim create\` in a new directory.`);
|
|
247
|
+
let token;
|
|
248
|
+
try {
|
|
249
|
+
token = await client.exchange(stored.identityAssertion);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error instanceof ClaimableServiceError && error.code === "project_claimed") throw new Error("This project was claimed. Run `neon claim status` to drop the local assertion, then `neon auth` or `neon link`.");
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
props.apiKey = token.accessToken;
|
|
255
|
+
props.apiHost = `${client.origin}/v1`;
|
|
256
|
+
props.apiClient = getApiClient({
|
|
257
|
+
apiKey: token.accessToken,
|
|
258
|
+
apiHost: props.apiHost
|
|
259
|
+
});
|
|
260
|
+
setAuthContext({
|
|
261
|
+
source: "claimable",
|
|
262
|
+
configDir: props.configDir,
|
|
263
|
+
credentialsPath: path
|
|
264
|
+
});
|
|
265
|
+
log.debug("Using the linked Claimable Neon project's short-lived access token");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (localContext.claimable !== void 0) log.warning("This directory is linked to a claimable project, but NEON_API_KEY or NEON_PROFILE is set. This command will use that account credential instead of the unclaimed project. Unset them to keep using the unclaimed project.");
|
|
232
269
|
const selection = selectCredential({
|
|
233
|
-
...
|
|
270
|
+
...inputs,
|
|
234
271
|
profileFlag: props.profile
|
|
235
272
|
});
|
|
236
273
|
const displaced = displacedProfileWarning(selection);
|
|
@@ -2,7 +2,7 @@ import { t as __exportAll } from "../_chunks/rolldown-runtime-8H4AJuhK.js";
|
|
|
2
2
|
import { isCi } from "../env.js";
|
|
3
3
|
import { log } from "../log.js";
|
|
4
4
|
import { getCliName } from "../utils/cli_name.js";
|
|
5
|
-
import { t as credentialInputs } from "../_chunks/auth_selection-
|
|
5
|
+
import { t as credentialInputs } from "../_chunks/auth_selection-ktL6uoEe.js";
|
|
6
6
|
import { BootstrapInputError, FALLBACK_TEMPLATES, ensureTargetUsable, fetchTemplates, findTemplate, scaffoldTemplate, templateIds } from "../init/bootstrap.js";
|
|
7
7
|
import { DO_NOT_SUBSTITUTE_HINT, formatInstallCommand, inferPackageManager, installArgs, installedPackageManagers, resolvePackageManager, runCommand } from "../utils/package_manager.js";
|
|
8
8
|
import { existsSync } from "node:fs";
|