From ea6dc721cf5e04e761bd4d2e90aa57724e797c3a Mon Sep 17 00:00:00 2001 From: Qwin B <309188780+DevQwinB@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:05:57 +0100 Subject: [PATCH 1/2] fix(web): fail closed when CLI linking has no database to write to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wallet links are Wallet rows in Postgres. The read path degrades gracefully with no DATABASE_URL — safeDbProfile and safeDbOperations return null and the caller falls through to a chain read, then to the curated demo profiles — and that is right for reads. It is exactly wrong for writes: the same fall-through makes a link appear to succeed while persisting nothing. The developer believes they are linked, the CLI believes it, and the failure surfaces later from some unrelated command that needed the binding. Add the write-path counterpart next to those helpers in lib/profiles.ts: isDatabaseConfigured, a typed DatabaseRequiredError carrying isConfigurationError, and requireDatabase. Only configuration is checked, not reachability — an unreachable database is a different failure with a different fix, and calling it "not configured" sends an operator to the wrong runbook. POST /api/cli/pair/complete checks the precondition first, before the body and before any signature: if the result cannot be stored, nothing else about the request matters. It answers 503 rather than a 4xx because nothing the caller sent is wrong and nothing they can do to their own account changes it, and carries isConfigurationError so a client can classify it without string-matching prose. Pairing verification itself is #268's; this route returns 501 once the precondition passes rather than pretending to complete a pairing it has not verified. /link reads the same signal server-side, so the page and the API cannot disagree, and disables approval with an explanation before the developer signs something that cannot be stored. docs/CLI.md documents the dependency, what each surface reports, and how an operator fixes it, cross-referenced from ENVIRONMENT.md and #191. --- apps/web/app/api/cli/pair/complete/route.ts | 84 +++++++++++++++++++++ apps/web/app/link/page.tsx | 69 +++++++++++++++++ apps/web/lib/cli-pair-complete.test.ts | 57 ++++++++++++++ apps/web/lib/database-required.test.ts | 59 +++++++++++++++ apps/web/lib/profiles.ts | 59 +++++++++++++++ docs/CLI.md | 65 ++++++++++++++++ docs/ENVIRONMENT.md | 2 +- 7 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/api/cli/pair/complete/route.ts create mode 100644 apps/web/app/link/page.tsx create mode 100644 apps/web/lib/cli-pair-complete.test.ts create mode 100644 apps/web/lib/database-required.test.ts create mode 100644 docs/CLI.md diff --git a/apps/web/app/api/cli/pair/complete/route.ts b/apps/web/app/api/cli/pair/complete/route.ts new file mode 100644 index 0000000..d2c2bc7 --- /dev/null +++ b/apps/web/app/api/cli/pair/complete/route.ts @@ -0,0 +1,84 @@ +import { + DATABASE_REQUIRED_CODE, + DatabaseRequiredError, + requireDatabase, +} from '../../../../../lib/profiles.ts'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +/** + * `POST /api/cli/pair/complete` — the point at which a CLI pairing becomes a + * `Wallet` row. + * + * **This route currently implements the database precondition and nothing + * else.** The pairing verification itself — the challenge, the signature, the + * single-use code — is #268's, and this deliberately does not guess at it: + * the handler returns `501` once the precondition passes, rather than + * pretending to complete a pairing it has not verified. + * + * The precondition is checked **first**, before anything else, and that + * ordering is the point of #277. A link is a row in Postgres. With no + * `DATABASE_URL` there is nowhere to put it, and the read path's graceful + * fall-through (`safeDbProfile`, `safeDbOperations` in `lib/profiles.ts`) + * would make the link appear to succeed while persisting nothing. Failing + * closed here means the developer is told the truth at the moment they act, + * instead of discovering it later from an unrelated command. + * + * See `docs/CLI.md` and #191 (database provisioning). + */ +export async function POST(): Promise { + try { + // Before parsing the body, before touching a signature: if the result + // cannot be stored, nothing else about this request matters. + requireDatabase('CLI wallet linking'); + } catch (err) { + if (err instanceof DatabaseRequiredError) { + return Response.json( + { + error: err.code, + message: err.message, + // The flag a client branches on. Without it the CLI has to + // string-match a message to know this is not the user's fault. + isConfigurationError: true, + docs: 'https://github.com/blockchain-maxis/signet/blob/main/docs/CLI.md#linking-requires-a-database', + }, + { + // 503, not 400 or 500: the service is correctly configured to refuse + // rather than broken, and the condition is not the caller's doing. + status: 503, + // Nothing here changes until an operator provisions a database. + headers: { 'cache-control': 'no-store' }, + }, + ); + } + throw err; + } + + return Response.json( + { + error: 'not_implemented', + message: + 'Pairing verification is not implemented yet (see issue #268). The database ' + + 'precondition for linking is enforced above.', + }, + { status: 501 }, + ); +} + +/** + * `GET /api/cli/pair/complete` — whether linking can succeed at all right now. + * + * Exists so the `/link` page can warn **before** the developer approves, + * rather than after they have signed something that cannot be stored. + */ +export function GET(): Response { + const configured = process.env.DATABASE_URL ? true : false; + return Response.json( + { + available: configured, + ...(configured ? {} : { reason: DATABASE_REQUIRED_CODE }), + }, + { headers: { 'cache-control': 'no-store' } }, + ); +} diff --git a/apps/web/app/link/page.tsx b/apps/web/app/link/page.tsx new file mode 100644 index 0000000..ee56fea --- /dev/null +++ b/apps/web/app/link/page.tsx @@ -0,0 +1,69 @@ +import { isDatabaseConfigured } from '../../lib/profiles.ts'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { + title: 'Link the CLI · Signet', + description: 'Approve a terminal pairing request for your Signet account.', +}; + +/** + * `/link` — the approval page a `signet link` pairing sends the developer to. + * + * The approval UI itself belongs to the pairing work (#258/#268). What this + * page owns today is the precondition: **if linking cannot be persisted, say + * so before the developer approves.** + * + * Checked server-side, on the same signal the API route enforces, so the two + * cannot disagree. Approving a link that will be refused seconds later is the + * worst version of this: the developer has signed something, believes they are + * linked, and finds out otherwise from an unrelated command. + */ +export default function LinkPage() { + const linkingAvailable = isDatabaseConfigured(); + + return ( +
+

