Private CLI workflow for reviewed depowit operations
This commit is contained in:
@@ -100,3 +100,18 @@ Verified: restricted runtime authorization; deferred balance constraints; fracti
|
||||
Final evidence: TypeScript/Vite test-image build passed; **149 existing regression tests and 10 isolated PostgreSQL integration tests passed**, zero failures/skips. PostgreSQL fixture resources were removed after testing. Existing user data directories and deployed node database settings were not switched. This verifies the current sandbox implementation, not production acceptance.
|
||||
|
||||
**Not closed:** live Monero and NFT settlement; production dataset/SLOs and pool contention benchmarks; audit scheduling and very-large-history startup cost; normalized durable real-deposit/withdrawal/compensation states; account recovery/second factor; external security/accounting review; coordinated node/financial/treasury backup; deployment TLS drills, PITR and failover rehearsal; funded exposure/fee/loss policies; legal/operations sign-off. The scalable database choice does not turn the existing sandbox into a production custodian.
|
||||
|
||||
### Reviewed Monero pending-credit operations (sandbox only)
|
||||
|
||||
`npm run custody:db -- ACTION` now supports these explicit, non-HTTP actions for the pending-credit ledger stage:
|
||||
|
||||
- `deposit-prepare-credit WALLET_FINGERPRINT OBSERVATION_ID` emits the exact review subject for an eligible, currently observed output.
|
||||
- `deposit-stage-credit APPROVAL_JSON_FILE` verifies the allow-listed reviewer signature and records the amount in a non-spendable pending liability.
|
||||
- `deposit-prepare-release CREDIT_UUID` emits the current output and unresolved-hold snapshot.
|
||||
- `deposit-release APPROVAL_JSON_FILE` moves the matching liability to `available` after a distinct reviewer signature.
|
||||
- `deposit-reconcile-holds WALLET_FINGERPRINT` records unseen/changed/reappeared observations as append-only holds.
|
||||
- `deposit-prepare-resolve-hold CREDIT_UUID HOLD_UUID` and `deposit-resolve-hold APPROVAL_JSON_FILE` resolve a hold only after a fresh eligible observation and a new reviewer signature.
|
||||
|
||||
The operator must separately provision the coordinator role with action `grant-monero-credit-runtime ROLE`; database migration remains explicit. The review policy comes from `WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE`, an owner-only JSON file containing only approved reviewer owner public-key hashes, a confirmation floor (at least 10 in this sandbox policy), and maximum observer age. Approval JSON files are bounded, absolute-path, regular files opened without following symlinks, and owner-only on POSIX. **These review-file commands currently refuse Windows** because the implementation cannot verify Windows ACLs; run them only in a secured POSIX environment. No reviewer private key is loaded by the application: snapshots are emitted for external signing and signed approval files are then submitted back to the CLI. Two-argument commands parse both identifiers explicitly. These CLI actions do not enable scanning, an HTTP endpoint, an unattended worker, or live deposits.
|
||||
|
||||
The added `tests/custody-db-args.test.ts` verifies two-argument IDs are preserved in the proper positions and rejects missing, empty, unknown, or extra arguments. `tests/monero-deposit-review-config.test.ts` checks strict policy parsing, approval-file bounds, POSIX owner-only permissions, no-follow/symlink rejection, and fail-closed Windows behavior. Full offline validation passed **194/194**; PostgreSQL integration passed **36/36** after the operator workflow changes. These tests verify offline command/config boundaries only; no operator keys or live RPC were used.
|
||||
|
||||
@@ -30,6 +30,8 @@ Status: active engineering plan; **not launch or payment authorization**. Update
|
||||
|
||||
**Reviewed pending-deposit ledger stage (2026-10-21):** schema v5 adds immutable per-output credit records and append-only observation-event holds; schema v6 adds append-only reviewer-signed release and hold-resolution records; v7 permits one signed release to resolve multiple holds while retaining one-time hold identity. A separate, non-HTTP coordinator verifies registered beneficiary allocation, observation freshness/unlock/confirmation/double-spend state, and an allow-listed P-256 reviewer signature. It can first stage an output into a non-spendable `deposit-pending` liability, then separately release that amount into `available` under another signed review bound to the current output and every unresolved hold. Reorg/disappearance/re-inclusion events create scoped holds; new purchases and withdrawals for that beneficiary are blocked while any remain unresolved. Holds can be resolved only after the output is again observed, unlocked, sufficiently confirmed, and signed off; journals are never reversed automatically. The dedicated coordinator DB role is explicitly provisioned using `grant-monero-credit-runtime` and remains separate from observer and custody-runtime roles. This remains isolated sandbox ledger machinery, not proof of treasury backing: no API/startup integration, reviewer key provisioning, continuous scan/hold worker, production deposit activation, withdrawal signer/worker, or live sale settlement exists. Validation: test image build passed; offline suite **190/190 passed**; disposable PostgreSQL integration suite **36/36 passed**. Tests cover schema upgrades, pending-credit review, concurrent duplicate release/reorg resolution, scoped spending holds, and cleanup of the disposable fixture. No external network or funds used.
|
||||
|
||||
**Offline deposit-review operator commands (2026-10-21):** `custody-db` now has explicit prepare/stage/release/reorg-hold/review-resolution actions, with exact argument-count checks and idempotent coordinator calls. They consume an owner-protected review-policy file and bounded signed-approval files; reviewer private keys are never loaded by the app. POSIX owner-only permissions and no-follow opens are required; these file operations refuse Windows until ACL verification is implemented. The existing `IS_MAINNET` guard keeps the CLI sandbox-only. This is an operator rehearsal interface, not a public deposit endpoint or live payment activation. Added argument-parser and protected-file tests; full offline suite passed **194/194** and PostgreSQL integration **36/36**.
|
||||
|
||||
## 1A. MVP release boundary — proposed safe first production-hosted release
|
||||
|
||||
The apartment/single-operator constraint changes the rollout and availability claims, not the safety evidence required to hold customer funds or valuable assets. Recommended MVP: **a public, clearly labeled non-monetary beta on the existing testnet**, with real XMR completely disabled, no custodial balances/deposits/withdrawals, and marketplace checkout/delivery disabled for valuable assets. Keep mining, browsing and wallet use only if they are explicitly described as experimental and users accept that data/ownership may be lost. This would be a production-hosted beta, **not mainnet production**, a custody MVP, or an independently secured chain.
|
||||
|
||||
+49
-2
@@ -7,11 +7,21 @@ import { importSqliteCustody } from '../server/market/custody-import.ts';
|
||||
import { PostgresCustody } from '../server/market/postgres-custody.ts';
|
||||
import { IS_MAINNET } from '../shared/network.ts';
|
||||
import { resolveCustodyIncident } from '../server/market/custody-incident.ts';
|
||||
import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '../server/market/monero-deposit-review-config.ts';
|
||||
import { parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
|
||||
import { prepareDepositCreditReview, postReviewedDepositPendingCredit, prepareDepositCreditReleaseReview,
|
||||
releaseReviewedDepositCredit, reconcileDepositCreditReorgHolds, prepareDepositReorgResolution,
|
||||
resolveReleasedDepositReorgHold, type DepositCreditApproval, type DepositCreditReleaseApproval,
|
||||
type DepositReorgResolutionApproval } from '../server/market/monero-deposit-credit.ts';
|
||||
|
||||
// Explicit operator task, never invoked by node startup. No passwords or database URLs printed.
|
||||
if (IS_MAINNET) throw new Error('Only sandbox financial schema operations are implemented');
|
||||
const [action, argument] = process.argv.slice(2);
|
||||
if (!['migrate', 'grant-runtime', 'grant-monero-credit-runtime', 'import-sqlite', 'audit', 'resolve-integrity'].includes(action)) throw new Error('Expected migrate, grant-runtime ROLE, grant-monero-credit-runtime ROLE, import-sqlite DATA_DIR, audit, or resolve-integrity EVIDENCE_UUID');
|
||||
const commandArgs = process.argv.slice(2);
|
||||
const depositActions = ['deposit-prepare-credit', 'deposit-stage-credit', 'deposit-prepare-release', 'deposit-release',
|
||||
'deposit-reconcile-holds', 'deposit-prepare-resolve-hold', 'deposit-resolve-hold'];
|
||||
const custodyActions = ['migrate', 'grant-runtime', 'grant-monero-credit-runtime', 'import-sqlite', 'audit', 'resolve-integrity'];
|
||||
const { action, argument, extraArgument } = parseCustodyDbArguments(commandArgs);
|
||||
if (!custodyActions.includes(action) && !depositActions.includes(action)) throw new Error('Unsupported custody operation');
|
||||
try {
|
||||
const config = await postgresConfig();
|
||||
if (action === 'audit') {
|
||||
@@ -27,6 +37,43 @@ try {
|
||||
console.log('Separate reviewed pending-credit coordinator grants applied; no credentials printed and no live deposits enabled.');
|
||||
}
|
||||
if (action === 'resolve-integrity') console.log(JSON.stringify(await resolveCustodyIncident(pool, argument ?? '')));
|
||||
if (depositActions.includes(action)) {
|
||||
const policy = await loadMoneroDepositReviewPolicy();
|
||||
if (action === 'deposit-prepare-credit') {
|
||||
if (!argument || !extraArgument) throw new Error('Expected deposit-prepare-credit WALLET_FINGERPRINT OBSERVATION_ID');
|
||||
console.log(JSON.stringify(await prepareDepositCreditReview(pool, argument, extraArgument, policy)));
|
||||
}
|
||||
if (action === 'deposit-stage-credit') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-stage-credit APPROVAL_JSON_FILE');
|
||||
const approval = await loadMoneroDepositReviewApproval(argument);
|
||||
// The coordinator validates the exact approval schema/signature before any write.
|
||||
console.log(JSON.stringify(await postReviewedDepositPendingCredit(pool, approval as DepositCreditApproval, policy)));
|
||||
}
|
||||
if (action === 'deposit-prepare-release') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-prepare-release CREDIT_UUID');
|
||||
console.log(JSON.stringify(await prepareDepositCreditReleaseReview(pool, argument, policy)));
|
||||
}
|
||||
if (action === 'deposit-release') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-release APPROVAL_JSON_FILE');
|
||||
const approval = await loadMoneroDepositReviewApproval(argument);
|
||||
// The coordinator validates the exact approval schema/signature before any write.
|
||||
console.log(JSON.stringify(await releaseReviewedDepositCredit(pool, approval as DepositCreditReleaseApproval, policy)));
|
||||
}
|
||||
if (action === 'deposit-reconcile-holds') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-reconcile-holds WALLET_FINGERPRINT');
|
||||
console.log(JSON.stringify({ holdsCreated: await reconcileDepositCreditReorgHolds(pool, argument) }));
|
||||
}
|
||||
if (action === 'deposit-prepare-resolve-hold') {
|
||||
if (!argument || !extraArgument) throw new Error('Expected deposit-prepare-resolve-hold CREDIT_UUID HOLD_UUID');
|
||||
console.log(JSON.stringify(await prepareDepositReorgResolution(pool, argument, extraArgument, policy)));
|
||||
}
|
||||
if (action === 'deposit-resolve-hold') {
|
||||
if (!argument || extraArgument) throw new Error('Expected deposit-resolve-hold APPROVAL_JSON_FILE');
|
||||
const approval = await loadMoneroDepositReviewApproval(argument);
|
||||
// The coordinator validates the exact approval schema/signature before any write.
|
||||
console.log(JSON.stringify(await resolveReleasedDepositReorgHold(pool, approval as DepositReorgResolutionApproval, policy)));
|
||||
}
|
||||
}
|
||||
if (action === 'import-sqlite') {
|
||||
if (!argument) throw new Error('Stopped source node DATA_DIR required');
|
||||
const directory = resolve(argument);
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
export const CUSTODY_DB_ACTION_ARGUMENT_COUNTS = {
|
||||
migrate: 0,
|
||||
'grant-runtime': 1,
|
||||
'grant-monero-credit-runtime': 1,
|
||||
'import-sqlite': 1,
|
||||
audit: 0,
|
||||
'resolve-integrity': 1,
|
||||
'deposit-prepare-credit': 2,
|
||||
'deposit-stage-credit': 1,
|
||||
'deposit-prepare-release': 1,
|
||||
'deposit-release': 1,
|
||||
'deposit-reconcile-holds': 1,
|
||||
'deposit-prepare-resolve-hold': 2,
|
||||
'deposit-resolve-hold': 1,
|
||||
} as const;
|
||||
|
||||
export type CustodyDbAction = keyof typeof CUSTODY_DB_ACTION_ARGUMENT_COUNTS;
|
||||
export type CustodyDbArguments = { action: CustodyDbAction; argument?: string; extraArgument?: string };
|
||||
|
||||
export function parseCustodyDbArguments(args: readonly string[]): CustodyDbArguments {
|
||||
const [action, ...values] = args;
|
||||
if (!action || !Object.hasOwn(CUSTODY_DB_ACTION_ARGUMENT_COUNTS, action)) {
|
||||
throw new Error(`Expected one of: ${Object.keys(CUSTODY_DB_ACTION_ARGUMENT_COUNTS).join(', ')}`);
|
||||
}
|
||||
const count = CUSTODY_DB_ACTION_ARGUMENT_COUNTS[action as CustodyDbAction];
|
||||
if (values.length !== count || values.some(value => value.length === 0)) {
|
||||
throw new Error(`Invalid argument count for custody operation: ${action}`);
|
||||
}
|
||||
return { action: action as CustodyDbAction, argument: values[0], extraArgument: values[1] };
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import type { DepositCreditPolicy } from './monero-deposit-credit.ts';
|
||||
|
||||
const MAX_REVIEW_CONFIG_BYTES = 4096;
|
||||
const MAX_APPROVAL_BYTES = 64 * 1024;
|
||||
const reviewerPolicySchema = z.object({
|
||||
approvedReviewerOwners: z.array(z.string().regex(/^[a-f0-9]{64}$/)).min(1).max(8),
|
||||
minimumConfirmations: z.number().int().min(10).max(100_000),
|
||||
maximumObservationAgeMs: z.number().int().min(10_000).max(15 * 60_000),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (new Set(value.approvedReviewerOwners).size !== value.approvedReviewerOwners.length) {
|
||||
context.addIssue({ code: 'custom', message: 'Reviewer owner allowlist must not contain duplicates' });
|
||||
}
|
||||
});
|
||||
|
||||
async function readPrivateJson(path: string, maximumBytes: number): Promise<unknown> {
|
||||
if (process.platform === 'win32') throw new Error('Owner-only ACL verification is unavailable on Windows');
|
||||
if (!isAbsolute(path)) throw new Error('Review files require an absolute path');
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const info = await file.stat();
|
||||
if (!info.isFile() || info.size < 1 || info.size > maximumBytes) {
|
||||
throw new Error('Review file must be a bounded regular file');
|
||||
}
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
throw new Error('Review file permissions must be owner-only');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0, position = 0;
|
||||
while (total <= maximumBytes) {
|
||||
const chunk = Buffer.allocUnsafe(Math.min(4096, maximumBytes + 1 - total));
|
||||
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
|
||||
if (bytesRead === 0) break;
|
||||
total += bytesRead; position += bytesRead;
|
||||
if (total > maximumBytes) throw new Error('Review file exceeds byte limit');
|
||||
chunks.push(chunk.subarray(0, bytesRead));
|
||||
}
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
||||
return JSON.parse(text) as unknown;
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
|
||||
/** Public keys/policy only; no reviewer private key is loaded by the application. */
|
||||
export async function loadMoneroDepositReviewPolicy(env: NodeJS.ProcessEnv = process.env): Promise<DepositCreditPolicy> {
|
||||
const path = env.WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE;
|
||||
if (!path) throw new Error('Monero deposit review policy file is required');
|
||||
try { return reviewerPolicySchema.parse(await readPrivateJson(path, MAX_REVIEW_CONFIG_BYTES)); }
|
||||
catch { throw new Error('Invalid or inaccessible protected Monero deposit review policy'); }
|
||||
}
|
||||
|
||||
/** Signed approvals are bearer authorizations, so read only owner-protected regular files. */
|
||||
export async function loadMoneroDepositReviewApproval(path: string): Promise<unknown> {
|
||||
try { return await readPrivateJson(path, MAX_APPROVAL_BYTES); }
|
||||
catch { throw new Error('Invalid or inaccessible protected Monero deposit review approval'); }
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseCustodyDbArguments } from '../server/market/custody-db-args.ts';
|
||||
|
||||
test('custody database CLI parses two-argument deposit operations without shifting arguments', () => {
|
||||
assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-credit', 'a'.repeat(64), `${'b'.repeat(64)}:${'c'.repeat(64)}`]), {
|
||||
action: 'deposit-prepare-credit', argument: 'a'.repeat(64), extraArgument: `${'b'.repeat(64)}:${'c'.repeat(64)}`,
|
||||
});
|
||||
assert.deepEqual(parseCustodyDbArguments(['deposit-prepare-resolve-hold', 'credit-id', 'hold-id']), {
|
||||
action: 'deposit-prepare-resolve-hold', argument: 'credit-id', extraArgument: 'hold-id',
|
||||
});
|
||||
});
|
||||
|
||||
test('custody database CLI rejects unknown, missing, empty, and excess arguments', () => {
|
||||
for (const args of [[], ['unknown'], ['audit', 'extra'], ['deposit-prepare-credit', 'wallet'],
|
||||
['deposit-prepare-credit', 'wallet', 'observation', 'extra'], ['deposit-release', '']]) {
|
||||
assert.throws(() => parseCustodyDbArguments(args));
|
||||
}
|
||||
});
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadMoneroDepositReviewApproval, loadMoneroDepositReviewPolicy } from '../server/market/monero-deposit-review-config.ts';
|
||||
|
||||
const owner = 'a'.repeat(64);
|
||||
|
||||
async function privateFile(path: string, content: string) {
|
||||
await writeFile(path, content, { mode: 0o600 });
|
||||
await chmod(path, 0o600);
|
||||
}
|
||||
|
||||
test('deposit review policy requires an owner-only protected file and strict bounds', async t => {
|
||||
if (process.platform === 'win32') {
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy({ WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE: 'C:\\private\\policy.json' }), /Invalid or inaccessible/);
|
||||
return;
|
||||
}
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wikideck-deposit-review-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const policyPath = join(directory, 'policy.json');
|
||||
const env = { WIKIDECK_MONERO_DEPOSIT_REVIEW_POLICY_FILE: policyPath };
|
||||
const valid = { approvedReviewerOwners: [owner], minimumConfirmations: 10, maximumObservationAgeMs: 120_000 };
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy({}), /required/);
|
||||
await privateFile(policyPath, JSON.stringify(valid));
|
||||
assert.deepEqual(await loadMoneroDepositReviewPolicy(env), valid);
|
||||
|
||||
await privateFile(policyPath, JSON.stringify({ ...valid, unexpected: true }));
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/);
|
||||
await privateFile(policyPath, JSON.stringify({ ...valid, minimumConfirmations: 2 }));
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/);
|
||||
|
||||
await privateFile(policyPath, JSON.stringify(valid)); await chmod(policyPath, 0o644);
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/);
|
||||
await rm(policyPath);
|
||||
await symlink(join(directory, 'missing-target.json'), policyPath);
|
||||
await assert.rejects(loadMoneroDepositReviewPolicy(env), /Invalid or inaccessible/);
|
||||
});
|
||||
|
||||
test('deposit approval file loader requires an absolute, bounded, private regular file', async t => {
|
||||
if (process.platform === 'win32') {
|
||||
await assert.rejects(loadMoneroDepositReviewApproval('C:\\private\\approval.json'), /Invalid or inaccessible/);
|
||||
return;
|
||||
}
|
||||
const directory = await mkdtemp(join(tmpdir(), 'wikideck-deposit-approval-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const approvalPath = join(directory, 'approval.json');
|
||||
const approval = { version: 1, signature: 'signed-review-fixture' };
|
||||
await assert.rejects(loadMoneroDepositReviewApproval('relative-approval.json'), /protected Monero deposit review approval/);
|
||||
await privateFile(approvalPath, JSON.stringify(approval));
|
||||
assert.deepEqual(await loadMoneroDepositReviewApproval(approvalPath), approval);
|
||||
|
||||
await privateFile(approvalPath, 'x'.repeat(64 * 1024 + 1));
|
||||
await assert.rejects(loadMoneroDepositReviewApproval(approvalPath), /protected Monero deposit review approval/);
|
||||
await privateFile(approvalPath, '{malformed');
|
||||
await assert.rejects(loadMoneroDepositReviewApproval(approvalPath), /protected Monero deposit review approval/);
|
||||
});
|
||||
Reference in New Issue
Block a user