Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/web/app/api/cli/pair/complete/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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' } },
);
}
69 changes: 69 additions & 0 deletions apps/web/app/link/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto flex min-h-screen max-w-xl flex-col justify-center gap-6 px-6 py-16">
<h1 className="text-2xl font-semibold">Link the Signet CLI</h1>

{linkingAvailable ? (
<p className="text-sm opacity-80">
Approve the pairing request shown in your terminal. The approval flow itself lands with
the CLI pairing work.
</p>
) : (
<section
role="alert"
aria-labelledby="linking-unavailable-title"
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-5"
>
<h2 id="linking-unavailable-title" className="text-base font-semibold">
Linking is unavailable on this deployment
</h2>
<p className="mt-2 text-sm opacity-90">
Wallet links are stored in a database, and this deployment has no{' '}
<code>DATABASE_URL</code> configured. Approving now would appear to succeed and save
nothing, so approval is disabled.
</p>
<p className="mt-2 text-sm opacity-90">
{/* 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{' '}
<a className="underline" href="https://github.com/blockchain-maxis/signet/issues/191">
issue #191
</a>{' '}
and{' '}
<a
className="underline"
href="https://github.com/blockchain-maxis/signet/blob/main/docs/CLI.md#linking-requires-a-database"
>
docs/CLI.md
</a>
.
</p>
</section>
)}
</main>
);
}
57 changes: 57 additions & 0 deletions apps/web/lib/cli-pair-complete.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
assert.equal(body.available, true);
assert.equal(body.reason, undefined);
});
59 changes: 59 additions & 0 deletions apps/web/lib/database-required.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
59 changes: 59 additions & 0 deletions apps/web/lib/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,65 @@ export async function safeDbProfile(handle: string): Promise<Profile | null> {
}
}

// --- 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
Expand Down
Loading
Loading