diff --git a/docs/FLOWCHAIN_CONTROL_PLANE_API.md b/docs/FLOWCHAIN_CONTROL_PLANE_API.md index e0b3a84c..daf31cca 100644 --- a/docs/FLOWCHAIN_CONTROL_PLANE_API.md +++ b/docs/FLOWCHAIN_CONTROL_PLANE_API.md @@ -1,10 +1,10 @@ # FlowChain Local Control Plane API -Status: local fixture-backed V0 contract. +Status: local runtime-backed V0 contract with deterministic fixture fallback. -This document defines the local JSON-RPC 2.0 API for the FlowChain / FlowMemory control-plane. It gives dashboard, agent, verifier, and devnet tooling one deterministic read surface for FlowMemory objects. +This document defines the local JSON-RPC 2.0 API for the FlowChain / FlowMemory control-plane. It gives dashboard, agent, verifier, bridge, and devnet tooling one deterministic local surface for FlowMemory objects. -It is not a production RPC endpoint, public L1 API, hosted service, wallet API, bridge API, token API, or verifier economics surface. +It is not a production RPC endpoint, public L1 API, hosted service, production wallet API, production bridge API, token API, or verifier economics surface. ## Runtime Boundary @@ -21,13 +21,17 @@ npm run control-plane:test npm run control-plane:demo npm run control-plane:smoke npm run control-plane:serve +npm run flowchain:full-smoke ``` -The service uses deterministic local files only. It does not require secrets, wallets, RPC URLs, private keys, API keys, or production services. +The service reads ignored local runtime files first and falls back to committed deterministic fixtures. It does not require secrets, RPC URLs, private keys, API keys, or production services. Primary data sources: ```text +devnet/local/state.json +devnet/local/launch-v0-state.json +devnet/local/handoff/generated/*.json fixtures/launch-core/flowmemory-launch-v0.json fixtures/launch-core/generated/devnet/state.json fixtures/launch-core/generated/devnet/indexer-handoff.json @@ -37,9 +41,12 @@ services/indexer/out/indexer-state.json services/verifier/out/reports.json services/verifier/fixtures/artifacts.json fixtures/handoff/sample-txs.json +services/bridge-relayer/out/bridge-observation.json +services/bridge-relayer/out/control-plane-observations.json +fixtures/bridge/base-sepolia-mock-deposit.json ``` -If the generated launch-core fixture is missing, the service rebuilds the in-memory view from indexer/verifier outputs or raw fixture receipts and artifact fixtures. This recovery path is local and read-only from the API caller perspective. +If the generated launch-core fixture is missing, the service rebuilds the in-memory view from indexer/verifier outputs or raw fixture receipts and artifact fixtures. This recovery path is local. Transaction and bridge observation write endpoints forward only into the existing local runtime or bridge-agent intake files. ## JSON-RPC Envelope @@ -113,22 +120,51 @@ GET /health Params: none. -Returns local stack status, fixture source status, block counters, object counters, capabilities, and limitations. +Returns local stack status, live/fixture source status, block counters, object counters, capabilities, and limitations. Key result fields: ```json { "schema": "flowmemory.control_plane.chain_status.v0", - "chainId": "flowmemory-local-alpha", - "environment": "local-devnet-fixture", - "source": "fixture", + "chainId": "flowmemory-local-devnet-v0", + "environment": "private-local-devnet", + "source": "local-runtime-file", "currentBlock": "123461", "finalizedBlock": "123457", "localOnly": true } ``` +### `node_status` + +Params: none. + +Returns bounded local runtime status, current block, state root, pending +transaction count, runtime mode, and source file. The current runtime reports +`longRunningNode: false` because it is still the deterministic Rust CLI, not a +daemon. + +### `peer_list` + +Params: none. + +Returns the local self peer for the single-process runtime. LAN peer discovery +is not implemented in this package. + +### `mempool_list` + +Params: + +```json +{ + "limit": 50 +} +``` + +Returns pending transaction envelopes from the local devnet state or +control-plane handoff. + ### `devnet_state` Params: @@ -195,6 +231,55 @@ Params: one of: { "txHash": "0x..." } ``` +### `transaction_submit` + +Params: one of: + +```json +{ "tx": { "type": "RegisterRootfield" } } +``` + +```json +{ "txs": [{ "type": "RegisterRootfield" }] } +``` + +```json +{ "signedTransaction": { "tx": { "type": "RegisterRootfield" }, "signature": "0x..." } } +``` + +The control-plane writes a local fixture under +`devnet/local/control-plane-intake/` and forwards it to the existing runtime +intake path: + +```powershell +cargo run --manifest-path crates/flowmemory-devnet/Cargo.toml -- --state devnet/local/state.json submit-fixture --fixture +``` + +The endpoint queues transactions only. It does not produce a block by itself. +Payloads containing secret-bearing fields such as `privateKey`, `mnemonic`, +`seedPhrase`, `rpcUrl`, `apiKey`, or webhook credentials are rejected. + +### `account_list` / `account_get` + +Return local public account rows for operator key references and `AgentAccount` +objects. Balances are no-value local devnet balances. + +### `balance_list` / `balance_get` + +Return explicit zero/no-value balances. The private/local runtime has no token +value, gas accounting, staking, rewards, or faucet funds. + +### `faucet_event_list` / `faucet_event_get` + +Return a stable disabled faucet event explaining that the local devnet has no +token value or faucet funds. + +### `wallet_metadata_list` / `wallet_metadata_get` + +Return public operator key-reference metadata only: key reference ids, operator +ids, signature scheme labels, and verifier-set roots. No signing secret +material is returned. + ### `rootfield_get` Params: @@ -443,6 +528,11 @@ Params: one of: Returns an `AgentMemoryView` and linked `RootfieldBundle`. +### `agent_account_list` / `agent_account_get` + +Return native private/local `AgentAccount` objects from the devnet runtime state +or handoff files. + ### `agent_list` Params: @@ -483,6 +573,11 @@ Params: one of: { "rootfieldId": "0x..." } ``` +### `model_passport_list` / `model_passport_get` + +Return native private/local `ModelPassport` objects from the devnet runtime +state or handoff files. + ### `challenge_get` Params: one of: @@ -556,6 +651,42 @@ Params: All params are optional. Returns native finality receipts when present and projected local finality rows for launch-core receipts. +### `bridge_observation_submit` + +Params: one of: + +```json +{ "observation": { "schema": "flowmemory.bridge_deposit_observation.v0" } } +``` + +```json +{ "deposit": { "schema": "flowmemory.bridge_deposit.v0" } } +``` + +Stores local bridge-agent observations in +`services/bridge-relayer/out/control-plane-observations.json`. This is local +observation intake only; it does not custody funds, sign releases, or implement +production bridge security. + +### `bridge_observation_list` / `bridge_observation_get` + +Return bridge observations from bridge-relayer output, control-plane intake, or +the committed mock deposit fixture. + +### `bridge_deposit_list` / `bridge_deposit_get` + +Return bridge deposit rows derived from bridge observations. + +### `bridge_credit_list` / `bridge_credit_get` + +Return local bridge-credit projections for observed deposits. Pending credits do +not imply production bridge accounting. + +### `withdrawal_list` / `withdrawal_get` + +Return local withdrawal handoff rows when present. Without native withdrawal +handoff data, `withdrawal_get` returns a stable `not_opened` placeholder. + ### `provenance_get` Params: one of: @@ -603,6 +734,9 @@ Allowed `source` values: - `devnetVerifierHandoff` - `devnetControlPlaneHandoff` - `txFixtures` +- `bridgeObservation` +- `bridgeObservationIntake` +- `bridgeDepositFixture` Returns the raw loaded local JSON object for dashboard/workbench debug views. It does not accept arbitrary filesystem paths. @@ -610,13 +744,22 @@ Returns the raw loaded local JSON object for dashboard/workbench debug views. It Dashboard agents should prefer: -1. `health` and `chain_status` for source health and global counters. -2. `block_list` and `transaction_list` for chain/devnet tables. -3. `rootfield_list` and `rootfield_get` for Rootfield detail. -4. `work_receipt_list`, `receipt_list`, `verifier_module_list`, and `verifier_report_list` for lifecycle tables. -5. `receipt_get`, `work_receipt_get`, `verifier_report_get`, and `provenance_get` for detail drawers. -6. `artifact_availability_list`, `memory_cell_list`, `agent_list`, and `model_list` for dashboard/workbench panels. -7. `challenge_get`, `challenge_list`, `finality_get`, and `finality_list` for local fixture challenge/finality labels. -8. `raw_json_get` for raw JSON inspection. - -The API is intentionally read-only for V0. Submit, challenge, wallet, live indexing, and production settlement methods require separate scoped work. +1. `health`, `chain_status`, and `node_status` for source health and global counters. +2. `block_list`, `transaction_list`, and `mempool_list` for chain/devnet tables. +3. `account_list`, `wallet_metadata_list`, `balance_list`, and `faucet_event_list` for local account panels. +4. `rootfield_list` and `rootfield_get` for Rootfield detail. +5. `agent_account_list`, `model_passport_list`, `work_receipt_list`, `receipt_list`, `verifier_module_list`, and `verifier_report_list` for lifecycle tables. +6. `receipt_get`, `work_receipt_get`, `verifier_report_get`, and `provenance_get` for detail drawers. +7. `artifact_availability_list`, `memory_cell_list`, `agent_list`, and `model_list` for dashboard/workbench panels. +8. `challenge_get`, `challenge_list`, `finality_get`, and `finality_list` for local challenge/finality labels. +9. `bridge_observation_list`, `bridge_deposit_list`, `bridge_credit_list`, and `withdrawal_list` for bridge-agent inspection. +10. `raw_json_get` for raw JSON inspection. + +HTTP helpers are available for browser workbench reads at `/node/status`, +`/peers`, `/mempool`, `/blocks`, `/transactions`, `/accounts`, `/balances`, +`/faucet/events`, `/wallets`, `/agents`, `/models`, `/work-receipts`, +`/artifacts/availability`, `/verifier-modules`, `/verifier-reports`, +`/memory-cells`, `/challenges`, `/finality`, `/bridge/observations`, +`/bridge/deposits`, `/bridge/credits`, and `/withdrawals`. Write helpers are +available at `POST /transactions` and `POST /bridge/observations`; JSON-RPC +remains the canonical API envelope. diff --git a/docs/INDEXER_VERIFIER_MVP.md b/docs/INDEXER_VERIFIER_MVP.md index 7b887c3e..8dee9f48 100644 --- a/docs/INDEXER_VERIFIER_MVP.md +++ b/docs/INDEXER_VERIFIER_MVP.md @@ -270,7 +270,7 @@ Those are future protocol decisions, not part of this local package. ## Local Control Plane -`services/control-plane` exposes the fixture-backed FlowChain / FlowMemory JSON-RPC 2.0 read API documented in `docs/FLOWCHAIN_CONTROL_PLANE_API.md`. +`services/control-plane` exposes the FlowChain / FlowMemory JSON-RPC 2.0 API documented in `docs/FLOWCHAIN_CONTROL_PLANE_API.md`. It now reads ignored `devnet/local/` runtime state and handoff files first, then falls back to committed deterministic fixtures. Run: @@ -281,7 +281,7 @@ npm run control-plane:smoke npm run control-plane:serve ``` -The control-plane reads committed launch-core, indexer, verifier, artifact, transaction fixture, and local devnet handoff files first. If the generated launch-core fixture is missing, it rebuilds an in-memory view from deterministic indexer/verifier fixtures. It exposes read methods for health, chain status, blocks, transactions, rootfields, agents, models, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, provenance, and raw JSON. It does not fetch production RPC data, store secrets, or make production API claims. +The control-plane exposes methods for health, chain status, node status, peers, mempool, blocks, transactions, transaction submission, accounts, balances, faucet status, wallet public metadata, rootfields, agents, agent accounts, models, model passports, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, bridge observations/deposits/credits/withdrawals, provenance, and raw JSON. `transaction_submit` forwards local test transactions to the existing Rust devnet `submit-fixture` intake path. `bridge_observation_submit` stores bridge-agent observations under `services/bridge-relayer/out/`. The smoke client queries every lifecycle object and runs no-secret response scanning. The package does not fetch production RPC data, store secrets, or make production API claims. ## Open Questions diff --git a/package.json b/package.json index d8cb0136..2690c263 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "flowchain:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-stop.ps1", "flowchain:demo": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-demo.ps1", "flowchain:smoke": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-smoke.ps1", + "flowchain:full-smoke": "npm run flowchain:smoke && npm run control-plane:smoke", "flowchain:export": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-export.ps1", "flowchain:import": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-import.ps1", "workbench:dev": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/flowchain-workbench.ps1", diff --git a/services/control-plane/README.md b/services/control-plane/README.md index f9888891..0d43d143 100644 --- a/services/control-plane/README.md +++ b/services/control-plane/README.md @@ -1,8 +1,8 @@ # FlowChain Control Plane V0 -This package exposes a local JSON-RPC 2.0 control-plane for FlowMemory and FlowChain fixture data. It is fixture-first, deterministic, and read-only. +This package exposes a local JSON-RPC 2.0 control-plane for FlowMemory and FlowChain runtime/fixture data. It reads ignored `devnet/local/` state first, falls back to committed deterministic fixtures, and forwards local write intake to the existing devnet and bridge-relayer paths. -It is not a production RPC endpoint, hosted service, wallet, sequencer, verifier network, token system, or production chain API. +It is not a production RPC endpoint, hosted service, production wallet, sequencer, verifier network, token system, production bridge, or production chain API. ## Commands @@ -13,6 +13,7 @@ npm run control-plane:demo npm run control-plane:test npm run control-plane:smoke npm run control-plane:serve +npm run flowchain:full-smoke ``` The demo and tests require no secrets, RPC URLs, wallets, or production services. @@ -25,10 +26,22 @@ The dispatcher supports: - `health` - `chain_status` - `devnet_state` +- `node_status` +- `peer_list` +- `mempool_list` - `block_get` - `block_list` +- `account_get` +- `account_list` +- `balance_get` +- `balance_list` +- `faucet_event_get` +- `faucet_event_list` +- `wallet_metadata_get` +- `wallet_metadata_list` - `transaction_get` - `transaction_list` +- `transaction_submit` - `rootfield_get` - `rootfield_list` - `artifact_get` @@ -46,12 +59,25 @@ The dispatcher supports: - `memory_cell_list` - `agent_get` - `agent_list` +- `agent_account_get` +- `agent_account_list` - `model_get` - `model_list` +- `model_passport_get` +- `model_passport_list` - `challenge_get` - `challenge_list` - `finality_get` - `finality_list` +- `bridge_observation_submit` +- `bridge_observation_get` +- `bridge_observation_list` +- `bridge_deposit_get` +- `bridge_deposit_list` +- `bridge_credit_get` +- `bridge_credit_list` +- `withdrawal_get` +- `withdrawal_list` - `provenance_get` - `raw_json_get` @@ -59,7 +85,13 @@ The API contract is documented in [docs/FLOWCHAIN_CONTROL_PLANE_API.md](../../do ## Data Sources -The loader reads committed deterministic outputs first: +The loader reads ignored local runtime outputs first when they exist: + +- `devnet/local/state.json` +- `devnet/local/launch-v0-state.json` +- `devnet/local/handoff/generated/*.json` + +It then falls back to committed deterministic outputs: - `fixtures/launch-core/flowmemory-launch-v0.json` - `fixtures/launch-core/generated/devnet/state.json` @@ -70,7 +102,10 @@ The loader reads committed deterministic outputs first: - `services/verifier/out/reports.json` - `services/verifier/fixtures/artifacts.json` - `fixtures/handoff/sample-txs.json` +- `fixtures/bridge/base-sepolia-mock-deposit.json` + +Bridge relayer output is read from `services/bridge-relayer/out/bridge-observation.json` and control-plane bridge intake is stored in `services/bridge-relayer/out/control-plane-observations.json`. -If the launch-core fixture is missing, the loader rebuilds the in-memory view from indexer/verifier outputs or the raw fixture receipts and artifact resolver. It does not fetch from live RPC or write production state. +If the launch-core fixture is missing, the loader rebuilds the in-memory view from indexer/verifier outputs or the raw fixture receipts and artifact resolver. It does not fetch from production RPC or write production state. -`npm run control-plane:smoke` runs an in-process JSON-RPC client over the complete local lifecycle surface: health, chain status, blocks, transactions, rootfields, agents, models, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, provenance, and raw JSON. +`npm run control-plane:smoke` runs an in-process JSON-RPC client over the complete local lifecycle surface: health, chain status, node status, peers, mempool, blocks, transactions, transaction submission, accounts, balances, faucet status, wallet public metadata, rootfields, agents, agent accounts, models, model passports, work receipts, artifact availability, verifier modules, verifier reports, memory cells, challenges, finality, bridge observations/deposits/credits/withdrawals, provenance, raw JSON, and no-secret response scanning. diff --git a/services/control-plane/src/fixture-state.ts b/services/control-plane/src/fixture-state.ts index 751f0669..b2329a74 100644 --- a/services/control-plane/src/fixture-state.ts +++ b/services/control-plane/src/fixture-state.ts @@ -31,11 +31,21 @@ export const DEFAULT_CONTROL_PLANE_PATHS: ControlPlanePaths = { indexerPath: "services/indexer/out/indexer-state.json", verifierPath: "services/verifier/out/reports.json", artifactsPath: "services/verifier/fixtures/artifacts.json", + devnetLocalStatePath: "devnet/local/state.json", + devnetLocalLaunchStatePath: "devnet/local/launch-v0-state.json", + devnetLocalIndexerHandoffPath: "devnet/local/handoff/generated/indexer-handoff.json", + devnetLocalVerifierHandoffPath: "devnet/local/handoff/generated/verifier-handoff.json", + devnetLocalControlPlaneHandoffPath: "devnet/local/handoff/generated/control-plane-handoff.json", devnetPath: "fixtures/launch-core/generated/devnet/state.json", devnetIndexerHandoffPath: "fixtures/launch-core/generated/devnet/indexer-handoff.json", devnetVerifierHandoffPath: "fixtures/launch-core/generated/devnet/verifier-handoff.json", devnetControlPlaneHandoffPath: "fixtures/launch-core/generated/devnet/control-plane-handoff.json", txFixturesPath: "fixtures/handoff/sample-txs.json", + runtimeStatePath: "devnet/local/state.json", + runtimeIntakeDir: "devnet/local/control-plane-intake", + bridgeObservationPath: "services/bridge-relayer/out/bridge-observation.json", + bridgeObservationIntakePath: "services/bridge-relayer/out/control-plane-observations.json", + bridgeDepositFixturePath: "fixtures/bridge/base-sepolia-mock-deposit.json", }; function resolveRepoPath(path: string): string { @@ -145,6 +155,23 @@ function loadOptionalSource( return value; } +function loadFirstOptionalSource( + name: string, + paths: string[], + sources: Record, +): JsonObject | null { + for (const path of paths) { + const value = maybeReadJson(path); + if (value !== null) { + sources[name] = sourceRecord(name, path, "loaded"); + return value; + } + } + + sources[name] = sourceRecord(name, paths[0] ?? "", "missing", paths.slice(1).join(", ")); + return null; +} + export function controlPlanePaths(overrides: Partial = {}): ControlPlanePaths { return { ...DEFAULT_CONTROL_PLANE_PATHS, @@ -159,11 +186,27 @@ export function loadControlPlaneState(overrides: Partial = {} const indexer = loadOrBuildIndexer(paths.indexerPath, sources); const verifier = loadOrBuildVerifier(paths.verifierPath, indexer, artifacts, sources); const launchCore = loadOrBuildLaunchCore(paths, indexer, verifier, sources); - const devnet = loadOptionalSource("devnet", paths.devnetPath, sources); - const devnetIndexerHandoff = loadOptionalSource("devnetIndexerHandoff", paths.devnetIndexerHandoffPath, sources); - const devnetVerifierHandoff = loadOptionalSource("devnetVerifierHandoff", paths.devnetVerifierHandoffPath, sources); - const devnetControlPlaneHandoff = loadOptionalSource("devnetControlPlaneHandoff", paths.devnetControlPlaneHandoffPath, sources); + const devnet = loadFirstOptionalSource("devnet", [ + paths.devnetLocalStatePath, + paths.devnetLocalLaunchStatePath, + paths.devnetPath, + ], sources); + const devnetIndexerHandoff = loadFirstOptionalSource("devnetIndexerHandoff", [ + paths.devnetLocalIndexerHandoffPath, + paths.devnetIndexerHandoffPath, + ], sources); + const devnetVerifierHandoff = loadFirstOptionalSource("devnetVerifierHandoff", [ + paths.devnetLocalVerifierHandoffPath, + paths.devnetVerifierHandoffPath, + ], sources); + const devnetControlPlaneHandoff = loadFirstOptionalSource("devnetControlPlaneHandoff", [ + paths.devnetLocalControlPlaneHandoffPath, + paths.devnetControlPlaneHandoffPath, + ], sources); const txFixtures = loadOptionalSource("txFixtures", paths.txFixturesPath, sources); + const bridgeObservation = loadOptionalSource("bridgeObservation", paths.bridgeObservationPath, sources); + const bridgeObservationIntake = loadOptionalSource("bridgeObservationIntake", paths.bridgeObservationIntakePath, sources); + const bridgeDepositFixture = loadOptionalSource("bridgeDepositFixture", paths.bridgeDepositFixturePath, sources); return { schema: "flowmemory.control_plane.state.v0", @@ -176,6 +219,9 @@ export function loadControlPlaneState(overrides: Partial = {} devnetVerifierHandoff, devnetControlPlaneHandoff, txFixtures, + bridgeObservation, + bridgeObservationIntake, + bridgeDepositFixture, sources, }; } diff --git a/services/control-plane/src/index.ts b/services/control-plane/src/index.ts index 6321783a..aa71477a 100644 --- a/services/control-plane/src/index.ts +++ b/services/control-plane/src/index.ts @@ -2,4 +2,6 @@ export * from "./errors.ts"; export * from "./fixture-state.ts"; export * from "./json-rpc.ts"; export * from "./methods.ts"; +export * from "./no-secret.ts"; +export * from "./runtime-intake.ts"; export * from "./types.ts"; diff --git a/services/control-plane/src/methods.ts b/services/control-plane/src/methods.ts index dc19c3d8..23fef7dc 100644 --- a/services/control-plane/src/methods.ts +++ b/services/control-plane/src/methods.ts @@ -1,8 +1,14 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + import { canonicalJson, keccak256Hex } from "../../shared/src/index.ts"; import { invalidParams, methodNotFound, objectNotFound } from "./errors.ts"; -import { loadControlPlaneState } from "./fixture-state.ts"; +import { controlPlanePaths, loadControlPlaneState, repoRoot } from "./fixture-state.ts"; +import { scanJsonForSecrets } from "./no-secret.ts"; +import { extractSubmittedTransactions, submitTransactionsToRuntime } from "./runtime-intake.ts"; import type { ControlPlaneContext, + ControlPlanePaths, ControlPlaneMethod, JsonObject, JsonValue, @@ -17,6 +23,14 @@ function stateFor(context: ControlPlaneContext): LoadedControlPlaneState { return context.state ?? loadControlPlaneState(context.paths); } +function pathsFor(context: ControlPlaneContext): ControlPlanePaths { + return controlPlanePaths(context.paths); +} + +function repoPath(path: string): string { + return resolve(repoRoot(), path); +} + function asObjectParams(params: JsonValue | undefined, method: string): JsonObject { if (params === undefined) { return {}; @@ -100,7 +114,29 @@ function stableId(schema: string, value: JsonValue): string { return keccak256Hex(new TextEncoder().encode(canonicalJson({ schema, value }))); } +function devnetSourceKind(state: LoadedControlPlaneState): string { + const path = state.sources.devnet?.path ?? ""; + return path.startsWith("devnet/local/") ? "local-runtime-file" : "committed-fixture"; +} + +function devnetSourcePath(state: LoadedControlPlaneState): string { + return state.sources.devnet?.path ?? "fixtures/launch-core/generated/devnet/state.json"; +} + +function handoffSourcePath(state: LoadedControlPlaneState, name: "devnetIndexerHandoff" | "devnetVerifierHandoff" | "devnetControlPlaneHandoff"): string { + return state.sources[name]?.path ?? "fixtures/launch-core/generated/devnet/state.json"; +} + function latestBlock(state: LoadedControlPlaneState): { blockNumber: string; blockHash: string } { + const devnetBlocks = devnetBlocksArray(state); + const devnetLatest = devnetBlocks[devnetBlocks.length - 1]; + if (devnetLatest !== undefined) { + return { + blockNumber: stringValue(devnetLatest.blockNumber) ?? "0", + blockHash: stringValue(devnetLatest.blockHash) ?? ZERO_ROOT, + }; + } + const latest = [...state.indexer.state.observations].sort((left, right) => { const block = BigInt(right.blockNumber) - BigInt(left.blockNumber); if (block !== 0n) { @@ -120,6 +156,12 @@ function latestBlock(state: LoadedControlPlaneState): { blockNumber: string; blo } function finalizedBlock(state: LoadedControlPlaneState): string { + const devnetBlocks = devnetBlocksArray(state); + const devnetLatest = devnetBlocks[devnetBlocks.length - 1]; + if (devnetLatest !== undefined) { + return stringValue(devnetLatest.blockNumber) ?? "0"; + } + const finalized = state.indexer.state.observations .filter((observation) => observation.lifecycleState === "finalized") .map((observation) => BigInt(observation.blockNumber)); @@ -250,6 +292,244 @@ function devnetFinalityReceipts(state: LoadedControlPlaneState): Record { + return devnetMap(state, "operatorKeyReferences"); +} + +function devnetBaseAnchors(state: LoadedControlPlaneState): Record { + return devnetMap(state, "baseAnchors"); +} + +function pendingTxRows(state: LoadedControlPlaneState): JsonObject[] { + const pending = asJsonArray(state.devnet?.pendingTxs).length > 0 + ? asJsonArray(state.devnet?.pendingTxs) + : asJsonArray(state.devnetControlPlaneHandoff?.pendingTxs); + return pending + .map((entry) => asJsonObject(entry)) + .filter((entry): entry is JsonObject => entry !== null) + .map((entry) => ({ + schema: "flowmemory.control_plane.mempool_tx.v0", + txId: stringValue(entry.txId) ?? stableId("flowmemory.control_plane.mempool_tx.v0", entry), + transactionId: stringValue(entry.txId) ?? stableId("flowmemory.control_plane.mempool_tx.v0", entry), + tx: asJsonObject(entry.tx) ?? entry, + source: "local-devnet-pending", + localOnly: true, + })); +} + +function publicWalletRows(state: LoadedControlPlaneState): JsonObject[] { + const rows: JsonObject[] = []; + for (const [keyReferenceId, value] of Object.entries(devnetOperatorKeyReferences(state))) { + const reference = asJsonObject(value) ?? {}; + rows.push({ + schema: "flowmemory.control_plane.wallet_public_metadata.v0", + walletId: keyReferenceId, + accountId: stringValue(reference.operatorId) ?? keyReferenceId, + keyReferenceId, + signatureScheme: stringValue(reference.signatureScheme) ?? "local-fixture", + verifierSetRoot: stringValue(reference.verifierSetRoot) ?? null, + publicKeyHint: stringValue(reference.publicKeyHint) ?? null, + secretMaterialBoundary: stringValue(reference.secretMaterialBoundary) ?? "no signing secret material is exposed by the control plane", + source: "local-devnet", + localOnly: true, + }); + } + return rows.sort((left, right) => String(left.walletId).localeCompare(String(right.walletId))); +} + +function accountRows(state: LoadedControlPlaneState): JsonObject[] { + const rows: JsonObject[] = []; + for (const wallet of publicWalletRows(state)) { + rows.push({ + schema: "flowmemory.control_plane.account.v0", + accountId: wallet.accountId, + accountType: "operator", + walletPublicMetadata: wallet, + balance: "0", + asset: "FLOWCHAIN_NO_VALUE", + spendable: false, + source: "local-devnet", + localOnly: true, + }); + } + for (const [agentId, value] of Object.entries(devnetAgentAccounts(state))) { + const agent = asJsonObject(value) ?? {}; + rows.push({ + schema: "flowmemory.control_plane.account.v0", + accountId: agentId, + accountType: "AgentAccount", + controller: stringValue(agent.controller) ?? null, + agentAccount: agent, + balance: "0", + asset: "FLOWCHAIN_NO_VALUE", + spendable: false, + source: "local-devnet", + localOnly: true, + }); + } + return rows.sort((left, right) => String(left.accountId).localeCompare(String(right.accountId))); +} + +function balanceRows(state: LoadedControlPlaneState): JsonObject[] { + return accountRows(state).map((account) => ({ + schema: "flowmemory.control_plane.balance.v0", + accountId: account.accountId, + accountType: account.accountType, + asset: "FLOWCHAIN_NO_VALUE", + balance: "0", + spendable: false, + noValue: true, + limitations: [ + "The private/local devnet has no token value, gas accounting, staking, faucet funds, or bridge asset balances.", + ], + localOnly: true, + })); +} + +function faucetEventRows(_state: LoadedControlPlaneState): JsonObject[] { + return [{ + schema: "flowmemory.control_plane.faucet_event.v0", + eventId: "faucet:disabled:no-value-local-devnet", + status: "disabled_no_value", + amount: "0", + asset: "FLOWCHAIN_NO_VALUE", + recipient: null, + reason: "The private/local devnet has no token value and no faucet funds.", + localOnly: true, + }]; +} + +function bridgeObservationFromDeposit(deposit: JsonObject): JsonObject { + return { + schema: "flowmemory.bridge_deposit_observation.v0", + observationId: stableId("flowmemory.bridge_deposit_observation.mock_projection.v0", deposit), + observedAt: "2026-05-13T00:00:00.000Z", + mode: "mock", + productionReady: false, + deposit, + guardrails: { + explicitChainId: true, + explicitContract: true, + explicitBlockRange: false, + noSecrets: true, + }, + source: "bridge-fixture-projection", + localOnly: true, + }; +} + +function bridgeObservationRows(state: LoadedControlPlaneState): JsonObject[] { + const rows: JsonObject[] = []; + const loaded = asJsonObject(state.bridgeObservation); + if (loaded !== null && loaded.schema === "flowmemory.bridge_deposit_observation.v0") { + rows.push({ + ...loaded, + source: "bridge-relayer-output", + localOnly: true, + }); + } + + const intake = asJsonArray(state.bridgeObservationIntake?.observations) + .map((entry) => asJsonObject(entry)) + .filter((entry): entry is JsonObject => entry !== null); + rows.push(...intake.map((entry) => ({ + ...entry, + source: "control-plane-bridge-intake", + localOnly: true, + }))); + + const fixtureDeposit = asJsonObject(state.bridgeDepositFixture); + if (fixtureDeposit !== null) { + const projected = bridgeObservationFromDeposit(fixtureDeposit); + if (!rows.some((row) => row.observationId === projected.observationId)) { + rows.push(projected); + } + } + + return rows.sort((left, right) => String(left.observationId).localeCompare(String(right.observationId))); +} + +function bridgeDepositRows(state: LoadedControlPlaneState): JsonObject[] { + return bridgeObservationRows(state).map((observation) => { + const deposit = asJsonObject(observation.deposit) ?? {}; + return { + schema: "flowmemory.control_plane.bridge_deposit.v0", + depositId: stringValue(deposit.depositId) ?? stableId("flowmemory.control_plane.bridge_deposit.v0", deposit), + observationId: observation.observationId, + status: stringValue(deposit.status) ?? "observed", + sourceChainId: deposit.sourceChainId ?? null, + sourceContract: stringValue(deposit.sourceContract) ?? null, + txHash: stringValue(deposit.txHash) ?? null, + logIndex: deposit.logIndex ?? null, + token: stringValue(deposit.token) ?? null, + amount: stringValue(deposit.amount) ?? "0", + sender: stringValue(deposit.sender) ?? null, + flowchainRecipient: stringValue(deposit.flowchainRecipient) ?? null, + nonce: stringValue(deposit.nonce) ?? null, + deposit, + observation, + productionReady: false, + localOnly: true, + }; + }); +} + +function bridgeCreditRows(state: LoadedControlPlaneState): JsonObject[] { + return bridgeDepositRows(state).map((deposit) => ({ + schema: "flowmemory.control_plane.bridge_credit.v0", + creditId: stableId("flowmemory.control_plane.bridge_credit.v0", stringValue(deposit.depositId) ?? ""), + depositId: deposit.depositId, + recipient: deposit.flowchainRecipient, + amount: deposit.amount, + token: deposit.token, + status: deposit.status === "accepted_local" ? "credited_local" : "pending_deposit_observation", + noValueAccounting: true, + limitations: [ + "Bridge credits are local observation records only; no production bridge custody or withdrawal finality is implied.", + ], + localOnly: true, + })); +} + +function withdrawalRows(state: LoadedControlPlaneState): JsonObject[] { + return Object.entries(firstDevnetMap(state, ["withdrawals", "bridgeWithdrawals"])).map(([withdrawalId, value]) => { + const withdrawal = asJsonObject(value) ?? {}; + return { + schema: "flowmemory.control_plane.withdrawal.v0", + withdrawalId, + status: stringValue(withdrawal.status) ?? "local", + withdrawal, + source: "local-devnet", + productionReady: false, + localOnly: true, + }; + }).sort((left, right) => String(left.withdrawalId).localeCompare(String(right.withdrawalId))); +} + +function writeBridgeObservationIntake(paths: ControlPlanePaths, observation: JsonObject): JsonObject { + const path = repoPath(paths.bridgeObservationIntakePath); + const existing = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as JsonObject : null; + const observations = asJsonArray(existing?.observations) + .map((entry) => asJsonObject(entry)) + .filter((entry): entry is JsonObject => entry !== null); + const normalized = { + ...observation, + schema: "flowmemory.bridge_deposit_observation.v0", + observationId: stringValue(observation.observationId) ?? stableId("flowmemory.bridge_deposit_observation.intake.v0", observation), + productionReady: false, + localOnly: true, + }; + const next = observations.filter((entry) => entry.observationId !== normalized.observationId); + next.push(normalized); + const payload = { + schema: "flowmemory.control_plane.bridge_observation_intake.v0", + observations: next.sort((left, right) => String(left.observationId).localeCompare(String(right.observationId))), + }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`); + return normalized; +} + function transactionRows(state: LoadedControlPlaneState): JsonObject[] { const rows: JsonObject[] = []; const txFixtures = txFixtureRows(state); @@ -283,6 +563,24 @@ function transactionRows(state: LoadedControlPlaneState): JsonObject[] { }); } + for (const pending of pendingTxRows(state)) { + const tx = asJsonObject(pending.tx); + rows.push({ + schema: "flowmemory.control_plane.transaction.v0", + transactionId: stringValue(pending.txId) ?? stringValue(pending.transactionId) ?? stableId("flowmemory.control_plane.pending_transaction.v0", pending), + txHash: stringValue(pending.txId) ?? stringValue(pending.transactionId) ?? stableId("flowmemory.control_plane.pending_transaction.v0", pending), + blockNumber: null, + blockHash: null, + transactionIndex: null, + status: "pending", + type: stringValue(tx?.type) ?? "unknown", + payload: tx, + receipt: null, + source: "local-devnet-mempool", + localOnly: true, + }); + } + const byHash = new Map(); for (const observation of state.indexer.state.observations) { const existing = byHash.get(observation.txHash) ?? { @@ -707,10 +1005,12 @@ function finalityRows(state: LoadedControlPlaneState): JsonObject[] { function health(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const missing = Object.values(state.sources).filter((source) => source.status === "missing").map((source) => source.name); + const required = new Set(["launchCore", "indexer", "verifier", "artifacts", "devnet", "txFixtures"]); + const missingRequired = missing.filter((name) => required.has(name)); return { schema: "flowmemory.control_plane.health.v0", service: "flowmemory-control-plane-v0", - status: missing.length === 0 ? "ok" : "degraded", + status: missingRequired.length === 0 ? "ok" : "degraded", localOnly: true, checks: { launchCore: state.sources.launchCore.status, @@ -720,6 +1020,9 @@ function health(_params: JsonValue | undefined, context: ControlPlaneContext): J devnet: state.sources.devnet.status, devnetControlPlaneHandoff: state.sources.devnetControlPlaneHandoff.status, txFixtures: state.sources.txFixtures.status, + bridgeObservation: state.sources.bridgeObservation.status, + bridgeObservationIntake: state.sources.bridgeObservationIntake.status, + bridgeDepositFixture: state.sources.bridgeDepositFixture.status, }, counts: { observations: state.indexer.state.observations.length, @@ -727,8 +1030,70 @@ function health(_params: JsonValue | undefined, context: ControlPlaneContext): J rootfields: rootfieldRows(state).length, blocks: blockRows(state).length, transactions: transactionRows(state).length, + pendingTransactions: pendingTxRows(state).length, + bridgeDeposits: bridgeDepositRows(state).length, }, - missingOptionalSources: missing, + missingRequiredSources: missingRequired, + missingOptionalSources: missing.filter((name) => !required.has(name)), + }; +} + +function nodeStatus(_params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const latest = latestBlock(state); + return { + schema: "flowmemory.control_plane.node_status.v0", + chainId: typeof state.devnet?.chainId === "string" ? state.devnet.chainId : "flowmemory-local-devnet-v0", + networkId: stringValue(asJsonObject(state.devnet?.config)?.networkId) ?? "flowmemory-private-local", + runtimeMode: "bounded-local-cli", + longRunningNode: false, + source: devnetSourceKind(state), + stateSource: state.sources.devnet, + currentBlock: latest.blockNumber, + currentBlockHash: latest.blockHash, + stateRoot: stringValue(state.devnetControlPlaneHandoff?.stateRoot) + ?? stringValue(state.devnetIndexerHandoff?.stateRoot) + ?? stringValue(state.devnet?.parentHash) + ?? ZERO_ROOT, + pendingTransactions: pendingTxRows(state).length, + peerCount: 0, + noValue: true, + localOnly: true, + }; +} + +function peerList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + asObjectParams(params, "peer_list"); + return { + schema: "flowmemory.control_plane.peer_list.v0", + count: 1, + peers: [{ + schema: "flowmemory.control_plane.peer.v0", + peerId: "local-single-process", + status: "self", + transport: "local-cli", + latestBlock: latestBlock(state).blockNumber, + localOnly: true, + }], + limitations: [ + "The current private/local runtime is a single-process deterministic CLI; LAN peer discovery is not implemented.", + ], + localOnly: true, + }; +} + +function mempoolList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "mempool_list"); + const limit = pageLimit(objectParams); + const rows = pendingTxRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.mempool_list.v0", + count: rows.length, + nextCursor: null, + transactions: rows, + localOnly: true, }; } @@ -738,10 +1103,10 @@ function chainStatus(_params: JsonValue | undefined, context: ControlPlaneContex return { schema: "flowmemory.control_plane.chain_status.v0", - chainId: "flowmemory-local-alpha", - settlementContext: "local fixture stack over FlowPulse and local no-value devnet handoff", - environment: "local-devnet-fixture", - source: "fixture", + chainId: typeof state.devnet?.chainId === "string" ? state.devnet.chainId : "flowmemory-local-alpha", + settlementContext: "local fixture stack over FlowPulse and local no-value devnet runtime state", + environment: "private-local-devnet", + source: devnetSourceKind(state), currentBlock: latest.blockNumber, currentBlockHash: latest.blockHash, finalizedBlock: finalizedBlock(state), @@ -765,18 +1130,40 @@ function chainStatus(_params: JsonValue | undefined, context: ControlPlaneContex finalityRows: finalityRows(state).length, blocks: blockRows(state).length, transactions: transactionRows(state).length, + pendingTransactions: pendingTxRows(state).length, devnetBlocks: devnetBlocksArray(state).length, + accounts: accountRows(state).length, + balances: balanceRows(state).length, + faucetEvents: faucetEventRows(state).length, + walletPublicMetadata: publicWalletRows(state).length, + bridgeObservations: bridgeObservationRows(state).length, + bridgeDeposits: bridgeDepositRows(state).length, + bridgeCredits: bridgeCreditRows(state).length, + withdrawals: withdrawalRows(state).length, }, capabilities: [ "health_reads", - "fixture_status_reads", + "live_local_state_reads", + "fixture_fallback_reads", + "node_status_reads", + "peer_reads", + "mempool_reads", "block_reads", "transaction_reads", + "transaction_submission", + "account_reads", + "balance_reads", + "faucet_event_reads", + "wallet_public_metadata_reads", "receipt_lookup", "verifier_report_lookup", "memory_lineage_lookup", "artifact_fixture_lookup", "devnet_handoff_reads", + "bridge_observation_intake", + "bridge_deposit_reads", + "bridge_credit_reads", + "withdrawal_reads", "raw_json_reads", ], limitations: [ @@ -816,10 +1203,12 @@ function devnetState(params: JsonValue | undefined, context: ControlPlaneContext memoryCellCount: Object.keys(devnetMemoryCells(state)).length, challengeCount: Object.keys(devnetChallenges(state)).length, finalityReceiptCount: Object.keys(devnetFinalityReceipts(state)).length, - baseAnchorCount: state.devnet?.baseAnchors && typeof state.devnet.baseAnchors === "object" && !Array.isArray(state.devnet.baseAnchors) - ? Object.keys(state.devnet.baseAnchors).length - : 0, + pendingTransactionCount: pendingTxRows(state).length, + baseAnchorCount: Object.keys(devnetBaseAnchors(state)).length, blocks: includeBlocks ? blocks : undefined, + pendingTransactions: includeBlocks ? pendingTxRows(state) : undefined, + mapRoots: state.devnetControlPlaneHandoff?.mapRoots ?? state.devnetIndexerHandoff?.mapRoots ?? null, + sourceKind: devnetSourceKind(state), source: state.sources.devnet, indexerHandoff: state.devnetIndexerHandoff === null ? null : { schema: state.devnetIndexerHandoff.schema, @@ -877,8 +1266,8 @@ function blockGet(params: JsonValue | undefined, context: ControlPlaneContext): provenance: { sources: [ block.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.block.v0") - : provenanceSource("indexer", "services/indexer/out/indexer-state.json", "flowmemory.indexer.persistence.v0"), + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.block.v0") + : provenanceSource("indexer", state.sources.indexer.path, "flowmemory.indexer.persistence.v0"), ], }, localOnly: true, @@ -924,14 +1313,161 @@ function transactionGet(params: JsonValue | undefined, context: ControlPlaneCont provenance: { sources: [ transaction.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.block.v0") - : provenanceSource("indexer", "services/indexer/out/indexer-state.json", "flowmemory.indexer.persistence.v0"), + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.block.v0") + : provenanceSource("indexer", state.sources.indexer.path, "flowmemory.indexer.persistence.v0"), ], }, localOnly: true, }; } +function transactionSubmit(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const objectParams = asObjectParams(params, "transaction_submit"); + const txs = extractSubmittedTransactions(objectParams); + const findings = scanJsonForSecrets(txs as JsonValue); + if (findings.length > 0) { + throw invalidParams("transaction_submit payload contains forbidden secret-bearing fields", { findings }); + } + try { + const submission = submitTransactionsToRuntime(pathsFor(context), txs); + return { + ...submission, + submissionId: stableId("flowmemory.control_plane.transaction_submission.v0", txs), + }; + } catch (error) { + throw invalidParams(error instanceof Error ? error.message : "transaction_submit failed"); + } +} + +function accountList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "account_list"); + const limit = pageLimit(objectParams); + const accountType = optionalString(objectParams, "accountType"); + const rows = accountRows(state) + .filter((account) => accountType === undefined || account.accountType === accountType) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.account_list.v0", + count: rows.length, + nextCursor: null, + accounts: rows, + localOnly: true, + }; +} + +function accountGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "account_get"); + const accountId = requiredString(objectParams, ["accountId", "agentId", "operatorId"], "account_get"); + const account = accountRows(state).find((candidate) => { + return candidate.accountId === accountId + || asJsonObject(candidate.agentAccount)?.agentId === accountId + || asJsonObject(candidate.walletPublicMetadata)?.accountId === accountId; + }); + if (account === undefined) { + throw objectNotFound(`account not found: ${accountId}`, { accountId }); + } + return { + schema: "flowmemory.control_plane.account_detail.v0", + account, + balance: balanceRows(state).find((candidate) => candidate.accountId === account.accountId) ?? null, + localOnly: true, + }; +} + +function balanceList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "balance_list"); + const limit = pageLimit(objectParams); + const accountId = optionalString(objectParams, "accountId"); + const rows = balanceRows(state) + .filter((balance) => accountId === undefined || balance.accountId === accountId) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.balance_list.v0", + count: rows.length, + nextCursor: null, + balances: rows, + localOnly: true, + }; +} + +function balanceGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "balance_get"); + const accountId = requiredString(objectParams, ["accountId", "agentId", "operatorId"], "balance_get"); + const balance = balanceRows(state).find((candidate) => candidate.accountId === accountId); + if (balance === undefined) { + throw objectNotFound(`balance not found: ${accountId}`, { accountId }); + } + return { + schema: "flowmemory.control_plane.balance_detail.v0", + balance, + localOnly: true, + }; +} + +function faucetEventList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "faucet_event_list"); + const limit = pageLimit(objectParams); + const rows = faucetEventRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.faucet_event_list.v0", + count: rows.length, + nextCursor: null, + faucetEvents: rows, + localOnly: true, + }; +} + +function faucetEventGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "faucet_event_get"); + const eventId = requiredString(objectParams, ["eventId"], "faucet_event_get"); + const event = faucetEventRows(state).find((candidate) => candidate.eventId === eventId); + if (event === undefined) { + throw objectNotFound(`faucet event not found: ${eventId}`, { eventId }); + } + return { + schema: "flowmemory.control_plane.faucet_event_detail.v0", + faucetEvent: event, + localOnly: true, + }; +} + +function walletMetadataList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "wallet_metadata_list"); + const limit = pageLimit(objectParams); + const rows = publicWalletRows(state).slice(0, limit); + return { + schema: "flowmemory.control_plane.wallet_metadata_list.v0", + count: rows.length, + nextCursor: null, + wallets: rows, + localOnly: true, + }; +} + +function walletMetadataGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "wallet_metadata_get"); + const walletId = requiredString(objectParams, ["walletId", "accountId", "keyReferenceId"], "wallet_metadata_get"); + const wallet = publicWalletRows(state).find((candidate) => { + return candidate.walletId === walletId || candidate.accountId === walletId || candidate.keyReferenceId === walletId; + }); + if (wallet === undefined) { + throw objectNotFound(`wallet metadata not found: ${walletId}`, { walletId }); + } + return { + schema: "flowmemory.control_plane.wallet_metadata_detail.v0", + wallet, + localOnly: true, + }; +} + function rootfieldGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "rootfield_get"); @@ -953,7 +1489,7 @@ function rootfieldGet(params: JsonValue | undefined, context: ControlPlaneContex provenance: { sources: [ bundle ? provenanceSource("flowmemory", "fixtures/launch-core/flowmemory-launch-v0.json", bundle.schema) : null, - devnetRootfield ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.rootfield.v0") : null, + devnetRootfield ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.rootfield.v0") : null, ].filter((entry): entry is JsonObject => entry !== null), }, localOnly: true, @@ -1015,7 +1551,7 @@ function artifactGet(params: JsonValue | undefined, context: ControlPlaneContext artifact: entry, resolverPolicyId: "flowmemory.local_devnet.artifact_commitment.v0", provenance: { - sources: [provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.artifact_commitment.v0")], + sources: [provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.artifact_commitment.v0")], }, localOnly: true, }; @@ -1065,7 +1601,7 @@ function artifactAvailabilityGet(params: JsonValue | undefined, context: Control sources: [provenanceSource("verifier", "services/verifier/fixtures/artifacts.json", "flowmemory.verifier.artifact_fixture.v0")], } : { - sources: [provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.artifact_commitment.v0")], + sources: [provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.artifact_commitment.v0")], }, localOnly: true, }; @@ -1100,7 +1636,7 @@ function receiptGet(params: JsonValue | undefined, context: ControlPlaneContext) transition: null, verifierReport: null, provenance: { - sources: [provenanceSource("devnet", "fixtures/launch-core/generated/devnet/verifier-handoff.json", "flowmemory.local_devnet.work_receipt.v0")], + sources: [provenanceSource("devnet", handoffSourcePath(state, "devnetVerifierHandoff"), "flowmemory.local_devnet.work_receipt.v0")], }, localOnly: true, }; @@ -1204,8 +1740,8 @@ function verifierModuleGet(params: JsonValue | undefined, context: ControlPlaneC provenance: { sources: [ verifierModule.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.verifier_module.v0") - : provenanceSource("verifier", "services/verifier/out/reports.json", "flowmemory.verifier.persistence.v0"), + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.verifier_module.v0") + : provenanceSource("verifier", state.sources.verifier.path, "flowmemory.verifier.persistence.v0"), ], }, localOnly: true, @@ -1235,7 +1771,7 @@ function verifierReportGet(params: JsonValue | undefined, context: ControlPlaneC report: devnetReport, memoryReceipt: null, provenance: { - sources: [provenanceSource("devnet", "fixtures/launch-core/generated/devnet/verifier-handoff.json", "flowmemory.local_devnet.verifier_report.v0")], + sources: [provenanceSource("devnet", handoffSourcePath(state, "devnetVerifierHandoff"), "flowmemory.local_devnet.verifier_report.v0")], }, localOnly: true, }; @@ -1250,9 +1786,31 @@ function verifierReportList(params: JsonValue | undefined, context: ControlPlane const rootfieldId = optionalString(objectParams, "rootfieldId"); const status = optionalString(objectParams, "status"); const limit = pageLimit(objectParams); - const reports = state.verifier.reports - .filter((report) => rootfieldId === undefined || report.reportCore.observation.rootfieldId === rootfieldId) - .filter((report) => status === undefined || report.reportCore.status === status) + const reports = [ + ...Object.entries(devnetReports(state)).map(([reportId, value]) => { + const report = asJsonObject(value) ?? {}; + return { + schema: "flowmemory.control_plane.verifier_report_row.v0", + reportId, + rootfieldId: stringValue(report.rootfieldId) ?? null, + status: stringValue(report.status) ?? "local", + report, + source: "local-devnet", + localOnly: true, + }; + }), + ...state.verifier.reports.map((report) => ({ + schema: "flowmemory.control_plane.verifier_report_row.v0", + reportId: report.reportId, + rootfieldId: report.reportCore.observation.rootfieldId, + status: report.reportCore.status, + report, + source: "verifier-fixture", + localOnly: true, + })), + ] + .filter((report) => rootfieldId === undefined || report.rootfieldId === rootfieldId) + .filter((report) => status === undefined || report.status === status) .slice(0, limit); return { @@ -1364,6 +1922,53 @@ function agentList(params: JsonValue | undefined, context: ControlPlaneContext): }; } +function agentAccountList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "agent_account_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = Object.entries(devnetAgentAccounts(state)) + .map(([agentId, value]) => { + const agent = asJsonObject(value) ?? {}; + return { + schema: "flowmemory.control_plane.agent_account.v0", + agentId, + status: stringValue(agent.status) ?? (agent.active === false ? "inactive" : "active"), + agentAccount: agent, + account: accountRows(state).find((account) => account.accountId === agentId) ?? null, + source: "local-devnet", + localOnly: true, + }; + }) + .filter((agent) => status === undefined || agent.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.agent_account_list.v0", + count: rows.length, + nextCursor: null, + agentAccounts: rows, + localOnly: true, + }; +} + +function agentAccountGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "agent_account_get"); + const agentId = requiredString(objectParams, ["agentId", "accountId"], "agent_account_get"); + const agent = Object.entries(devnetAgentAccounts(state)).find(([id, value]) => id === agentId || asJsonObject(value)?.agentId === agentId); + if (agent === undefined) { + throw objectNotFound(`agent account not found: ${agentId}`, { agentId }); + } + return { + schema: "flowmemory.control_plane.agent_account_detail.v0", + agentId: agent[0], + agentAccount: agent[1] as JsonObject, + account: accountRows(state).find((candidate) => candidate.accountId === agent[0]) ?? null, + provenance: provenanceForObject(state, agent[0]), + localOnly: true, + }; +} + function modelList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "model_list"); @@ -1383,6 +1988,53 @@ function modelList(params: JsonValue | undefined, context: ControlPlaneContext): }; } +function modelPassportList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "model_passport_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = Object.entries(devnetModels(state)) + .map(([modelId, value]) => { + const model = asJsonObject(value) ?? {}; + return { + schema: "flowmemory.control_plane.model_passport.v0", + modelId, + status: stringValue(model.status) ?? (model.active === false ? "inactive" : "active"), + modelPassport: model, + source: "local-devnet", + localOnly: true, + }; + }) + .filter((model) => status === undefined || model.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.model_passport_list.v0", + count: rows.length, + nextCursor: null, + modelPassports: rows, + localOnly: true, + }; +} + +function modelPassportGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "model_passport_get"); + const modelId = requiredString(objectParams, ["modelId", "modelPassportId"], "model_passport_get"); + const model = Object.entries(devnetModels(state)).find(([id, value]) => { + const passport = asJsonObject(value); + return id === modelId || passport?.modelPassportId === modelId; + }); + if (model === undefined) { + throw objectNotFound(`model passport not found: ${modelId}`, { modelId }); + } + return { + schema: "flowmemory.control_plane.model_passport_detail.v0", + modelId: model[0], + modelPassport: model[1] as JsonObject, + localOnly: true, + }; +} + function modelGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { const state = stateFor(context); const objectParams = asObjectParams(params, "model_get"); @@ -1397,7 +2049,7 @@ function modelGet(params: JsonValue | undefined, context: ControlPlaneContext): provenance: { sources: [ model.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.model_passport.v0") + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.model_passport.v0") : provenanceSource("flowmemory", "fixtures/launch-core/flowmemory-launch-v0.json", "flowmemory.agent_memory_view.v0", "Projected model row; no ModelPassport fixture exists yet."), ], }, @@ -1550,6 +2202,170 @@ function finalityList(params: JsonValue | undefined, context: ControlPlaneContex }; } +function bridgeObservationSubmit(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const objectParams = asObjectParams(params, "bridge_observation_submit"); + const findings = scanJsonForSecrets(objectParams); + if (findings.length > 0) { + throw invalidParams("bridge_observation_submit payload contains forbidden secret-bearing fields", { findings }); + } + const observationParam = asJsonObject(objectParams.observation); + const depositParam = asJsonObject(objectParams.deposit); + if (observationParam === null && depositParam === null) { + throw invalidParams("bridge_observation_submit requires observation or deposit"); + } + const observation = observationParam ?? bridgeObservationFromDeposit(depositParam ?? {}); + const stored = writeBridgeObservationIntake(pathsFor(context), observation); + return { + schema: "flowmemory.control_plane.bridge_observation_submission.v0", + observation: stored, + accepted: true, + productionReady: false, + localOnly: true, + }; +} + +function bridgeObservationList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_observation_list"); + const limit = pageLimit(objectParams); + const mode = optionalString(objectParams, "mode"); + const rows = bridgeObservationRows(state) + .filter((observation) => mode === undefined || observation.mode === mode) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.bridge_observation_list.v0", + count: rows.length, + nextCursor: null, + observations: rows, + localOnly: true, + }; +} + +function bridgeObservationGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_observation_get"); + const observationId = requiredString(objectParams, ["observationId", "depositId"], "bridge_observation_get"); + const observation = bridgeObservationRows(state).find((candidate) => { + return candidate.observationId === observationId || asJsonObject(candidate.deposit)?.depositId === observationId; + }); + if (observation === undefined) { + throw objectNotFound(`bridge observation not found: ${observationId}`, { observationId }); + } + return { + schema: "flowmemory.control_plane.bridge_observation.v0", + observation, + localOnly: true, + }; +} + +function bridgeDepositList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_deposit_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = bridgeDepositRows(state) + .filter((deposit) => status === undefined || deposit.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.bridge_deposit_list.v0", + count: rows.length, + nextCursor: null, + deposits: rows, + localOnly: true, + }; +} + +function bridgeDepositGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_deposit_get"); + const depositId = requiredString(objectParams, ["depositId", "observationId", "txHash"], "bridge_deposit_get"); + const deposit = bridgeDepositRows(state).find((candidate) => { + return candidate.depositId === depositId || candidate.observationId === depositId || candidate.txHash === depositId; + }); + if (deposit === undefined) { + throw objectNotFound(`bridge deposit not found: ${depositId}`, { depositId }); + } + return { + schema: "flowmemory.control_plane.bridge_deposit_detail.v0", + deposit, + localOnly: true, + }; +} + +function bridgeCreditList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_credit_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = bridgeCreditRows(state) + .filter((credit) => status === undefined || credit.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.bridge_credit_list.v0", + count: rows.length, + nextCursor: null, + credits: rows, + localOnly: true, + }; +} + +function bridgeCreditGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "bridge_credit_get"); + const creditId = requiredString(objectParams, ["creditId", "depositId"], "bridge_credit_get"); + const credit = bridgeCreditRows(state).find((candidate) => candidate.creditId === creditId || candidate.depositId === creditId); + if (credit === undefined) { + throw objectNotFound(`bridge credit not found: ${creditId}`, { creditId }); + } + return { + schema: "flowmemory.control_plane.bridge_credit_detail.v0", + credit, + localOnly: true, + }; +} + +function withdrawalList(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "withdrawal_list"); + const limit = pageLimit(objectParams); + const status = optionalString(objectParams, "status"); + const rows = withdrawalRows(state) + .filter((withdrawal) => status === undefined || withdrawal.status === status) + .slice(0, limit); + return { + schema: "flowmemory.control_plane.withdrawal_list.v0", + count: rows.length, + nextCursor: null, + withdrawals: rows, + extensionPoint: rows.length === 0 ? "No local withdrawal handoff objects exist yet; production bridge withdrawals remain out of scope." : undefined, + localOnly: true, + }; +} + +function withdrawalGet(params: JsonValue | undefined, context: ControlPlaneContext): JsonValue { + const state = stateFor(context); + const objectParams = asObjectParams(params, "withdrawal_get"); + const withdrawalId = requiredString(objectParams, ["withdrawalId", "depositId", "creditId"], "withdrawal_get"); + const withdrawal = withdrawalRows(state).find((candidate) => candidate.withdrawalId === withdrawalId); + if (withdrawal === undefined) { + return { + schema: "flowmemory.control_plane.withdrawal_detail.v0", + withdrawalId, + status: "not_opened", + withdrawal: null, + extensionPoint: "No local withdrawal object exists for this id. Production bridge withdrawal handling is not implemented.", + productionReady: false, + localOnly: true, + }; + } + return { + schema: "flowmemory.control_plane.withdrawal_detail.v0", + withdrawal, + productionReady: false, + localOnly: true, + }; +} + function findObject(state: LoadedControlPlaneState, key: string): { type: string; object: JsonObject } { const receipt = receiptByAnyId(state, key); if (receipt !== undefined) { @@ -1623,6 +2439,18 @@ function findObject(state: LoadedControlPlaneState, key: string): { type: string if (devnetFinality !== undefined) { return { type: "devnet_finality_receipt", object: devnetFinality as JsonObject }; } + const account = accountRows(state).find((candidate) => candidate.accountId === key); + if (account !== undefined) { + return { type: "account", object: account }; + } + const bridgeDeposit = bridgeDepositRows(state).find((candidate) => candidate.depositId === key || candidate.observationId === key || candidate.txHash === key); + if (bridgeDeposit !== undefined) { + return { type: "bridge_deposit", object: bridgeDeposit }; + } + const bridgeCredit = bridgeCreditRows(state).find((candidate) => candidate.creditId === key || candidate.depositId === key); + if (bridgeCredit !== undefined) { + return { type: "bridge_credit", object: bridgeCredit }; + } throw objectNotFound(`object not found: ${key}`, { id: key }); } @@ -1649,31 +2477,31 @@ function provenanceForObject(state: LoadedControlPlaneState, key: string): JsonO sources.push(provenanceSource("flowmemory", "fixtures/launch-core/flowmemory-launch-v0.json", "flowmemory.launch_core.v0")); } if (selectedReceipt !== undefined || report !== undefined) { - sources.push(provenanceSource("verifier", "services/verifier/out/reports.json", "flowmemory.verifier.persistence.v0")); + sources.push(provenanceSource("verifier", state.sources.verifier.path, "flowmemory.verifier.persistence.v0")); } if (selectedSignal !== undefined) { - sources.push(provenanceSource("indexer", "services/indexer/out/indexer-state.json", "flowmemory.indexer.persistence.v0")); + sources.push(provenanceSource("indexer", state.sources.indexer.path, "flowmemory.indexer.persistence.v0")); } if (block !== undefined || transaction !== undefined) { const source = block?.source ?? transaction?.source; sources.push(source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.state.v0") - : provenanceSource("indexer", "services/indexer/out/indexer-state.json", "flowmemory.indexer.persistence.v0")); + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.state.v0") + : provenanceSource("indexer", state.sources.indexer.path, "flowmemory.indexer.persistence.v0")); } if (model !== undefined) { sources.push(model.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.model_passport.v0") + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.model_passport.v0") : provenanceSource("flowmemory", "fixtures/launch-core/flowmemory-launch-v0.json", "flowmemory.agent_memory_view.v0", "Projected model row.")); } if (verifierModule !== undefined) { sources.push(verifierModule.source === "local-devnet" - ? provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.verifier_module.v0") - : provenanceSource("verifier", "services/verifier/out/reports.json", "flowmemory.verifier.persistence.v0")); + ? provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.verifier_module.v0") + : provenanceSource("verifier", state.sources.verifier.path, "flowmemory.verifier.persistence.v0")); } if (artifactAvailability !== undefined) { sources.push(artifactAvailability.source === "verifier-fixture" ? provenanceSource("verifier", "services/verifier/fixtures/artifacts.json", "flowmemory.verifier.artifact_fixture.v0") - : provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.artifact_commitment.v0")); + : provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.artifact_commitment.v0")); } links.receiptId = selectedReceipt?.receiptId; @@ -1701,7 +2529,7 @@ function provenanceForObject(state: LoadedControlPlaneState, key: string): JsonO ?? devnetChallenges(state)[key] ?? devnetFinalityReceipts(state)[key]; if (devnetTarget !== undefined) { - sources.push(provenanceSource("devnet", "fixtures/launch-core/generated/devnet/state.json", "flowmemory.local_devnet.state.v0")); + sources.push(provenanceSource("devnet", devnetSourcePath(state), "flowmemory.local_devnet.state.v0")); } } @@ -1760,6 +2588,9 @@ function rawJsonGet(params: JsonValue | undefined, context: ControlPlaneContext) devnetVerifierHandoff: state.devnetVerifierHandoff, devnetControlPlaneHandoff: state.devnetControlPlaneHandoff, txFixtures: state.txFixtures, + bridgeObservation: state.bridgeObservation, + bridgeObservationIntake: state.bridgeObservationIntake, + bridgeDepositFixture: state.bridgeDepositFixture, }; if (!Object.prototype.hasOwnProperty.call(allowed, source)) { @@ -1786,10 +2617,22 @@ export const CONTROL_PLANE_METHODS: Record = health, chain_status: chainStatus, devnet_state: devnetState, + node_status: nodeStatus, + peer_list: peerList, + mempool_list: mempoolList, block_get: blockGet, block_list: blockList, + account_get: accountGet, + account_list: accountList, + balance_get: balanceGet, + balance_list: balanceList, + faucet_event_get: faucetEventGet, + faucet_event_list: faucetEventList, + wallet_metadata_get: walletMetadataGet, + wallet_metadata_list: walletMetadataList, transaction_get: transactionGet, transaction_list: transactionList, + transaction_submit: transactionSubmit, rootfield_get: rootfieldGet, rootfield_list: rootfieldList, artifact_get: artifactGet, @@ -1807,12 +2650,25 @@ export const CONTROL_PLANE_METHODS: Record = memory_cell_list: memoryCellList, agent_get: agentGet, agent_list: agentList, + agent_account_get: agentAccountGet, + agent_account_list: agentAccountList, model_get: modelGet, model_list: modelList, + model_passport_get: modelPassportGet, + model_passport_list: modelPassportList, challenge_get: challengeGet, challenge_list: challengeList, finality_get: finalityGet, finality_list: finalityList, + bridge_observation_submit: bridgeObservationSubmit, + bridge_observation_get: bridgeObservationGet, + bridge_observation_list: bridgeObservationList, + bridge_deposit_get: bridgeDepositGet, + bridge_deposit_list: bridgeDepositList, + bridge_credit_get: bridgeCreditGet, + bridge_credit_list: bridgeCreditList, + withdrawal_get: withdrawalGet, + withdrawal_list: withdrawalList, provenance_get: provenanceGet, raw_json_get: rawJsonGet, }; diff --git a/services/control-plane/src/no-secret.ts b/services/control-plane/src/no-secret.ts new file mode 100644 index 00000000..a80b1f82 --- /dev/null +++ b/services/control-plane/src/no-secret.ts @@ -0,0 +1,60 @@ +import type { JsonValue } from "./types.ts"; + +const FORBIDDEN_KEY_PATTERN = /^(privateKey|mnemonic|seedPhrase|rpcUrl|apiKey|webhookUrl|secretKey|accessToken|bearerToken|rpcSecret)$/i; +const FORBIDDEN_VALUE_PATTERNS = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----/, + /\bseed phrase\b/i, + /\bmnemonic phrase\b/i, + /\brpc secret\b/i, + /\bapi key\b/i, +]; + +export interface SecretScanFinding { + path: string; + reason: string; +} + +function scan(value: JsonValue | undefined, path: string, findings: SecretScanFinding[]): void { + if (value === null || value === undefined) { + return; + } + + if (typeof value === "string") { + for (const pattern of FORBIDDEN_VALUE_PATTERNS) { + if (pattern.test(value)) { + findings.push({ path, reason: "forbidden secret marker in string value" }); + } + } + return; + } + + if (typeof value !== "object") { + return; + } + + if (Array.isArray(value)) { + value.forEach((entry, index) => scan(entry, `${path}[${index}]`, findings)); + return; + } + + for (const [key, entry] of Object.entries(value)) { + const childPath = `${path}.${key}`; + if (FORBIDDEN_KEY_PATTERN.test(key)) { + findings.push({ path: childPath, reason: "forbidden secret-bearing key" }); + } + scan(entry, childPath, findings); + } +} + +export function scanJsonForSecrets(value: JsonValue): SecretScanFinding[] { + const findings: SecretScanFinding[] = []; + scan(value, "$", findings); + return findings; +} + +export function assertNoSecrets(value: JsonValue): void { + const findings = scanJsonForSecrets(value); + if (findings.length > 0) { + throw new Error(`control-plane response secret scan failed: ${JSON.stringify(findings, null, 2)}`); + } +} diff --git a/services/control-plane/src/runtime-intake.ts b/services/control-plane/src/runtime-intake.ts new file mode 100644 index 00000000..98e5e869 --- /dev/null +++ b/services/control-plane/src/runtime-intake.ts @@ -0,0 +1,113 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { repoRoot } from "./fixture-state.ts"; +import type { ControlPlanePaths, JsonObject, JsonValue } from "./types.ts"; + +export interface RuntimeSubmission { + schema: "flowmemory.control_plane.transaction_submission.v0"; + txs: JsonObject[]; + intakePath: string; + runtimeStatePath: string; + queued: string[]; + runtime: { + command: string; + status: number | null; + stderr: string; + }; + localOnly: true; +} + +function asObject(value: JsonValue | undefined, label: string): JsonObject { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as JsonObject; +} + +function txFromSignedEnvelope(value: JsonObject): JsonObject { + const tx = value.tx ?? value.transaction; + if (tx === null || typeof tx !== "object" || Array.isArray(tx)) { + throw new Error("signed transaction envelope must contain tx or transaction object"); + } + return tx as JsonObject; +} + +export function extractSubmittedTransactions(params: JsonObject): JsonObject[] { + if (Array.isArray(params.txs)) { + return params.txs.map((entry, index) => asObject(entry, `txs[${index}]`)); + } + + if (params.tx !== undefined) { + return [asObject(params.tx, "tx")]; + } + + if (Array.isArray(params.signedTransactions)) { + return params.signedTransactions.map((entry, index) => txFromSignedEnvelope(asObject(entry, `signedTransactions[${index}]`))); + } + + if (params.signedTransaction !== undefined) { + return [txFromSignedEnvelope(asObject(params.signedTransaction, "signedTransaction"))]; + } + + throw new Error("transaction_submit requires tx, txs, signedTransaction, or signedTransactions"); +} + +function resolveRepoPath(path: string): string { + return resolve(repoRoot(), path); +} + +export function submitTransactionsToRuntime(paths: ControlPlanePaths, txs: JsonObject[]): RuntimeSubmission { + const intakeDir = resolveRepoPath(paths.runtimeIntakeDir); + mkdirSync(intakeDir, { recursive: true }); + + const fixture = { + schema: "flowmemory.control_plane.transaction_intake_fixture.v0", + txs, + }; + const intakePath = resolve(intakeDir, `${Date.now()}-${process.pid}.json`); + writeFileSync(intakePath, `${JSON.stringify(fixture, null, 2)}\n`); + + const runtimeStatePath = resolveRepoPath(paths.runtimeStatePath); + mkdirSync(dirname(runtimeStatePath), { recursive: true }); + + const args = [ + "run", + "--manifest-path", + "crates/flowmemory-devnet/Cargo.toml", + "--", + "--state", + runtimeStatePath, + "submit-fixture", + "--fixture", + intakePath, + ]; + const result = spawnSync("cargo", args, { + cwd: repoRoot(), + encoding: "utf8", + }); + + if (result.error !== undefined) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`runtime transaction intake failed: ${result.stderr || result.stdout}`); + } + + const stdout = JSON.parse(result.stdout) as { queued?: unknown }; + const queued = Array.isArray(stdout.queued) ? stdout.queued.map(String) : []; + return { + schema: "flowmemory.control_plane.transaction_submission.v0", + txs, + intakePath, + runtimeStatePath, + queued, + runtime: { + command: `cargo ${args.join(" ")}`, + status: result.status, + stderr: result.stderr, + }, + localOnly: true, + }; +} diff --git a/services/control-plane/src/server.ts b/services/control-plane/src/server.ts index 84f4a9b2..04a9772d 100644 --- a/services/control-plane/src/server.ts +++ b/services/control-plane/src/server.ts @@ -25,6 +25,47 @@ function writeJson(res: ServerResponse, statusCode: number, body: unknown): void res.end(`${JSON.stringify(body)}\n`); } +const getRoutes: Record = { + "/health": "health", + "/state": "devnet_state", + "/node/status": "node_status", + "/peers": "peer_list", + "/mempool": "mempool_list", + "/blocks": "block_list", + "/transactions": "transaction_list", + "/accounts": "account_list", + "/balances": "balance_list", + "/faucet/events": "faucet_event_list", + "/wallets": "wallet_metadata_list", + "/agents": "agent_account_list", + "/models": "model_passport_list", + "/work-receipts": "work_receipt_list", + "/artifacts/availability": "artifact_availability_list", + "/verifier-modules": "verifier_module_list", + "/verifier-reports": "verifier_report_list", + "/memory-cells": "memory_cell_list", + "/challenges": "challenge_list", + "/finality": "finality_list", + "/bridge/observations": "bridge_observation_list", + "/bridge/deposits": "bridge_deposit_list", + "/bridge/credits": "bridge_credit_list", + "/withdrawals": "withdrawal_list", +}; + +function paramsFromSearch(searchParams: URLSearchParams): Record { + const params: Record = {}; + for (const [key, value] of searchParams.entries()) { + if (/^\d+$/.test(value) && key === "limit") { + params[key] = Number(value); + } else if (value === "true" || value === "false") { + params[key] = value === "true"; + } else { + params[key] = value; + } + } + return params; +} + function parseArgs(args: string[]): ServerOptions { const options: ServerOptions = { host: "127.0.0.1", @@ -58,7 +99,6 @@ function parseArgs(args: string[]): ServerOptions { } export function startControlPlaneServer(options: ServerOptions): ReturnType { - const state = loadControlPlaneState(); const server = createServer((req, res) => { if (req.method === "OPTIONS") { res.writeHead(204, jsonHeaders); @@ -66,19 +106,21 @@ export function startControlPlaneServer(options: ServerOptions): ReturnType { try { const payload = JSON.parse(body) as unknown; - const response = dispatchJsonRpc(payload, { state }); + const state = loadControlPlaneState(); + const rpcPayload = url.pathname === "/transactions" + ? { jsonrpc: "2.0", id: "transaction_submit", method: "transaction_submit", params: payload } + : url.pathname === "/bridge/observations" + ? { jsonrpc: "2.0", id: "bridge_observation_submit", method: "bridge_observation_submit", params: payload } + : payload; + const response = dispatchJsonRpc(rpcPayload, { state }); if (response === undefined) { res.writeHead(204, jsonHeaders); res.end(); diff --git a/services/control-plane/src/smoke.ts b/services/control-plane/src/smoke.ts index 482a23e9..137e323c 100644 --- a/services/control-plane/src/smoke.ts +++ b/services/control-plane/src/smoke.ts @@ -1,7 +1,11 @@ import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { dispatchJsonRpc } from "./json-rpc.ts"; import { loadControlPlaneState } from "./fixture-state.ts"; +import { assertNoSecrets } from "./no-secret.ts"; import type { JsonObject, RpcErrorResponse, RpcSuccessResponse } from "./types.ts"; function firstDevnetBlock(state: ReturnType): JsonObject { @@ -22,6 +26,7 @@ function stringField(value: unknown, name: string): string { export function runControlPlaneSmoke(): JsonObject { const state = loadControlPlaneState(); + const tempDir = mkdtempSync(join(tmpdir(), "flowmemory-control-plane-smoke-")); const rootfieldId = state.launchCore.rootfieldBundles[0]?.rootfieldId; const receipt = state.launchCore.memoryReceipts[0]; const reportId = receipt?.reportId; @@ -29,71 +34,135 @@ export function runControlPlaneSmoke(): JsonObject { const block = firstDevnetBlock(state); const txIds = Array.isArray(block.txIds) ? block.txIds : []; const txId = stringField(txIds[0], "devnet txId"); + const walletId = Object.keys((state.devnet?.operatorKeyReferences ?? {}) as Record)[0]; + const agentId = Object.keys((state.devnet?.agentAccounts ?? {}) as Record)[0]; + const modelId = Object.keys((state.devnet?.modelPassports ?? {}) as Record)[0]; + const memoryCellId = Object.keys((state.devnet?.memoryCells ?? {}) as Record)[0]; + const challengeId = Object.keys((state.devnet?.challenges ?? {}) as Record)[0]; + const finalityReceiptId = Object.keys((state.devnet?.finalityReceipts ?? {}) as Record)[0]; + const bridgeDepositId = typeof state.bridgeDepositFixture?.depositId === "string" ? state.bridgeDepositFixture.depositId : undefined; - if (rootfieldId === undefined || receipt === undefined || reportId === undefined || artifactUri === undefined) { + if ( + rootfieldId === undefined + || receipt === undefined + || reportId === undefined + || artifactUri === undefined + || walletId === undefined + || agentId === undefined + || modelId === undefined + || memoryCellId === undefined + || challengeId === undefined + || finalityReceiptId === undefined + || bridgeDepositId === undefined + ) { throw new Error("control-plane smoke requires launch-core rootfield, receipt, report, and artifact fixture data"); } - const requests = [ - { jsonrpc: "2.0", id: "health", method: "health" }, - { jsonrpc: "2.0", id: "chain", method: "chain_status" }, - { jsonrpc: "2.0", id: "devnet", method: "devnet_state", params: { includeBlocks: true } }, - { jsonrpc: "2.0", id: "blocks", method: "block_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "block", method: "block_get", params: { blockNumber: stringField(block.blockNumber, "blockNumber"), includeTransactions: true } }, - { jsonrpc: "2.0", id: "transactions", method: "transaction_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "transaction", method: "transaction_get", params: { txId } }, - { jsonrpc: "2.0", id: "rootfields", method: "rootfield_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "rootfield", method: "rootfield_get", params: { rootfieldId } }, - { jsonrpc: "2.0", id: "agents", method: "agent_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "agent", method: "agent_get", params: { rootfieldId } }, - { jsonrpc: "2.0", id: "models", method: "model_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "model", method: "model_get", params: { rootfieldId } }, - { jsonrpc: "2.0", id: "workReceipts", method: "work_receipt_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "workReceipt", method: "work_receipt_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "artifactAvailability", method: "artifact_availability_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "artifact", method: "artifact_availability_get", params: { uri: artifactUri } }, - { jsonrpc: "2.0", id: "modules", method: "verifier_module_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "module", method: "verifier_module_get", params: { resolverPolicyId: receipt.resolverPolicyId } }, - { jsonrpc: "2.0", id: "reports", method: "verifier_report_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "report", method: "verifier_report_get", params: { reportId } }, - { jsonrpc: "2.0", id: "receipts", method: "receipt_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "receipt", method: "receipt_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "memoryCells", method: "memory_cell_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "memoryCell", method: "memory_cell_get", params: { rootfieldId } }, - { jsonrpc: "2.0", id: "challenges", method: "challenge_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "challenge", method: "challenge_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "finalityList", method: "finality_list", params: { limit: 10 } }, - { jsonrpc: "2.0", id: "finality", method: "finality_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "provenance", method: "provenance_get", params: { receiptId: receipt.receiptId } }, - { jsonrpc: "2.0", id: "raw", method: "raw_json_get", params: { source: "launchCore" } }, - ] as const; + try { + const requests = [ + { jsonrpc: "2.0", id: "health", method: "health" }, + { jsonrpc: "2.0", id: "chain", method: "chain_status" }, + { jsonrpc: "2.0", id: "node", method: "node_status" }, + { jsonrpc: "2.0", id: "peers", method: "peer_list" }, + { jsonrpc: "2.0", id: "mempool", method: "mempool_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "devnet", method: "devnet_state", params: { includeBlocks: true } }, + { jsonrpc: "2.0", id: "blocks", method: "block_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "block", method: "block_get", params: { blockNumber: stringField(block.blockNumber, "blockNumber"), includeTransactions: true } }, + { jsonrpc: "2.0", id: "transactions", method: "transaction_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "transaction", method: "transaction_get", params: { txId } }, + { jsonrpc: "2.0", id: "transactionSubmit", method: "transaction_submit", params: { tx: { type: "RegisterRootfield", rootfieldId: "rootfield:smoke:queued-only", owner: "operator:smoke", schemaHash: "0x0d05a0ad7f9c8650e1f9b6f92a9714d7e9b7c29fcd067a8e3d48ccf8a84d1e7a", metadataHash: "0x2b49f44f3d7f2a97970cc7ee3cb3cb9e5db4c4ab65f9fd797f0c703275c9eabc" } } }, + { jsonrpc: "2.0", id: "accounts", method: "account_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "account", method: "account_get", params: { accountId: agentId } }, + { jsonrpc: "2.0", id: "balances", method: "balance_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "balance", method: "balance_get", params: { accountId: agentId } }, + { jsonrpc: "2.0", id: "faucetEvents", method: "faucet_event_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "faucetEvent", method: "faucet_event_get", params: { eventId: "faucet:disabled:no-value-local-devnet" } }, + { jsonrpc: "2.0", id: "wallets", method: "wallet_metadata_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "wallet", method: "wallet_metadata_get", params: { walletId } }, + { jsonrpc: "2.0", id: "rootfields", method: "rootfield_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "rootfield", method: "rootfield_get", params: { rootfieldId } }, + { jsonrpc: "2.0", id: "agents", method: "agent_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "agent", method: "agent_get", params: { rootfieldId } }, + { jsonrpc: "2.0", id: "agentAccounts", method: "agent_account_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "agentAccount", method: "agent_account_get", params: { agentId } }, + { jsonrpc: "2.0", id: "models", method: "model_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "model", method: "model_get", params: { rootfieldId } }, + { jsonrpc: "2.0", id: "modelPassports", method: "model_passport_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "modelPassport", method: "model_passport_get", params: { modelId } }, + { jsonrpc: "2.0", id: "workReceipts", method: "work_receipt_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "workReceipt", method: "work_receipt_get", params: { receiptId: receipt.receiptId } }, + { jsonrpc: "2.0", id: "artifactAvailability", method: "artifact_availability_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "artifact", method: "artifact_availability_get", params: { uri: artifactUri } }, + { jsonrpc: "2.0", id: "modules", method: "verifier_module_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "module", method: "verifier_module_get", params: { resolverPolicyId: receipt.resolverPolicyId } }, + { jsonrpc: "2.0", id: "reports", method: "verifier_report_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "report", method: "verifier_report_get", params: { reportId } }, + { jsonrpc: "2.0", id: "receipts", method: "receipt_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "receipt", method: "receipt_get", params: { receiptId: receipt.receiptId } }, + { jsonrpc: "2.0", id: "memoryCells", method: "memory_cell_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "memoryCell", method: "memory_cell_get", params: { memoryCellId } }, + { jsonrpc: "2.0", id: "challenges", method: "challenge_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "challenge", method: "challenge_get", params: { challengeId } }, + { jsonrpc: "2.0", id: "finalityList", method: "finality_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "finality", method: "finality_get", params: { objectId: finalityReceiptId } }, + { jsonrpc: "2.0", id: "bridgeObservationSubmit", method: "bridge_observation_submit", params: { deposit: state.bridgeDepositFixture } }, + { jsonrpc: "2.0", id: "bridgeObservations", method: "bridge_observation_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "bridgeObservation", method: "bridge_observation_get", params: { depositId: bridgeDepositId } }, + { jsonrpc: "2.0", id: "bridgeDeposits", method: "bridge_deposit_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "bridgeDeposit", method: "bridge_deposit_get", params: { depositId: bridgeDepositId } }, + { jsonrpc: "2.0", id: "bridgeCredits", method: "bridge_credit_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "bridgeCredit", method: "bridge_credit_get", params: { depositId: bridgeDepositId } }, + { jsonrpc: "2.0", id: "withdrawals", method: "withdrawal_list", params: { limit: 10 } }, + { jsonrpc: "2.0", id: "withdrawal", method: "withdrawal_get", params: { depositId: bridgeDepositId } }, + { jsonrpc: "2.0", id: "provenance", method: "provenance_get", params: { receiptId: receipt.receiptId } }, + { jsonrpc: "2.0", id: "raw", method: "raw_json_get", params: { source: "launchCore" } }, + { jsonrpc: "2.0", id: "rawBridge", method: "raw_json_get", params: { source: "bridgeDepositFixture" } }, + ] as const; - const response = dispatchJsonRpc([...requests], { state }); - if (!Array.isArray(response)) { - throw new Error("control-plane smoke expected batch JSON-RPC response"); - } + const response = dispatchJsonRpc([...requests], { + state, + paths: { + runtimeStatePath: join(tempDir, "state.json"), + runtimeIntakeDir: join(tempDir, "intake"), + bridgeObservationIntakePath: join(tempDir, "bridge-observations.json"), + }, + }); + if (!Array.isArray(response)) { + throw new Error("control-plane smoke expected batch JSON-RPC response"); + } - const errors = response.filter((entry): entry is RpcErrorResponse => "error" in entry); - if (errors.length > 0) { - throw new Error(`control-plane smoke failed: ${JSON.stringify(errors, null, 2)}`); - } + const errors = response.filter((entry): entry is RpcErrorResponse => "error" in entry); + if (errors.length > 0) { + throw new Error(`control-plane smoke failed: ${JSON.stringify(errors, null, 2)}`); + } - const successes = response as RpcSuccessResponse[]; - return { - schema: "flowmemory.control_plane.smoke.v0", - ok: true, - methodCount: requests.length, - responseSchemas: successes.map((entry) => (entry.result as JsonObject).schema), - queried: { - rootfieldId, - receiptId: receipt.receiptId, - reportId, - artifactUri, - blockNumber: stringField(block.blockNumber, "blockNumber"), - txId, - }, - localOnly: true, - }; + const successes = response as RpcSuccessResponse[]; + successes.forEach((entry) => assertNoSecrets(entry.result)); + return { + schema: "flowmemory.control_plane.smoke.v0", + ok: true, + methodCount: requests.length, + responseSchemas: successes.map((entry) => (entry.result as JsonObject).schema), + noSecretResponseScan: "passed", + queried: { + rootfieldId, + receiptId: receipt.receiptId, + reportId, + artifactUri, + blockNumber: stringField(block.blockNumber, "blockNumber"), + txId, + agentId, + modelId, + memoryCellId, + challengeId, + finalityReceiptId, + bridgeDepositId, + }, + localOnly: true, + }; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } } if (process.argv[1] === fileURLToPath(import.meta.url)) { diff --git a/services/control-plane/src/types.ts b/services/control-plane/src/types.ts index d0a0b948..49f27316 100644 --- a/services/control-plane/src/types.ts +++ b/services/control-plane/src/types.ts @@ -16,10 +16,22 @@ export type ControlPlaneMethod = | "health" | "chain_status" | "devnet_state" + | "node_status" + | "peer_list" + | "mempool_list" | "block_get" | "block_list" + | "account_get" + | "account_list" + | "balance_get" + | "balance_list" + | "faucet_event_get" + | "faucet_event_list" + | "wallet_metadata_get" + | "wallet_metadata_list" | "transaction_get" | "transaction_list" + | "transaction_submit" | "rootfield_get" | "rootfield_list" | "artifact_get" @@ -37,12 +49,25 @@ export type ControlPlaneMethod = | "memory_cell_list" | "agent_get" | "agent_list" + | "agent_account_get" + | "agent_account_list" | "model_get" | "model_list" + | "model_passport_get" + | "model_passport_list" | "challenge_get" | "challenge_list" | "finality_get" | "finality_list" + | "bridge_observation_submit" + | "bridge_observation_get" + | "bridge_observation_list" + | "bridge_deposit_get" + | "bridge_deposit_list" + | "bridge_credit_get" + | "bridge_credit_list" + | "withdrawal_get" + | "withdrawal_list" | "provenance_get" | "raw_json_get"; @@ -51,11 +76,21 @@ export interface ControlPlanePaths { indexerPath: string; verifierPath: string; artifactsPath: string; + devnetLocalStatePath: string; + devnetLocalLaunchStatePath: string; + devnetLocalIndexerHandoffPath: string; + devnetLocalVerifierHandoffPath: string; + devnetLocalControlPlaneHandoffPath: string; devnetPath: string; devnetIndexerHandoffPath: string; devnetVerifierHandoffPath: string; devnetControlPlaneHandoffPath: string; txFixturesPath: string; + runtimeStatePath: string; + runtimeIntakeDir: string; + bridgeObservationPath: string; + bridgeObservationIntakePath: string; + bridgeDepositFixturePath: string; } export interface DataSourceRecord { @@ -77,6 +112,9 @@ export interface LoadedControlPlaneState { devnetVerifierHandoff: JsonObject | null; devnetControlPlaneHandoff: JsonObject | null; txFixtures: JsonObject | null; + bridgeObservation: JsonObject | null; + bridgeObservationIntake: JsonObject | null; + bridgeDepositFixture: JsonObject | null; sources: Record; } diff --git a/services/control-plane/test/control-plane.test.ts b/services/control-plane/test/control-plane.test.ts index fe925d2e..062d8da4 100644 --- a/services/control-plane/test/control-plane.test.ts +++ b/services/control-plane/test/control-plane.test.ts @@ -9,6 +9,7 @@ import { canonicalJson } from "../../shared/src/index.ts"; import { dispatchJsonRpc, loadControlPlaneState, + scanJsonForSecrets, type RpcErrorResponse, type RpcSuccessResponse, } from "../src/index.ts"; @@ -66,10 +67,12 @@ test("keeps deterministic chain status response snapshots", () => { }; assert.equal(snapshot(first), snapshot(second)); - assert.equal( - snapshot(first), - "{\"capabilities\":[\"health_reads\",\"fixture_status_reads\",\"block_reads\",\"transaction_reads\",\"receipt_lookup\",\"verifier_report_lookup\",\"memory_lineage_lookup\",\"artifact_fixture_lookup\",\"devnet_handoff_reads\",\"raw_json_reads\"],\"chainId\":\"flowmemory-local-alpha\",\"counts\":{\"agents\":2,\"artifactAvailability\":5,\"blocks\":11,\"challenges\":1,\"devnetBlocks\":2,\"duplicates\":1,\"finalityRows\":9,\"memoryCells\":1,\"memoryReceipts\":8,\"memorySignals\":8,\"models\":2,\"observations\":8,\"rejectedLogs\":2,\"rootfields\":2,\"transactions\":23,\"verifierModules\":3,\"verifierReports\":8,\"workReceipts\":9},\"schema\":\"flowmemory.control_plane.chain_status.v0\"}", - ); + assert.equal(first.result.chainId, "flowmemory-local-devnet-v0"); + assert.ok((first.result.capabilities as string[]).includes("live_local_state_reads")); + assert.ok((first.result.capabilities as string[]).includes("transaction_submission")); + assert.ok((first.result.capabilities as string[]).includes("bridge_observation_intake")); + assert.equal(first.result.counts.observations, 8); + assert.equal(first.result.counts.bridgeDeposits, 1); }); test("recovers when generated launch/indexer/verifier fixtures are missing", () => { @@ -169,10 +172,38 @@ test("smoke client queries the complete local lifecycle surface", () => { assert.equal(smoke.schema, "flowmemory.control_plane.smoke.v0"); assert.equal(smoke.ok, true); - assert.equal(smoke.methodCount, 31); + assert.equal(smoke.methodCount, 57); + assert.equal(smoke.noSecretResponseScan, "passed"); assert.ok((smoke.responseSchemas as string[]).includes("flowmemory.control_plane.raw_json.v0")); }); +test("detects secret-bearing response keys", () => { + const findings = scanJsonForSecrets({ + schema: "test", + privateKey: "not-allowed", + }); + + assert.equal(findings.length, 1); + assert.equal(findings[0]?.reason, "forbidden secret-bearing key"); +}); + +test("rejects transaction submissions with secret-bearing fields", () => { + const response = dispatchJsonRpc({ + jsonrpc: "2.0", + id: 1, + method: "transaction_submit", + params: { + tx: { + type: "RegisterRootfield", + privateKey: "not-allowed", + }, + }, + }) as RpcErrorResponse; + + assert.equal(response.error.code, -32602); + assert.equal(response.error.data.reasonCode, "params.invalid"); +}); + test("HTTP server exposes browser-safe health and state endpoints", async () => { const server = startControlPlaneServer({ host: "127.0.0.1", port: 0 }); @@ -196,6 +227,20 @@ test("HTTP server exposes browser-safe health and state endpoints", async () => assert.equal(state.status, 200); assert.equal(state.headers.get("access-control-allow-origin"), "*"); assert.equal((await state.json()).schema, "flowmemory.control_plane.devnet_state.v0"); + + const node = await fetch(`http://127.0.0.1:${port}/node/status`, { + headers: { Origin: "http://127.0.0.1:5173" }, + }); + assert.equal(node.status, 200); + assert.equal(node.headers.get("access-control-allow-origin"), "*"); + assert.equal((await node.json()).schema, "flowmemory.control_plane.node_status.v0"); + + const deposits = await fetch(`http://127.0.0.1:${port}/bridge/deposits?limit=1`, { + headers: { Origin: "http://127.0.0.1:5173" }, + }); + assert.equal(deposits.status, 200); + assert.equal(deposits.headers.get("access-control-allow-origin"), "*"); + assert.equal((await deposits.json()).schema, "flowmemory.control_plane.bridge_deposit_list.v0"); } finally { await new Promise((resolve, reject) => { server.close((error) => {