From e881647fea84c8cd99940c434a43e839c439d283 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 11 Aug 2026 07:44:25 -0700 Subject: [PATCH 01/19] Add claimable project workflow to Neon CLI Let agents provision temporary projects without account credentials, reuse short-lived tokens across CLI and neon.ts commands, and hand the project to a human through the claim ceremony. --- .changeset/calm-bears-claim.md | 8 + packages/cli/README.md | 45 ++ packages/cli/src/api.ts | 14 + packages/cli/src/auth_context.ts | 10 +- packages/cli/src/claimable/api.test.ts | 192 ++++++ packages/cli/src/claimable/api.ts | 489 ++++++++++++++ packages/cli/src/claimable/state.test.ts | 188 ++++++ packages/cli/src/claimable/state.ts | 192 ++++++ packages/cli/src/commands/auth.ts | 70 +- packages/cli/src/commands/claim.test.ts | 69 ++ packages/cli/src/commands/claim.ts | 629 ++++++++++++++++++ packages/cli/src/commands/config.ts | 38 +- packages/cli/src/commands/index.ts | 2 + packages/cli/src/config_services.test.ts | 45 ++ packages/cli/src/config_services.ts | 36 + packages/cli/src/context.ts | 24 + .../config/src/lib/wrap-neon-error.test.ts | 22 +- packages/config/src/lib/wrap-neon-error.ts | 31 +- 18 files changed, 2070 insertions(+), 34 deletions(-) create mode 100644 .changeset/calm-bears-claim.md create mode 100644 packages/cli/src/claimable/api.test.ts create mode 100644 packages/cli/src/claimable/api.ts create mode 100644 packages/cli/src/claimable/state.test.ts create mode 100644 packages/cli/src/claimable/state.ts create mode 100644 packages/cli/src/commands/claim.test.ts create mode 100644 packages/cli/src/commands/claim.ts create mode 100644 packages/cli/src/config_services.test.ts create mode 100644 packages/cli/src/config_services.ts diff --git a/.changeset/calm-bears-claim.md b/.changeset/calm-bears-claim.md new file mode 100644 index 00000000..fe9c58d5 --- /dev/null +++ b/.changeset/calm-bears-claim.md @@ -0,0 +1,8 @@ +--- +"neon": minor +"@neon/config": patch +--- + +Add `neon claim` and its `claimable` alias for creating, using, claiming, listing, and deleting temporary Claimable Neon projects without an account. + +Recognize Claimable Neon capability errors in Config-as-Code so unavailable pre-claim services keep their actionable claim guidance instead of being reported as API-key failures. diff --git a/packages/cli/README.md b/packages/cli/README.md index 03670a51..b40b0eda 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -64,6 +64,50 @@ neon projects list --api-key For information about obtaining an Neon API key, see [Authentication](https://neon.com/docs/reference/api/get-started), in the _Neon API Reference_. +## Create a project without an account + +`neon claim create` provisions a temporary Claimable Neon project for an agent without +requiring a Neon account or opening a browser: + +```bash +# Lakebase Postgres is always included +neon claim create + +# Request Managed Better Auth and the Data API too +neon claim create --service auth --service data-api +``` + +When the current directory has a `neon.ts`, `claim create` also requests every service +declared there. Explicit `--service` values are added to that set. Object Storage, Functions, +and the AI Gateway are sent to the service so demand is recorded, but are reported as +unavailable until the project is claimed; the CLI does not silently remove them. + +The command writes: + +- a `.neon` context that identifies the project and Claimable Neon service; +- an owner-only identity assertion under the CLI config directory; +- `DATABASE_URL` and any granted Auth or Data API variables to `.env` or `.env.local + (disable this with `--no-env-pull`). + +Subsequent project commands automatically exchange the assertion for a short-lived agent +token. The allowlisted pre-claim surface includes project inspection, `connection-string`, +`psql`, `env pull`, and `neon.ts` status, plan, and apply operations for granted services. + +```bash +neon claim status # lifecycle and transfer status +neon projects get # regular CLI command, same agent token +neon psql -- -c "select now()" +neon config plan +neon env pull --service postgres --service auth --service data-api + +neon claim accept # open the human transfer ceremony +neon claim delete --yes # permanently delete an unclaimed project +neon claim list # projects whose assertions are saved locally +``` + +`neon claimable` is an alias for `neon claim`. For local service development, set +`CLAIMABLE_NEON_HOST=http://localhost:8787`; non-local origins must use HTTPS. + ## Project and branch creation Choose the PostgreSQL version when creating a project: @@ -1145,6 +1189,7 @@ Id Name Project Created At Last Used At Last | Command | Subcommands | Description | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------- | | [auth](https://neon.com/docs/reference/cli-auth) | | Authenticate | +| claim (`claimable`) | `create`, `status`, `accept`, `list`, `delete` | Manage claimable projects | | profile | `list`, `create`, `rotate-key`, `remove` | Manage named sets of credentials | | api-keys | `list`, `create`, `revoke` | Manage API keys | | [projects](https://neon.com/docs/reference/cli-projects) | `list`, `create`, `update`, `delete`, `get` | Manage projects | diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index c29fdfc8..3f9acfba 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -127,6 +127,13 @@ export function messageFromBody(body: unknown): string | undefined { const message = body.message; if (typeof message === "string") return message; } + if (body && typeof body === "object" && "error" in body) { + const error = body.error; + if (error && typeof error === "object" && "message" in error) { + const message = error.message; + if (typeof message === "string") return message; + } + } return undefined; } @@ -136,6 +143,13 @@ export function codeFromBody(body: unknown): string | undefined { const code = body.code; if (typeof code === "string") return code; } + if (body && typeof body === "object" && "error" in body) { + const error = body.error; + if (error && typeof error === "object" && "code" in error) { + const code = error.code; + if (typeof code === "string") return code; + } + } return undefined; } diff --git a/packages/cli/src/auth_context.ts b/packages/cli/src/auth_context.ts index 66274158..29e2c970 100644 --- a/packages/cli/src/auth_context.ts +++ b/packages/cli/src/auth_context.ts @@ -5,7 +5,11 @@ import { isOwnedCredentialPath } from "./config.js"; * The 401 handler runs outside yargs, so it needs the exact authentication source * to avoid clearing DEFAULT after a named-profile failure. */ -export type AuthSource = "api-key" | "profile-api-key" | "stored-credentials"; +export type AuthSource = + | "api-key" + | "profile-api-key" + | "stored-credentials" + | "claimable"; export type AuthContext = { source: AuthSource; @@ -80,6 +84,10 @@ export const authFailureMessage = (context: AuthContext | null): string => { 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 -\`.`; } + 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\`.`; + } + // Reached only when the session was not ours to clear, i.e. an adopted credentials file. // Saying "check --api-key" there would be nonsense; the fix is to sign in again. if (context?.source === "stored-credentials") { diff --git a/packages/cli/src/claimable/api.test.ts b/packages/cli/src/claimable/api.test.ts new file mode 100644 index 00000000..fbd9ca99 --- /dev/null +++ b/packages/cli/src/claimable/api.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { codeFromBody, messageFromBody } from "../api.js"; +import { + ClaimableServiceError, + parseClaimCodeResponse, + parseClaimStatusResponse, + parseCredentialsResponse, + parseRegistrationResponse, + parseTokenResponse, +} from "./api.js"; + +describe("Claimable Neon response validation", () => { + it("accepts the registration contract without exposing extra fields", () => { + expect( + parseRegistrationResponse({ + registration_id: "reg-test", + identity_assertion: "signed-assertion", + assertion_expires: 1786718400, + scopes: ["postgres.read", "postgres.write", "data_api.read"], + project: { + id: "project-test", + branch_id: "br-test", + expires_at: "2026-08-14T12:00:00.000Z", + }, + capabilities: [ + { capability: "postgres", granted: true }, + { capability: "data_api", granted: true }, + ], + claim: { + start_url: + "https://claimable.neon.tech/claim?registration_id=reg-test", + }, + ignored_by_cli: "not projected", + }), + ).toEqual({ + registrationId: "reg-test", + identityAssertion: "signed-assertion", + assertionExpires: 1786718400, + scopes: ["postgres.read", "postgres.write", "data_api.read"], + project: { + id: "project-test", + branchId: "br-test", + expiresAt: "2026-08-14T12:00:00.000Z", + }, + capabilities: [ + { capability: "postgres", granted: true }, + { capability: "data_api", granted: true }, + ], + claimStartUrl: + "https://claimable.neon.tech/claim?registration_id=reg-test", + }); + }); + + it("accepts token, credentials, and claim ceremony responses", () => { + expect( + parseTokenResponse({ + access_token: "short-lived-token", + token_type: "Bearer", + expires_in: 900, + scope: "postgres.read postgres.write", + }), + ).toEqual({ + accessToken: "short-lived-token", + expiresIn: 900, + scope: "postgres.read postgres.write", + }); + expect( + parseTokenResponse({ + access_token: "claim-status-token", + token_type: "Bearer", + expires_in: 900, + scope: "", + }), + ).toEqual({ + accessToken: "claim-status-token", + expiresIn: 900, + scope: "", + }); + + expect( + parseCredentialsResponse({ + project_id: "project-test", + branch_id: "br-test", + database_url: "postgresql://user:secret@example.test/neondb", + services: { + data_api: { url: "https://data.example.test" }, + auth: { + base_url: "https://auth.example.test", + jwks_url: + "https://auth.example.test/.well-known/jwks.json", + }, + }, + expires_at: "2026-08-14T12:00:00.000Z", + }), + ).toEqual({ + projectId: "project-test", + branchId: "br-test", + databaseUrl: "postgresql://user:secret@example.test/neondb", + services: { + dataApi: { url: "https://data.example.test" }, + auth: { + baseUrl: "https://auth.example.test", + jwksUrl: "https://auth.example.test/.well-known/jwks.json", + }, + }, + expiresAt: "2026-08-14T12:00:00.000Z", + }); + + expect( + parseClaimCodeResponse({ + user_code: "ABCD-2345", + verification_uri: "https://claimable.neon.tech/claim", + verification_uri_complete: + "https://claimable.neon.tech/claim?user_code=ABCD-2345", + expires_in: 900, + interval: 5, + }), + ).toEqual({ + userCode: "ABCD-2345", + verificationUri: "https://claimable.neon.tech/claim", + verificationUriComplete: + "https://claimable.neon.tech/claim?user_code=ABCD-2345", + expiresIn: 900, + interval: 5, + }); + + expect( + parseClaimStatusResponse({ + state: "accepted", + expires_at: "2026-08-11T13:10:00.000Z", + reconciled: false, + }), + ).toEqual({ + state: "accepted", + expiresAt: "2026-08-11T13:10:00.000Z", + reconciled: false, + }); + }); + + it("refuses malformed upstream responses at the boundary", () => { + expect(() => + parseRegistrationResponse({ + registration_id: "reg-test", + identity_assertion: "signed-assertion", + }), + ).toThrow("registering an anonymous identity"); + + expect(() => + parseTokenResponse({ + access_token: "short-lived-token", + token_type: "Basic", + }), + ).toThrow("exchanging the identity assertion"); + }); +}); + +describe("ClaimableServiceError", () => { + it("keeps actionable metadata without placing response data in the message", () => { + const error = new ClaimableServiceError( + 403, + { + code: "insufficient_scope", + message: "This token cannot delete the project.", + retryable: false, + requestId: "request-test", + }, + { secret: "must-not-appear" }, + ); + + expect(error.message).toBe("This token cannot delete the project."); + expect(error.code).toBe("insufficient_scope"); + expect(error.retryable).toBe(false); + expect(error.requestId).toBe("request-test"); + expect(error.message).not.toContain("must-not-appear"); + }); +}); + +describe("proxied Neon API errors", () => { + it("reads Claimable Neon's nested error envelope", () => { + const body = { + error: { + code: "capability_requires_claim", + message: "Claim this project before deploying functions.", + }, + }; + + expect(codeFromBody(body)).toBe("capability_requires_claim"); + expect(messageFromBody(body)).toBe( + "Claim this project before deploying functions.", + ); + }); +}); diff --git a/packages/cli/src/claimable/api.ts b/packages/cli/src/claimable/api.ts new file mode 100644 index 00000000..724016f8 --- /dev/null +++ b/packages/cli/src/claimable/api.ts @@ -0,0 +1,489 @@ +const REQUEST_TIMEOUT_MS = 60_000; +const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +export const DEFAULT_CLAIMABLE_ORIGIN = "https://claimable.neon.tech"; + +export type ClaimableCapability = + | "postgres" + | "data_api" + | "auth" + | "storage" + | "functions" + | "ai_gateway"; + +export type CapabilityDecision = + | { capability: string; granted: true } + | { + capability: string; + granted: false; + reason: string; + message: string; + }; + +export type Registration = { + registrationId: string; + identityAssertion: string; + assertionExpires: number; + scopes: string[]; + project: { + id: string; + branchId: string; + expiresAt: string; + }; + capabilities: CapabilityDecision[]; + claimStartUrl: string; +}; + +export type ClaimableAccessToken = { + accessToken: string; + expiresIn: number; + scope: string; +}; + +export type ClaimableCredentials = { + projectId: string; + branchId: string; + databaseUrl: string; + expiresAt: string; + services: { + auth?: { + baseUrl: string; + jwksUrl: string; + }; + dataApi?: { url: string }; + }; +}; + +export type ClaimCode = { + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number; + interval: number; +}; + +export type ClaimStatus = { + state: string; + expiresAt: string; + reconciled: boolean; +}; + +type ClaimableErrorMetadata = { + code: string; + message: string; + retryable: boolean; + requestId?: string; +}; + +export class ClaimableServiceError extends Error { + readonly status: number; + readonly code: string; + readonly retryable: boolean; + readonly requestId?: string; + readonly details: unknown; + + constructor( + status: number, + metadata: ClaimableErrorMetadata, + details?: unknown, + ) { + super(metadata.message); + this.name = "ClaimableServiceError"; + this.status = status; + this.code = metadata.code; + this.retryable = metadata.retryable; + this.requestId = metadata.requestId; + this.details = details; + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const record = (value: unknown, action: string): Record => { + if (!isRecord(value)) { + throw invalidResponse(action); + } + return value; +}; + +const stringField = ( + value: Record, + key: string, + action: string, +): string => { + const field = value[key]; + if (typeof field !== "string" || field.length === 0) { + throw invalidResponse(action); + } + return field; +}; + +const stringFieldAllowEmpty = ( + value: Record, + key: string, + action: string, +): string => { + const field = value[key]; + if (typeof field !== "string") { + throw invalidResponse(action); + } + return field; +}; + +const numberField = ( + value: Record, + key: string, + action: string, +): number => { + const field = value[key]; + if (typeof field !== "number" || !Number.isFinite(field) || field <= 0) { + throw invalidResponse(action); + } + return field; +}; + +const booleanField = ( + value: Record, + key: string, + action: string, +): boolean => { + const field = value[key]; + if (typeof field !== "boolean") { + throw invalidResponse(action); + } + return field; +}; + +const stringArrayField = ( + value: Record, + key: string, + action: string, +): string[] => { + const field = value[key]; + if ( + !Array.isArray(field) || + !field.every((item) => typeof item === "string" && item.length > 0) + ) { + throw invalidResponse(action); + } + return field; +}; + +const invalidResponse = (action: string): Error => + new Error( + `Claimable Neon returned an invalid response while ${action}. The response was not used.`, + ); + +const parseCapabilityDecision = ( + value: unknown, + action: string, +): CapabilityDecision => { + const decision = record(value, action); + const capability = stringField(decision, "capability", action); + const granted = booleanField(decision, "granted", action); + if (granted) return { capability, granted: true }; + return { + capability, + granted: false, + reason: stringField(decision, "reason", action), + message: stringField(decision, "message", action), + }; +}; + +export const parseRegistrationResponse = (value: unknown): Registration => { + const action = "registering an anonymous identity"; + const response = record(value, action); + const project = record(response.project, action); + const claim = record(response.claim, action); + const capabilities = response.capabilities; + if (!Array.isArray(capabilities)) throw invalidResponse(action); + return { + registrationId: stringField(response, "registration_id", action), + identityAssertion: stringField(response, "identity_assertion", action), + assertionExpires: numberField(response, "assertion_expires", action), + scopes: stringArrayField(response, "scopes", action), + project: { + id: stringField(project, "id", action), + branchId: stringField(project, "branch_id", action), + expiresAt: stringField(project, "expires_at", action), + }, + capabilities: capabilities.map((item) => + parseCapabilityDecision(item, action), + ), + claimStartUrl: stringField(claim, "start_url", action), + }; +}; + +export const parseTokenResponse = (value: unknown): ClaimableAccessToken => { + const action = "exchanging the identity assertion"; + const response = record(value, action); + if (response.token_type !== "Bearer") throw invalidResponse(action); + return { + accessToken: stringField(response, "access_token", action), + expiresIn: numberField(response, "expires_in", action), + scope: stringFieldAllowEmpty(response, "scope", action), + }; +}; + +export const parseCredentialsResponse = ( + value: unknown, +): ClaimableCredentials => { + const action = "fetching project credentials"; + const response = record(value, action); + const services = record(response.services, action); + const authValue = services.auth; + const dataApiValue = services.data_api; + const auth = + authValue === undefined + ? undefined + : (() => { + const parsed = record(authValue, action); + return { + baseUrl: stringField(parsed, "base_url", action), + jwksUrl: stringField(parsed, "jwks_url", action), + }; + })(); + const dataApi = + dataApiValue === undefined + ? undefined + : (() => { + const parsed = record(dataApiValue, action); + return { url: stringField(parsed, "url", action) }; + })(); + return { + projectId: stringField(response, "project_id", action), + branchId: stringField(response, "branch_id", action), + databaseUrl: stringField(response, "database_url", action), + expiresAt: stringField(response, "expires_at", action), + services: { + ...(auth === undefined ? {} : { auth }), + ...(dataApi === undefined ? {} : { dataApi }), + }, + }; +}; + +export const parseClaimCodeResponse = (value: unknown): ClaimCode => { + const action = "starting the claim ceremony"; + const response = record(value, action); + return { + userCode: stringField(response, "user_code", action), + verificationUri: stringField(response, "verification_uri", action), + verificationUriComplete: stringField( + response, + "verification_uri_complete", + action, + ), + expiresIn: numberField(response, "expires_in", action), + interval: numberField(response, "interval", action), + }; +}; + +export const parseClaimStatusResponse = (value: unknown): ClaimStatus => { + const action = "fetching claim status"; + const response = record(value, action); + return { + state: stringField(response, "state", action), + expiresAt: stringField(response, "expires_at", action), + reconciled: booleanField(response, "reconciled", action), + }; +}; + +const normalizeOrigin = (origin: string): string => { + let url: URL; + try { + url = new URL(origin); + } catch { + throw new Error(`Invalid Claimable Neon origin "${origin}".`); + } + 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}".`, + ); + } + if (url.protocol !== "https:" && url.hostname !== "localhost") { + throw new Error( + "Claimable Neon requires HTTPS except when testing against localhost.", + ); + } + return url.origin; +}; + +const parseErrorMetadata = ( + status: number, + statusText: string, + payload: unknown, +): ClaimableErrorMetadata => { + if (!isRecord(payload) || !isRecord(payload.error)) { + return { + code: "unknown_error", + message: `Claimable Neon returned HTTP ${status} ${statusText}.`, + retryable: status === 429 || status >= 500, + }; + } + const error = payload.error; + return { + code: + typeof error.code === "string" && error.code.length > 0 + ? error.code + : "unknown_error", + message: + typeof error.message === "string" && error.message.length > 0 + ? error.message + : `Claimable Neon returned HTTP ${status} ${statusText}.`, + retryable: + typeof error.retryable === "boolean" + ? error.retryable + : status === 429 || status >= 500, + requestId: + typeof error.request_id === "string" ? error.request_id : undefined, + }; +}; + +const readJson = async (response: Response): Promise => { + const text = await response.text(); + if (text.trim() === "") return undefined; + try { + return JSON.parse(text); + } catch { + throw new Error( + `Claimable Neon returned non-JSON content with HTTP ${response.status}.`, + ); + } +}; + +export class ClaimableClient { + readonly origin: string; + + constructor(origin = DEFAULT_CLAIMABLE_ORIGIN) { + this.origin = normalizeOrigin(origin); + } + + async register(input: { + capabilities: readonly ClaimableCapability[]; + source: string; + }): Promise { + return parseRegistrationResponse( + await this.request("/v1/agent/identity", { + method: "POST", + json: { + type: "anonymous", + capabilities: input.capabilities, + source: input.source, + }, + }), + ); + } + + async exchange(identityAssertion: string): Promise { + return parseTokenResponse( + await this.request("/v1/oauth2/token", { + method: "POST", + form: new URLSearchParams({ + grant_type: JWT_BEARER_GRANT, + assertion: identityAssertion, + resource: `${this.origin}/`, + }), + }), + ); + } + + async credentials( + projectId: string, + accessToken: string, + ): Promise { + return parseCredentialsResponse( + await this.request( + `/v1/databases/${encodeURIComponent(projectId)}/credentials`, + { accessToken }, + ), + ); + } + + async createClaim( + projectId: string, + accessToken: string, + ): Promise { + return parseClaimCodeResponse( + await this.request( + `/v1/databases/${encodeURIComponent(projectId)}/claim`, + { method: "POST", accessToken }, + ), + ); + } + + async claimStatus( + projectId: string, + accessToken: string, + ): Promise { + return parseClaimStatusResponse( + await this.request( + `/v1/databases/${encodeURIComponent(projectId)}/claim`, + { accessToken }, + ), + ); + } + + async deleteProject(projectId: string, accessToken: string): Promise { + await this.request(`/v1/databases/${encodeURIComponent(projectId)}`, { + method: "DELETE", + accessToken, + }); + } + + private async request( + path: string, + options: { + method?: "GET" | "POST" | "DELETE"; + accessToken?: string; + json?: unknown; + form?: URLSearchParams; + } = {}, + ): Promise { + const headers = new Headers({ accept: "application/json" }); + if (options.accessToken) { + headers.set("authorization", `Bearer ${options.accessToken}`); + } + let body: string | undefined; + if (options.json !== undefined) { + headers.set("content-type", "application/json"); + body = JSON.stringify(options.json); + } else if (options.form !== undefined) { + headers.set("content-type", "application/x-www-form-urlencoded"); + body = options.form.toString(); + } + + let response: Response; + try { + response = await fetch(new URL(path, `${this.origin}/`), { + method: options.method ?? "GET", + headers, + body, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new Error( + `Could not reach Claimable Neon at ${this.origin}. Check the connection and retry.`, + ); + } + const payload = await readJson(response); + if (!response.ok) { + throw new ClaimableServiceError( + response.status, + parseErrorMetadata( + response.status, + response.statusText, + payload, + ), + payload, + ); + } + return payload; + } +} diff --git a/packages/cli/src/claimable/state.test.ts b/packages/cli/src/claimable/state.test.ts new file mode 100644 index 00000000..6a40d0a0 --- /dev/null +++ b/packages/cli/src/claimable/state.test.ts @@ -0,0 +1,188 @@ +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + claimableCredentialsPath, + listClaimableCredentials, + readClaimableCredentials, + removeClaimableCredentials, + resolveClaimableContext, + shouldUseClaimableCredentials, + writeClaimableCredentials, +} from "./state.js"; + +const temporaryDirectories: string[] = []; + +const temporaryDirectory = (): string => { + const directory = mkdtempSync(join(tmpdir(), "neon-claimable-state-")); + temporaryDirectories.push(directory); + return directory; +}; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +const credentials = { + version: 1, + origin: "https://claimable.neon.tech", + registrationId: "reg_test", + projectId: "project-test", + branchId: "br-test", + identityAssertion: "signed-identity-assertion", + expiresAt: "2026-08-14T12:00:00.000Z", +} as const; + +describe("claimable credentials", () => { + it("writes an owner-only secret file and reads it back", () => { + const configDir = temporaryDirectory(); + + writeClaimableCredentials(configDir, credentials); + + const path = claimableCredentialsPath(configDir, credentials.projectId); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect( + readClaimableCredentials(configDir, credentials.projectId), + ).toEqual(credentials); + }); + + it("repairs permissive file permissions on replacement", () => { + const configDir = temporaryDirectory(); + writeClaimableCredentials(configDir, credentials); + const path = claimableCredentialsPath(configDir, credentials.projectId); + chmodSync(path, 0o644); + + writeClaimableCredentials(configDir, credentials); + + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(readFileSync(path, "utf8")).not.toContain("undefined"); + }); + + it("lists and removes only Claimable Neon credential files", () => { + const configDir = temporaryDirectory(); + writeClaimableCredentials(configDir, credentials); + writeClaimableCredentials(configDir, { + ...credentials, + projectId: "another-project", + }); + + expect( + listClaimableCredentials(configDir).map((item) => item.projectId), + ).toEqual(["another-project", "project-test"]); + + removeClaimableCredentials(configDir, "project-test"); + expect(readClaimableCredentials(configDir, "project-test")).toBeNull(); + expect(listClaimableCredentials(configDir)).toHaveLength(1); + }); + + it("rejects project ids that could escape the config directory", () => { + const configDir = temporaryDirectory(); + + expect(() => + claimableCredentialsPath(configDir, "../credentials"), + ).toThrow("Invalid Claimable Neon project ID"); + }); +}); + +describe("claimable context", () => { + it("resolves a versioned marker with a matching project", () => { + expect( + resolveClaimableContext({ + projectId: "project-test", + branch: "br-test", + claimable: { + version: 1, + origin: "https://claimable.neon.tech", + }, + }), + ).toEqual({ + projectId: "project-test", + branch: "br-test", + origin: "https://claimable.neon.tech", + }); + }); + + it("returns null for an ordinary Neon context", () => { + expect( + resolveClaimableContext({ + orgId: "org-test", + projectId: "project-test", + branch: "main", + }), + ).toBeNull(); + }); + + it("refuses a malformed marker rather than falling back to account auth", () => { + expect(() => + resolveClaimableContext( + JSON.parse( + '{"projectId":"project-test","claimable":{"version":2,"origin":"https://claimable.neon.tech"}}', + ), + ), + ).toThrow("Unsupported Claimable Neon context version"); + }); +}); + +describe("claimable credential selection", () => { + const noInputs = { + apiKeyFlag: "", + apiKeyEnv: "", + profileEnv: "", + }; + + it("uses the linked claimable project when no account credential was selected", () => { + expect( + shouldUseClaimableCredentials(noInputs, undefined, { + projectId: "project-test", + claimable: { + version: 1, + origin: "https://claimable.neon.tech", + }, + }), + ).toBe(true); + }); + + it("lets every explicit or ambient account selection override the local marker", () => { + const context = { + projectId: "project-test", + claimable: { + version: 1, + origin: "https://claimable.neon.tech", + }, + } as const; + + expect( + shouldUseClaimableCredentials( + { ...noInputs, apiKeyFlag: "napi_explicit" }, + undefined, + context, + ), + ).toBe(false); + expect( + shouldUseClaimableCredentials( + { ...noInputs, apiKeyEnv: "napi_ambient" }, + undefined, + context, + ), + ).toBe(false); + expect( + shouldUseClaimableCredentials( + { ...noInputs, profileEnv: "work" }, + undefined, + context, + ), + ).toBe(false); + expect(shouldUseClaimableCredentials(noInputs, "work", context)).toBe( + false, + ); + }); +}); diff --git a/packages/cli/src/claimable/state.ts b/packages/cli/src/claimable/state.ts new file mode 100644 index 00000000..2e2d28fe --- /dev/null +++ b/packages/cli/src/claimable/state.ts @@ -0,0 +1,192 @@ +import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import type { CredentialInputs } from "@neon-internals/cli-core/auth_selection"; +import { writeSecretFile } from "@neon-internals/cli-core/secure_file"; +import type { Context } from "../context.js"; + +const FILE_PREFIX = "claimable-credential."; +const FILE_SUFFIX = ".json"; +const PROJECT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; + +export type StoredClaimableCredentials = { + version: 1; + origin: string; + registrationId: string; + projectId: string; + branchId: string; + identityAssertion: string; + expiresAt: string; +}; + +export type ResolvedClaimableContext = { + origin: string; + projectId: string; + branch?: string; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const nonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +const assertProjectId = (projectId: string): void => { + if (!PROJECT_ID.test(projectId)) { + throw new Error(`Invalid Claimable Neon project ID "${projectId}".`); + } +}; + +export const claimableCredentialsPath = ( + configDir: string, + projectId: string, +): string => { + assertProjectId(projectId); + return join(configDir, `${FILE_PREFIX}${projectId}${FILE_SUFFIX}`); +}; + +const parseStoredCredentials = ( + value: unknown, + path: string, + expectedProjectId?: string, +): StoredClaimableCredentials => { + 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.`, + ); + } + assertProjectId(value.projectId); + if ( + expectedProjectId !== undefined && + value.projectId !== expectedProjectId + ) { + throw new Error( + `${path} belongs to a different Claimable Neon project. Delete it and run \`neon claim create\` again.`, + ); + } + return { + version: 1, + origin: value.origin, + registrationId: value.registrationId, + projectId: value.projectId, + branchId: value.branchId, + identityAssertion: value.identityAssertion, + expiresAt: value.expiresAt, + }; +}; + +export const writeClaimableCredentials = ( + configDir: string, + credentials: StoredClaimableCredentials, +): void => { + const path = claimableCredentialsPath(configDir, credentials.projectId); + writeSecretFile(path, JSON.stringify(credentials)); +}; + +export const readClaimableCredentials = ( + configDir: string, + projectId: string, +): StoredClaimableCredentials | null => { + const path = claimableCredentialsPath(configDir, projectId); + if (!existsSync(path)) return null; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch { + throw new Error( + `${path} is not valid JSON, so the Claimable Neon credential cannot be read. Delete it and run \`neon claim create\` again.`, + ); + } + return parseStoredCredentials(parsed, path, projectId); +}; + +export const listClaimableCredentials = ( + configDir: string, +): StoredClaimableCredentials[] => { + if (!existsSync(configDir)) return []; + return readdirSync(configDir) + .filter( + (name) => + name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX), + ) + .sort() + .map((name) => { + const path = join(configDir, name); + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch { + throw new Error( + `${path} is not valid JSON, so the Claimable Neon credential cannot be listed. Delete it and run \`neon claim create\` again.`, + ); + } + return parseStoredCredentials(parsed, path); + }) + .sort((left, right) => left.projectId.localeCompare(right.projectId)); +}; + +export const removeClaimableCredentials = ( + configDir: string, + projectId: string, +): void => { + const path = claimableCredentialsPath(configDir, projectId); + try { + unlinkSync(path); + } catch (error) { + if ( + isRecord(error) && + typeof error.code === "string" && + error.code === "ENOENT" + ) { + return; + } + throw error; + } +}; + +export const resolveClaimableContext = ( + context: Context, +): ResolvedClaimableContext | null => { + const marker: unknown = context.claimable; + if (marker === undefined) return null; + if (!isRecord(marker)) { + throw new Error( + 'The linked .neon file has an invalid "claimable" marker. Run `neon link` to replace it.', + ); + } + if (marker.version !== 1) { + throw new Error( + `Unsupported Claimable Neon context version in .neon. Update the Neon CLI before using this project.`, + ); + } + if (!nonEmptyString(marker.origin) || !nonEmptyString(context.projectId)) { + throw new Error( + 'The linked .neon file has an incomplete "claimable" marker. Run `neon link` to replace it.', + ); + } + assertProjectId(context.projectId); + return { + origin: marker.origin, + projectId: context.projectId, + ...(nonEmptyString(context.branch) ? { branch: context.branch } : {}), + }; +}; + +export const shouldUseClaimableCredentials = ( + inputs: CredentialInputs, + profileFlag: string | undefined, + context: Context, +): boolean => + inputs.apiKeyFlag.trim() === "" && + inputs.apiKeyEnv.trim() === "" && + inputs.profileEnv.trim() === "" && + (profileFlag === undefined || profileFlag.trim() === "") && + resolveClaimableContext(context) !== null; diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 5af36a9e..846576e5 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -35,13 +35,24 @@ import type { NeonApiClient } from "../api.js"; import { getApiClient } from "../api.js"; import { auth, refreshToken } from "../auth.js"; import { setAuthContext } from "../auth_context.js"; +import { ClaimableClient } from "../claimable/api.js"; import { + claimableCredentialsPath, + readClaimableCredentials, + resolveClaimableContext, + shouldUseClaimableCredentials, +} from "../claimable/state.js"; +import { credentialsPath as defaultCredentialsPath } from "../config.js"; +import { + currentContextFile, + isClaimCommand, isConfigInit, isCurrentBranchProbe, isMcpCommand, isMcpOauth, isProfileCommand, isSkillsCommand, + readContextFile, } from "../context.js"; import { storeFor } from "../credential_io.js"; import { isCi } from "../env.js"; @@ -65,6 +76,7 @@ type AuthProps = { allowUnsafeTls?: boolean; profile?: string; keyring?: boolean; + contextFile?: string | ((cwd?: string) => string); }; export const locationForAuth = ( @@ -436,6 +448,12 @@ export const ensureAuth = async ( return; } + // `claim` owns its assertion exchange and can create the project before a context exists. + // Account auth here would open a browser before the command ever reaches Claimable Neon. + if (isClaimCommand(props)) { + return; + } + // `dev` runs a function locally. It injects the selected branch's env vars // when credentials happen to be available, but must never trigger an // interactive login: use an API key or existing stored credentials if @@ -466,9 +484,59 @@ export const ensureAuth = async ( return; } + const inputs = credentialInputs(); + const contextFile = + typeof props.contextFile === "function" + ? props.contextFile() + : (props.contextFile ?? currentContextFile()); + const localContext = readContextFile(contextFile); + if (shouldUseClaimableCredentials(inputs, props.profile, localContext)) { + const linked = resolveClaimableContext(localContext); + if (linked === null) { + throw new Error( + "The linked Claimable Neon context could not be resolved.", + ); + } + const stored = readClaimableCredentials( + props.configDir, + linked.projectId, + ); + const path = claimableCredentialsPath( + props.configDir, + linked.projectId, + ); + 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.`, + ); + } + const client = new ClaimableClient(stored.origin); + if (client.origin !== new ClaimableClient(linked.origin).origin) { + throw new Error( + `The linked .neon file and ${path} name different Claimable Neon services. Run \`neon link\` to replace the local context.`, + ); + } + const token = await client.exchange(stored.identityAssertion); + props.apiKey = token.accessToken; + props.apiHost = `${client.origin}/v1`; + props.apiClient = getApiClient({ + apiKey: token.accessToken, + apiHost: props.apiHost, + }); + setAuthContext({ + source: "claimable", + configDir: props.configDir, + credentialsPath: path, + }); + log.debug( + "Using the linked Claimable Neon project's short-lived access token", + ); + return; + } + // Throws when `--api-key` and `--profile` are both passed. const selection = selectCredential({ - ...credentialInputs(), + ...inputs, profileFlag: props.profile, }); diff --git a/packages/cli/src/commands/claim.test.ts b/packages/cli/src/commands/claim.test.ts new file mode 100644 index 00000000..460295dc --- /dev/null +++ b/packages/cli/src/commands/claim.test.ts @@ -0,0 +1,69 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { claimableCapabilities, findNeonConfig } from "./claim.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("claimable service requests", () => { + it("always requests Postgres and maps the shared CLI service vocabulary", () => { + expect(claimableCapabilities([])).toEqual(["postgres"]); + expect( + claimableCapabilities([ + "auth", + "data-api", + "functions", + "object-storage", + "ai-gateway", + ]), + ).toEqual([ + "postgres", + "data_api", + "auth", + "storage", + "functions", + "ai_gateway", + ]); + }); + + it("does not suppress services that require claiming", () => { + expect( + claimableCapabilities([ + "object-storage", + "functions", + "ai-gateway", + ]), + ).toEqual(["postgres", "storage", "functions", "ai_gateway"]); + }); +}); + +describe("claimable neon.ts discovery", () => { + it("finds the closest config while walking to the repository root", () => { + const root = mkdtempSync(join(tmpdir(), "neon-claim-config-")); + temporaryDirectories.push(root); + writeFileSync(join(root, ".git"), "gitdir: test"); + const config = join(root, "neon.ts"); + writeFileSync(config, "export default {};"); + const nested = join(root, "packages", "app"); + mkdirSync(nested, { recursive: true }); + + expect(findNeonConfig(nested)).toBe(config); + }); + + it("does not escape a repository that has no config", () => { + const root = mkdtempSync(join(tmpdir(), "neon-claim-config-")); + temporaryDirectories.push(root); + writeFileSync(join(root, ".git"), "gitdir: test"); + const nested = join(root, "packages", "app"); + mkdirSync(nested, { recursive: true }); + + expect(findNeonConfig(nested)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts new file mode 100644 index 00000000..62392441 --- /dev/null +++ b/packages/cli/src/commands/claim.ts @@ -0,0 +1,629 @@ +import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { loadConfigFromFile } from "@neon/config-runtime"; +import { credentialInputs } from "@neon-internals/cli-core/auth_selection"; +import open from "open"; +import prompts from "prompts"; +import type yargs from "yargs"; +import { + type ClaimableCapability, + ClaimableClient, + ClaimableServiceError, + DEFAULT_CLAIMABLE_ORIGIN, +} from "../claimable/api.js"; +import { + listClaimableCredentials, + readClaimableCredentials, + removeClaimableCredentials, + resolveClaimableContext, + type StoredClaimableCredentials, + writeClaimableCredentials, +} from "../claimable/state.js"; +import { declaredNeonServices } from "../config_services.js"; +import { + applyContext, + contextBranch, + ensureGitignored, + readContextFile, +} from "../context.js"; +import { isCi } from "../env.js"; +import { mergeEnvFile, resolveEnvFilePath } from "../env_file.js"; +import { log } from "../log.js"; +import { + deprecatedServiceMessage, + NEON_SERVICES, + type NeonService, + parseServices, + servicesFlagValue, + servicesOption, +} from "../neon_services.js"; +import { noPassthrough } from "../utils/flags.js"; +import { writer } from "../writer.js"; + +type ClaimProps = { + _: (string | number)[]; + output: "yaml" | "json" | "table"; + configDir: string; + contextFile: string; + claimableHost: string; + profile?: string; + apiKey: string; +}; + +type CreateProps = ClaimProps & { + services?: readonly NeonService[]; + config?: string; + file?: string; + envPull: boolean; +}; + +type AcceptProps = ClaimProps & { + open: boolean; +}; + +type DeleteProps = ClaimProps & { + yes: boolean; +}; + +const CAPABILITY_FOR_SERVICE: Readonly< + Record +> = { + postgres: "postgres", + auth: "auth", + "data-api": "data_api", + functions: "functions", + "object-storage": "storage", + "ai-gateway": "ai_gateway", +}; + +const CAPABILITY_ORDER: readonly ClaimableCapability[] = [ + "postgres", + "data_api", + "auth", + "storage", + "functions", + "ai_gateway", +]; + +export const claimableCapabilities = ( + services: readonly NeonService[], +): ClaimableCapability[] => { + const requested = new Set(["postgres"]); + for (const service of services) { + requested.add(CAPABILITY_FOR_SERVICE[service]); + } + return CAPABILITY_ORDER.filter((capability) => requested.has(capability)); +}; + +const CONFIG_FILENAMES = [ + "neon.ts", + "neon.mts", + "neon.js", + "neon.mjs", +] as const; + +export const findNeonConfig = (cwd = process.cwd()): string | undefined => { + let current = resolve(cwd); + const stop = resolve(homedir()); + while (true) { + for (const name of CONFIG_FILENAMES) { + const candidate = join(current, name); + if (existsSync(candidate)) return candidate; + } + if (existsSync(join(current, ".git")) || current === stop) + return undefined; + const parent = dirname(current); + if (parent === current) return undefined; + current = parent; + } +}; + +const servicesFromConfig = async ( + explicitPath: string | undefined, +): Promise => { + const path = explicitPath ?? findNeonConfig(); + if (!path) return []; + const { config } = await loadConfigFromFile({ path }); + return declaredNeonServices(config); +}; + +const removeFileIfPresent = (path: string): void => { + try { + unlinkSync(path); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ) { + return; + } + throw error; + } +}; + +const failureMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export const command = "claim"; +export const aliases = ["claimable"]; +export const describe = "Create and claim temporary Neon projects"; + +export const builder = (argv: yargs.Argv) => + argv + .usage("$0 claim [options]") + .option("claimable-host", { + describe: "Claimable Neon service origin", + type: "string", + default: + process.env.CLAIMABLE_NEON_HOST ?? DEFAULT_CLAIMABLE_ORIGIN, + hidden: true, + }) + .command( + "create", + "Create a temporary Neon project without an account", + (y) => + y + .option( + "service", + servicesOption({ + key: "service", + allowed: NEON_SERVICES, + describe: + "Services to request for the claimable project", + also: "Postgres is always included. Services unavailable before claim are recorded and reported.", + }), + ) + .option("file", { + describe: + "Target dotenv file. Defaults to an existing .env, otherwise .env.local", + type: "string", + }) + .option("config", { + describe: + "Path to neon.ts. Defaults to walking up from the current directory", + type: "string", + }) + .option("env-pull", { + describe: + "Write the provisioned DATABASE_URL and service URLs to a dotenv file", + type: "boolean", + default: true, + }) + .strict() + .check(noPassthrough("claim create")), + async (args) => { + const rawServices = servicesFlagValue(args.service); + const services = rawServices + ? parseServices(rawServices, { + allowed: NEON_SERVICES, + flag: "--service", + onDeprecated: (used, canonical) => + log.warning( + deprecatedServiceMessage(used, canonical), + ), + }) + : []; + const configuredServices = await servicesFromConfig( + typeof args.config === "string" ? args.config : undefined, + ); + await create({ + ...(args as unknown as CreateProps), + services: [ + ...new Set([...services, ...configuredServices]), + ], + }); + }, + ) + .command( + "status", + "Show the linked claimable project's lifecycle and claim status", + (y) => y.strict().check(noPassthrough("claim status")), + async (args) => await status(args as unknown as ClaimProps), + ) + .command( + "accept", + "Start the browser ceremony that transfers the project to your Neon account", + (y) => + y + .option("open", { + describe: "Open the verification URL in a browser", + type: "boolean", + default: true, + }) + .strict() + .check(noPassthrough("claim accept")), + async (args) => await accept(args as unknown as AcceptProps), + ) + .command( + "list", + "List claimable projects saved on this machine", + (y) => y.strict().check(noPassthrough("claim list")), + async (args) => list(args as unknown as ClaimProps), + ) + .command( + "delete", + "Permanently delete the linked unclaimed project", + (y) => + y + .option("yes", { + alias: "y", + describe: "Skip the confirmation prompt", + type: "boolean", + default: false, + }) + .strict() + .check(noPassthrough("claim delete")), + async (args) => await deleteProject(args as unknown as DeleteProps), + ) + .demandCommand(1, "Run `neon claim --help` to see the subcommands."); + +export const handler = (_args: yargs.Arguments) => { + /* subcommands only */ +}; + +const rejectExplicitAccountCredential = (props: ClaimProps): void => { + const inputs = credentialInputs(); + if (inputs.apiKeyFlag.trim() !== "" || props.profile?.trim()) { + throw new Error( + "Claimable Neon does not use a Neon account credential. Remove --api-key or --profile.", + ); + } +}; + +const linkedCredentials = ( + props: ClaimProps, +): { + context: ReturnType; + linked: NonNullable>; + credentials: StoredClaimableCredentials; + client: ClaimableClient; +} => { + rejectExplicitAccountCredential(props); + const context = readContextFile(props.contextFile); + const linked = resolveClaimableContext(context); + if (linked === null) { + throw new Error( + "This directory is not linked to a claimable project. Run `neon claim create` first.", + ); + } + const credentials = readClaimableCredentials( + props.configDir, + linked.projectId, + ); + if (credentials === null) { + throw new Error( + `The identity assertion for ${linked.projectId} is missing. The project cannot be managed from this machine; claim it through its existing verification URL or run \`neon link\` after it is claimed.`, + ); + } + const client = new ClaimableClient(credentials.origin); + if (client.origin !== new ClaimableClient(linked.origin).origin) { + throw new Error( + "The .neon context and saved identity assertion name different Claimable Neon services. Run `neon link` to replace the local context.", + ); + } + return { context, linked, credentials, client }; +}; + +const create = async (props: CreateProps): Promise => { + rejectExplicitAccountCredential(props); + const existing = readContextFile(props.contextFile); + if (existing.projectId || existing.orgId || existing.claimable) { + throw new Error( + `${props.contextFile} already links this directory to a Neon project. Run \`neon claim create\` from an unlinked directory.`, + ); + } + const contextFileExisted = existsSync(props.contextFile); + const envFile = props.envPull + ? resolveEnvFilePath(process.cwd(), props.file) + : undefined; + const envFileExisted = envFile ? existsSync(envFile) : false; + const previousEnv = + envFileExisted && envFile ? readFileSync(envFile) : undefined; + + const client = new ClaimableClient(props.claimableHost); + const registration = await client.register({ + capabilities: claimableCapabilities(props.services ?? []), + source: "neon_cli", + }); + const stored: StoredClaimableCredentials = { + version: 1, + origin: client.origin, + registrationId: registration.registrationId, + projectId: registration.project.id, + branchId: registration.project.branchId, + identityAssertion: registration.identityAssertion, + expiresAt: registration.project.expiresAt, + }; + let localStateWritten = false; + let contextWritten = false; + let envWriteAttempted = false; + let accessToken: string | undefined; + try { + // Persist the durable assertion before any later network call. If cleanup itself fails, + // the user still has enough state to retry `neon claim delete`. + writeClaimableCredentials(props.configDir, stored); + localStateWritten = true; + applyContext(props.contextFile, { + projectId: registration.project.id, + branch: registration.project.branchId, + claimable: { version: 1, origin: client.origin }, + }); + contextWritten = true; + + const token = await client.exchange(registration.identityAssertion); + accessToken = token.accessToken; + const credentials = await client.credentials( + registration.project.id, + token.accessToken, + ); + if ( + credentials.projectId !== registration.project.id || + credentials.branchId !== registration.project.branchId + ) { + throw new Error( + "Claimable Neon returned credentials for a different project. The project was not kept.", + ); + } + + if (envFile) { + const env = { + DATABASE_URL: credentials.databaseUrl, + ...(credentials.services.dataApi + ? { NEON_DATA_API_URL: credentials.services.dataApi.url } + : {}), + ...(credentials.services.auth + ? { + NEON_AUTH_BASE_URL: + credentials.services.auth.baseUrl, + NEON_AUTH_JWKS_URL: + credentials.services.auth.jwksUrl, + } + : {}), + }; + envWriteAttempted = true; + mergeEnvFile(envFile, env); + ensureGitignored(envFile); + } + + const granted = registration.capabilities + .filter((decision) => decision.granted) + .map((decision) => decision.capability); + const denied = registration.capabilities + .filter((decision) => !decision.granted) + .map((decision) => ({ + capability: decision.capability, + reason: decision.reason, + message: decision.message, + })); + writer(props).end( + { + project_id: registration.project.id, + branch_id: registration.project.branchId, + state: "unclaimed", + expires_at: registration.project.expiresAt, + granted_capabilities: granted, + denied_capabilities: denied, + claim_url: registration.claimStartUrl, + ...(envFile ? { env_file: envFile } : {}), + }, + { + fields: [ + "project_id", + "branch_id", + "state", + "expires_at", + "granted_capabilities", + "denied_capabilities", + "claim_url", + "env_file", + ], + }, + ); + } catch (error) { + let remoteDeleted = false; + try { + const cleanupToken = + accessToken ?? + (await client.exchange(registration.identityAssertion)) + .accessToken; + await client.deleteProject(registration.project.id, cleanupToken); + remoteDeleted = true; + } catch (cleanupError) { + log.error( + "Claimable project %s could not be cleaned up after create failed: %s", + registration.project.id, + failureMessage(cleanupError), + ); + if (localStateWritten && contextWritten) { + log.error( + "Retry cleanup with `neon claim delete --yes` from this directory.", + ); + } + } + + if (remoteDeleted) { + try { + if (localStateWritten) { + removeClaimableCredentials( + props.configDir, + registration.project.id, + ); + } + if (contextWritten) { + if (contextFileExisted) { + applyContext(props.contextFile, existing); + } else { + removeFileIfPresent(props.contextFile); + } + } + if (envWriteAttempted && envFile) { + if (envFileExisted && previousEnv) { + writeFileSync(envFile, previousEnv); + } else { + removeFileIfPresent(envFile); + } + } + } catch (rollbackError) { + log.error( + "Project cleanup succeeded, but local rollback failed: %s", + failureMessage(rollbackError), + ); + } + } + throw error; + } +}; + +const status = async (props: ClaimProps): Promise => { + const { linked, credentials, client } = linkedCredentials(props); + try { + const token = await client.exchange(credentials.identityAssertion); + let claimState = "unclaimed"; + let reconciled = false; + let claimExpiresAt: string | undefined; + try { + const claim = await client.claimStatus( + linked.projectId, + token.accessToken, + ); + claimState = claim.state; + reconciled = claim.reconciled; + claimExpiresAt = claim.expiresAt; + } catch (error) { + if ( + !(error instanceof ClaimableServiceError) || + error.code !== "not_found" + ) { + throw error; + } + } + if (reconciled) { + finishClaimedContext(props); + } + writer(props).end( + { + project_id: linked.projectId, + state: claimState, + reconciled, + project_expires_at: credentials.expiresAt, + ...(claimExpiresAt ? { claim_expires_at: claimExpiresAt } : {}), + }, + { + fields: [ + "project_id", + "state", + "reconciled", + "project_expires_at", + "claim_expires_at", + ], + }, + ); + } catch (error) { + if ( + error instanceof ClaimableServiceError && + error.code === "project_claimed" + ) { + finishClaimedContext(props); + writer(props).end( + { + project_id: linked.projectId, + state: "claimed", + reconciled: true, + }, + { fields: ["project_id", "state", "reconciled"] }, + ); + return; + } + throw error; + } +}; + +const accept = async (props: AcceptProps): Promise => { + const { linked, credentials, client } = linkedCredentials(props); + const token = await client.exchange(credentials.identityAssertion); + const claim = await client.createClaim(linked.projectId, token.accessToken); + + writer(props).end( + { + project_id: linked.projectId, + user_code: claim.userCode, + verification_url: claim.verificationUriComplete, + expires_in_seconds: claim.expiresIn, + }, + { + fields: [ + "project_id", + "user_code", + "verification_url", + "expires_in_seconds", + ], + }, + ); + + if (props.open && !isCi()) { + await open(claim.verificationUriComplete); + } else if (props.open) { + log.info( + "Browser opening is disabled in CI. Open %s", + claim.verificationUriComplete, + ); + } +}; + +const list = (props: ClaimProps): void => { + rejectExplicitAccountCredential(props); + const projects = listClaimableCredentials(props.configDir).map((item) => ({ + project_id: item.projectId, + branch_id: item.branchId, + expires_at: item.expiresAt, + origin: item.origin, + })); + writer(props).end(projects, { + fields: ["project_id", "branch_id", "expires_at", "origin"], + emptyMessage: "No Claimable Neon projects are saved on this machine.", + }); +}; + +const deleteProject = async (props: DeleteProps): Promise => { + const { linked, credentials, client } = linkedCredentials(props); + if (!props.yes) { + if (isCi() || !process.stdin.isTTY) { + throw new Error( + "Deleting a claimable project requires confirmation. Re-run interactively or pass --yes.", + ); + } + const { proceed } = await prompts({ + type: "confirm", + name: "proceed", + message: `Permanently delete ${linked.projectId}?`, + initial: false, + }); + if (!proceed) { + log.info("Claimable project was not deleted."); + return; + } + } + const token = await client.exchange(credentials.identityAssertion); + await client.deleteProject(linked.projectId, token.accessToken); + removeClaimableCredentials(props.configDir, linked.projectId); + if (existsSync(props.contextFile)) { + applyContext(props.contextFile, {}); + } + writer(props).end( + { project_id: linked.projectId, state: "deleted" }, + { fields: ["project_id", "state"] }, + ); +}; + +const finishClaimedContext = (props: ClaimProps): void => { + const context = readContextFile(props.contextFile); + if (!context.projectId) return; + removeClaimableCredentials(props.configDir, context.projectId); + applyContext(props.contextFile, { + projectId: context.projectId, + ...(contextBranch(context) ? { branch: contextBranch(context) } : {}), + }); +}; diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 2f33f7ec..d17c8986 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -24,6 +24,7 @@ import chalk from "chalk"; import type yargs from "yargs"; import { getApiClient, type NeonApiClient } from "../api.js"; import { type NeonConfigView, toNeonConfigView } from "../config_format.js"; +import { declaredNeonServices } from "../config_services.js"; import { CONFIG_INIT_NONE_MEANS, CONFIG_INIT_SERVICES, @@ -747,20 +748,6 @@ export const applyCmd = async (props: ConfigProps): Promise => { type ReportMode = "plan" | "apply"; -/** - * A static service toggle (`auth` / `dataApi` / `preview.aiGateway`) is "on" unless - * explicitly disabled: `true` / `{}` / `{ enabled: true }` enable it; `false` / - * `{ enabled: false }` / absent leave it off. Mirrors the runtime's `isServiceEnabled` - * (which isn't exported), kept tiny and pure so it can be read straight off the policy. - */ -const isToggleEnabled = ( - toggle: boolean | { enabled?: boolean } | undefined, -): boolean => { - if (toggle === undefined) return false; - if (typeof toggle === "boolean") return toggle; - return toggle.enabled !== false; -}; - /** * Human-readable list of the services a `neon.ts` policy utilizes on the branch, shown under * the plan/apply table. Postgres is always present (every branch has it); the rest are listed @@ -771,17 +758,18 @@ const isToggleEnabled = ( * lives in the per-branch closure), so reading it straight off `config` is accurate. */ const utilizedServices = (config: Config): string[] => { - const services = ["Postgres"]; - if (isToggleEnabled(config.auth)) services.push("Neon Auth"); - if (isToggleEnabled(config.dataApi)) services.push("Data API"); - if (Object.keys(config.preview?.buckets ?? {}).length > 0) { - services.push("Object Storage"); - } - if (Object.keys(config.preview?.functions ?? {}).length > 0) { - services.push("Functions"); - } - if (isToggleEnabled(config.preview?.aiGateway)) services.push("AI Gateway"); - return services; + const labels: Record = { + postgres: "Postgres", + auth: "Neon Auth", + "data-api": "Data API", + "object-storage": "Object Storage", + functions: "Functions", + "ai-gateway": "AI Gateway", + }; + return [ + labels.postgres, + ...declaredNeonServices(config).map((service) => labels[service]), + ]; }; /** diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index ef5980e9..2cc50af4 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -5,6 +5,7 @@ import * as bootstrap from "./bootstrap.js"; import * as branches from "./branches.js"; import * as bucket from "./bucket.js"; import * as checkout from "./checkout.js"; +import * as claim from "./claim.js"; import * as config from "./config.js"; import * as cs from "./connection_string.js"; import * as dataApi from "./data_api.js"; @@ -59,6 +60,7 @@ export default [ checkout, link, open, + claim, init, mcp, skills, diff --git a/packages/cli/src/config_services.test.ts b/packages/cli/src/config_services.test.ts new file mode 100644 index 00000000..7d2402e0 --- /dev/null +++ b/packages/cli/src/config_services.test.ts @@ -0,0 +1,45 @@ +import { defineConfig } from "@neon/config-runtime"; +import { describe, expect, it } from "vitest"; +import { declaredNeonServices } from "./config_services.js"; + +describe("declaredNeonServices", () => { + it("maps every static neon.ts service without adding Postgres", () => { + const config = defineConfig({ + auth: true, + dataApi: { enabled: true }, + preview: { + buckets: { + assets: { access: "private" }, + }, + functions: { + api: { name: "API", source: "./api.ts" }, + }, + aiGateway: true, + }, + branch: () => ({}), + }); + + expect(declaredNeonServices(config)).toEqual([ + "auth", + "data-api", + "object-storage", + "functions", + "ai-gateway", + ]); + }); + + it("omits explicitly disabled toggles and empty preview maps", () => { + const config = defineConfig({ + auth: false, + dataApi: { enabled: false }, + preview: { + buckets: {}, + functions: {}, + aiGateway: { enabled: false }, + }, + branch: () => ({}), + }); + + expect(declaredNeonServices(config)).toEqual([]); + }); +}); diff --git a/packages/cli/src/config_services.ts b/packages/cli/src/config_services.ts new file mode 100644 index 00000000..fbc8a746 --- /dev/null +++ b/packages/cli/src/config_services.ts @@ -0,0 +1,36 @@ +import type { Config } from "@neon/config-runtime"; +import type { NeonService } from "./neon_services.js"; + +/** + * A static service toggle is on unless explicitly disabled: `true`, `{}`, and + * `{ enabled: true }` enable it; `false`, `{ enabled: false }`, and absence leave it off. + */ +const isToggleEnabled = ( + toggle: boolean | { enabled?: boolean } | undefined, +): boolean => { + if (toggle === undefined) return false; + if (typeof toggle === "boolean") return toggle; + return toggle.enabled !== false; +}; + +/** + * Static services declared by a neon.ts policy. + * + * Postgres is omitted because every Neon project has it. Callers that construct a + * Claimable Neon capability request add Postgres unconditionally. + */ +export const declaredNeonServices = (config: Config): NeonService[] => { + const services: NeonService[] = []; + if (isToggleEnabled(config.auth)) services.push("auth"); + if (isToggleEnabled(config.dataApi)) services.push("data-api"); + if (Object.keys(config.preview?.buckets ?? {}).length > 0) { + services.push("object-storage"); + } + if (Object.keys(config.preview?.functions ?? {}).length > 0) { + services.push("functions"); + } + if (isToggleEnabled(config.preview?.aiGateway)) { + services.push("ai-gateway"); + } + return services; +}; diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index acc74daa..12abfbb6 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -5,6 +5,12 @@ import type yargs from "yargs"; import { log } from "./log.js"; +export type ClaimableContext = { + version: 1; + /** Claimable Neon authorization-server origin, without the `/v1` API prefix. */ + origin: string; +}; + export type Context = { orgId?: string; projectId?: string; @@ -20,6 +26,12 @@ export type Context = { * dropped the next time the context is written. */ branchId?: string; + /** + * Present while this project is owned by Claimable Neon. The durable identity assertion + * stays in the owner-only CLI config directory; `.neon` carries only the public issuer + * needed to find it. + */ + claimable?: ClaimableContext; }; /** @@ -83,6 +95,13 @@ export const isConfigInit = (args: { export const isProfileCommand = (args: { _: (string | number)[] }): boolean => args._[0] === "profile" || args._[0] === "profiles"; +/** + * `claim` / `claimable` talks to the Claimable Neon authorization service and manages its + * own durable assertion. It must never trigger account authentication first. + */ +export const isClaimCommand = (args: { _: (string | number)[] }): boolean => + args._[0] === "claim" || args._[0] === "claimable"; + /** * `neon api-keys …`, under either spelling. Exempts the group from context enrichment: how * far a credential reaches must come from an explicit flag, never from `.neon`. @@ -227,6 +246,11 @@ export const enrichFromContext = ( if (isSkillsCommand(args)) { return; } + // Claim commands resolve their own project and assertion. Letting `.neon` populate their + // arguments would make `claim list` accidentally target whichever directory it runs from. + if (isClaimCommand(args)) { + return; + } const context = readContextFile(args.contextFile); if (!args.orgId) { args.orgId = context.orgId; diff --git a/packages/config/src/lib/wrap-neon-error.test.ts b/packages/config/src/lib/wrap-neon-error.test.ts index dc93bd1e..0bf4262c 100644 --- a/packages/config/src/lib/wrap-neon-error.test.ts +++ b/packages/config/src/lib/wrap-neon-error.test.ts @@ -4,7 +4,7 @@ import { wrapNeonError } from "./wrap-neon-error.js"; function axiosLike( status: number, - body?: { message?: string; code?: string; request_id?: string }, + body?: object, ): { response: { status: number; data?: object } } { const err: { response: { status: number; data?: object } } = { response: { status }, @@ -52,6 +52,26 @@ describe("wrapNeonError — HTTP status mapping", () => { ); }); + test("maps a nested Claimable Neon capability error without API-key advice", () => { + const err = wrapNeonError( + axiosLike(403, { + error: { + code: "capability_requires_claim", + message: "functions requires a claimed project", + request_id: "req-claimable", + }, + }), + CTX, + ); + const p = err as PlatformError; + expect(p.code).toBe(ErrorCode.FeatureUnavailable); + expect(p.message).toContain("functions requires a claimed project"); + expect(p.message).toContain("Claim the project"); + expect(p.message).not.toContain("API key"); + expect(p.details.requestId).toBe("req-claimable"); + expect(p.details.neonCode).toBe("capability_requires_claim"); + }); + test("404 → NotFound + verifies project id when present", () => { const err = wrapNeonError( axiosLike(404, { message: "project not found" }), diff --git a/packages/config/src/lib/wrap-neon-error.ts b/packages/config/src/lib/wrap-neon-error.ts index f89f46d0..7dc574b3 100644 --- a/packages/config/src/lib/wrap-neon-error.ts +++ b/packages/config/src/lib/wrap-neon-error.ts @@ -49,6 +49,17 @@ export function wrapNeonError( : ""; const apiSummaryWithRequestId = `${apiSummary}${requestIdSuffix}.`; + if (httpInfo.neonCode === "capability_requires_claim") { + return new PlatformError( + ErrorCode.FeatureUnavailable, + [ + `${context.op} failed: ${httpInfo.neonMessage ?? "This capability requires a claimed project."}`, + "Claim the project before enabling this service.", + ].join(" "), + { cause: err, details: httpDetails(context, httpInfo) }, + ); + } + switch (httpInfo.status) { case 401: return new PlatformError( @@ -151,12 +162,20 @@ function extractHttpInfo(err: unknown): HttpInfo | null { const out: HttpInfo = { status }; if (data !== null && typeof data === "object") { const dataObj = data as Record; - if (typeof dataObj.message === "string" && dataObj.message !== "") - out.neonMessage = dataObj.message; - if (typeof dataObj.code === "string" && dataObj.code !== "") - out.neonCode = dataObj.code; - if (typeof dataObj.request_id === "string" && dataObj.request_id !== "") - out.requestId = dataObj.request_id; + const nested = dataObj.error; + const errorObj = + nested !== null && typeof nested === "object" + ? (nested as Record) + : dataObj; + if (typeof errorObj.message === "string" && errorObj.message !== "") + out.neonMessage = errorObj.message; + if (typeof errorObj.code === "string" && errorObj.code !== "") + out.neonCode = errorObj.code; + if ( + typeof errorObj.request_id === "string" && + errorObj.request_id !== "" + ) + out.requestId = errorObj.request_id; } return out; } From 3e8c66391b280ebd39f8977008d4e949921f510b Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 16 Aug 2026 09:01:32 -0700 Subject: [PATCH 02/19] Call Claimable Neon /v1/projects paths. --- packages/cli/src/claimable/api.test.ts | 110 ++++++++++++++++++++++++- packages/cli/src/claimable/api.ts | 8 +- packages/cli/src/commands/auth.ts | 1 - 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/claimable/api.test.ts b/packages/cli/src/claimable/api.test.ts index fbd9ca99..a1dc2485 100644 --- a/packages/cli/src/claimable/api.test.ts +++ b/packages/cli/src/claimable/api.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; import { codeFromBody, messageFromBody } from "../api.js"; import { + ClaimableClient, ClaimableServiceError, parseClaimCodeResponse, parseClaimStatusResponse, @@ -190,3 +196,105 @@ describe("proxied Neon API errors", () => { ); }); }); + +const listen = async ( + handler: (req: IncomingMessage, res: ServerResponse) => void, +) => { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "localhost", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("test server did not bind a port"); + } + return { + origin: `http://localhost:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +}; + +describe("ClaimableClient resource paths", () => { + const seen: { method?: string; url?: string }[] = []; + let close: (() => Promise) | undefined; + + afterEach(async () => { + seen.length = 0; + await close?.(); + close = undefined; + }); + + it("calls /v1/projects/{id} for credentials, claim, and delete", async () => { + const projectId = "quiet-fog-12345678"; + const server = await listen((req, res) => { + seen.push({ method: req.method, url: req.url }); + if (req.url === `/v1/projects/${projectId}/credentials`) { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + project_id: projectId, + branch_id: "br-test", + database_url: + "postgresql://user:secret@example.test/neondb", + services: {}, + expires_at: "2026-08-14T12:00:00.000Z", + }), + ); + return; + } + if (req.url === `/v1/projects/${projectId}/claim`) { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify( + req.method === "POST" + ? { + user_code: "ABCD-2345", + verification_uri: "http://127.0.0.1/claim", + verification_uri_complete: + "http://127.0.0.1/claim?user_code=ABCD-2345", + expires_in: 900, + interval: 5, + } + : { + state: "pending", + expires_at: "2026-08-14T12:00:00.000Z", + reconciled: false, + }, + ), + ); + return; + } + if ( + req.method === "DELETE" && + req.url === `/v1/projects/${projectId}` + ) { + res.writeHead(204); + res.end(); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + error: { code: "not_found", message: req.url }, + }), + ); + }); + close = server.close; + const client = new ClaimableClient(server.origin); + + await client.credentials(projectId, "access-token"); + await client.createClaim(projectId, "access-token"); + await client.claimStatus(projectId, "access-token"); + await client.deleteProject(projectId, "access-token"); + + expect(seen).toEqual([ + { method: "GET", url: `/v1/projects/${projectId}/credentials` }, + { method: "POST", url: `/v1/projects/${projectId}/claim` }, + { method: "GET", url: `/v1/projects/${projectId}/claim` }, + { method: "DELETE", url: `/v1/projects/${projectId}` }, + ]); + }); +}); diff --git a/packages/cli/src/claimable/api.ts b/packages/cli/src/claimable/api.ts index 724016f8..a87f8ceb 100644 --- a/packages/cli/src/claimable/api.ts +++ b/packages/cli/src/claimable/api.ts @@ -400,7 +400,7 @@ export class ClaimableClient { ): Promise { return parseCredentialsResponse( await this.request( - `/v1/databases/${encodeURIComponent(projectId)}/credentials`, + `/v1/projects/${encodeURIComponent(projectId)}/credentials`, { accessToken }, ), ); @@ -412,7 +412,7 @@ export class ClaimableClient { ): Promise { return parseClaimCodeResponse( await this.request( - `/v1/databases/${encodeURIComponent(projectId)}/claim`, + `/v1/projects/${encodeURIComponent(projectId)}/claim`, { method: "POST", accessToken }, ), ); @@ -424,14 +424,14 @@ export class ClaimableClient { ): Promise { return parseClaimStatusResponse( await this.request( - `/v1/databases/${encodeURIComponent(projectId)}/claim`, + `/v1/projects/${encodeURIComponent(projectId)}/claim`, { accessToken }, ), ); } async deleteProject(projectId: string, accessToken: string): Promise { - await this.request(`/v1/databases/${encodeURIComponent(projectId)}`, { + await this.request(`/v1/projects/${encodeURIComponent(projectId)}`, { method: "DELETE", accessToken, }); diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 846576e5..beb89c11 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -42,7 +42,6 @@ import { resolveClaimableContext, shouldUseClaimableCredentials, } from "../claimable/state.js"; -import { credentialsPath as defaultCredentialsPath } from "../config.js"; import { currentContextFile, isClaimCommand, From b2640f5730bbcee44d6d3d7b38eba0ae81525a20 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 16 Aug 2026 09:08:08 -0700 Subject: [PATCH 03/19] Refine Claimable Neon comments --- packages/cli/src/commands/auth.ts | 3 +-- packages/cli/src/commands/claim.ts | 5 ++--- packages/cli/src/config_services.ts | 11 +---------- packages/cli/src/context.ts | 12 +++--------- 4 files changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index beb89c11..5316826f 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -447,8 +447,7 @@ export const ensureAuth = async ( return; } - // `claim` owns its assertion exchange and can create the project before a context exists. - // Account auth here would open a browser before the command ever reaches Claimable Neon. + // Claim commands exchange their own assertion, so account auth must not open first. if (isClaimCommand(props)) { return; } diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 62392441..9af8ca25 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -261,7 +261,7 @@ export const builder = (argv: yargs.Argv) => .demandCommand(1, "Run `neon claim --help` to see the subcommands."); export const handler = (_args: yargs.Arguments) => { - /* subcommands only */ + /* Yargs requires a handler for command groups. */ }; const rejectExplicitAccountCredential = (props: ClaimProps): void => { @@ -342,8 +342,7 @@ const create = async (props: CreateProps): Promise => { let envWriteAttempted = false; let accessToken: string | undefined; try { - // Persist the durable assertion before any later network call. If cleanup itself fails, - // the user still has enough state to retry `neon claim delete`. + // Save the assertion first so failed cleanup can still be retried. writeClaimableCredentials(props.configDir, stored); localStateWritten = true; applyContext(props.contextFile, { diff --git a/packages/cli/src/config_services.ts b/packages/cli/src/config_services.ts index fbc8a746..95b4025f 100644 --- a/packages/cli/src/config_services.ts +++ b/packages/cli/src/config_services.ts @@ -1,10 +1,6 @@ import type { Config } from "@neon/config-runtime"; import type { NeonService } from "./neon_services.js"; -/** - * A static service toggle is on unless explicitly disabled: `true`, `{}`, and - * `{ enabled: true }` enable it; `false`, `{ enabled: false }`, and absence leave it off. - */ const isToggleEnabled = ( toggle: boolean | { enabled?: boolean } | undefined, ): boolean => { @@ -13,12 +9,7 @@ const isToggleEnabled = ( return toggle.enabled !== false; }; -/** - * Static services declared by a neon.ts policy. - * - * Postgres is omitted because every Neon project has it. Callers that construct a - * Claimable Neon capability request add Postgres unconditionally. - */ +/** Postgres is omitted because every project includes it. */ export const declaredNeonServices = (config: Config): NeonService[] => { const services: NeonService[] = []; if (isToggleEnabled(config.auth)) services.push("auth"); diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 12abfbb6..36ee409e 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -27,9 +27,8 @@ export type Context = { */ branchId?: string; /** - * Present while this project is owned by Claimable Neon. The durable identity assertion - * stays in the owner-only CLI config directory; `.neon` carries only the public issuer - * needed to find it. + * The assertion stays outside `.neon` so repository context never contains the owner + * credential. */ claimable?: ClaimableContext; }; @@ -95,10 +94,6 @@ export const isConfigInit = (args: { export const isProfileCommand = (args: { _: (string | number)[] }): boolean => args._[0] === "profile" || args._[0] === "profiles"; -/** - * `claim` / `claimable` talks to the Claimable Neon authorization service and manages its - * own durable assertion. It must never trigger account authentication first. - */ export const isClaimCommand = (args: { _: (string | number)[] }): boolean => args._[0] === "claim" || args._[0] === "claimable"; @@ -246,8 +241,7 @@ export const enrichFromContext = ( if (isSkillsCommand(args)) { return; } - // Claim commands resolve their own project and assertion. Letting `.neon` populate their - // arguments would make `claim list` accidentally target whichever directory it runs from. + // Claim commands bypass enrichment so `claim list` never targets the current directory. if (isClaimCommand(args)) { return; } From d44a4de4fc46f1c64d2a8f81e9337417d9dcd802 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 16 Aug 2026 09:15:55 -0700 Subject: [PATCH 04/19] Accept a Claimable Neon registration without a claim object. --- packages/cli/src/claimable/api.test.ts | 6 ------ packages/cli/src/claimable/api.ts | 3 --- packages/cli/src/commands/claim.ts | 2 -- 3 files changed, 11 deletions(-) diff --git a/packages/cli/src/claimable/api.test.ts b/packages/cli/src/claimable/api.test.ts index a1dc2485..469258b6 100644 --- a/packages/cli/src/claimable/api.test.ts +++ b/packages/cli/src/claimable/api.test.ts @@ -32,10 +32,6 @@ describe("Claimable Neon response validation", () => { { capability: "postgres", granted: true }, { capability: "data_api", granted: true }, ], - claim: { - start_url: - "https://claimable.neon.tech/claim?registration_id=reg-test", - }, ignored_by_cli: "not projected", }), ).toEqual({ @@ -52,8 +48,6 @@ describe("Claimable Neon response validation", () => { { capability: "postgres", granted: true }, { capability: "data_api", granted: true }, ], - claimStartUrl: - "https://claimable.neon.tech/claim?registration_id=reg-test", }); }); diff --git a/packages/cli/src/claimable/api.ts b/packages/cli/src/claimable/api.ts index a87f8ceb..297a7cc4 100644 --- a/packages/cli/src/claimable/api.ts +++ b/packages/cli/src/claimable/api.ts @@ -31,7 +31,6 @@ export type Registration = { expiresAt: string; }; capabilities: CapabilityDecision[]; - claimStartUrl: string; }; export type ClaimableAccessToken = { @@ -195,7 +194,6 @@ export const parseRegistrationResponse = (value: unknown): Registration => { const action = "registering an anonymous identity"; const response = record(value, action); const project = record(response.project, action); - const claim = record(response.claim, action); const capabilities = response.capabilities; if (!Array.isArray(capabilities)) throw invalidResponse(action); return { @@ -211,7 +209,6 @@ export const parseRegistrationResponse = (value: unknown): Registration => { capabilities: capabilities.map((item) => parseCapabilityDecision(item, action), ), - claimStartUrl: stringField(claim, "start_url", action), }; }; diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 9af8ca25..fb2da9f8 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -405,7 +405,6 @@ const create = async (props: CreateProps): Promise => { expires_at: registration.project.expiresAt, granted_capabilities: granted, denied_capabilities: denied, - claim_url: registration.claimStartUrl, ...(envFile ? { env_file: envFile } : {}), }, { @@ -416,7 +415,6 @@ const create = async (props: CreateProps): Promise => { "expires_at", "granted_capabilities", "denied_capabilities", - "claim_url", "env_file", ], }, From 5aed900181aa5be49c6c1bb8e3d0cb680c8e7164 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 16 Aug 2026 09:33:12 -0700 Subject: [PATCH 05/19] Fix claimable recovery copy and keep accept succeeding without a browser. --- packages/cli/README.md | 8 ++++---- packages/cli/src/claimable/api.ts | 2 +- packages/cli/src/claimable/state.ts | 4 ++-- packages/cli/src/commands/auth.ts | 19 ++++++++++++++++--- packages/cli/src/commands/claim.ts | 14 +++++++++++--- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index b40b0eda..fb86872f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -86,12 +86,12 @@ The command writes: - a `.neon` context that identifies the project and Claimable Neon service; - an owner-only identity assertion under the CLI config directory; -- `DATABASE_URL` and any granted Auth or Data API variables to `.env` or `.env.local +- `DATABASE_URL` and any granted Auth or Data API variables to `.env` or `.env.local` (disable this with `--no-env-pull`). Subsequent project commands automatically exchange the assertion for a short-lived agent -token. The allowlisted pre-claim surface includes project inspection, `connection-string`, -`psql`, `env pull`, and `neon.ts` status, plan, and apply operations for granted services. +token and send API calls to Claimable Neon. The service decides which operations are +allowed before claim. ```bash neon claim status # lifecycle and transfer status @@ -100,7 +100,7 @@ neon psql -- -c "select now()" neon config plan neon env pull --service postgres --service auth --service data-api -neon claim accept # open the human transfer ceremony +neon claim accept # create a claim code and open the transfer URL neon claim delete --yes # permanently delete an unclaimed project neon claim list # projects whose assertions are saved locally ``` diff --git a/packages/cli/src/claimable/api.ts b/packages/cli/src/claimable/api.ts index 297a7cc4..06c3f165 100644 --- a/packages/cli/src/claimable/api.ts +++ b/packages/cli/src/claimable/api.ts @@ -261,7 +261,7 @@ export const parseCredentialsResponse = ( }; export const parseClaimCodeResponse = (value: unknown): ClaimCode => { - const action = "starting the claim ceremony"; + const action = "creating a claim code"; const response = record(value, action); return { userCode: stringField(response, "user_code", action), diff --git a/packages/cli/src/claimable/state.ts b/packages/cli/src/claimable/state.ts index 2e2d28fe..68bf5f7d 100644 --- a/packages/cli/src/claimable/state.ts +++ b/packages/cli/src/claimable/state.ts @@ -159,7 +159,7 @@ export const resolveClaimableContext = ( if (marker === undefined) return null; if (!isRecord(marker)) { throw new Error( - 'The linked .neon file has an invalid "claimable" marker. Run `neon link` to replace it.', + 'The linked .neon file has an invalid "claimable" marker. Delete the claimable field from .neon, or delete .neon.', ); } if (marker.version !== 1) { @@ -169,7 +169,7 @@ export const resolveClaimableContext = ( } if (!nonEmptyString(marker.origin) || !nonEmptyString(context.projectId)) { throw new Error( - 'The linked .neon file has an incomplete "claimable" marker. Run `neon link` to replace it.', + 'The linked .neon file has an incomplete "claimable" marker. Delete the claimable field from .neon, or delete .neon.', ); } assertProjectId(context.projectId); diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 5316826f..c45dc975 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -35,7 +35,7 @@ import type { NeonApiClient } from "../api.js"; import { getApiClient } from "../api.js"; import { auth, refreshToken } from "../auth.js"; import { setAuthContext } from "../auth_context.js"; -import { ClaimableClient } from "../claimable/api.js"; +import { ClaimableClient, ClaimableServiceError } from "../claimable/api.js"; import { claimableCredentialsPath, readClaimableCredentials, @@ -511,10 +511,23 @@ export const ensureAuth = async ( const client = new ClaimableClient(stored.origin); if (client.origin !== new ClaimableClient(linked.origin).origin) { throw new Error( - `The linked .neon file and ${path} name different Claimable Neon services. Run \`neon link\` to replace the local context.`, + `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.`, ); } - const token = await client.exchange(stored.identityAssertion); + let token; + try { + token = await client.exchange(stored.identityAssertion); + } catch (error) { + 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`.", + ); + } + throw error; + } props.apiKey = token.accessToken; props.apiHost = `${client.origin}/v1`; props.apiClient = getApiClient({ diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index fb2da9f8..252db91d 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -225,7 +225,7 @@ export const builder = (argv: yargs.Argv) => ) .command( "accept", - "Start the browser ceremony that transfers the project to your Neon account", + "Create a claim code and open the URL where a human signs in and takes the project", (y) => y .option("open", { @@ -301,7 +301,7 @@ const linkedCredentials = ( const client = new ClaimableClient(credentials.origin); if (client.origin !== new ClaimableClient(linked.origin).origin) { throw new Error( - "The .neon context and saved identity assertion name different Claimable Neon services. Run `neon link` to replace the local context.", + "The .neon context and saved identity assertion name different Claimable Neon services. Delete .neon or the assertion file and run `neon claim create` in a new directory.", ); } return { context, linked, credentials, client }; @@ -561,7 +561,12 @@ const accept = async (props: AcceptProps): Promise => { ); if (props.open && !isCi()) { - await open(claim.verificationUriComplete); + open(claim.verificationUriComplete).catch(() => { + log.info( + "Could not open a browser. Open %s", + claim.verificationUriComplete, + ); + }); } else if (props.open) { log.info( "Browser opening is disabled in CI. Open %s", @@ -623,4 +628,7 @@ const finishClaimedContext = (props: ClaimProps): void => { projectId: context.projectId, ...(contextBranch(context) ? { branch: contextBranch(context) } : {}), }); + log.info( + "Dropped the local identity assertion. The next command needs `neon auth` or `neon link`.", + ); }; From eff578e3df43067013df3766cdf3938c572ea6f6 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 16 Aug 2026 09:49:08 -0700 Subject: [PATCH 06/19] Read Claimable Neon errors from NeonApiError.body. --- .../config/src/lib/wrap-neon-error.test.ts | 48 ++++++++++++++++++ packages/config/src/lib/wrap-neon-error.ts | 49 ++++++++++++------- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/packages/config/src/lib/wrap-neon-error.test.ts b/packages/config/src/lib/wrap-neon-error.test.ts index 0bf4262c..926c2ecc 100644 --- a/packages/config/src/lib/wrap-neon-error.test.ts +++ b/packages/config/src/lib/wrap-neon-error.test.ts @@ -1,3 +1,4 @@ +import { NeonApiError } from "@neon/sdk"; import { describe, expect, test } from "vitest"; import { ErrorCode, PlatformError } from "./errors.js"; import { wrapNeonError } from "./wrap-neon-error.js"; @@ -72,6 +73,53 @@ describe("wrapNeonError — HTTP status mapping", () => { expect(p.details.neonCode).toBe("capability_requires_claim"); }); + test("maps a generated-SDK NeonApiError whose nested envelope is in body", () => { + const sdkError = new NeonApiError( + "Neon API request failed with status 403.", + { + status: 403, + body: { + error: { + code: "capability_requires_claim", + message: "functions requires a claimed project", + request_id: "req-sdk", + }, + }, + }, + ); + const err = wrapNeonError(sdkError, CTX); + const p = err as PlatformError; + expect(p.code).toBe(ErrorCode.FeatureUnavailable); + expect(p.message).toContain("functions requires a claimed project"); + expect(p.message).not.toContain("API key"); + expect(p.details.requestId).toBe("req-sdk"); + expect(p.details.neonCode).toBe("capability_requires_claim"); + }); + + test("maps an unwrap-shaped NeonApiError stuffed into response.data", () => { + const sdkError = new NeonApiError( + "Neon API request failed with status 403.", + { + status: 403, + body: { + error: { + code: "capability_requires_claim", + message: "functions requires a claimed project", + request_id: "req-unwrap", + }, + }, + }, + ); + const err = wrapNeonError( + { response: { status: 403, data: sdkError } }, + CTX, + ); + const p = err as PlatformError; + expect(p.code).toBe(ErrorCode.FeatureUnavailable); + expect(p.message).not.toContain("API key"); + expect(p.details.requestId).toBe("req-unwrap"); + }); + test("404 → NotFound + verifies project id when present", () => { const err = wrapNeonError( axiosLike(404, { message: "project not found" }), diff --git a/packages/config/src/lib/wrap-neon-error.ts b/packages/config/src/lib/wrap-neon-error.ts index 7dc574b3..b5403491 100644 --- a/packages/config/src/lib/wrap-neon-error.ts +++ b/packages/config/src/lib/wrap-neon-error.ts @@ -1,3 +1,4 @@ +import { NeonApiError } from "@neon/sdk"; import { ErrorCode, PlatformError } from "./errors.js"; /** @@ -152,31 +153,45 @@ interface HttpInfo { requestId?: string; } +function takeErrorFields(payload: unknown, out: HttpInfo): void { + if (payload === null || typeof payload !== "object") return; + const dataObj = payload as Record; + const nested = dataObj.error; + const errorObj = + nested !== null && typeof nested === "object" + ? (nested as Record) + : dataObj; + if (typeof errorObj.message === "string" && errorObj.message !== "") + out.neonMessage = errorObj.message; + if (typeof errorObj.code === "string" && errorObj.code !== "") + out.neonCode = errorObj.code; + if (typeof errorObj.request_id === "string" && errorObj.request_id !== "") + out.requestId = errorObj.request_id; +} + +function fromNeonApiError(err: NeonApiError): HttpInfo { + const out: HttpInfo = { status: err.status }; + takeErrorFields(err.body, out); + if (out.neonCode === undefined && err.code !== undefined) + out.neonCode = err.code; + if (out.neonMessage === undefined && err.message !== "") + out.neonMessage = err.message; + if (out.requestId === undefined && err.requestId !== undefined) + out.requestId = err.requestId; + return out; +} + function extractHttpInfo(err: unknown): HttpInfo | null { + if (err instanceof NeonApiError) return fromNeonApiError(err); if (err === null || typeof err !== "object") return null; const response = (err as { response?: unknown }).response; if (response === null || typeof response !== "object") return null; const status = (response as { status?: unknown }).status; if (typeof status !== "number") return null; const data = (response as { data?: unknown }).data; + if (data instanceof NeonApiError) return fromNeonApiError(data); const out: HttpInfo = { status }; - if (data !== null && typeof data === "object") { - const dataObj = data as Record; - const nested = dataObj.error; - const errorObj = - nested !== null && typeof nested === "object" - ? (nested as Record) - : dataObj; - if (typeof errorObj.message === "string" && errorObj.message !== "") - out.neonMessage = errorObj.message; - if (typeof errorObj.code === "string" && errorObj.code !== "") - out.neonCode = errorObj.code; - if ( - typeof errorObj.request_id === "string" && - errorObj.request_id !== "" - ) - out.requestId = errorObj.request_id; - } + takeErrorFields(data, out); return out; } From 3de803223da725dbd6574fab1ea95706f010c434 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 16 Aug 2026 09:58:30 -0700 Subject: [PATCH 07/19] Show denied capabilities and warn on ambient account credentials. --- packages/cli/src/commands/claim.ts | 15 +++++++++++++++ packages/config/src/lib/wrap-neon-error.test.ts | 3 ++- packages/config/src/lib/wrap-neon-error.ts | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 252db91d..7f859afc 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -309,6 +309,12 @@ const linkedCredentials = ( const create = async (props: CreateProps): Promise => { rejectExplicitAccountCredential(props); + const ambient = credentialInputs(); + if (ambient.apiKeyEnv.trim() !== "" || ambient.profileEnv.trim() !== "") { + log.warning( + "NEON_API_KEY or NEON_PROFILE is set. Later commands will use that account credential instead of this claimable project. Unset them to keep using the unclaimed project.", + ); + } const existing = readContextFile(props.contextFile); if (existing.projectId || existing.orgId || existing.claimable) { throw new Error( @@ -417,6 +423,15 @@ const create = async (props: CreateProps): Promise => { "denied_capabilities", "env_file", ], + renderColumns: { + denied_capabilities: (row) => + row.denied_capabilities + .map( + (decision) => + `${decision.capability} (${decision.reason})`, + ) + .join("\n"), + }, }, ); } catch (error) { diff --git a/packages/config/src/lib/wrap-neon-error.test.ts b/packages/config/src/lib/wrap-neon-error.test.ts index 926c2ecc..48c0edc0 100644 --- a/packages/config/src/lib/wrap-neon-error.test.ts +++ b/packages/config/src/lib/wrap-neon-error.test.ts @@ -67,7 +67,8 @@ describe("wrapNeonError — HTTP status mapping", () => { const p = err as PlatformError; expect(p.code).toBe(ErrorCode.FeatureUnavailable); expect(p.message).toContain("functions requires a claimed project"); - expect(p.message).toContain("Claim the project"); + expect(p.message).toContain("npx neon claim accept"); + expect(p.message).toContain("req-claimable"); expect(p.message).not.toContain("API key"); expect(p.details.requestId).toBe("req-claimable"); expect(p.details.neonCode).toBe("capability_requires_claim"); diff --git a/packages/config/src/lib/wrap-neon-error.ts b/packages/config/src/lib/wrap-neon-error.ts index b5403491..ea3ac064 100644 --- a/packages/config/src/lib/wrap-neon-error.ts +++ b/packages/config/src/lib/wrap-neon-error.ts @@ -55,7 +55,8 @@ export function wrapNeonError( ErrorCode.FeatureUnavailable, [ `${context.op} failed: ${httpInfo.neonMessage ?? "This capability requires a claimed project."}`, - "Claim the project before enabling this service.", + "Run `npx neon claim accept` before enabling this service.", + apiSummaryWithRequestId, ].join(" "), { cause: err, details: httpDetails(context, httpInfo) }, ); From 37a4e382c0b7111f33f423ff90c9084b79ecd254 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 16 Aug 2026 10:08:35 -0700 Subject: [PATCH 08/19] Name the claim-then-relink path and warn when ambient credentials win. --- packages/cli/src/commands/auth.ts | 6 ++++++ packages/cli/src/commands/claim.ts | 2 +- packages/config/src/lib/wrap-neon-error.test.ts | 4 ++++ packages/config/src/lib/wrap-neon-error.ts | 4 ++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index c45dc975..e08b5e0e 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -545,6 +545,12 @@ export const ensureAuth = async ( return; } + if (localContext.claimable !== undefined) { + 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.", + ); + } + // Throws when `--api-key` and `--profile` are both passed. const selection = selectCredential({ ...inputs, diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 7f859afc..9a6969f2 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -428,7 +428,7 @@ const create = async (props: CreateProps): Promise => { row.denied_capabilities .map( (decision) => - `${decision.capability} (${decision.reason})`, + `${decision.capability}: ${decision.message}`, ) .join("\n"), }, diff --git a/packages/config/src/lib/wrap-neon-error.test.ts b/packages/config/src/lib/wrap-neon-error.test.ts index 48c0edc0..82238f3f 100644 --- a/packages/config/src/lib/wrap-neon-error.test.ts +++ b/packages/config/src/lib/wrap-neon-error.test.ts @@ -66,8 +66,12 @@ describe("wrapNeonError — HTTP status mapping", () => { ); const p = err as PlatformError; expect(p.code).toBe(ErrorCode.FeatureUnavailable); + expect(p.message).toContain( + "this capability requires a claimed project", + ); expect(p.message).toContain("functions requires a claimed project"); expect(p.message).toContain("npx neon claim accept"); + expect(p.message).toContain("npx neon auth"); expect(p.message).toContain("req-claimable"); expect(p.message).not.toContain("API key"); expect(p.details.requestId).toBe("req-claimable"); diff --git a/packages/config/src/lib/wrap-neon-error.ts b/packages/config/src/lib/wrap-neon-error.ts index ea3ac064..7e39c2d2 100644 --- a/packages/config/src/lib/wrap-neon-error.ts +++ b/packages/config/src/lib/wrap-neon-error.ts @@ -54,9 +54,9 @@ export function wrapNeonError( return new PlatformError( ErrorCode.FeatureUnavailable, [ - `${context.op} failed: ${httpInfo.neonMessage ?? "This capability requires a claimed project."}`, - "Run `npx neon claim accept` before enabling this service.", + `${context.op} failed: this capability requires a claimed project.`, apiSummaryWithRequestId, + "Run `npx neon claim accept`, complete the sign-in, then `npx neon auth` or `npx neon link` and re-run.", ].join(" "), { cause: err, details: httpDetails(context, httpInfo) }, ); From 1d65e475518f25d6adfb15461cece1cc4c1c2702 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Sun, 16 Aug 2026 10:13:00 -0700 Subject: [PATCH 09/19] Create the config directory before writing a claimable assertion. --- packages/cli/src/claimable/state.test.ts | 10 ++++++++++ packages/cli/src/claimable/state.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/claimable/state.test.ts b/packages/cli/src/claimable/state.test.ts index 6a40d0a0..c456a212 100644 --- a/packages/cli/src/claimable/state.test.ts +++ b/packages/cli/src/claimable/state.test.ts @@ -43,6 +43,16 @@ const credentials = { } as const; describe("claimable credentials", () => { + it("creates the config directory when it does not exist", () => { + const configDir = join(temporaryDirectory(), "missing", "neon"); + + writeClaimableCredentials(configDir, credentials); + + expect( + readClaimableCredentials(configDir, credentials.projectId), + ).toEqual(credentials); + }); + it("writes an owner-only secret file and reads it back", () => { const configDir = temporaryDirectory(); diff --git a/packages/cli/src/claimable/state.ts b/packages/cli/src/claimable/state.ts index 68bf5f7d..0518c952 100644 --- a/packages/cli/src/claimable/state.ts +++ b/packages/cli/src/claimable/state.ts @@ -1,4 +1,10 @@ -import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, +} from "node:fs"; import { join } from "node:path"; import type { CredentialInputs } from "@neon-internals/cli-core/auth_selection"; import { writeSecretFile } from "@neon-internals/cli-core/secure_file"; @@ -87,6 +93,7 @@ export const writeClaimableCredentials = ( configDir: string, credentials: StoredClaimableCredentials, ): void => { + mkdirSync(configDir, { recursive: true }); const path = claimableCredentialsPath(configDir, credentials.projectId); writeSecretFile(path, JSON.stringify(credentials)); }; From b2af05eafe11868b21227557a7b8358a7d81d018 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Mon, 17 Aug 2026 05:05:38 -0700 Subject: [PATCH 10/19] Keep claim create from telling agents to unset a working Neon login. --- packages/cli/src/commands/claim.cli.test.ts | 122 ++++++++++++++++++++ packages/cli/src/commands/claim.ts | 6 - 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/commands/claim.cli.test.ts diff --git a/packages/cli/src/commands/claim.cli.test.ts b/packages/cli/src/commands/claim.cli.test.ts new file mode 100644 index 00000000..19d5c876 --- /dev/null +++ b/packages/cli/src/commands/claim.cli.test.ts @@ -0,0 +1,122 @@ +/** + * These tests cannot use `testCliCommand` because it always passes `--api-key`, + * which `claim` rejects. + */ + +import { fork } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import strip from "strip-ansi"; +import { afterEach, beforeAll, describe, expect, test } from "vitest"; + +const cleanups: Array<() => void> = []; +afterEach(() => { + while (cleanups.length > 0) cleanups.shift()?.(); +}); + +let unreachableOrigin = ""; +beforeAll(async () => { + unreachableOrigin = await new Promise((res, rej) => { + const probe = createServer(); + probe.on("error", rej); + probe.listen(0, "localhost", () => { + const { port } = probe.address() as AddressInfo; + probe.close((err) => + err ? rej(err) : res(`http://localhost:${port}`), + ); + }); + }); +}); + +type Run = { code: number | null; stdout: string; stderr: string }; + +const makeWorkspace = (): { configDir: string; contextFile: string } => { + const dir = mkdtempSync(join(tmpdir(), "neon-claim-cli-")); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, ".git"), "gitdir: test"); + const configDir = join(dir, "config"); + mkdirSync(configDir); + return { configDir, contextFile: join(dir, ".neon") }; +}; + +const runCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise => { + const { configDir, contextFile } = makeWorkspace(); + return new Promise((res, rej) => { + const cp = fork( + join(process.cwd(), "./dist/cli.js"), + [ + "--no-analytics", + "--config-dir", + configDir, + "--context-file", + contextFile, + "--claimable-host", + unreachableOrigin, + ...args, + ], + { + stdio: "pipe", + env: { PATH: process.env.PATH ?? "", HOME: tmpdir(), ...env }, + }, + ); + cp.stdin?.end(); + let stdout = ""; + let stderr = ""; + cp.stdout?.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + cp.stderr?.on("data", (d: Buffer) => { + stderr += d.toString(); + }); + cp.on("error", rej); + cp.on("close", (code) => + res({ code, stdout: strip(stdout), stderr: strip(stderr) }), + ); + }); +}; + +const reachedClaimableService = (stderr: string) => + stderr.includes(`Could not reach Claimable Neon at ${unreachableOrigin}`); + +describe("claim create with ambient credentials", () => { + test.each([ + { NEON_API_KEY: "napi_ambient" }, + { NEON_PROFILE: "work" }, + { NEON_API_KEY: "napi_ambient", NEON_PROFILE: "work" }, + ])("creates without warning or throwing when %o is set", async (env) => { + const { code, stderr } = await runCli( + ["claim", "create", "--no-env-pull"], + env, + ); + + expect(code).toBe(1); + expect(reachedClaimableService(stderr)).toBe(true); + expect(stderr).not.toContain("Unset"); + expect(stderr).not.toContain("NEON_API_KEY or NEON_PROFILE is set"); + expect(stderr).not.toContain("does not use a Neon account credential"); + }); +}); + +describe("claim create with explicit credential flags", () => { + test.each([ + ["--api-key", "napi_flag"], + ["--profile", "work"], + ] as const)("%s still fails before contacting Claimable Neon", async (flag, value) => { + const { code, stderr } = await runCli([ + flag, + value, + "claim", + "create", + "--no-env-pull", + ]); + + expect(code).toBe(1); + expect(stderr).toContain( + "Claimable Neon does not use a Neon account credential. Remove --api-key or --profile.", + ); + expect(reachedClaimableService(stderr)).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 9a6969f2..f6f54c17 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -309,12 +309,6 @@ const linkedCredentials = ( const create = async (props: CreateProps): Promise => { rejectExplicitAccountCredential(props); - const ambient = credentialInputs(); - if (ambient.apiKeyEnv.trim() !== "" || ambient.profileEnv.trim() !== "") { - log.warning( - "NEON_API_KEY or NEON_PROFILE is set. Later commands will use that account credential instead of this claimable project. Unset them to keep using the unclaimed project.", - ); - } const existing = readContextFile(props.contextFile); if (existing.projectId || existing.orgId || existing.claimable) { throw new Error( From 5190fa2c16a7b87a3ceafe75d628ac2c0a498c22 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 07:23:37 -0700 Subject: [PATCH 11/19] Keep neon claim on the writer tables, not box-drawing output. Claim create and list already go through writer; joining denied capabilities with newlines flattened into a missing separator, and claim list was not pinned to the full-width no-box contract. --- packages/cli/src/claimable/state.test.ts | 2 + packages/cli/src/commands/claim.cli.test.ts | 58 ++++++++++++++++++++- packages/cli/src/commands/claim.ts | 11 +++- packages/cli/src/list_tables.test.ts | 27 ++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/claimable/state.test.ts b/packages/cli/src/claimable/state.test.ts index c456a212..290778e8 100644 --- a/packages/cli/src/claimable/state.test.ts +++ b/packages/cli/src/claimable/state.test.ts @@ -147,6 +147,8 @@ describe("claimable credential selection", () => { apiKeyFlag: "", apiKeyEnv: "", profileEnv: "", + profileFlag: "", + configDir: "", }; it("uses the linked claimable project when no account credential was selected", () => { diff --git a/packages/cli/src/commands/claim.cli.test.ts b/packages/cli/src/commands/claim.cli.test.ts index 19d5c876..6a2b53f8 100644 --- a/packages/cli/src/commands/claim.cli.test.ts +++ b/packages/cli/src/commands/claim.cli.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import strip from "strip-ansi"; import { afterEach, beforeAll, describe, expect, test } from "vitest"; +import { writeClaimableCredentials } from "../claimable/state.js"; const cleanups: Array<() => void> = []; afterEach(() => { @@ -42,8 +43,15 @@ const makeWorkspace = (): { configDir: string; contextFile: string } => { return { configDir, contextFile: join(dir, ".neon") }; }; -const runCli = (args: string[], env: NodeJS.ProcessEnv = {}): Promise => { +const BOX = /[┌┐└┘├┤┬┴┼─│]/; + +const runCli = ( + args: string[], + env: NodeJS.ProcessEnv = {}, + setup?: (workspace: { configDir: string; contextFile: string }) => void, +): Promise => { const { configDir, contextFile } = makeWorkspace(); + setup?.({ configDir, contextFile }); return new Promise((res, rej) => { const cp = fork( join(process.cwd(), "./dist/cli.js"), @@ -120,3 +128,51 @@ describe("claim create with explicit credential flags", () => { expect(reachedClaimableService(stderr)).toBe(false); }); }); + +describe("claim list table output", () => { + test("empty list is a message, not a box table", async () => { + const { code, stdout, stderr } = await runCli(["claim", "list"]); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(stdout).not.toMatch(BOX); + expect(stdout).toContain( + "No Claimable Neon projects are saved on this machine.", + ); + }); + + test("prints every column at full width without boxes", async () => { + const projectId = "wandering-haze-25754674"; + const branchId = "br-main-branch-123456"; + const origin = "https://claimable.neon.tech"; + const expiresAt = "2026-08-24T12:00:00.000Z"; + const { code, stdout, stderr } = await runCli( + ["claim", "list"], + {}, + ({ configDir }) => { + writeClaimableCredentials(configDir, { + version: 1, + origin, + registrationId: "reg_test", + projectId, + branchId, + identityAssertion: "assertion", + expiresAt, + }); + }, + ); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(stdout).not.toMatch(BOX); + expect(stdout).toContain("Project Id"); + expect(stdout).toContain("Branch Id"); + expect(stdout).toContain("Expires At"); + expect(stdout).toContain("Origin"); + expect(stdout).toContain(projectId); + expect(stdout).toContain(branchId); + expect(stdout).toContain(expiresAt); + expect(stdout).toContain(origin); + expect(stdout.trimEnd().split("\n")).toHaveLength(2); + }); +}); diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index f6f54c17..31113136 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -147,6 +147,13 @@ const removeFileIfPresent = (path: string): void => { const failureMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); +export const CLAIM_LIST_FIELDS = [ + "project_id", + "branch_id", + "expires_at", + "origin", +] as const; + export const command = "claim"; export const aliases = ["claimable"]; export const describe = "Create and claim temporary Neon projects"; @@ -424,7 +431,7 @@ const create = async (props: CreateProps): Promise => { (decision) => `${decision.capability}: ${decision.message}`, ) - .join("\n"), + .join(", "), }, }, ); @@ -593,7 +600,7 @@ const list = (props: ClaimProps): void => { origin: item.origin, })); writer(props).end(projects, { - fields: ["project_id", "branch_id", "expires_at", "origin"], + fields: CLAIM_LIST_FIELDS, emptyMessage: "No Claimable Neon projects are saved on this machine.", }); }; diff --git a/packages/cli/src/list_tables.test.ts b/packages/cli/src/list_tables.test.ts index d816e35c..cc58cc33 100644 --- a/packages/cli/src/list_tables.test.ts +++ b/packages/cli/src/list_tables.test.ts @@ -1,6 +1,7 @@ import stripAnsi from "strip-ansi"; import { describe, expect, it } from "vitest"; import { BRANCH_FIELDS } from "./commands/branches.js"; +import { CLAIM_LIST_FIELDS } from "./commands/claim.js"; import { PROJECT_FIELDS, RECOVERABLE_PROJECT_FIELDS, @@ -137,6 +138,32 @@ describe("list field order", () => { expect(header).toContain(titleCase(field)); } }); + + it("prints claim list as full-width columns without boxes", () => { + const origin = "https://claimable.neon.tech"; + const out = formatHumanChunk({ + data: [ + { + project_id: PROJECT_ID, + branch_id: BRANCH_ID, + expires_at: TIMESTAMP, + origin, + }, + ], + fields: CLAIM_LIST_FIELDS, + width: 40, + colorTitle: false, + }); + expect(out).not.toMatch(BOX); + const header = headerOf(out); + for (const field of CLAIM_LIST_FIELDS) { + expect(header).toContain(titleCase(field)); + } + expect(header.indexOf("Expires At")).toBeGreaterThan(-1); + expect(stripAnsi(out)).toContain(PROJECT_ID); + expect(stripAnsi(out)).toContain(origin); + expect(stripAnsi(out).trimEnd().split("\n")).toHaveLength(2); + }); }); describe("inspect list columns", () => { From 24a2de1ba7aef1737ea1d4cb0d94dbaeb363f4bb Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 07:37:52 -0700 Subject: [PATCH 12/19] Tell agents to run neon claim status after sign-in. Accept plus auth or link leaves .neon.claimable in place, so the next command still uses the assertion instead of the new account credential. --- packages/config/src/lib/wrap-neon-error.test.ts | 1 + packages/config/src/lib/wrap-neon-error.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/config/src/lib/wrap-neon-error.test.ts b/packages/config/src/lib/wrap-neon-error.test.ts index 82238f3f..99e7fa34 100644 --- a/packages/config/src/lib/wrap-neon-error.test.ts +++ b/packages/config/src/lib/wrap-neon-error.test.ts @@ -71,6 +71,7 @@ describe("wrapNeonError — HTTP status mapping", () => { ); expect(p.message).toContain("functions requires a claimed project"); expect(p.message).toContain("npx neon claim accept"); + expect(p.message).toContain("npx neon claim status"); expect(p.message).toContain("npx neon auth"); expect(p.message).toContain("req-claimable"); expect(p.message).not.toContain("API key"); diff --git a/packages/config/src/lib/wrap-neon-error.ts b/packages/config/src/lib/wrap-neon-error.ts index 7e39c2d2..9f6bcb90 100644 --- a/packages/config/src/lib/wrap-neon-error.ts +++ b/packages/config/src/lib/wrap-neon-error.ts @@ -56,7 +56,7 @@ export function wrapNeonError( [ `${context.op} failed: this capability requires a claimed project.`, apiSummaryWithRequestId, - "Run `npx neon claim accept`, complete the sign-in, then `npx neon auth` or `npx neon link` and re-run.", + "Run `npx neon claim accept`, complete the sign-in, then `npx neon claim status` to drop the local assertion, then `npx neon auth` or `npx neon link` and re-run.", ].join(" "), { cause: err, details: httpDetails(context, httpInfo) }, ); From 7a96cebb179fff398bd13d41c7d19e4231bbf549 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 07:46:56 -0700 Subject: [PATCH 13/19] Omit Denied Capabilities from claim create when none are denied. An empty array still counts as present, so the headline command printed a blank label row. --- packages/cli/src/commands/claim.test.ts | 40 ++++++++++++++++++++++++- packages/cli/src/commands/claim.ts | 27 +++++++++++------ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/claim.test.ts b/packages/cli/src/commands/claim.test.ts index 460295dc..cb4c4a7c 100644 --- a/packages/cli/src/commands/claim.test.ts +++ b/packages/cli/src/commands/claim.test.ts @@ -1,8 +1,14 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import stripAnsi from "strip-ansi"; import { afterEach, describe, expect, it } from "vitest"; -import { claimableCapabilities, findNeonConfig } from "./claim.js"; +import { formatHumanChunk } from "../human_table.js"; +import { + claimableCapabilities, + claimCreateFields, + findNeonConfig, +} from "./claim.js"; const temporaryDirectories: string[] = []; @@ -67,3 +73,35 @@ describe("claimable neon.ts discovery", () => { expect(findNeonConfig(nested)).toBeUndefined(); }); }); + +describe("claim create table fields", () => { + it("omits Denied Capabilities when nothing is denied", () => { + expect(claimCreateFields([])).not.toContain("denied_capabilities"); + const out = formatHumanChunk({ + data: { + project_id: "quiet-fog-12345678", + branch_id: "br-quiet-fog-12345678", + state: "unclaimed", + expires_at: "2026-08-24T12:00:00.000Z", + granted_capabilities: ["postgres"], + denied_capabilities: [], + env_file: "/tmp/.env.local", + }, + fields: claimCreateFields([]), + colorTitle: false, + }); + expect(stripAnsi(out)).not.toContain("Denied"); + expect(stripAnsi(out)).toContain("Granted Capabilities"); + }); + + it("keeps Denied Capabilities when a capability is denied", () => { + const denied = [ + { + capability: "functions", + message: + "functions is unavailable until the project is claimed", + }, + ]; + expect(claimCreateFields(denied)).toContain("denied_capabilities"); + }); +}); diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 31113136..ca1feb55 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -154,6 +154,23 @@ export const CLAIM_LIST_FIELDS = [ "origin", ] as const; +export const CLAIM_CREATE_FIELDS = [ + "project_id", + "branch_id", + "state", + "expires_at", + "granted_capabilities", + "denied_capabilities", + "env_file", +] as const; + +export const claimCreateFields = ( + denied: readonly unknown[], +): (typeof CLAIM_CREATE_FIELDS)[number][] => + CLAIM_CREATE_FIELDS.filter( + (field) => field !== "denied_capabilities" || denied.length > 0, + ); + export const command = "claim"; export const aliases = ["claimable"]; export const describe = "Create and claim temporary Neon projects"; @@ -415,15 +432,7 @@ const create = async (props: CreateProps): Promise => { ...(envFile ? { env_file: envFile } : {}), }, { - fields: [ - "project_id", - "branch_id", - "state", - "expires_at", - "granted_capabilities", - "denied_capabilities", - "env_file", - ], + fields: claimCreateFields(denied), renderColumns: { denied_capabilities: (row) => row.denied_capabilities From f8fab13d04bb0a8bea62fbe8eedd4792ed4c636f Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 11:32:22 -0700 Subject: [PATCH 14/19] Shorten claim comments and drop the assertion-location reassurance. --- packages/cli/src/commands/claim.cli.test.ts | 5 +---- packages/cli/src/context.ts | 6 +----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/commands/claim.cli.test.ts b/packages/cli/src/commands/claim.cli.test.ts index 6a2b53f8..05a9be2d 100644 --- a/packages/cli/src/commands/claim.cli.test.ts +++ b/packages/cli/src/commands/claim.cli.test.ts @@ -1,7 +1,4 @@ -/** - * These tests cannot use `testCliCommand` because it always passes `--api-key`, - * which `claim` rejects. - */ +/** `testCliCommand` always passes `--api-key`, which `claim` rejects. */ import { fork } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; diff --git a/packages/cli/src/context.ts b/packages/cli/src/context.ts index 36ee409e..929d517a 100644 --- a/packages/cli/src/context.ts +++ b/packages/cli/src/context.ts @@ -7,7 +7,7 @@ import { log } from "./log.js"; export type ClaimableContext = { version: 1; - /** Claimable Neon authorization-server origin, without the `/v1` API prefix. */ + /** Omits the `/v1` API prefix. */ origin: string; }; @@ -26,10 +26,6 @@ export type Context = { * dropped the next time the context is written. */ branchId?: string; - /** - * The assertion stays outside `.neon` so repository context never contains the owner - * credential. - */ claimable?: ClaimableContext; }; From 6b1f2d182b6608e00cb7b7df55b612b2629fa02d Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 11:54:48 -0700 Subject: [PATCH 15/19] Clear expired claim records and accept a project id from the list. --- .changeset/calm-bears-claim.md | 2 +- packages/cli/README.md | 7 +- packages/cli/e2e/claim.e2e.test.ts | 157 +++++++++++++++ packages/cli/src/claimable/state.test.ts | 46 +++++ packages/cli/src/claimable/state.ts | 23 +++ packages/cli/src/commands/auth.ts | 6 + packages/cli/src/commands/claim.cli.test.ts | 99 +++++++++- packages/cli/src/commands/claim.ts | 201 +++++++++++++++----- 8 files changed, 493 insertions(+), 48 deletions(-) create mode 100644 packages/cli/e2e/claim.e2e.test.ts diff --git a/.changeset/calm-bears-claim.md b/.changeset/calm-bears-claim.md index fe9c58d5..5d1d02cc 100644 --- a/.changeset/calm-bears-claim.md +++ b/.changeset/calm-bears-claim.md @@ -3,6 +3,6 @@ "@neon/config": patch --- -Add `neon claim` and its `claimable` alias for creating, using, claiming, listing, and deleting temporary Claimable Neon projects without an account. +Add `neon claim` and its `claimable` alias for creating, using, claiming, listing, and deleting temporary Claimable Neon projects without an account. `status`, `accept`, and `delete` take an optional project id from `claim list`, and `delete` drops a local record after the identity assertion expires. Recognize Claimable Neon capability errors in Config-as-Code so unavailable pre-claim services keep their actionable claim guidance instead of being reported as API-key failures. diff --git a/packages/cli/README.md b/packages/cli/README.md index fb86872f..1677ee92 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -96,15 +96,20 @@ allowed before claim. ```bash neon claim status # lifecycle and transfer status neon projects get # regular CLI command, same agent token -neon psql -- -c "select now()" +neon psql --role-name neondb_owner -- -c "select now()" neon config plan neon env pull --service postgres --service auth --service data-api neon claim accept # create a claim code and open the transfer URL neon claim delete --yes # permanently delete an unclaimed project neon claim list # projects whose assertions are saved locally +neon claim delete --yes ``` +`status`, `accept`, and `delete` take an optional project id from `claim list`, so a +project stays manageable after its original directory is gone. `delete` also drops a +local record whose identity assertion has expired or been revoked. + `neon claimable` is an alias for `neon claim`. For local service development, set `CLAIMABLE_NEON_HOST=http://localhost:8787`; non-local origins must use HTTPS. diff --git a/packages/cli/e2e/claim.e2e.test.ts b/packages/cli/e2e/claim.e2e.test.ts new file mode 100644 index 00000000..5fa0200e --- /dev/null +++ b/packages/cli/e2e/claim.e2e.test.ts @@ -0,0 +1,157 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect } from "vitest"; +import { e2eTest, runCli } from "./helpers.js"; + +type CreatedClaim = { + project_id: string; + branch_id: string; + state: string; +}; + +type ClaimStatus = { + project_id: string; + state: string; + reconciled: boolean; +}; + +type BareProject = { + id: string; +}; + +const cleanups: Array<() => void> = []; +afterEach(() => { + while (cleanups.length > 0) cleanups.shift()?.(); +}); + +const isolatedDirs = (): { + configDir: string; + contextFile: string; + cwd: string; +} => { + const root = mkdtempSync(join(tmpdir(), "neon-claim-e2e-")); + cleanups.push(() => rmSync(root, { recursive: true, force: true })); + const configDir = join(root, "config"); + mkdirSync(configDir); + return { + configDir, + contextFile: join(root, ".neon"), + cwd: join(root, "workspace"), + }; +}; + +const anonymous = { + apiKey: null, + env: { + NEON_API_KEY: undefined, + NEON_PROFILE: undefined, + }, +} as const; + +const claimHostArgs = (): string[] => { + const host = process.env.CLAIMABLE_NEON_HOST; + return host ? ["--claimable-host", host] : []; +}; + +const runAnonymousJson = async ( + args: string[], + dirs: { configDir: string; contextFile: string; cwd?: string }, +): Promise => { + const result = await runCli(args, { + ...anonymous, + configDir: dirs.configDir, + contextFile: dirs.contextFile, + ...(dirs.cwd ? { cwd: dirs.cwd } : {}), + }); + if (result.code !== 0) { + throw new Error( + `neon ${args.join(" ")} exited ${result.code}\n${result.stderr || result.stdout}`, + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch { + throw new Error( + `neon ${args.join(" ")} did not print JSON:\n${result.stdout}`, + ); + } +}; + +describe.sequential("e2e — neon claim against live Claimable Neon", () => { + e2eTest( + "creates, uses through ensureAuth, reports status, and deletes by project id", + async () => { + const createdIn = isolatedDirs(); + mkdirSync(createdIn.cwd); + let projectId: string | undefined; + try { + const created = await runAnonymousJson( + ["claim", "create", "--no-env-pull", ...claimHostArgs()], + createdIn, + ); + projectId = created.project_id; + expect(created.state).toBe("unclaimed"); + + const fetched = await runAnonymousJson( + ["projects", "get", created.project_id], + createdIn, + ); + expect(fetched.id).toBe(created.project_id); + + const liveStatus = await runAnonymousJson( + ["claim", "status", ...claimHostArgs()], + createdIn, + ); + expect(liveStatus).toMatchObject({ + project_id: created.project_id, + reconciled: false, + }); + expect(liveStatus.state).not.toBe("expired"); + + const orphaned = isolatedDirs(); + const deleted = await runAnonymousJson<{ + project_id: string; + state: string; + }>( + [ + "claim", + "delete", + created.project_id, + "--yes", + ...claimHostArgs(), + ], + { ...orphaned, configDir: createdIn.configDir }, + ); + expect(deleted).toEqual({ + project_id: created.project_id, + state: "deleted", + }); + projectId = undefined; + + const listed = await runAnonymousJson( + ["claim", "list"], + { ...orphaned, configDir: createdIn.configDir }, + ); + expect(listed).toEqual([]); + } finally { + if (projectId !== undefined) { + await runCli( + [ + "claim", + "delete", + projectId, + "--yes", + ...claimHostArgs(), + ], + { + ...anonymous, + configDir: createdIn.configDir, + contextFile: createdIn.contextFile, + }, + ); + } + } + }, + ); +}); diff --git a/packages/cli/src/claimable/state.test.ts b/packages/cli/src/claimable/state.test.ts index 290778e8..3cb87904 100644 --- a/packages/cli/src/claimable/state.test.ts +++ b/packages/cli/src/claimable/state.test.ts @@ -4,11 +4,13 @@ import { readFileSync, rmSync, statSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + assertionHasExpired, claimableCredentialsPath, listClaimableCredentials, readClaimableCredentials, @@ -101,6 +103,50 @@ describe("claimable credentials", () => { claimableCredentialsPath(configDir, "../credentials"), ).toThrow("Invalid Claimable Neon project ID"); }); + + it("reads credentials that omit assertionExpires", () => { + const configDir = temporaryDirectory(); + writeClaimableCredentials(configDir, credentials); + + expect( + assertionHasExpired( + readClaimableCredentials(configDir, credentials.projectId) ?? + credentials, + ), + ).toBe(false); + }); + + it("treats a stored assertionExpires in the past as expired", () => { + expect( + assertionHasExpired( + { ...credentials, assertionExpires: 1 }, + 1_700_000_000_000, + ), + ).toBe(true); + expect( + assertionHasExpired( + { ...credentials, assertionExpires: 2_000_000_000 }, + 1_700_000_000_000, + ), + ).toBe(false); + }); + + it("rejects a stored assertionExpires that is not a unix timestamp", () => { + const configDir = temporaryDirectory(); + writeClaimableCredentials(configDir, { + ...credentials, + assertionExpires: 1_800_000_000, + }); + const path = claimableCredentialsPath(configDir, credentials.projectId); + writeFileSync( + path, + JSON.stringify({ ...credentials, assertionExpires: 0 }), + ); + + expect(() => + readClaimableCredentials(configDir, credentials.projectId), + ).toThrow("valid Claimable Neon credential"); + }); }); describe("claimable context", () => { diff --git a/packages/cli/src/claimable/state.ts b/packages/cli/src/claimable/state.ts index 0518c952..3f24fec0 100644 --- a/packages/cli/src/claimable/state.ts +++ b/packages/cli/src/claimable/state.ts @@ -22,6 +22,7 @@ export type StoredClaimableCredentials = { branchId: string; identityAssertion: string; expiresAt: string; + assertionExpires?: number; }; export type ResolvedClaimableContext = { @@ -78,6 +79,7 @@ const parseStoredCredentials = ( `${path} belongs to a different Claimable Neon project. Delete it and run \`neon claim create\` again.`, ); } + const assertionExpires = optionalUnixSeconds(value.assertionExpires, path); return { version: 1, origin: value.origin, @@ -86,9 +88,30 @@ const parseStoredCredentials = ( branchId: value.branchId, identityAssertion: value.identityAssertion, expiresAt: value.expiresAt, + ...(assertionExpires === undefined ? {} : { assertionExpires }), }; }; +const optionalUnixSeconds = ( + value: unknown, + path: string, +): number | undefined => { + if (value === undefined) return undefined; + 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.`, + ); + } + return value; +}; + +export const assertionHasExpired = ( + credentials: StoredClaimableCredentials, + now = Date.now(), +): boolean => + credentials.assertionExpires !== undefined && + credentials.assertionExpires * 1000 <= now; + export const writeClaimableCredentials = ( configDir: string, credentials: StoredClaimableCredentials, diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index e08b5e0e..ad1ba54d 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -37,6 +37,7 @@ import { auth, refreshToken } from "../auth.js"; import { setAuthContext } from "../auth_context.js"; import { ClaimableClient, ClaimableServiceError } from "../claimable/api.js"; import { + assertionHasExpired, claimableCredentialsPath, readClaimableCredentials, resolveClaimableContext, @@ -508,6 +509,11 @@ export const ensureAuth = async ( `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.`, ); } + 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.`, + ); + } const client = new ClaimableClient(stored.origin); if (client.origin !== new ClaimableClient(linked.origin).origin) { throw new Error( diff --git a/packages/cli/src/commands/claim.cli.test.ts b/packages/cli/src/commands/claim.cli.test.ts index 05a9be2d..c448192b 100644 --- a/packages/cli/src/commands/claim.cli.test.ts +++ b/packages/cli/src/commands/claim.cli.test.ts @@ -8,7 +8,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import strip from "strip-ansi"; import { afterEach, beforeAll, describe, expect, test } from "vitest"; -import { writeClaimableCredentials } from "../claimable/state.js"; +import { + readClaimableCredentials, + writeClaimableCredentials, +} from "../claimable/state.js"; const cleanups: Array<() => void> = []; afterEach(() => { @@ -29,7 +32,12 @@ beforeAll(async () => { }); }); -type Run = { code: number | null; stdout: string; stderr: string }; +type Run = { + code: number | null; + stdout: string; + stderr: string; + configDir: string; +}; const makeWorkspace = (): { configDir: string; contextFile: string } => { const dir = mkdtempSync(join(tmpdir(), "neon-claim-cli-")); @@ -78,7 +86,12 @@ const runCli = ( }); cp.on("error", rej); cp.on("close", (code) => - res({ code, stdout: strip(stdout), stderr: strip(stderr) }), + res({ + code, + stdout: strip(stdout), + stderr: strip(stderr), + configDir, + }), ); }); }; @@ -126,6 +139,86 @@ describe("claim create with explicit credential flags", () => { }); }); +const expiredCredentials = { + version: 1 as const, + origin: "https://claimable.neon.tech", + registrationId: "reg_expired", + projectId: "quiet-fog-12345678", + branchId: "br-quiet-fog-12345678", + identityAssertion: "expired-assertion", + expiresAt: "2026-08-24T12:00:00.000Z", + assertionExpires: 1, +}; + +describe("claim status and delete after the assertion expires", () => { + test("status reports expired without contacting the service", async () => { + const { code, stdout, stderr } = await runCli( + [ + "claim", + "status", + expiredCredentials.projectId, + "--output", + "json", + ], + {}, + ({ configDir }) => { + writeClaimableCredentials(configDir, expiredCredentials); + }, + ); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toMatchObject({ + project_id: expiredCredentials.projectId, + state: "expired", + reconciled: false, + }); + expect(reachedClaimableService(stderr)).toBe(false); + }); + + test("delete drops a listed project that has no .neon", async () => { + const { code, stdout, stderr, configDir } = await runCli( + [ + "claim", + "delete", + expiredCredentials.projectId, + "--yes", + "--output", + "json", + ], + {}, + ({ configDir }) => { + writeClaimableCredentials(configDir, expiredCredentials); + }, + ); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + project_id: expiredCredentials.projectId, + state: "cleared", + }); + expect(reachedClaimableService(stderr)).toBe(false); + expect( + readClaimableCredentials(configDir, expiredCredentials.projectId), + ).toBeNull(); + }); + + test("accept refuses an expired assertion without contacting the service", async () => { + const { code, stderr } = await runCli( + ["claim", "accept", expiredCredentials.projectId, "--no-open"], + {}, + ({ configDir }) => { + writeClaimableCredentials(configDir, expiredCredentials); + }, + ); + + expect(code).toBe(1); + expect(stderr).toContain("has expired"); + expect(reachedClaimableService(stderr)).toBe(false); + }); +}); + describe("claim list table output", () => { test("empty list is a message, not a box table", async () => { const { code, stdout, stderr } = await runCli(["claim", "list"]); diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index ca1feb55..82f46303 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -13,6 +13,7 @@ import { DEFAULT_CLAIMABLE_ORIGIN, } from "../claimable/api.js"; import { + assertionHasExpired, listClaimableCredentials, readClaimableCredentials, removeClaimableCredentials, @@ -49,6 +50,7 @@ type ClaimProps = { claimableHost: string; profile?: string; apiKey: string; + projectId?: string; }; type CreateProps = ClaimProps & { @@ -242,16 +244,29 @@ export const builder = (argv: yargs.Argv) => }, ) .command( - "status", - "Show the linked claimable project's lifecycle and claim status", - (y) => y.strict().check(noPassthrough("claim status")), + "status [project-id]", + "Show a claimable project's lifecycle and claim status", + (y) => + y + .positional("project-id", { + describe: + "Project from `neon claim list`. Defaults to the project linked in this directory", + type: "string", + }) + .strict() + .check(noPassthrough("claim status")), async (args) => await status(args as unknown as ClaimProps), ) .command( - "accept", + "accept [project-id]", "Create a claim code and open the URL where a human signs in and takes the project", (y) => y + .positional("project-id", { + describe: + "Project from `neon claim list`. Defaults to the project linked in this directory", + type: "string", + }) .option("open", { describe: "Open the verification URL in a browser", type: "boolean", @@ -268,10 +283,15 @@ export const builder = (argv: yargs.Argv) => async (args) => list(args as unknown as ClaimProps), ) .command( - "delete", - "Permanently delete the linked unclaimed project", + "delete [project-id]", + "Permanently delete an unclaimed project, or drop a local record that can no longer reach the service", (y) => y + .positional("project-id", { + describe: + "Project from `neon claim list`. Defaults to the project linked in this directory", + type: "string", + }) .option("yes", { alias: "y", describe: "Skip the confirmation prompt", @@ -297,38 +317,67 @@ const rejectExplicitAccountCredential = (props: ClaimProps): void => { } }; -const linkedCredentials = ( +const resolveTarget = ( props: ClaimProps, ): { - context: ReturnType; - linked: NonNullable>; + projectId: string; credentials: StoredClaimableCredentials; client: ClaimableClient; + contextMatches: boolean; } => { rejectExplicitAccountCredential(props); + const requested = props.projectId?.trim(); const context = readContextFile(props.contextFile); const linked = resolveClaimableContext(context); - if (linked === null) { + const projectId = requested || linked?.projectId; + if (projectId === undefined) { throw new Error( - "This directory is not linked to a claimable project. Run `neon claim create` first.", + "This directory is not linked to a claimable project. Pass a project id from `neon claim list`, or run `neon claim create` first.", ); } - const credentials = readClaimableCredentials( - props.configDir, - linked.projectId, - ); + const credentials = readClaimableCredentials(props.configDir, projectId); if (credentials === null) { throw new Error( - `The identity assertion for ${linked.projectId} is missing. The project cannot be managed from this machine; claim it through its existing verification URL or run \`neon link\` after it is claimed.`, + `The identity assertion for ${projectId} is missing. The project cannot be managed from this machine; claim it through its existing verification URL or run \`neon link\` after it is claimed.`, ); } const client = new ClaimableClient(credentials.origin); - if (client.origin !== new ClaimableClient(linked.origin).origin) { + const contextMatches = linked?.projectId === projectId; + if ( + contextMatches && + linked !== null && + client.origin !== new ClaimableClient(linked.origin).origin + ) { throw new Error( "The .neon context and saved identity assertion name different Claimable Neon services. Delete .neon or the assertion file and run `neon claim create` in a new directory.", ); } - return { context, linked, credentials, client }; + return { projectId, credentials, client, contextMatches }; +}; + +const requireLiveIdentity = (credentials: StoredClaimableCredentials): void => { + if (assertionHasExpired(credentials)) { + throw new Error( + `The identity assertion for ${credentials.projectId} has expired. Run \`neon claim delete ${credentials.projectId} --yes\` to drop the local record.`, + ); + } +}; + +const isUnusableIdentity = (error: unknown): error is ClaimableServiceError => + error instanceof ClaimableServiceError && + (error.code === "invalid_grant" || + error.code === "project_expired" || + error.code === "not_found"); + +const clearLocalRecord = ( + props: ClaimProps, + projectId: string, + contextMatches: boolean, +): void => { + removeClaimableCredentials(props.configDir, projectId); + if (contextMatches && existsSync(props.contextFile)) { + applyContext(props.contextFile, {}); + } }; const create = async (props: CreateProps): Promise => { @@ -360,6 +409,7 @@ const create = async (props: CreateProps): Promise => { branchId: registration.project.branchId, identityAssertion: registration.identityAssertion, expiresAt: registration.project.expiresAt, + assertionExpires: registration.assertionExpires, }; let localStateWritten = false; let contextWritten = false; @@ -500,7 +550,27 @@ const create = async (props: CreateProps): Promise => { }; const status = async (props: ClaimProps): Promise => { - const { linked, credentials, client } = linkedCredentials(props); + const { projectId, credentials, client, contextMatches } = + resolveTarget(props); + if (assertionHasExpired(credentials)) { + writer(props).end( + { + project_id: projectId, + state: "expired", + reconciled: false, + project_expires_at: credentials.expiresAt, + }, + { + fields: [ + "project_id", + "state", + "reconciled", + "project_expires_at", + ], + }, + ); + return; + } try { const token = await client.exchange(credentials.identityAssertion); let claimState = "unclaimed"; @@ -508,7 +578,7 @@ const status = async (props: ClaimProps): Promise => { let claimExpiresAt: string | undefined; try { const claim = await client.claimStatus( - linked.projectId, + projectId, token.accessToken, ); claimState = claim.state; @@ -523,11 +593,11 @@ const status = async (props: ClaimProps): Promise => { } } if (reconciled) { - finishClaimedContext(props); + finishClaimedContext(props, projectId, contextMatches); } writer(props).end( { - project_id: linked.projectId, + project_id: projectId, state: claimState, reconciled, project_expires_at: credentials.expiresAt, @@ -548,10 +618,10 @@ const status = async (props: ClaimProps): Promise => { error instanceof ClaimableServiceError && error.code === "project_claimed" ) { - finishClaimedContext(props); + finishClaimedContext(props, projectId, contextMatches); writer(props).end( { - project_id: linked.projectId, + project_id: projectId, state: "claimed", reconciled: true, }, @@ -559,18 +629,38 @@ const status = async (props: ClaimProps): Promise => { ); return; } + if (isUnusableIdentity(error)) { + writer(props).end( + { + project_id: projectId, + state: "expired", + reconciled: false, + project_expires_at: credentials.expiresAt, + }, + { + fields: [ + "project_id", + "state", + "reconciled", + "project_expires_at", + ], + }, + ); + return; + } throw error; } }; const accept = async (props: AcceptProps): Promise => { - const { linked, credentials, client } = linkedCredentials(props); + const { projectId, credentials, client } = resolveTarget(props); + requireLiveIdentity(credentials); const token = await client.exchange(credentials.identityAssertion); - const claim = await client.createClaim(linked.projectId, token.accessToken); + const claim = await client.createClaim(projectId, token.accessToken); writer(props).end( { - project_id: linked.projectId, + project_id: projectId, user_code: claim.userCode, verification_url: claim.verificationUriComplete, expires_in_seconds: claim.expiresIn, @@ -615,7 +705,8 @@ const list = (props: ClaimProps): void => { }; const deleteProject = async (props: DeleteProps): Promise => { - const { linked, credentials, client } = linkedCredentials(props); + const { projectId, credentials, client, contextMatches } = + resolveTarget(props); if (!props.yes) { if (isCi() || !process.stdin.isTTY) { throw new Error( @@ -625,7 +716,7 @@ const deleteProject = async (props: DeleteProps): Promise => { const { proceed } = await prompts({ type: "confirm", name: "proceed", - message: `Permanently delete ${linked.projectId}?`, + message: `Permanently delete ${projectId}?`, initial: false, }); if (!proceed) { @@ -633,26 +724,50 @@ const deleteProject = async (props: DeleteProps): Promise => { return; } } - const token = await client.exchange(credentials.identityAssertion); - await client.deleteProject(linked.projectId, token.accessToken); - removeClaimableCredentials(props.configDir, linked.projectId); - if (existsSync(props.contextFile)) { - applyContext(props.contextFile, {}); + if (assertionHasExpired(credentials)) { + clearLocalRecord(props, projectId, contextMatches); + writer(props).end( + { project_id: projectId, state: "cleared" }, + { fields: ["project_id", "state"] }, + ); + return; + } + try { + const token = await client.exchange(credentials.identityAssertion); + await client.deleteProject(projectId, token.accessToken); + } catch (error) { + if (!isUnusableIdentity(error)) { + throw error; + } + clearLocalRecord(props, projectId, contextMatches); + writer(props).end( + { project_id: projectId, state: "cleared" }, + { fields: ["project_id", "state"] }, + ); + return; } + clearLocalRecord(props, projectId, contextMatches); writer(props).end( - { project_id: linked.projectId, state: "deleted" }, + { project_id: projectId, state: "deleted" }, { fields: ["project_id", "state"] }, ); }; -const finishClaimedContext = (props: ClaimProps): void => { - const context = readContextFile(props.contextFile); - if (!context.projectId) return; - removeClaimableCredentials(props.configDir, context.projectId); - applyContext(props.contextFile, { - projectId: context.projectId, - ...(contextBranch(context) ? { branch: contextBranch(context) } : {}), - }); +const finishClaimedContext = ( + props: ClaimProps, + projectId: string, + contextMatches: boolean, +): void => { + removeClaimableCredentials(props.configDir, projectId); + if (contextMatches && existsSync(props.contextFile)) { + const context = readContextFile(props.contextFile); + applyContext(props.contextFile, { + projectId: context.projectId, + ...(contextBranch(context) + ? { branch: contextBranch(context) } + : {}), + }); + } log.info( "Dropped the local identity assertion. The next command needs `neon auth` or `neon link`.", ); From b188b70e138d0ab6b3cd3d1b9af88f4e334fa36e Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 12:01:45 -0700 Subject: [PATCH 16/19] Create the claim e2e workspace before spawning the CLI. --- packages/cli/e2e/claim.e2e.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/e2e/claim.e2e.test.ts b/packages/cli/e2e/claim.e2e.test.ts index 5fa0200e..2297614f 100644 --- a/packages/cli/e2e/claim.e2e.test.ts +++ b/packages/cli/e2e/claim.e2e.test.ts @@ -33,11 +33,13 @@ const isolatedDirs = (): { const root = mkdtempSync(join(tmpdir(), "neon-claim-e2e-")); cleanups.push(() => rmSync(root, { recursive: true, force: true })); const configDir = join(root, "config"); + const cwd = join(root, "workspace"); mkdirSync(configDir); + mkdirSync(cwd); return { configDir, contextFile: join(root, ".neon"), - cwd: join(root, "workspace"), + cwd, }; }; @@ -83,7 +85,6 @@ describe.sequential("e2e — neon claim against live Claimable Neon", () => { "creates, uses through ensureAuth, reports status, and deletes by project id", async () => { const createdIn = isolatedDirs(); - mkdirSync(createdIn.cwd); let projectId: string | undefined; try { const created = await runAnonymousJson( From 1342219284447b7689fda3754d996a49365bc7d9 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 12:30:27 -0700 Subject: [PATCH 17/19] Show expired versus unclaimed on neon claim list. --- .changeset/calm-bears-claim.md | 2 +- packages/cli/README.md | 5 ++-- packages/cli/src/commands/claim.cli.test.ts | 30 ++++++++++++++++++++- packages/cli/src/commands/claim.ts | 6 +++-- packages/cli/src/list_tables.test.ts | 5 ++-- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/.changeset/calm-bears-claim.md b/.changeset/calm-bears-claim.md index 5d1d02cc..cd01765f 100644 --- a/.changeset/calm-bears-claim.md +++ b/.changeset/calm-bears-claim.md @@ -3,6 +3,6 @@ "@neon/config": patch --- -Add `neon claim` and its `claimable` alias for creating, using, claiming, listing, and deleting temporary Claimable Neon projects without an account. `status`, `accept`, and `delete` take an optional project id from `claim list`, and `delete` drops a local record after the identity assertion expires. +Add `neon claim` and its `claimable` alias for creating, using, claiming, listing, and deleting temporary Claimable Neon projects without an account. `status`, `accept`, and `delete` take an optional project id from `claim list`. `list` prints `state` so an expired assertion is visible without running `status`, and `delete` drops a local record after the identity assertion expires. Recognize Claimable Neon capability errors in Config-as-Code so unavailable pre-claim services keep their actionable claim guidance instead of being reported as API-key failures. diff --git a/packages/cli/README.md b/packages/cli/README.md index 1677ee92..7e2067e6 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -102,12 +102,13 @@ neon env pull --service postgres --service auth --service data-api neon claim accept # create a claim code and open the transfer URL neon claim delete --yes # permanently delete an unclaimed project -neon claim list # projects whose assertions are saved locally +neon claim list # local records, including expired neon claim delete --yes ``` `status`, `accept`, and `delete` take an optional project id from `claim list`, so a -project stays manageable after its original directory is gone. `delete` also drops a +project stays manageable after its original directory is gone. `list` prints `state` +(`unclaimed` or `expired`) and `project_expires_at`. `delete` also drops a local record whose identity assertion has expired or been revoked. `neon claimable` is an alias for `neon claim`. For local service development, set diff --git a/packages/cli/src/commands/claim.cli.test.ts b/packages/cli/src/commands/claim.cli.test.ts index c448192b..dfe50261 100644 --- a/packages/cli/src/commands/claim.cli.test.ts +++ b/packages/cli/src/commands/claim.cli.test.ts @@ -257,12 +257,40 @@ describe("claim list table output", () => { expect(stdout).not.toMatch(BOX); expect(stdout).toContain("Project Id"); expect(stdout).toContain("Branch Id"); - expect(stdout).toContain("Expires At"); + expect(stdout).toContain("State"); + expect(stdout).toContain("Project Expires At"); expect(stdout).toContain("Origin"); + expect(stdout).toContain("unclaimed"); expect(stdout).toContain(projectId); expect(stdout).toContain(branchId); expect(stdout).toContain(expiresAt); expect(stdout).toContain(origin); expect(stdout.trimEnd().split("\n")).toHaveLength(2); }); + + test("marks a locally expired assertion as expired", async () => { + const projectId = "wandering-haze-25754674"; + const { code, stdout, stderr } = await runCli( + ["claim", "list"], + {}, + ({ configDir }) => { + writeClaimableCredentials(configDir, { + version: 1, + origin: "https://claimable.neon.tech", + registrationId: "reg_test", + projectId, + branchId: "br-main-branch-123456", + identityAssertion: "assertion", + expiresAt: "2026-08-24T12:00:00.000Z", + assertionExpires: 1_700_000_000, + }); + }, + ); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(stdout).toContain("expired"); + expect(stdout).not.toContain("unclaimed"); + expect(stdout).toContain(projectId); + }); }); diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 82f46303..97cd49a7 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -152,7 +152,8 @@ const failureMessage = (error: unknown): string => export const CLAIM_LIST_FIELDS = [ "project_id", "branch_id", - "expires_at", + "state", + "project_expires_at", "origin", ] as const; @@ -695,7 +696,8 @@ const list = (props: ClaimProps): void => { const projects = listClaimableCredentials(props.configDir).map((item) => ({ project_id: item.projectId, branch_id: item.branchId, - expires_at: item.expiresAt, + state: assertionHasExpired(item) ? "expired" : "unclaimed", + project_expires_at: item.expiresAt, origin: item.origin, })); writer(props).end(projects, { diff --git a/packages/cli/src/list_tables.test.ts b/packages/cli/src/list_tables.test.ts index cc58cc33..ecd369f4 100644 --- a/packages/cli/src/list_tables.test.ts +++ b/packages/cli/src/list_tables.test.ts @@ -146,7 +146,8 @@ describe("list field order", () => { { project_id: PROJECT_ID, branch_id: BRANCH_ID, - expires_at: TIMESTAMP, + state: "unclaimed", + project_expires_at: TIMESTAMP, origin, }, ], @@ -159,7 +160,7 @@ describe("list field order", () => { for (const field of CLAIM_LIST_FIELDS) { expect(header).toContain(titleCase(field)); } - expect(header.indexOf("Expires At")).toBeGreaterThan(-1); + expect(header.indexOf("Project Expires At")).toBeGreaterThan(-1); expect(stripAnsi(out)).toContain(PROJECT_ID); expect(stripAnsi(out)).toContain(origin); expect(stripAnsi(out).trimEnd().split("\n")).toHaveLength(2); From 66d8732c3c5fe07c4e6872ba6e6f409b90db1f26 Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 12:56:49 -0700 Subject: [PATCH 18/19] Print CLI service names and consult both expiry clocks on claim list. --- .changeset/calm-bears-claim.md | 2 +- packages/cli/README.md | 3 +- packages/cli/src/commands/claim.cli.test.ts | 28 +++++++++++- packages/cli/src/commands/claim.test.ts | 2 +- packages/cli/src/commands/claim.ts | 49 ++++++++++++++++----- 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/.changeset/calm-bears-claim.md b/.changeset/calm-bears-claim.md index cd01765f..bc0d87a7 100644 --- a/.changeset/calm-bears-claim.md +++ b/.changeset/calm-bears-claim.md @@ -3,6 +3,6 @@ "@neon/config": patch --- -Add `neon claim` and its `claimable` alias for creating, using, claiming, listing, and deleting temporary Claimable Neon projects without an account. `status`, `accept`, and `delete` take an optional project id from `claim list`. `list` prints `state` so an expired assertion is visible without running `status`, and `delete` drops a local record after the identity assertion expires. +Add `neon claim` and its `claimable` alias for creating, using, claiming, listing, and deleting temporary Claimable Neon projects without an account. `status`, `accept`, and `delete` take an optional project id from `claim list`. `list` prints `state` from the assertion clock and the project expiry, and `delete` drops a local record after the identity assertion expires. `create` prints CLI service names and `project_expires_at`. Recognize Claimable Neon capability errors in Config-as-Code so unavailable pre-claim services keep their actionable claim guidance instead of being reported as API-key failures. diff --git a/packages/cli/README.md b/packages/cli/README.md index 7e2067e6..2cbda7d0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -108,7 +108,8 @@ neon claim delete --yes `status`, `accept`, and `delete` take an optional project id from `claim list`, so a project stays manageable after its original directory is gone. `list` prints `state` -(`unclaimed` or `expired`) and `project_expires_at`. `delete` also drops a +(`unclaimed` or `expired`) from the identity assertion clock and the project +expiry, plus `project_expires_at`. `delete` also drops a local record whose identity assertion has expired or been revoked. `neon claimable` is an alias for `neon claim`. For local service development, set diff --git a/packages/cli/src/commands/claim.cli.test.ts b/packages/cli/src/commands/claim.cli.test.ts index dfe50261..0b6b53c1 100644 --- a/packages/cli/src/commands/claim.cli.test.ts +++ b/packages/cli/src/commands/claim.cli.test.ts @@ -235,7 +235,7 @@ describe("claim list table output", () => { const projectId = "wandering-haze-25754674"; const branchId = "br-main-branch-123456"; const origin = "https://claimable.neon.tech"; - const expiresAt = "2026-08-24T12:00:00.000Z"; + const expiresAt = "2027-08-24T12:00:00.000Z"; const { code, stdout, stderr } = await runCli( ["claim", "list"], {}, @@ -268,6 +268,32 @@ describe("claim list table output", () => { expect(stdout.trimEnd().split("\n")).toHaveLength(2); }); + test("marks a past project expiry as expired even when the assertion is live", async () => { + const projectId = "wandering-haze-25754674"; + const { code, stdout, stderr } = await runCli( + ["claim", "list"], + {}, + ({ configDir }) => { + writeClaimableCredentials(configDir, { + version: 1, + origin: "https://claimable.neon.tech", + registrationId: "reg_test", + projectId, + branchId: "br-main-branch-123456", + identityAssertion: "assertion", + expiresAt: "2026-08-24T12:00:00.000Z", + assertionExpires: 4_000_000_000, + }); + }, + ); + + expect(code).toBe(0); + expect(stderr).toBe(""); + expect(stdout).toContain("expired"); + expect(stdout).not.toContain("unclaimed"); + expect(stdout).toContain(projectId); + }); + test("marks a locally expired assertion as expired", async () => { const projectId = "wandering-haze-25754674"; const { code, stdout, stderr } = await runCli( diff --git a/packages/cli/src/commands/claim.test.ts b/packages/cli/src/commands/claim.test.ts index cb4c4a7c..e8fedf67 100644 --- a/packages/cli/src/commands/claim.test.ts +++ b/packages/cli/src/commands/claim.test.ts @@ -82,7 +82,7 @@ describe("claim create table fields", () => { project_id: "quiet-fog-12345678", branch_id: "br-quiet-fog-12345678", state: "unclaimed", - expires_at: "2026-08-24T12:00:00.000Z", + project_expires_at: "2026-08-24T12:00:00.000Z", granted_capabilities: ["postgres"], denied_capabilities: [], env_file: "/tmp/.env.local", diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 97cd49a7..0ec0e8d0 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -88,6 +88,25 @@ const CAPABILITY_ORDER: readonly ClaimableCapability[] = [ "ai_gateway", ]; +const SERVICE_FOR_CAPABILITY: Readonly< + Record +> = { + postgres: "postgres", + auth: "auth", + data_api: "data-api", + functions: "functions", + storage: "object-storage", + ai_gateway: "ai-gateway", +}; + +const isClaimableCapability = (value: string): value is ClaimableCapability => + Object.hasOwn(SERVICE_FOR_CAPABILITY, value); + +const cliServiceName = (capability: string): string => + isClaimableCapability(capability) + ? SERVICE_FOR_CAPABILITY[capability] + : capability; + export const claimableCapabilities = ( services: readonly NeonService[], ): ClaimableCapability[] => { @@ -161,7 +180,7 @@ export const CLAIM_CREATE_FIELDS = [ "project_id", "branch_id", "state", - "expires_at", + "project_expires_at", "granted_capabilities", "denied_capabilities", "env_file", @@ -464,11 +483,11 @@ const create = async (props: CreateProps): Promise => { const granted = registration.capabilities .filter((decision) => decision.granted) - .map((decision) => decision.capability); + .map((decision) => cliServiceName(decision.capability)); const denied = registration.capabilities .filter((decision) => !decision.granted) .map((decision) => ({ - capability: decision.capability, + capability: cliServiceName(decision.capability), reason: decision.reason, message: decision.message, })); @@ -477,7 +496,7 @@ const create = async (props: CreateProps): Promise => { project_id: registration.project.id, branch_id: registration.project.branchId, state: "unclaimed", - expires_at: registration.project.expiresAt, + project_expires_at: registration.project.expiresAt, granted_capabilities: granted, denied_capabilities: denied, ...(envFile ? { env_file: envFile } : {}), @@ -693,13 +712,21 @@ const accept = async (props: AcceptProps): Promise => { const list = (props: ClaimProps): void => { rejectExplicitAccountCredential(props); - const projects = listClaimableCredentials(props.configDir).map((item) => ({ - project_id: item.projectId, - branch_id: item.branchId, - state: assertionHasExpired(item) ? "expired" : "unclaimed", - project_expires_at: item.expiresAt, - origin: item.origin, - })); + const projects = listClaimableCredentials(props.configDir).map((item) => { + const projectMs = Date.parse(item.expiresAt); + const projectExpired = + Number.isFinite(projectMs) && projectMs <= Date.now(); + return { + project_id: item.projectId, + branch_id: item.branchId, + state: + assertionHasExpired(item) || projectExpired + ? "expired" + : "unclaimed", + project_expires_at: item.expiresAt, + origin: item.origin, + }; + }); writer(props).end(projects, { fields: CLAIM_LIST_FIELDS, emptyMessage: "No Claimable Neon projects are saved on this machine.", From a8cfffe76b241f4a1b827d9a4929ee57a4424e9f Mon Sep 17 00:00:00 2001 From: Andre Landgraf Date: Tue, 25 Aug 2026 15:06:18 -0700 Subject: [PATCH 19/19] Avoid Object.hasOwn so claim.ts typechecks on the CLI lib target. --- packages/cli/src/commands/claim.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/claim.ts b/packages/cli/src/commands/claim.ts index 0ec0e8d0..17f33581 100644 --- a/packages/cli/src/commands/claim.ts +++ b/packages/cli/src/commands/claim.ts @@ -100,7 +100,7 @@ const SERVICE_FOR_CAPABILITY: Readonly< }; const isClaimableCapability = (value: string): value is ClaimableCapability => - Object.hasOwn(SERVICE_FOR_CAPABILITY, value); + value in SERVICE_FOR_CAPABILITY; const cliServiceName = (capability: string): string => isClaimableCapability(capability)