Link the Signet CLI

+ + {linkingAvailable ? ( +

+ Approve the pairing request shown in your terminal. The approval flow itself lands with + the CLI pairing work. +

+ ) : ( +
+

+ Linking is unavailable on this deployment +

+

+ Wallet links are stored in a database, and this deployment has no{' '} + DATABASE_URL configured. Approving now would appear to succeed and save + nothing, so approval is disabled. +

+

+ {/* Whose problem this is, stated plainly. Nothing the developer + does to their own account will change it. */} + This is a configuration problem with the Signet deployment, not with your account. + Whoever operates this deployment needs to provision a database — see{' '} + + issue #191 + {' '} + and{' '} + + docs/CLI.md + + . +

+
+ )} +
+ ); +} diff --git a/apps/web/lib/cli-pair-complete.test.ts b/apps/web/lib/cli-pair-complete.test.ts new file mode 100644 index 0000000..47aa70d --- /dev/null +++ b/apps/web/lib/cli-pair-complete.test.ts @@ -0,0 +1,57 @@ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { GET, POST } from '../app/api/cli/pair/complete/route.ts'; + +const original = process.env.DATABASE_URL; + +afterEach(() => { + if (original === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = original; +}); + +test('POST fails closed with 503 when no database is configured', async () => { + delete process.env.DATABASE_URL; + + const res = await POST(); + assert.equal(res.status, 503); + + const body = (await res.json()) as Record; + assert.equal(body.error, 'database_required'); + // 503 and this flag together are what let the CLI report a deployment + // problem rather than blaming the user's wallet or signature. + assert.equal(body.isConfigurationError, true); + assert.match(String(body.message), /requires a database/); + assert.match(String(body.docs), /docs\/CLI\.md/); + // Nothing changes until an operator acts, so nothing should be cached. + assert.equal(res.headers.get('cache-control'), 'no-store'); +}); + +test('the refusal is not a 4xx — nothing the caller sent is wrong', async () => { + delete process.env.DATABASE_URL; + const res = await POST(); + assert.ok(res.status >= 500, `expected a server-side status, got ${res.status}`); +}); + +test('POST gets past the precondition once a database is configured', async () => { + process.env.DATABASE_URL = 'postgres://localhost:5432/signet'; + + const res = await POST(); + // 501, not 200: pairing verification is #268's, and this route will not + // pretend to complete a pairing it has not verified. + assert.equal(res.status, 501); + const body = (await res.json()) as Record; + assert.equal(body.error, 'not_implemented'); +}); + +test('GET reports availability so /link can warn before approval', async () => { + delete process.env.DATABASE_URL; + let body = (await GET().json()) as Record; + assert.equal(body.available, false); + assert.equal(body.reason, 'database_required'); + + process.env.DATABASE_URL = 'postgres://localhost:5432/signet'; + body = (await GET().json()) as Record; + assert.equal(body.available, true); + assert.equal(body.reason, undefined); +}); diff --git a/apps/web/lib/database-required.test.ts b/apps/web/lib/database-required.test.ts new file mode 100644 index 0000000..28f8933 --- /dev/null +++ b/apps/web/lib/database-required.test.ts @@ -0,0 +1,59 @@ +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DATABASE_REQUIRED_CODE, + DatabaseRequiredError, + isDatabaseConfigured, + requireDatabase, + safeDbProfile, +} from './profiles.ts'; + +const original = process.env.DATABASE_URL; + +afterEach(() => { + if (original === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = original; +}); + +test('isDatabaseConfigured tracks DATABASE_URL', () => { + delete process.env.DATABASE_URL; + assert.equal(isDatabaseConfigured(), false); + + process.env.DATABASE_URL = 'postgres://localhost:5432/signet'; + assert.equal(isDatabaseConfigured(), true); +}); + +test('requireDatabase throws a typed, classifiable error when unconfigured', () => { + delete process.env.DATABASE_URL; + + assert.throws( + () => requireDatabase('CLI wallet linking'), + (err: unknown) => { + assert.ok(err instanceof DatabaseRequiredError); + assert.equal(err.code, DATABASE_REQUIRED_CODE); + // The flag is what lets a client say "deployment problem" without + // string-matching prose. + assert.equal(err.isConfigurationError, true); + assert.match(err.message, /requires a database/); + // Points at the fix and at #191, so the message is actionable by + // whoever can actually act on it. + assert.match(err.message, /DATABASE_URL/); + assert.match(err.message, /#191/); + return true; + }, + ); +}); + +test('requireDatabase is a no-op once a database is configured', () => { + process.env.DATABASE_URL = 'postgres://localhost:5432/signet'; + assert.doesNotThrow(() => requireDatabase('CLI wallet linking')); +}); + +test('the read path still degrades gracefully — this changes writes only', async () => { + delete process.env.DATABASE_URL; + // The whole point of #277 is that the two paths differ: reads fall through, + // writes fail closed. A regression that made reads throw would break every + // preview deployment. + assert.equal(await safeDbProfile('aquawolf'), null); +}); diff --git a/apps/web/lib/profiles.ts b/apps/web/lib/profiles.ts index 66f4017..a9f6498 100644 --- a/apps/web/lib/profiles.ts +++ b/apps/web/lib/profiles.ts @@ -242,6 +242,65 @@ export async function safeDbProfile(handle: string): Promise { } } +// --- Write path ----------------------------------------------------------- +// +// Everything above degrades: no `DATABASE_URL` means `safeDbProfile` / +// `safeDbOperations` return null and the caller falls through to the chain or +// the curated demo data. That is right for reads — a preview deployment with +// nothing provisioned still renders. +// +// It is exactly wrong for writes. A wallet link is a `Wallet` row; with no +// database there is nowhere to put it, and the same fall-through would make +// the link *appear* to succeed while persisting nothing. A link that silently +// persists nothing is worse than a refusal: the developer believes they are +// linked, the CLI believes it, and the next command that needs the binding +// fails somewhere far away from the cause. +// +// So the write path fails closed, loudly, and says whose problem it is. + +/** Machine-readable code for a write refused because no database is provisioned. */ +export const DATABASE_REQUIRED_CODE = 'database_required'; + +/** + * Whether a database is configured for this deployment. + * + * Only checks configuration, not reachability: an unreachable database is a + * different failure with a different fix, and reporting it as "not configured" + * would send an operator to the wrong runbook. + */ +export function isDatabaseConfigured(): boolean { + return Boolean(process.env.DATABASE_URL); +} + +/** + * Thrown by write paths that cannot proceed without a database. + * + * Carries `isConfigurationError` so a client can classify it without parsing + * prose. Nothing the user did caused this, and nothing they can do fixes it — + * telling them their wallet or signature was bad would send them to debug the + * one thing that is working. + */ +export class DatabaseRequiredError extends Error { + readonly code = DATABASE_REQUIRED_CODE; + + readonly isConfigurationError = true; + + constructor(operation: string) { + super( + `${operation} requires a database. This Signet deployment has no DATABASE_URL ` + + `configured, so there is nowhere to persist the result. This is a deployment ` + + `configuration problem, not a problem with your account — see docs/CLI.md ` + + `and issue #191.`, + ); + this.name = 'DatabaseRequiredError'; + } +} + +/** Throw unless a database is configured. Call before any write. */ +export function requireDatabase(operation: string): void { + if (!isDatabaseConfigured()) throw new DatabaseRequiredError(operation); +} + /** * A Stellar `Address`: either a `G…` account or a `C…` contract StrKey. The * registry binds a handle to an `Address`, so a contract-controlled identity diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..b534e2f --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,65 @@ +# Signet CLI + +`signet link` pairs a terminal with a Signet account: the CLI opens an approval +page, you approve in the browser, and the pairing is recorded against your +account. + +> **Status.** The CLI itself is in progress (#251 and the issues that follow). +> What is documented and enforced today is the one hard infrastructure +> dependency the feature has — see below. + +--- + +## Linking requires a database + +**A Signet deployment with no `DATABASE_URL` configured cannot link a wallet.** +Linking is refused up front rather than failing later. + +### Why + +A wallet link is a `Wallet` row in Postgres. There is no other place it can go. + +Everything on the _read_ path degrades gracefully without a database: +`safeDbProfile` and `safeDbOperations` in +[`apps/web/lib/profiles.ts`](../apps/web/lib/profiles.ts) return `null` and the +caller falls through to a live chain read, then to the curated demo profiles. +A preview deployment with nothing provisioned still renders `/p/{handle}`. + +The write path cannot do that. If linking fell through the same way, the link +would _appear_ to succeed and persist nothing: the CLI would print success, the +developer would believe they were linked, and the failure would surface later +from some unrelated command that needed the binding. **A link that silently +persists nothing is worse than a refusal.** + +See [#191](https://github.com/blockchain-maxis/signet/issues/191) for database +provisioning, and [`ENVIRONMENT.md`](ENVIRONMENT.md) for `DATABASE_URL` itself. + +### What you see + +| Where | With no `DATABASE_URL` | +| ----------------------------- | ---------------------------------------------------------------------------------------- | +| `POST /api/cli/pair/complete` | `503` with `{"error":"database_required","isConfigurationError":true,…}` | +| `GET /api/cli/pair/complete` | `{"available":false,"reason":"database_required"}` | +| `/link` | Approval is disabled, with an explanation, **before** you approve | +| CLI | A deployment configuration error naming `DATABASE_URL` — not a wallet or signature error | + +The status is **`503`, not `4xx`**: nothing about the request was wrong, and +nothing the developer does to their own account will change the outcome. The +`isConfigurationError` flag is there so a client can classify it without +string-matching a message. + +`/link` checks the same signal server-side and refuses _before_ approval, +so nobody signs an approval that cannot be stored. + +### Fixing it + +This is for whoever operates the deployment, not for the developer trying to +link: + +1. Provision Postgres and set `DATABASE_URL` for `apps/web`. +2. Apply migrations — `pnpm db:deploy` (or `pnpm db:migrate` locally). +3. Confirm with `GET /api/health`: `checks.db` should no longer report + `"skipped"`. + +`GET /api/cli/pair/complete` returning `{"available":true}` means linking can +proceed. diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 55b7643..a4e8755 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -18,7 +18,7 @@ Required means “must be set for that surface to do its job in production.” O | Variable | Consumed by | Required / optional | Default | Behaviour when unset | | --- | --- | --- | --- | --- | -| `DATABASE_URL` | web, indexer | **Required** for indexer. **Optional** for web. | _(none)_ | **Web:** profile/activity loaders skip Postgres and use the static manifest (and chain resolve when configured) — demo `/p/*` keeps working. `/api/health` reports `checks.db: "skipped"`. Dashboard account writes that need Prisma return empty. **Indexer:** process refuses to start (`DATABASE_URL is required`). | +| `DATABASE_URL` | web, indexer | **Required** for indexer. **Optional** for web. | _(none)_ | **Web:** profile/activity loaders skip Postgres and use the static manifest (and chain resolve when configured) — demo `/p/*` keeps working. `/api/health` reports `checks.db: "skipped"`. Dashboard account writes that need Prisma return empty. **CLI linking is refused outright** (`503 database_required`) rather than degrading, because a link is a `Wallet` row and a link that persists nothing is worse than a refusal — see [`CLI.md`](CLI.md#linking-requires-a-database) and [#191](https://github.com/blockchain-maxis/signet/issues/191). **Indexer:** process refuses to start (`DATABASE_URL is required`). | | `STELLAR_NETWORK` | _(declared for ops; not read by current TS)_ | Optional | `testnet` in `.env.example` | No runtime effect today. Prefer `NEXT_PUBLIC_STELLAR_NETWORK` (web) and `INDEXER_NETWORK` (indexer). Kept so deploy docs and local `.env` stay aligned. | | `STELLAR_HORIZON_URL` | _(declared for ops; not read by current TS)_ | Optional | `https://horizon-testnet.stellar.org` | No runtime effect today. Indexer reads `INDEXER_HORIZON_URL` instead (same default). | | `SOROBAN_RPC_URL` | web (server) | Optional | Falls through to `NEXT_PUBLIC_SOROBAN_RPC_URL`, then `https://soroban-testnet.stellar.org` | Server-side registry reads (`lib/chain.ts`, directory, profile chain resolve, and the `/api/health` registry check) use the public URL / testnet default. Client claim flow never sees this var (uses `NEXT_PUBLIC_SOROBAN_RPC_URL` only). | From 75e26b9bcefce3a7e1ebae3e518dc809129962e1 Mon Sep 17 00:00:00 2001 From: Qwin B <309188780+DevQwinB@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:46:50 +0100 Subject: [PATCH 2/2] docs(cli): install, link flow, CI usage, exit codes and troubleshooting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ covers deployment, environment, indexer, registry integration, demo data and troubleshooting. Terminal linking — the path that produces a real career record — was undocumented, and it spans a CLI, a browser, a keystore and a loopback port. Every one of those is a place a developer gets stuck. Documents install and npx usage, the link flow as five steps each stating what it proves, keystore identity selection across platforms, CI usage, self-hosted baseUrl, --json, the exit-code table, and a symptom → cause → fix table following TROUBLESHOOTING.md's existing shape. Two things are stated rather than glossed. The stellar CLI dependency is explained as the reason Signet never holds secret key material at all, rather than listed as a prerequisite. And the browser and wallet proofs are described as deliberately separate: the handle proof happens where the session lives, the wallet proof where the key lives, and neither component sees the other's secret. The CI section documents STELLAR_SIGN_WITH_KEY as the variable actually read, with SIGNET_DEPLOY_KEY shown as the secret name a pipeline stores it under. Issue 288's acceptance names SIGNET_DEPLOY_KEY directly; 254 specifies passing through to stellar tx sign, which already honours STELLAR_SIGN_WITH_KEY. Introducing a second name for the same secret would mean copying it between variables in every pipeline, so the doc reconciles the two rather than picking one silently. --- docs/CLI.md | 330 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 316 insertions(+), 14 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index b534e2f..98cc954 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,12 +1,266 @@ # Signet CLI -`signet link` pairs a terminal with a Signet account: the CLI opens an approval -page, you approve in the browser, and the pairing is recorded against your -account. +`signet link` binds the wallet you deploy contracts with to your Signet handle. +That binding is what turns on-chain deploys into a verifiable career record, so +the flow is built to prove the link rather than to assert it. -> **Status.** The CLI itself is in progress (#251 and the issues that follow). -> What is documented and enforced today is the one hard infrastructure -> dependency the feature has — see below. +> **Status.** The CLI is being built across [#251](https://github.com/blockchain-maxis/signet/issues/251) +> and the issues that follow it. This document describes the designed +> behaviour and is the contract those issues are implemented against — where a +> section describes something not yet on `main`, the issue that lands it is +> named. The one part enforced today is +> [Linking requires a database](#linking-requires-a-database). + +--- + +## Contents + +- [Install](#install) +- [Prerequisites](#prerequisites) +- [The link flow, and what each step proves](#the-link-flow-and-what-each-step-proves) +- [Choosing an identity](#choosing-an-identity) +- [Configuration and precedence](#configuration-and-precedence) +- [Using it from CI](#using-it-from-ci) +- [Self-hosted deployments](#self-hosted-deployments) +- [Machine-readable output](#machine-readable-output) +- [Exit codes](#exit-codes) +- [Linking requires a database](#linking-requires-a-database) +- [Troubleshooting](#troubleshooting) + +--- + +## Install + +No install step is required: + +```bash +npx @signet/cli link +``` + +The npm package is a thin wrapper that fetches the cross-compiled binary for +your platform ([#293](https://github.com/blockchain-maxis/signet/issues/293)). +For repeated use, install it once: + +```bash +npm install -g @signet/cli +signet link +``` + +`npx` is the documented default because linking is something most developers do +once per machine, and a one-shot command should not leave a global install +behind. + +--- + +## Prerequisites + +**The [`stellar` CLI](https://developers.stellar.org/docs/tools/cli/install-cli), +version 25.2.0 or newer, on your `PATH`.** + +This is not an incidental dependency. The Signet CLI **never handles your +secret key** — not in memory, not in `argv`, not in logs, not in a crash dump. +Identity listing, public-key resolution and signing are all delegated to +`stellar`: + +``` +stellar keys ls # which identities exist +stellar keys public-key # resolve the G… address +stellar tx sign --sign-with-key # sign; the secret never leaves stellar +``` + +Delegating means Signet takes on no key custody, and inherits `stellar`'s OS +secure-store support and `--sign-with-ledger` hardware signing for free +([#253](https://github.com/blockchain-maxis/signet/issues/253)). + +The version is checked before any work happens, so a missing or too-old +`stellar` produces a message naming the required version and the install page, +rather than a raw exec error pointing at the wrong tool +([#297](https://github.com/blockchain-maxis/signet/issues/297)). + +If you have no `stellar` identity, you have deployed no contracts and have +nothing to link yet. + +--- + +## The link flow, and what each step proves + +``` +$ signet link + + ✓ stellar 25.2.0 + ✓ identity: deploy-key (GCEX…7QK4) + + Opening https://signet.dev/link/HRTV-2K9P + + Waiting for approval… (expires in 5:00) + + ✓ linked @aquawolf ← GCEX…7QK4 +``` + +Five steps, each proving something the next one relies on: + +| Step | What happens | What it proves | +| ------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Preflight | `stellar --version` is checked; an identity is resolved | The signer exists and can be reached before anything user-visible starts | +| 2. Pair | The CLI asks the deployment to mint a single-use pairing code, and starts a loopback server on `127.0.0.1` | The pairing is bound to _this_ process; the code is useless to anyone who did not start it | +| 3. Approve | Your browser opens the approval page; you sign in as your handle and approve | **You control the handle.** Browser-side session, never the CLI's business | +| 4. Sign | The CLI signs a challenge with the deploy identity via `stellar tx sign` | **You control the wallet.** The signature is over a challenge scoped to this pairing ([#269](https://github.com/blockchain-maxis/signet/issues/269)) | +| 5. Complete | The browser posts back to the loopback server; the CLI completes the pairing | Both proofs arrived in one session, so the handle and the wallet are the same person | + +Steps 3 and 4 are separate on purpose. The handle proof happens in the browser +where the session lives; the wallet proof happens on the machine where the key +lives. Neither component ever sees the other's secret. + +**No browser?** Over SSH, in a container, or with `--no-browser`, the CLI +prints the URL for you to open manually. The auto-open failing is never fatal — +a command that hangs with no visible way to proceed is the worst first-run +experience on a remote box +([#257](https://github.com/blockchain-maxis/signet/issues/257)). + +--- + +## Choosing an identity + +The CLI lists identities with `stellar keys ls` and picks one by this rule: + +1. `--source ` if given. +2. The last identity you linked with, from the config file. +3. If exactly one identity exists, that one. +4. Otherwise, prompt. + +```bash +signet link --source deploy-key +``` + +### Where the keystore lives + +You do not need to know this — `stellar` owns the format and may change it, and +the Signet CLI never reads it directly. It is documented only so you know +whether an identity is available to the account running the command: + +| Platform | Location | +| -------- | ---------------------------------------------------------------------------- | +| Linux | `$XDG_CONFIG_HOME/stellar/identity/` (usually `~/.config/stellar/identity/`) | +| macOS | `~/.config/stellar/identity/` | +| Windows | `%APPDATA%\stellar\identity\` | + +The practical consequence: an identity created as your user is not visible to +`root`, to a different user, or inside a container that does not mount that +directory. That is the usual cause of "no identity found" on a machine where +`stellar keys ls` clearly works. + +--- + +## Configuration and precedence + +The CLI reads a config file from your OS config directory +(`os.UserConfigDir()`) for the deployment URL and the last identity used, so +repeat runs need no flags +([#262](https://github.com/blockchain-maxis/signet/issues/262)). + +**Precedence, highest first:** + +``` +--url / --source > SIGNET_URL / STELLAR_SIGN_WITH_KEY > config file > default +``` + +| Setting | Flag | Environment | Config key | Default | +| ---------------- | ---------- | ----------------------- | ---------- | -------------------- | +| Deployment URL | `--url` | `SIGNET_URL` | `baseUrl` | `https://signet.dev` | +| Signing identity | `--source` | `STELLAR_SIGN_WITH_KEY` | `identity` | prompt | + +No config file is needed for the default deployment. + +--- + +## Using it from CI + +CI cannot answer an interactive prompt, so the signing identity must come from +the environment. `stellar tx sign --sign-with-key` accepts an identity name, a +raw `SC…` secret, or a seed phrase, and reads `STELLAR_SIGN_WITH_KEY` itself +([#254](https://github.com/blockchain-maxis/signet/issues/254)). + +```yaml +- name: Link the deploy wallet + env: + STELLAR_SIGN_WITH_KEY: ${{ secrets.SIGNET_DEPLOY_KEY }} + run: npx @signet/cli link --no-browser --json +``` + +Store the deploy key in your CI secret store under whatever name you like — +`SIGNET_DEPLOY_KEY` above is a convention, not something the CLI reads. The CLI +reads **`STELLAR_SIGN_WITH_KEY`**, because that is the variable `stellar tx +sign` already honours, and introducing a second name for the same secret would +mean copying it between variables in every pipeline. + +When either `--sign-with-key` or `STELLAR_SIGN_WITH_KEY` is set, no prompt +appears. The value is never echoed and never logged. + +> The approval step still needs a human in a browser once. CI usage is for +> re-linking and verification after the first interactive link, not for +> bootstrapping a handle unattended — an unattended path would defeat the +> handle proof in step 3. + +--- + +## Self-hosted deployments + +Signet is Apache-2.0 and [`docs/DEPLOYMENT.md`](DEPLOYMENT.md) documents running +your own. Point the CLI at it: + +```bash +signet link --url https://signet.internal.example +# or persist it +SIGNET_URL=https://signet.internal.example signet link +``` + +The URL must be `https` in any deployment reachable off `localhost`: the +approval page posts back to a loopback address, and browsers apply +[Private Network Access](https://developer.chrome.com/blog/private-network-access-preflight) +rules to that request. See +[loopback blocked](#loopback-blocked) below. + +Your deployment **must have a database** — see the next section. + +--- + +## Machine-readable output + +`--json` writes a single JSON object to stdout and nothing else, so a pipeline +can parse the result without scraping human-formatted text that is free to +change between releases ([#264](https://github.com/blockchain-maxis/signet/issues/264)): + +```console +$ signet link --json +{"handle":"aquawolf","publicKey":"GCEX…7QK4","network":"testnet","status":"linked"} +``` + +All human-facing output goes to stderr in this mode, so `stdout` stays valid +JSON even when the command is also printing progress. + +--- + +## Exit codes + +Scripts wrap this command, and an undifferentiated non-zero exit forces log +parsing. Every failure class has its own code +([#259](https://github.com/blockchain-maxis/signet/issues/259)): + +| Code | Meaning | Retryable | +| ---- | ---------------------------------------------------------------------------- | ------------------------------- | +| `0` | Linked | — | +| `1` | Unexpected error | No — report it | +| `2` | Configuration error (bad `--url`, unparseable config, missing/old `stellar`) | No | +| `3` | No identity found | No | +| `4` | Signing failed | No | +| `5` | Network error reaching the deployment | Yes | +| `6` | Timed out waiting for approval | Yes | +| `7` | Approval rejected in the browser | No | +| `8` | Wallet already linked to another handle | No | +| `9` | Deployment cannot link — no database configured | Yes, once the operator fixes it | + +Codes `5`, `6` and `9` are the only ones worth retrying automatically. `9` in +particular is not your problem to fix — see below. --- @@ -36,20 +290,20 @@ provisioning, and [`ENVIRONMENT.md`](ENVIRONMENT.md) for `DATABASE_URL` itself. ### What you see -| Where | With no `DATABASE_URL` | -| ----------------------------- | ---------------------------------------------------------------------------------------- | -| `POST /api/cli/pair/complete` | `503` with `{"error":"database_required","isConfigurationError":true,…}` | -| `GET /api/cli/pair/complete` | `{"available":false,"reason":"database_required"}` | -| `/link` | Approval is disabled, with an explanation, **before** you approve | -| CLI | A deployment configuration error naming `DATABASE_URL` — not a wallet or signature error | +| Where | With no `DATABASE_URL` | +| ----------------------------- | ------------------------------------------------------------------------ | +| `POST /api/cli/pair/complete` | `503` with `{"error":"database_required","isConfigurationError":true,…}` | +| `GET /api/cli/pair/complete` | `{"available":false,"reason":"database_required"}` | +| `/link` | Approval is disabled, with an explanation, **before** you approve | +| CLI | Exit code `9`, naming `DATABASE_URL` — not a wallet or signature error | The status is **`503`, not `4xx`**: nothing about the request was wrong, and nothing the developer does to their own account will change the outcome. The `isConfigurationError` flag is there so a client can classify it without string-matching a message. -`/link` checks the same signal server-side and refuses _before_ approval, -so nobody signs an approval that cannot be stored. +`/link` checks the same signal server-side and refuses _before_ approval, so +nobody signs an approval that cannot be stored. ### Fixing it @@ -63,3 +317,51 @@ link: `GET /api/cli/pair/complete` returning `{"available":true}` means linking can proceed. + +--- + +## Troubleshooting + +Symptom → cause → fix, following the same shape as +[`TROUBLESHOOTING.md`](TROUBLESHOOTING.md). + +| Symptom | Cause | Fix | +| ------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `no identity found` (exit `3`) | No `stellar` identity, or one that belongs to a different user | `stellar keys generate `, or run as the user who owns the keystore — see [Choosing an identity](#choosing-an-identity) | +| `stellar: command not found` / `unknown flag: --sign-with-key` (exit `2`) | `stellar` missing or older than 25.2.0 | Install or upgrade from the [CLI install page](https://developers.stellar.org/docs/tools/cli/install-cli) | +| Nothing opens; command sits at "Waiting for approval" | No browser — SSH, container, or headless | Open the printed URL manually, or pass `--no-browser` | +| Browser shows the page, approval appears to work, CLI never returns | [Loopback blocked](#loopback-blocked) | Update the CLI; check the browser console for a CORS/Private Network Access error | +| `timed out waiting for approval` (exit `6`) | The approval window expired | Re-run `signet link` and approve while it is waiting | +| `wallet already linked` (exit `8`) | That `G…` address is bound to a different handle | Unlink from the other handle first (`signet unlink`), or link a different wallet | +| `linking requires a database` (exit `9`) | The **deployment** has no `DATABASE_URL` | Not yours to fix — see [Linking requires a database](#linking-requires-a-database) | + +### Loopback blocked + +The approval page is served over HTTPS; the callback target is +`http://127.0.0.1:`. Loopback is a potentially-trustworthy origin, so +mixed-content blocking does not apply — but Chrome sends a CORS preflight for +public → private requests and refuses the real request unless the loopback +server opts in with `Access-Control-Allow-Private-Network: true`. + +An older CLI that does not answer that preflight fails **silently**: the browser +reports an opaque network error and the CLI simply waits out its timeout. It is +also invisible in local development, because `localhost → localhost` is not a +public → private transition. + +**Fix:** update the CLI. If it persists, open the browser console on the +approval page — a `Private Network Access` or CORS error there confirms it, and +anything else points elsewhere. See +[#272](https://github.com/blockchain-maxis/signet/issues/272). + +### Still stuck + +Re-run with `--json` and include stdout, the exit code, `stellar --version`, and +your OS in an issue. Never paste a secret key or the contents of your keystore. + +--- + +## Related docs + +- [`ENVIRONMENT.md`](ENVIRONMENT.md) — every variable Signet reads +- [`DEPLOYMENT.md`](DEPLOYMENT.md) — running your own deployment +- [`TROUBLESHOOTING.md`](TROUBLESHOOTING.md) — first-run failures elsewhere in the project