Persistent PostgreSQL sandbox deployment
This commit is contained in:
@@ -43,6 +43,8 @@ COPY shared ./shared
|
||||
COPY scripts/node-backup.ts ./scripts/node-backup.ts
|
||||
COPY scripts/ops-check.ts ./scripts/ops-check.ts
|
||||
COPY scripts/custody-db.ts ./scripts/custody-db.ts
|
||||
COPY scripts/custody-deployment-init.ts ./scripts/custody-deployment-init.ts
|
||||
COPY scripts/custody-deployment-check.ts ./scripts/custody-deployment-check.ts
|
||||
COPY --from=randomx-build /native/build/wikideck-randomx ./native/wikideck-randomx
|
||||
RUN mkdir -p /app/data && chown node:node /app/data
|
||||
USER node
|
||||
|
||||
@@ -62,6 +62,8 @@ For an existing host install, keep the old data directory intact and set `WIKIDE
|
||||
|
||||
### Optional custody accounting preview
|
||||
|
||||
For a **persistent PostgreSQL-backed local stack**, use [the deployment guide](docs/persistent-custody-sandbox.md) and [compose.custody.yaml](compose.custody.yaml), or the VS Code **Start persistent custody sandbox** task. It starts PostgreSQL plus Wikideck at port 3422, with generated scoped credentials, verified database TLS and explicit migrations. Existing single/three-node commands are unchanged. This uses separate volumes and does not migrate existing SQLite custody or enable real payments.
|
||||
|
||||
The marketplace's **Custody preview** tab is separate from existing mock checkout. Explicit `WIKIDECK_CUSTODY_MODE=sandbox` on the server enables fictitious test balances, signed fixed-price reservations and withdrawal holds on testnet only. Default is `off`; live modes and mainnet sandbox fail closed. Use a disposable data directory and configure `PUBLIC_ORIGIN` to the exact browser origin. No Monero addresses, wallet RPC or real withdrawals are available. [PostgreSQL financial storage](docs/postgres-financial-storage.md) is selectable with `WIKIDECK_CUSTODY_STORAGE=postgres`, explicit schema/role setup and a secret-file connection configuration; existing SQLite custody requires a stopped-node import, never automatic fallback.
|
||||
|
||||
Reservations stop at `awaiting-lock` because reviewed NFT sale-lock consensus rules are not implemented. They can be cancelled while their outbox intent is blocked; they do not deliver cards. Local SQLite custody is included by stopped-node backup/restore when present; PostgreSQL-bound nodes require coordinated database/node backups and the SQLite-only backup command refuses them. Real treasury observation/settlement, signing controls and production review remain future work. See [the implementation record](docs/custodial-market-design.md).
|
||||
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
# Persistent LOCAL testnet sandbox. Separate stack/data from existing single/three-node compose files.
|
||||
# Invoke this file explicitly. Never combine it with the disposable PostgreSQL test fixture.
|
||||
services:
|
||||
provision:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/postgres/Dockerfile
|
||||
volumes:
|
||||
- pg-admin:/secrets/admin
|
||||
- pg-owner:/secrets/owner
|
||||
- pg-runtime:/secrets/runtime
|
||||
- pg-tls:/secrets/tls
|
||||
network_mode: none
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:mode=1777,size=16m"]
|
||||
restart: "no"
|
||||
database:
|
||||
image: wikideck:postgres-sandbox
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/postgres/database.Dockerfile
|
||||
depends_on:
|
||||
provision:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
POSTGRES_DB: wikideck_custody
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD_FILE: /secrets/admin/password
|
||||
POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256
|
||||
command: [postgres, -c, ssl=on, -c, ssl_min_protocol_version=TLSv1.2,
|
||||
-c, ssl_cert_file=/tls/server.crt, -c, ssl_key_file=/tls/server.key,
|
||||
-c, hba_file=/etc/wikideck/pg_hba.conf, -c, log_statement=none,
|
||||
-c, log_min_error_statement=panic, -c, log_parameter_max_length_on_error=0]
|
||||
volumes:
|
||||
- pg-data:/var/lib/postgresql/data
|
||||
- pg-admin:/secrets/admin:ro
|
||||
- pg-tls:/tls:ro
|
||||
networks: [financial]
|
||||
healthcheck:
|
||||
test: [CMD-SHELL, "pg_isready -U postgres -d wikideck_custody"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
restart: unless-stopped
|
||||
shm_size: 128m
|
||||
stop_grace_period: 30s
|
||||
migrate:
|
||||
image: wikideck:custody-sandbox
|
||||
build: {context: ., target: runtime}
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
WIKIDECK_NETWORK: testnet
|
||||
WIKIDECK_LOCAL_PG_BOOTSTRAP: sandbox-only
|
||||
command: [node, --experimental-strip-types, scripts/custody-deployment-init.ts]
|
||||
volumes:
|
||||
- pg-admin:/secrets/admin:ro
|
||||
- pg-owner:/secrets/owner:ro
|
||||
- pg-runtime:/secrets/runtime:ro
|
||||
networks: [financial]
|
||||
read_only: true
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
restart: "no"
|
||||
wikideck:
|
||||
image: wikideck:custody-sandbox
|
||||
build: {context: ., target: runtime}
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
ports: ["127.0.0.1:${WIKIDECK_CUSTODY_PORT:-3422}:3001"]
|
||||
environment:
|
||||
WIKIDECK_NETWORK: testnet
|
||||
WIKIDECK_PAYMENT_MODE: mock
|
||||
WIKIDECK_CUSTODY_MODE: sandbox
|
||||
WIKIDECK_CUSTODY_STORAGE: postgres
|
||||
WIKIDECK_CUSTODY_PG_URL_FILE: /secrets/runtime/url
|
||||
WIKIDECK_CUSTODY_PG_CA_FILE: /secrets/runtime/ca.crt
|
||||
PUBLIC_ORIGIN: "http://127.0.0.1:${WIKIDECK_CUSTODY_PORT:-3422}"
|
||||
volumes:
|
||||
- custody-node:/app/data
|
||||
- pg-runtime:/secrets/runtime:ro
|
||||
networks: [financial, web]
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:mode=1777,size=32m,noexec,nosuid"]
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
pids_limit: 128
|
||||
mem_limit: 1g
|
||||
stop_grace_period: 30s
|
||||
networks:
|
||||
financial: {internal: true}
|
||||
web: {}
|
||||
volumes:
|
||||
pg-data:
|
||||
pg-admin:
|
||||
pg-owner:
|
||||
pg-runtime:
|
||||
pg-tls:
|
||||
custody-node:
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
FROM node:24-alpine
|
||||
RUN apk add --no-cache openssl
|
||||
COPY deploy/postgres/setup.mjs /setup.mjs
|
||||
CMD ["node", "/setup.mjs"]
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
FROM postgres:17-alpine
|
||||
COPY deploy/postgres/pg_hba.conf /etc/wikideck/pg_hba.conf
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
# Unix socket is for the official local bootstrap only; no database host port is published.
|
||||
local all all trust
|
||||
hostnossl all all all reject
|
||||
hostssl wikideck_custody postgres all scram-sha-256
|
||||
hostssl wikideck_custody custody_owner all scram-sha-256
|
||||
hostssl wikideck_custody custody_runtime all scram-sha-256
|
||||
host all all all reject
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, chownSync, openSync, fsyncSync, closeSync } from 'node:fs';
|
||||
|
||||
// Local SANDBOX provisioning only. No secrets pass through logs, command arguments or source control.
|
||||
const roots = ['/secrets/admin', '/secrets/owner', '/secrets/runtime', '/secrets/tls'];
|
||||
for (const root of roots) mkdirSync(root, { recursive: true, mode: 0o755 });
|
||||
const files = ['/secrets/admin/password', '/secrets/admin/url', '/secrets/owner/url', '/secrets/runtime/url',
|
||||
'/secrets/tls/server.key', '/secrets/tls/server.crt', '/secrets/owner/ca.crt', '/secrets/runtime/ca.crt', '/secrets/admin/ca.crt'];
|
||||
const marker = '/secrets/admin/complete';
|
||||
if (existsSync(marker)) {
|
||||
if (readFileSync(marker, 'utf8') !== 'wikideck-local-pg-v1' || files.some(f => !existsSync(f))) {
|
||||
throw new Error('Incomplete secret volumes; restore the matching volumes, never regenerate over an existing database');
|
||||
}
|
||||
execFileSync('openssl', ['x509', '-checkend', '86400', '-noout', '-in', '/secrets/tls/server.crt'], { stdio: 'ignore' });
|
||||
console.log('Existing local sandbox credentials/certificate retained.');
|
||||
} else {
|
||||
if (files.some(existsSync)) throw new Error('Partial secret initialization; inspect preserved volumes before retrying');
|
||||
const admin = randomBytes(32).toString('hex'), owner = randomBytes(32).toString('hex'), runtime = randomBytes(32).toString('hex');
|
||||
const put = (file, text, mode = 0o600) => {
|
||||
writeFileSync(file, text, { flag: 'wx', mode }); chownSync(file, 1000, 1000);
|
||||
const fd = openSync(file, 'r'); try { fsyncSync(fd); } finally { closeSync(fd); }
|
||||
};
|
||||
// PostgreSQL's unprivileged entrypoint also reads POSTGRES_PASSWORD_FILE. This separate scoped
|
||||
// admin volume is never mounted into the application; the password file must be readable there.
|
||||
put('/secrets/admin/password', admin, 0o644);
|
||||
put('/secrets/admin/url', `postgresql://postgres:${admin}@database:5432/wikideck_custody`);
|
||||
put('/secrets/owner/url', `postgresql://custody_owner:${owner}@database:5432/wikideck_custody`);
|
||||
put('/secrets/runtime/url', `postgresql://custody_runtime:${runtime}@database:5432/wikideck_custody`);
|
||||
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:3072', '-sha256', '-nodes', '-days', '30',
|
||||
'-subj', '/CN=Wikideck local sandbox CA', '-keyout', '/tmp/ca.key', '-out', '/tmp/ca.crt',
|
||||
'-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign'], { stdio: 'ignore' });
|
||||
execFileSync('openssl', ['req', '-new', '-newkey', 'rsa:3072', '-nodes', '-subj', '/CN=database',
|
||||
'-keyout', '/secrets/tls/server.key', '-out', '/tmp/server.csr'], { stdio: 'ignore' });
|
||||
writeFileSync('/tmp/extensions', 'subjectAltName=DNS:database\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n');
|
||||
execFileSync('openssl', ['x509', '-req', '-in', '/tmp/server.csr', '-CA', '/tmp/ca.crt', '-CAkey', '/tmp/ca.key',
|
||||
'-CAcreateserial', '-days', '30', '-sha256', '-extfile', '/tmp/extensions', '-out', '/secrets/tls/server.crt'], { stdio: 'ignore' });
|
||||
for (const root of ['admin', 'owner', 'runtime']) put(`/secrets/${root}/ca.crt`, readFileSync('/tmp/ca.crt'), 0o644);
|
||||
// postgres:17-alpine runs as UID/GID 70. Private key never mounted into Wikideck or migrator.
|
||||
chownSync('/secrets/tls/server.key', 70, 70); chmodSync('/secrets/tls/server.key', 0o600);
|
||||
chmodSync('/secrets/tls/server.crt', 0o644);
|
||||
put(marker, 'wikideck-local-pg-v1', 0o644);
|
||||
console.log('Provisioned fresh local sandbox credentials and verified-TLS certificate; no secrets logged.');
|
||||
}
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
# Persistent local PostgreSQL custody sandbox
|
||||
|
||||
Status: implemented deployment rehearsal, **not a real-money or public production deployment**. Date: 2026-10-20. Related: [financial storage](postgres-financial-storage.md), [production plan](production-plan.md).
|
||||
|
||||
## Start and inspect
|
||||
|
||||
Use [compose.custody.yaml](../compose.custody.yaml) **by itself**, with a stable Compose project name. Existing single-node/three-node Compose files remain unchanged and do not start PostgreSQL. Do not combine this file with [the disposable test fixture](../compose.postgres-test.yaml).
|
||||
|
||||
The VS Code **Start persistent custody sandbox** task runs the fresh/persistent stack using project `wikideck-custody-local`. Equivalent CLI invocation is `docker compose -p wikideck-custody-local -f compose.custody.yaml up -d --build`. Browser: [http://127.0.0.1:3422/](http://127.0.0.1:3422/), then **Marketplace → Custody preview**. Override `WIKIDECK_CUSTODY_PORT` if needed; origin configuration follows that port. The default bind is loopback only.
|
||||
|
||||
Services:
|
||||
|
||||
| Service | Lifecycle / purpose |
|
||||
| --- | --- |
|
||||
| `provision` | One-shot, network disabled. Generates random passwords and local CA/server certificate into scoped named volumes. Reuses complete credentials on later starts; partial/missing sets fail instead of silently rotating them. |
|
||||
| `database` | Persistent PostgreSQL 17 with no published database port, internal network, TLS-only TCP HBA, SCRAM passwords, private key mounted only here. Health-gated startup. |
|
||||
| `migrate` | Explicit one-shot local bootstrap: separate owner/runtime roles, versioned schema, restricted grants. Holds admin secrets unavailable to Wikideck. Unknown schema/credential state fails closed. |
|
||||
| `wikideck` | Existing non-root application runtime with only the runtime credential and public CA mounted read-only, its own persistent node volume, mock payments and PostgreSQL-backed custody sandbox. |
|
||||
|
||||
Completed one-shot services are expected to show **Exited (0)**. PostgreSQL and Wikideck should remain running; a stopped migrator with exit zero is not a missing database.
|
||||
|
||||
## Persistent volumes and isolation
|
||||
|
||||
Named volumes separately retain PostgreSQL data, node data, admin credentials, schema-owner credentials, runtime credentials and database TLS material. Generated secrets are not written into source, command arguments, Compose environment or browser variables. The app receives no admin/owner credential or server private key. Generated files are scoped by volume mounts; the admin password file is readable inside the trusted database/provisioning containers for the PostgreSQL entrypoint, not mounted in the app.
|
||||
|
||||
This is a **new independent market**, not a migration of any existing node or wallet balance. Do not attach an existing SQLite custody volume and expect automatic import. Follow the stopped-node, backup and source-freezing procedure in [ADR 003](postgres-financial-storage.md) for that case. Do not point multiple independent market identities at this one custody schema.
|
||||
|
||||
Keep the same project name. A different project name creates different volumes and therefore a new database/custodian. Existing preview containers/ports and user data were not changed by the rehearsal.
|
||||
|
||||
## TLS and credentials
|
||||
|
||||
The database presents a certificate with SAN `DNS:database`; the driver validates the chain and hostname using the mounted CA. No insecure transport override is set. Server-side HBA rejects plaintext TCP even with correct credentials. Unix-socket local trust remains only for the official local PostgreSQL bootstrap/health path; it is not a published network endpoint. PostgreSQL logs are configured not to print SQL error statements/parameters that could contain bootstrap credentials.
|
||||
|
||||
Certificates expire after **30 days**. This is deliberate local-rehearsal material, not a certificate lifecycle solution for production. Provisioning refuses certificates with less than one day remaining and does not regenerate passwords or certificates over an existing database. The CA private key is temporary during first provisioning and not retained; renewal therefore requires a deliberately reviewed replacement CA/certificate and coordinated trust update. Do not delete random secret volumes to “fix” renewal. A running database may keep a certificate loaded beyond expiry; connection verification still fails closed for new clients. External monitoring/expiry alerts and a tested rotation procedure are required before longer operation.
|
||||
|
||||
Passwords are not automatically rotated on restart. Missing credentials paired with existing PostgreSQL storage will not reset database roles. Preserve matching secret and data volumes securely; losing them requires controlled recovery. Docker administrators can read volumes; this is not protection against host compromise. Pin intended images/digests, configure encrypted backups and host firewall, and review actual deployment infrastructure before public/valuable use.
|
||||
|
||||
## Check and stop
|
||||
|
||||
The **Verify persistent custody after recreation** task runs [the deployment check](../scripts/custody-deployment-check.ts) in the app container. It verifies TLS, plaintext rejection, runtime role, sandbox API flags and exact journal accounting; prints only the public custodian ID and aggregate fictitious amounts. It does not reveal credentials or customer records.
|
||||
|
||||
The optional **Check persistent custody TLS and credit** task adds a single 1-XMR **fictitious** test credit under an ephemeral test identity before auditing. It does not create Monero outputs, touch existing wallets or transfer NFTs. Repeating that task creates another fictitious customer credit; use the read-only verification task for routine checks. Test identities are not recoverable users and should never be used with valuable funds.
|
||||
|
||||
The **Stop persistent custody without deleting data** task uses Compose `down` without volume removal. Restarting with the same project/file recreates containers and reuses database, credentials, TLS and node state. **Never add `--volumes`/`-v` to normal shutdown:** it destroys custody history and credentials for this stack. Unclean node shutdown may leave the existing exclusive node lock; verify the old process is gone and follow the stale-lock review procedure—do not clear it automatically.
|
||||
|
||||
Existing SQLite-only node backup intentionally refuses PostgreSQL-bound nodes. This stack does not yet supply a coordinated backup job: full PostgreSQL backups, protected role/secret material and matching node state must be covered together. Named-volume persistence is **not a backup**, and Docker Desktop resets, disk loss or volume removal can still lose data. WAL/PITR, off-host encrypted copies, restore/failover exercises and RPO/RTO remain production gates.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
Rehearsal: provisioned a fresh isolated stack, observed healthy PostgreSQL and successful migrations, checked an actual **TLSv1.3** session under `custody_runtime`, and submitted a signed fictitious credit. Removed all four containers and both networks using `down`, retained volumes, recreated with `up`, and verified the **same custodian ID, one journal and 1,000,000,000,000 atomic units** in both assets and claims. Re-running setup retained existing credentials, role grants and schema; no second credit was added. Actual plaintext TCP authentication was rejected by the database. The browser served the app at port 3422.
|
||||
|
||||
TypeScript/Vite runtime/test builds pass. Final offline regression run: **164 tests passed, zero failures/skips**. Deployment regression tests verify sandbox configuration, no published database port, runtime secret isolation, persistent data, TLS/SCRAM HBA and fail-closed partial provisioning. This is not independent certification of secrets provisioning or production hardening.
|
||||
|
||||
Still absent: real Monero observer/signer/deposits/withdrawals, NFT candidate activation/settlement, managed certificates/rotation, automatic coordinated backups, production capacity/availability guarantees and release approval. The existing production plan remains authoritative.
|
||||
@@ -47,6 +47,8 @@ The operator CLI is [scripts/custody-db.ts](../scripts/custody-db.ts), available
|
||||
|
||||
The CLI is included in the runtime image. No existing deployment, database secret or fund wallet was changed automatically by this implementation.
|
||||
|
||||
**Persistent local deployment now available:** [deployment guide](persistent-custody-sandbox.md) and [standalone Compose stack](../compose.custody.yaml) provision isolated named volumes, scoped generated credentials, verified PostgreSQL TLS/SCRAM, separate roles and a one-shot migration service. Recreated containers retained a signed fictitious credit and custodian identity in rehearsal. This explicitly started a new sandbox, not a cutover of existing node data or a live-custody deployment; production backup, certificate rotation and activation gates remain open.
|
||||
|
||||
## Explicit SQLite cutover and rollback limits
|
||||
|
||||
[Importer](../server/market/custody-import.ts) is bounded to the existing sandbox source size (128 MiB), not a general live-money migration utility. The CLI takes the same exclusive stopped-node directory lock as node startup. Calling the underlying library assumes the caller has already stopped all source writers.
|
||||
|
||||
@@ -67,6 +67,8 @@ Can run alongside P0/P1. Addresses SEC-01/SEC-06 rather than adding more fixed c
|
||||
|
||||
**Next financial-storage increment:** [ADR 003 — PostgreSQL](postgres-financial-storage.md) selects and integrates the custody SQL backend with existing HTTP callers, exact constrained accounting, explicit migration/runtime roles, source-freezing SQLite import and dump/restore rehearsal. The adapter remains on the fictitious sandbox domain; it is not a production deposit/withdrawal worker. SQLite node-only backup deliberately refuses PostgreSQL-bound financial state. Production hardware/SLO tests, coordinated backup/PITR/failover, real payment schema/worker controls and independent review remain open, so this does not close M2/M4 or authorize funds.
|
||||
|
||||
**Deployment increment:** [persistent local custody stack](persistent-custody-sandbox.md) now starts PostgreSQL and the actual app together, with private TLS/SCRAM networking, separate owner/runtime roles, explicit migrations and scoped persistent credential/data volumes. A signed fictitious credit and custodian identity survived container/network removal and recreation; the server rejected plaintext authentication. Existing deployments and SQLite custody were not switched. Production certificate rotation, coordinated backup/restore and external operating review remain open; all XMR remains simulated.
|
||||
|
||||
- Indexed canonical ownership, NFT/transfer lookup and paged wallet/explorer APIs: browser signing must not require loading all history. Tip-pinned queries detect reorgs and preserve complete ownership semantics.
|
||||
- Incremental market reconciliation from durable canonical/reorg events. Maintain rollback-safe indexes and a recoverable cursor; catch-up after restart must not miss delivery or payout holds. Bound worker time/concurrency; move expensive work off the request event loop where appropriate.
|
||||
- A versioned production market/settlement schema with append-only financial events and balanced accounting entries for buyer liabilities, host revenue, author liabilities, seller liabilities, paid/refunded amounts and actual network fees. Derived views are rebuildable; financial history and idempotency keys are not discarded to reclaim space.
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
import { Pool } from 'pg';
|
||||
import { generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
import { auditPostgres } from '../server/market/postgres-custody.ts';
|
||||
import { custodyMessage, CUSTODY_POLICY, type CustodyCommand, type CustodyPage } from '../shared/custody.ts';
|
||||
import { address } from '../server/testnet/consensus.ts';
|
||||
|
||||
// Run inside the new sandbox application container. Optional explicit fictitious credit probe only.
|
||||
const credit = process.argv.slice(2);
|
||||
if (credit.length && (credit.length !== 1 || credit[0] !== '--credit')) throw new Error('Expected no arguments or --credit');
|
||||
const configuration = await postgresConfig();
|
||||
const pool = new Pool(configuration);
|
||||
try {
|
||||
// Verify server policy too: correct credentials must NOT authenticate over plaintext TCP.
|
||||
const plaintext = new Pool({ ...configuration, ssl: false, connectionTimeoutMillis: 3000 });
|
||||
try {
|
||||
let rejected = false;
|
||||
try { await plaintext.query('SELECT 1'); } catch { rejected = true; }
|
||||
if (!rejected) throw new Error('Database unexpectedly permits plaintext authentication');
|
||||
} finally { await plaintext.end(); }
|
||||
const c = await pool.connect();
|
||||
try {
|
||||
const tls = await c.query('SELECT ssl,version FROM pg_stat_ssl WHERE pid=pg_backend_pid()');
|
||||
if (tls.rows[0]?.ssl !== true) throw new Error('Database connection is not encrypted');
|
||||
const role = await c.query('SELECT current_user AS role');
|
||||
if (role.rows[0].role !== 'custody_runtime') throw new Error('Expected restricted sandbox runtime role');
|
||||
const page = await fetch('http://127.0.0.1:3001/api/custody', { signal: AbortSignal.timeout(10000) });
|
||||
if (!page.ok) throw new Error('Custody API unavailable');
|
||||
const data = await page.json() as CustodyPage;
|
||||
if (data.info.mode !== 'sandbox' || data.info.liveDeposits || data.info.liveWithdrawals || data.info.nftSettlement) throw new Error('Unexpected custody capabilities');
|
||||
if (credit.length) {
|
||||
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicKey = keys.publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
const terms: Omit<CustodyCommand, 'signature'> = { version: 1, mode: 'sandbox', policy: CUSTODY_POLICY,
|
||||
network: data.info.network, chainId: data.info.chainId, custodianId: data.info.custodianId,
|
||||
actor: address(publicKey), publicKey, nonce: randomUUID(), expiresAt: Date.now() + 60000, action: { type: 'mock-credit' } };
|
||||
const signature = sign('sha256', Buffer.from(custodyMessage(terms)), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64');
|
||||
const result = await fetch('http://127.0.0.1:3001/api/custody/command', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...terms, signature }), signal: AbortSignal.timeout(10000) });
|
||||
if (!result.ok || (await result.json()).availableAtomicXmr !== '1000000000000') throw new Error('Fictitious credit probe failed');
|
||||
// Test key never printed/persisted. The resulting fictitious liability remains in the sandbox journal.
|
||||
}
|
||||
await c.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
||||
const audit = await auditPostgres(c); await c.query('COMMIT');
|
||||
console.log(JSON.stringify({ custodianId: data.info.custodianId, tls: tls.rows[0].version,
|
||||
role: role.rows[0].role, sandbox: true, ...audit }));
|
||||
} finally { c.release(); }
|
||||
} catch {
|
||||
console.error('Sandbox deployment check failed; no connection secrets logged.'); process.exitCode = 1;
|
||||
} finally { await pool.end(); }
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
import { Pool } from 'pg';
|
||||
import { postgresConfig } from '../server/market/postgres-config.ts';
|
||||
import { migrateCustody, grantCustodyRuntime } from '../server/market/postgres-schema.ts';
|
||||
import { IS_MAINNET } from '../shared/network.ts';
|
||||
|
||||
// Explicit one-shot service in the local persistent sandbox stack, NOT HTTP-startup migrations.
|
||||
if (IS_MAINNET || process.env.WIKIDECK_LOCAL_PG_BOOTSTRAP !== 'sandbox-only') throw new Error('Local sandbox bootstrap only');
|
||||
const ca = '/secrets/admin/ca.crt';
|
||||
const config = (role: string) => postgresConfig({ WIKIDECK_CUSTODY_PG_URL_FILE: `/secrets/${role}/url`, WIKIDECK_CUSTODY_PG_CA_FILE: ca });
|
||||
try {
|
||||
const admin = new Pool(await config('admin'));
|
||||
try {
|
||||
const ownerConfig = await config('owner'), runtimeConfig = await config('runtime');
|
||||
for (const [role, password] of [['custody_owner', ownerConfig.password], ['custody_runtime', runtimeConfig.password]] as const) {
|
||||
// Static role allowlist; password is a generated hex token. Values are never printed, even on error.
|
||||
if (typeof password !== 'string' || !/^[a-f0-9]{64}$/.test(password)) throw new Error('Invalid generated credential');
|
||||
const exists = await admin.query('SELECT 1 FROM pg_roles WHERE rolname=$1', [role]);
|
||||
if (!exists.rowCount) await admin.query(`CREATE ROLE ${role} LOGIN PASSWORD '${password}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`);
|
||||
}
|
||||
await admin.query('REVOKE CREATE ON SCHEMA public FROM PUBLIC; REVOKE ALL ON DATABASE wikideck_custody FROM PUBLIC');
|
||||
await admin.query('GRANT CONNECT,CREATE ON DATABASE wikideck_custody TO custody_owner; GRANT CONNECT ON DATABASE wikideck_custody TO custody_runtime');
|
||||
} finally { await admin.end(); }
|
||||
const owner = new Pool(await config('owner'));
|
||||
try { await migrateCustody(owner); await grantCustodyRuntime(owner, 'custody_runtime'); }
|
||||
finally { await owner.end(); }
|
||||
console.log('Local PostgreSQL schema and restricted runtime grants ready. Sandbox payments only.');
|
||||
} catch {
|
||||
console.error('Local PostgreSQL bootstrap failed; inspect roles, secret volumes, TLS and schema privately. No reset or fallback performed.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const read = (path: string) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
test('persistent custody deployment remains separate, sandbox-only and password authenticated without a database host port', async () => {
|
||||
const compose = (await read('compose.custody.yaml')).replaceAll('\r\n', '\n');
|
||||
assert.match(compose, /WIKIDECK_CUSTODY_MODE: sandbox/);
|
||||
assert.match(compose, /WIKIDECK_CUSTODY_STORAGE: postgres/);
|
||||
assert.match(compose, /WIKIDECK_PAYMENT_MODE: mock/);
|
||||
assert.match(compose, /financial: \{internal: true\}/);
|
||||
assert.match(compose, /POSTGRES_PASSWORD_FILE:/);
|
||||
assert.doesNotMatch(compose, /POSTGRES_HOST_AUTH_METHOD|LOCAL_TEST|5432:5432|tmpfs:.*postgresql/);
|
||||
const database = compose.split(' database:')[1].split(' migrate:')[0];
|
||||
assert.doesNotMatch(database, /ports:/);
|
||||
assert.match(database, /pg-data:\/var\/lib\/postgresql\/data/);
|
||||
const application = compose.split(' wikideck:')[1].split('networks:\n financial:')[0];
|
||||
assert.match(application, /pg-runtime:\/secrets\/runtime:ro/);
|
||||
assert.doesNotMatch(application, /pg-admin:|pg-owner:|pg-tls:/);
|
||||
});
|
||||
test('database HBA requires TLS+SCRAM over TCP and provisioning refuses partial credential replacement', async () => {
|
||||
const hba = await read('deploy/postgres/pg_hba.conf'), setup = await read('deploy/postgres/setup.mjs');
|
||||
assert.match(hba, /hostnossl all all all reject/);
|
||||
assert.match(hba, /hostssl wikideck_custody custody_runtime all scram-sha-256/);
|
||||
assert.doesNotMatch(hba, /^host.*trust/m);
|
||||
assert.match(setup, /randomBytes\(32\)/);
|
||||
assert.match(setup, /Partial secret initialization/);
|
||||
assert.match(setup, /subjectAltName=DNS:database/);
|
||||
assert.match(setup, /-checkend/);
|
||||
});
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"allowImportingTsExtensions": true, "resolveJsonModule": true,
|
||||
"noEmit": true, "types": ["node", "vite/client"]
|
||||
},
|
||||
"include": ["src", "server", "shared", "tests", "scripts/custody-db.ts", "vite.config.ts"]
|
||||
"include": ["src", "server", "shared", "tests", "scripts/custody-db.ts", "scripts/custody-deployment-init.ts", "scripts/custody-deployment-check.ts", "vite.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user