diff --git a/apps/web/app/api/cli/pair/approve/route.ts b/apps/web/app/api/cli/pair/approve/route.ts index d6f0b75..a9400a2 100644 --- a/apps/web/app/api/cli/pair/approve/route.ts +++ b/apps/web/app/api/cli/pair/approve/route.ts @@ -45,7 +45,8 @@ export async function POST(req: Request) { expired: 'This pairing has expired — restart it from the CLI', 'already-used': 'This pairing has already been approved', 'no-profile': 'Claim a handle before pairing a CLI', - unavailable: 'Pairing is unavailable', + unavailable: + 'CLI linking requires a database, and this deployment has none configured. This is a deployment configuration problem, not something you did. The operator needs to provision DATABASE_URL.', }; return NextResponse.json( { error: messages[outcome] ?? outcome }, diff --git a/apps/web/app/api/cli/pair/complete/route.ts b/apps/web/app/api/cli/pair/complete/route.ts index 707a4e2..85e17d6 100644 --- a/apps/web/app/api/cli/pair/complete/route.ts +++ b/apps/web/app/api/cli/pair/complete/route.ts @@ -37,7 +37,8 @@ const FAILURE_STATUS: Record = { }; const FAILURE_MESSAGE: Record = { - unavailable: 'Pairing is unavailable', + unavailable: + 'CLI linking requires a database, and this deployment has none configured. This is a deployment configuration problem, not something you did. The operator needs to provision DATABASE_URL.', 'not-found': 'Pairing not found — restart it from the CLI', expired: 'This pairing has expired — restart it from the CLI', 'not-approved': 'This pairing has not been approved in the browser yet', diff --git a/apps/web/app/api/cli/pair/reject/route.ts b/apps/web/app/api/cli/pair/reject/route.ts index 3cedd9c..850fb29 100644 --- a/apps/web/app/api/cli/pair/reject/route.ts +++ b/apps/web/app/api/cli/pair/reject/route.ts @@ -32,7 +32,8 @@ const OUTCOME_MESSAGE: Record = { 'not-found': 'Pairing not found — it may have already been used', expired: 'This pairing has expired — restart it from the CLI', 'already-used': 'This pairing has already been answered', - unavailable: 'Pairing is unavailable', + unavailable: + 'CLI linking requires a database, and this deployment has none configured. This is a deployment configuration problem, not something you did. The operator needs to provision DATABASE_URL.', }; export async function POST(req: Request) { diff --git a/apps/web/app/api/cli/pair/start/route.ts b/apps/web/app/api/cli/pair/start/route.ts index c1f79a3..c78b80a 100644 --- a/apps/web/app/api/cli/pair/start/route.ts +++ b/apps/web/app/api/cli/pair/start/route.ts @@ -36,7 +36,13 @@ export async function POST(req: Request) { const pairing = await startPairing(network || getNetworkPassphrase(), publicKey ?? null); if (!pairing) { - return NextResponse.json({ error: 'Pairing is unavailable' }, { status: 503 }); + return NextResponse.json( + { + error: + 'CLI linking requires a database, and this deployment has none configured. This is a deployment configuration problem, not something you did. The operator needs to provision DATABASE_URL.', + }, + { status: 503 }, + ); } return NextResponse.json(pairing, { headers: { 'cache-control': 'no-store' } }); diff --git a/apps/web/app/api/cli/pair/status/route.ts b/apps/web/app/api/cli/pair/status/route.ts index 14aa346..5b1a8a7 100644 --- a/apps/web/app/api/cli/pair/status/route.ts +++ b/apps/web/app/api/cli/pair/status/route.ts @@ -38,7 +38,10 @@ export async function GET(req: Request) { // An unknown token and a wrong token are the same answer on purpose: // this endpoint must not become an oracle for which tokens are real. const status = result.reason === 'unavailable' ? 503 : 404; - const error = result.reason === 'unavailable' ? 'Pairing is unavailable' : 'Pairing not found'; + const error = + result.reason === 'unavailable' + ? 'CLI linking requires a database, and this deployment has none configured. This is a deployment configuration problem, not something you did. The operator needs to provision DATABASE_URL.' + : 'Pairing not found'; return NextResponse.json({ error }, { status, headers: { 'cache-control': 'no-store' } }); } diff --git a/apps/web/app/api/cli/unlink/route.ts b/apps/web/app/api/cli/unlink/route.ts index c9239cd..529f3ad 100644 --- a/apps/web/app/api/cli/unlink/route.ts +++ b/apps/web/app/api/cli/unlink/route.ts @@ -27,7 +27,8 @@ const OUTCOME_STATUS: Record = { }; const OUTCOME_MESSAGE: Record = { - unavailable: 'Wallet unlinking is unavailable on this deployment', + unavailable: + 'CLI wallet unlinking requires a database, and this deployment has none configured. This is a deployment configuration problem, not something you did. The operator needs to provision DATABASE_URL.', 'bad-challenge': 'Invalid or unsigned challenge transaction', replayed: 'This signed challenge has already been used', 'not-linked': 'That wallet is not linked to any profile', diff --git a/apps/web/app/link/page.tsx b/apps/web/app/link/page.tsx index 8e7a109..657d5f5 100644 --- a/apps/web/app/link/page.tsx +++ b/apps/web/app/link/page.tsx @@ -23,7 +23,8 @@ const REFUSAL: Record = { expired: 'That pairing code has expired. Run `signet link` again to get a new one.', 'already-used': 'That pairing code has already been answered. Start a new one from your terminal.', - unavailable: 'CLI linking is unavailable on this deployment — no database is configured.', + unavailable: + 'CLI linking needs a database and this deployment has none configured, so nothing could be saved even if you approved. This is the operator’s configuration to fix, not yours.', }; function Shell({ children }: { children: React.ReactNode }) { diff --git a/cli/internal/pair/pair.go b/cli/internal/pair/pair.go index 11e5dda..efec4ab 100644 --- a/cli/internal/pair/pair.go +++ b/cli/internal/pair/pair.go @@ -163,10 +163,19 @@ func (c *Client) do(ctx context.Context, method, path string, body any, out any) Error string `json:"error"` } _ = json.NewDecoder(resp.Body).Decode(&problem) + + // 503 from these endpoints means the deployment cannot do this at all + // — it has no database to write a link into (#277). That is the + // operator's problem, not the developer's, and reporting it as a + // network error would send them looking at their own connection. + kind := exitcode.ErrNetwork + if resp.StatusCode == http.StatusServiceUnavailable { + kind = exitcode.ErrConfiguration + } if problem.Error != "" { - return fmt.Errorf("%w: %s", exitcode.ErrNetwork, problem.Error) + return fmt.Errorf("%w: %s", kind, problem.Error) } - return fmt.Errorf("%w: %s returned %s", exitcode.ErrNetwork, path, resp.Status) + return fmt.Errorf("%w: %s returned %s", kind, path, resp.Status) } if out == nil { diff --git a/cli/internal/pair/pair_test.go b/cli/internal/pair/pair_test.go index 289803f..13f4d23 100644 --- a/cli/internal/pair/pair_test.go +++ b/cli/internal/pair/pair_test.go @@ -145,3 +145,27 @@ func TestUnlink_SurfacesARefusal(t *testing.T) { t.Fatalf("error lost the server's message: %v", err) } } + +func TestDo_A503IsAConfigurationProblemNotANetworkOne(t *testing.T) { + // #277: a deployment with no database cannot link at all. Reporting that + // as a network error sends the developer to check their own connection. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, `{"error":"CLI linking requires a database, and this deployment has none configured."}`) + })) + defer srv.Close() + + _, err := New(srv.URL).Start(context.Background(), "testnet", "GABC") + if err == nil { + t.Fatal("expected an error") + } + if !errors.Is(err, exitcode.ErrConfiguration) { + t.Fatalf("err = %v, want a configuration error", err) + } + if errors.Is(err, exitcode.ErrNetwork) { + t.Fatal("a missing database is not a network failure") + } + if !strings.Contains(err.Error(), "requires a database") { + t.Fatalf("err lost the server's explanation: %v", err) + } +} diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index d680490..0266e57 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. The `/handles` directory falls back to discovering handles from the registry's event stream, which a public RPC serves for only ~11h (`REGISTRY_EVENT_WINDOW_LEDGERS`), so handles claimed before that are not listed. `/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. The `/handles` directory falls back to discovering handles from the registry's event stream, which a public RPC serves for only ~11h (`REGISTRY_EVENT_WINDOW_LEDGERS`), so handles claimed before that are not listed. `/api/health` reports `checks.db: "skipped"`. Dashboard account writes that need Prisma return empty. **CLI linking (`signet link`/`unlink`) is unavailable and fails closed** — the whole flow ends in a `Wallet` row, so with nowhere to write one every pairing endpoint answers `503` and `/link` says so before the developer approves rather than accepting an approval that could persist nothing. Provisioning this in production is #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). | @@ -46,7 +46,7 @@ These are intentional product behaviour, not failures: | Condition | What the user / operator sees | | --- | --- | -| No `DATABASE_URL` | Web serves static (and optional on-chain) profiles. No indexer. Health: `db: skipped`, overall `ok`. | +| No `DATABASE_URL` | Web serves static (and optional on-chain) profiles. No indexer. CLI linking refuses (`503`) rather than appearing to succeed. Health: `db: skipped`, overall `ok`. | | No registry id (`NEXT_PUBLIC_IDENTITY_REGISTRY_ID` and `REGISTRY_CONTRACT_ID` empty) | Claim button/flow disabled with an honest “not configured / Phase 2” message. No doomed RPC calls for resolve. | | No `INDEXER_REGISTRY_CONTRACT_ID` (and no public registry fallback) | Indexer starts (if `DATABASE_URL` is set) but **attestation worker no-ops**; seed/curated bindings stay authoritative until a registry id is provided. | | No `SIGNET_AUTH_SECRET` in production | Auth endpoints error; do not deploy web with `NODE_ENV=production` without a secret. |