Skip to content

Add neon claim for temporary projects without an account - #423

Merged
andrelandgraf merged 19 commits into
mainfrom
feat/claimable-neon
Aug 25, 2026
Merged

Add neon claim for temporary projects without an account#423
andrelandgraf merged 19 commits into
mainfrom
feat/claimable-neon

Conversation

@andrelandgraf

@andrelandgraf andrelandgraf commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

The problem

Every path into the Neon CLI starts with a Neon account. neon auth opens a browser, --api-key and --profile want a key you already minted. An agent that lands in an empty directory on a machine with no Neon credential has nothing to connect to, and the human is the one who has to go and sign up before any code can run against a database.

Claimable Neon (https://claimable.neon.tech) hands out temporary projects to an anonymous caller and lets a human take ownership later. The CLI had no way to talk to it, so an agent that wanted a database still had to stop and ask.

What this adds

neon claim, with neon claimable as an alias, and five subcommands: create, status, accept, list, delete.

claim create registers an anonymous identity with Claimable Neon, gets a project back, saves a signed identity assertion on this machine and links the directory. From there the rest of the CLI works against that project: when a directory carries the claimable marker and no account credential is in play, the CLI exchanges the assertion for a short-lived bearer token and points the API host at the Claimable Neon origin.

The identity assertion is the only secret that lands on disk. Access tokens are exchanged per command and expire in minutes.

Creating a project

# Lakebase Postgres is always requested
neon claim create

# Ask for more, on top of whatever the local neon.ts declares
neon claim create --service auth --service data-api

Output, from the built CLI in this worktree against a local stub of the Claimable Neon endpoints:

Project Id            wandering-haze-25754674
Branch Id             br-quiet-sky-12345678
State                 unclaimed
Project Expires At    2026-08-26T19:58:00.000Z
Granted Capabilities  postgres, data-api
Denied Capabilities   object-storage: Object Storage is available after the project is claimed., ai-gateway: The AI Gateway is available after the project is claimed.
Env File              /private/tmp/claimcreate.iyi5/ws/.env.local

The capability names printed here are the CLI's own service names (data-api, object-storage, ai-gateway), the same vocabulary --service, status and list use, so nobody has to learn the service's data_api / storage / ai_gateway spelling. Denied Capabilities is dropped from the table when nothing is denied.

Services that a neon.ts in scope declares are added to the request automatically. Services the platform cannot grant before a claim are still sent, so demand is recorded, and they come back denied with a reason rather than being silently dropped from the request.

Three things land on disk:

.neon           projectId, branch and a { "claimable": { "version": 1, "origin": ... } } marker
.env.local      DATABASE_URL plus any granted Auth or Data API variables (--no-env-pull skips this)
<config-dir>/claimable-credential.<project-id>.json    mode 0600

The dotenv target follows the existing rule: an existing .env if there is one, otherwise .env.local, and --file overrides. The file is added to .gitignore the same way neon env pull does it.

.env.local from the run above:

DATABASE_URL="postgresql://neondb_owner:demo@ep-quiet-sky-12345678-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require"
NEON_DATA_API_URL=https://app-quiet-sky-12345678.dpl.myneon.app

create refuses to run in a directory that is already linked:

ERROR: /tmp/claimdel.1mB2/ws/.neon already links this directory to a Neon project. Run `neon claim create` from an unlinked directory.

Using, claiming and cleaning up

neon claim status                 # lifecycle and transfer state
neon projects get <project-id>    # ordinary CLI command, same short-lived token
neon claim accept                 # claim code plus the URL where a human signs in
neon claim list                   # every record saved on this machine
neon claim delete --yes           # permanently delete an unclaimed project

status, accept and delete take an optional project id, so a project stays manageable after the directory it was created in is gone:

neon claim delete wandering-haze-25754674 --yes

Real output for each, same stub service:

$ neon claim status
Project Id          wandering-haze-25754674
State               pending
Reconciled          false
Project Expires At  2026-08-26T19:58:00.000Z
Claim Expires At    2026-08-25T20:13:00.000Z

$ neon claim accept --no-open
Project Id          wandering-haze-25754674
User Code           QJTM-4XFD
Verification Url    https://claimable.neon.tech/claim?code=QJTM-4XFD
Expires In Seconds  900

$ neon claim delete --yes
Project Id  wandering-haze-25754674
State       deleted

accept opens the verification URL in a browser by default. --no-open prints it instead, and CI never opens a browser.

list is a full-width writer table like neon projects list, with no box drawing:

$ neon claim list
Project Id               Branch Id              State      Project Expires At        Origin
wandering-haze-25754674  br-quiet-sky-12345678  unclaimed  2027-08-24T12:00:00.000Z  https://claimable.neon.tech

state is expired when either clock has run out, the identity assertion's or the project's. Both are read locally, so list never has to reach the service:

$ neon claim list
Project Id               Branch Id              State    Project Expires At        Origin
wandering-haze-25754674  br-quiet-sky-12345678  expired  2026-08-24T12:00:00.000Z  https://claimable.neon.tech

An empty list prints a sentence: No Claimable Neon projects are saved on this machine.

delete has two outcomes. deleted means the project was removed from the service. cleared means the assertion had expired or the service no longer accepts it, so only the local record and the .neon link were dropped. Either way the directory is unlinked afterwards.

Once a human completes the claim, status sees reconciled, deletes the local assertion and leaves the .neon project link in place, with a note that the next command needs neon auth or neon link. That is a one-way step on this machine: the anonymous identity is gone and the project now belongs to an account.

What decides which credential is used

An account credential always wins. The claimable assertion is used only when --api-key, --profile, NEON_API_KEY and NEON_PROFILE are all absent. When one of them is set in a claimable directory, the command runs against the account and warns:

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.

The claim subcommands themselves reject --api-key and --profile before any network call, because there is no account credential involved in registering or claiming. Ambient NEON_API_KEY and NEON_PROFILE are tolerated there, so an agent on a machine that already has a working Neon login is never told to unset it.

Nothing changes for anyone who is not in a claimable directory. ensureAuth reaches the new branch only after the existing checks, and only when the .neon file carries the marker.

Error handling and rollback

Claimable Neon returns errors as { "error": { "code", "message", "request_id" } }, one level deeper than the Neon API's flat body. Three places learned to read that shape:

  • codeFromBody and messageFromBody in the CLI, so a proxied error keeps its code.
  • wrapNeonError in @neon/config, which also now recognizes a NeonApiError directly rather than only an axios-shaped response.data.
  • capability_requires_claim maps to FeatureUnavailable with the claim path spelled out, instead of the generic 403 that tells you to check your API key.

Every response from the service is validated at the boundary and projected to known fields. A malformed body raises Claimable Neon returned an invalid response while <action>. The response was not used. rather than being half-consumed. An origin has to be a scheme and a host with nothing else on it, and HTTPS unless the host is localhost.

create is transactional. If anything after registration fails, it deletes the project it just created and restores .neon and the dotenv file to what they were. If that remote delete also fails, it says so and names the retry: neon claim delete --yes.

Also in here

  • declaredNeonServices is pulled out of commands/config.ts into config_services.ts, so claim create and config plan agree on which services a neon.ts declares. config plan output is unchanged; the toggle logic moved as-is.
  • claim is exempt from .neon context enrichment, so claim list enumerates the machine rather than the current directory.
  • README gets a section on creating a project without an account, and a row in the command table.
  • list_tables.test.ts gains a case pinning claim list to the full-width columns.
  • Changeset: neon minor, @neon/config patch.

Verification

packages/cli was built with pnpm exec tsdown first, because the CLI-level tests and the output above spawn dist/cli.js. Then:

$ pnpm exec vitest run src/claimable src/commands/claim.test.ts src/commands/claim.cli.test.ts \
    src/config_services.test.ts src/list_tables.test.ts
Test Files  6 passed (6)
     Tests  70 passed (70)

$ cd packages/config && pnpm exec vitest run src/lib/wrap-neon-error.test.ts
Test Files  1 passed (1)
     Tests  17 passed (17)

Behaviours covered:

  • Postgres is always requested, and every --service value maps to the service's capability name.
  • Capabilities that need a claim are still sent, and come back reported.
  • neon.ts discovery walks up to the repository root and stops there.
  • The assertion file is written 0600, permissive modes are repaired on rewrite, and a project id that could escape the config directory is rejected.
  • A stored assertion expiry in the past reads as expired; a missing one reads as live.
  • A malformed or future-versioned claimable marker throws instead of falling back to account auth.
  • Every explicit and ambient account credential turns the claimable path off.
  • claim status, claim delete and claim accept handle an expired assertion without contacting the service, spawning the real built binary against an origin with nothing listening on it.
  • claim create fails before the network when --api-key or --profile is passed, and does not warn when only ambient credentials are set.
  • claim list prints every column at full width, marks a past project expiry and a past assertion expiry as expired and prints a message when empty.
  • The client calls /v1/projects/{id}/credentials, /v1/projects/{id}/claim and DELETE /v1/projects/{id}, asserted against a real HTTP server.
  • wrapNeonError maps the nested capability error from three shapes: an axios-like body, a NeonApiError and a NeonApiError stuffed into response.data.

The command output shown above was produced by running packages/cli/dist/cli.js in this worktree against a local HTTP stub of the Claimable Neon endpoints. The flow, formatting and files written are the CLI's; the project id, URLs and timestamps come from the stub.

Not run: packages/cli/e2e/claim.e2e.test.ts against the live service. The e2e suite's setup file calls the harness orphan sweep, which needs NEON_API_KEY. That test covers the part no local run can: create against the real service, neon projects get through the exchanged token, claim status and delete by project id from a directory that never had the .neon. It needs one green run before merge.

For your attention

  • The live contract is unverified here. Response parsing is pinned to the shapes in claimable/api.ts, and every one of them is a guess about the service until the e2e run happens. A field rename on the service side surfaces as Claimable Neon returned an invalid response, which is the right failure but still a failure.
  • --claimable-host is hidden, and CLAIMABLE_NEON_HOST overrides the default origin. Both exist for pointing at a local service during development. Non-localhost origins are forced to HTTPS.
  • Deleting .neon by hand strands the assertion file. claim list still shows the project and claim delete <project-id> --yes still removes it, so the recovery path exists, but nothing sweeps the config directory on its own.
  • status dropping the assertion after a claim is irreversible on that machine. If the human claims the project and the agent still needs it, the way back is neon auth or neon link against the now-owned project.
  • create writes the dotenv file before the table prints. On a rollback the previous contents are restored byte for byte, and the file is removed if it did not exist, but an editor holding the old buffer will not know.
  • Denied capabilities are informational. The CLI reports what the service refused and carries on with a project that has fewer services than asked for, rather than failing the create. An agent that needs Object Storage has to read the output.

@andrelandgraf andrelandgraf changed the title Add Claimable Neon commands and agent-token auth Add neon claim for temporary projects without an account Aug 17, 2026
@andrelandgraf
andrelandgraf force-pushed the feat/claimable-neon branch 2 times, most recently from 72755ec to 20aead7 Compare August 25, 2026 14:08
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.
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.
Accept plus auth or link leaves .neon.claimable in place, so the next
command still uses the assertion instead of the new account credential.
An empty array still counts as present, so the headline command printed
a blank label row.
@andrelandgraf
andrelandgraf merged commit 512baf3 into main Aug 25, 2026
18 checks passed
@andrelandgraf
andrelandgraf deleted the feat/claimable-neon branch August 25, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant