diff --git a/docs/guides/miden-dashboard/docker-compose.yml b/docs/guides/miden-dashboard/docker-compose.yml index 5c347b68..f0697879 100644 --- a/docs/guides/miden-dashboard/docker-compose.yml +++ b/docs/guides/miden-dashboard/docker-compose.yml @@ -28,6 +28,7 @@ services: ports: - "3000:3000" - "50051:50051" + - "127.0.0.1:9464:9464" depends_on: postgres: condition: service_healthy @@ -40,6 +41,10 @@ services: - DATABASE_URL=postgres://guardian:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/guardian - GUARDIAN_KEYSTORE_PATH=/var/guardian/keystore - GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE=/etc/guardian/operators.json + - GUARDIAN_METRICS_ENABLED=true + - GUARDIAN_METRICS_ADDR=0.0.0.0:9464 + - GUARDIAN_METRICS_BEARER_TOKEN=devtoken + - GUARDIAN_METRICS_REFRESH_INTERVAL_SECS=15 postgres: image: postgres:16-alpine diff --git a/docs/guides/observability/docker-compose.yml b/docs/guides/observability/docker-compose.yml index 7863ef86..0601121b 100644 --- a/docs/guides/observability/docker-compose.yml +++ b/docs/guides/observability/docker-compose.yml @@ -57,6 +57,8 @@ services: volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: - server healthcheck: @@ -69,7 +71,7 @@ services: image: grafana/grafana:11.4.0 ports: # 3001 avoids clashing with the server's HTTP port 3000. - - "127.0.0.1:3001:3000" + - "127.0.0.1:3002:3000" volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ./grafana/dashboards:/var/lib/grafana/dashboards:ro diff --git a/docs/guides/observability/prometheus/prometheus.yml b/docs/guides/observability/prometheus/prometheus.yml index c917d783..4028c09e 100644 --- a/docs/guides/observability/prometheus/prometheus.yml +++ b/docs/guides/observability/prometheus/prometheus.yml @@ -11,7 +11,7 @@ scrape_configs: # guardian-server running on the host instead (e.g. `cargo run`), point # this at `host.docker.internal:9464` and add an `extra_hosts` mapping # for it on this service. - - targets: ["server:9464"] + - targets: ["host.docker.internal:9464"] # Matches GUARDIAN_METRICS_BEARER_TOKEN on the server. `devtoken` is a # throwaway for this local stack — in production use `credentials_file:` # pointing at a mounted secret instead of an inline value, and never diff --git a/examples/operator-cli/.gitignore b/examples/operator-cli/.gitignore new file mode 100644 index 00000000..8190aa22 --- /dev/null +++ b/examples/operator-cli/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +operator-key.bin +package-lock.json diff --git a/examples/operator-cli/list-accounts.ts b/examples/operator-cli/list-accounts.ts new file mode 100644 index 00000000..d89dd481 --- /dev/null +++ b/examples/operator-cli/list-accounts.ts @@ -0,0 +1,126 @@ +/** + * Guardian operator CLI demo — authenticate and list accounts. + * + * First run (no key file): generates a Falcon keypair, writes the secret key + * to ./operator-key.txt, prints the operators.json entry, and exits. + * + * Subsequent runs: reads the key from ./operator-key.txt and authenticates. + * + * Usage: + * npx tsx list-accounts.ts + * + * Point at a different instance: + * GUARDIAN_URL=https://guardian.example npx tsx list-accounts.ts + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { GuardianOperatorHttpClient } from '@openzeppelin/guardian-operator-client'; +import { AuthSecretKey, Word, getNativeModule } from '@miden-sdk/miden-sdk'; + +const GUARDIAN_URL = process.env.GUARDIAN_URL ?? 'http://127.0.0.1:3000'; +const KEY_FILE = './operator-key.bin'; + +function bytesToHex(b: Uint8Array): string { + return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); +} + +// Session cookies are not persisted automatically by Node.js fetch — wire them up manually. +function makeCookieFetch(): typeof fetch { + const jar: Record = {}; + return async (input, init) => { + const headers = new Headers(init?.headers); + const cookieHeader = Object.entries(jar) + .map(([k, v]) => `${k}=${v}`) + .join('; '); + if (cookieHeader) headers.set('cookie', cookieHeader); + + const res = await fetch(input, { ...init, headers }); + + // getSetCookie() returns each Set-Cookie header as a separate string (Node 18+). + const setCookies = + typeof res.headers.getSetCookie === 'function' + ? res.headers.getSetCookie() + : [res.headers.get('set-cookie') ?? ''].filter(Boolean); + + for (const raw of setCookies) { + const [pair] = raw.split(';'); + const eq = pair?.indexOf('=') ?? -1; + if (eq > 0) jar[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); + } + + return res; + }; +} + + +async function main() { + if (!existsSync(KEY_FILE)) { + // First run: generate a keypair, save the secret key to a file, exit. + const secretKey = AuthSecretKey.rpoFalconWithRNG(undefined); + writeFileSync(KEY_FILE, secretKey.serialize()); + // toHex() already includes the 0x prefix + const commitment = secretKey.publicKey().toCommitment().toHex(); + const pubKeyHex = '0x' + bytesToHex(secretKey.publicKey().serialize().slice(1)); + + const entry = JSON.stringify({ public_key: pubKeyHex, permissions: ['dashboard:read', 'accounts:pause'] }, null, 2); + + console.log(`\nNo key file found — generated a new Falcon keypair → ${KEY_FILE}\n`); + console.log('1. Add this entry to docs/guides/miden-dashboard/operators.json:\n'); + console.log(entry.split('\n').map((l) => ` ${l}`).join('\n')); + console.log(`\n Commitment (for reference): ${commitment}\n`); + console.log('2. Re-run (key loads automatically from operator-key.bin):\n'); + console.log(' npx tsx list-accounts.ts\n'); + return; + } + + // The napi compat layer wrongly converts Buffer→Array for deserialize, so bypass it. + const secretKey = getNativeModule().AuthSecretKey.deserialize(readFileSync(KEY_FILE)) as AuthSecretKey; + // toHex() already includes the 0x prefix + const commitment = secretKey.publicKey().toCommitment().toHex(); + + const client = new GuardianOperatorHttpClient({ + baseUrl: GUARDIAN_URL, + fetch: makeCookieFetch(), + }); + + // Step 1: request challenge + process.stdout.write(`Authenticating ${commitment.slice(0, 20)}… `); + const { challenge } = await client.challenge(commitment); + + // Step 2: sign the challenge digest with the Falcon key + const sig = secretKey.sign(Word.fromHex(challenge.signingDigest)); + const sigHex = bytesToHex(sig.serialize().slice(1)); // drop leading scheme byte + + // Step 3: verify → server sets session cookie + const { operatorId } = await client.verify({ + commitment, + signature: sigHex, + }); + console.log(`ok (operator: ${operatorId})\n`); + + // List accounts + const { items, nextCursor } = await client.listAccounts({ limit: 50 }); + const totalLabel = `${items.length}${nextCursor ? '+' : ''}`; + + console.log(`Accounts on ${GUARDIAN_URL} (${totalLabel} total)`); + console.log('─'.repeat(80)); + + if (items.length === 0) { + console.log(' No accounts registered yet.'); + } else { + for (const a of items) { + const status = a.pausedAt ? 'PAUSED' : 'active'; + console.log( + ` [${status.padEnd(6)}] ${a.accountId}` + + ` scheme=${a.authScheme}` + + ` signers=${a.authorizedSignerCount}`, + ); + if (a.pausedReason) console.log(` reason: ${a.pausedReason}`); + } + } +} + +main().catch((e) => { + console.error('\n' + (e instanceof Error ? e.message : String(e))); + process.exit(1); +}); diff --git a/examples/operator-cli/package.json b/examples/operator-cli/package.json new file mode 100644 index 00000000..81ed075c --- /dev/null +++ b/examples/operator-cli/package.json @@ -0,0 +1,19 @@ +{ + "name": "guardian-operator-cli", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx list-accounts.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@miden-sdk/miden-sdk": "0.15.7", + "@openzeppelin/guardian-operator-client": "file:../../packages/guardian-operator-client" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "tsx": "^4.19.2", + "typescript": "^5.4.0" + } +} diff --git a/examples/operator-cli/tsconfig.json b/examples/operator-cli/tsconfig.json new file mode 100644 index 00000000..f8485291 --- /dev/null +++ b/examples/operator-cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["*.ts"] +}