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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/guides/miden-dashboard/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ services:
ports:
- "3000:3000"
- "50051:50051"
- "127.0.0.1:9464:9464"
depends_on:
postgres:
condition: service_healthy
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/observability/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the Grafana port references.

Grafana now binds to 127.0.0.1:3002:3000, but Lines 73 and 80 still refer to port 3001. Users following the instructions will open the wrong URL. Change those references to 3002.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/guides/observability/docker-compose.yml` at line 74, Update the Grafana
URL references in the surrounding Docker Compose documentation from port 3001 to
port 3002, matching the 127.0.0.1:3002:3000 binding while leaving unrelated port
references unchanged.

volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/observability/prometheus/prometheus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 \
  '9464|host\.docker\.internal|extra_hosts|ports:' \
  docs/guides/miden-dashboard/docker-compose.yml \
  docs/guides/observability/docker-compose.yml \
  docs/guides/observability/prometheus/prometheus.yml

Repository: OpenZeppelin/guardian

Length of output: 7961


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' docs/guides/observability/docker-compose.yml
printf '\n--- prometheus config ---\n'
cat -n docs/guides/observability/prometheus/prometheus.yml
printf '\n--- miden dashboard relevant sections ---\n'
sed -n '1,110p' docs/guides/miden-dashboard/docker-compose.yml

Repository: OpenZeppelin/guardian

Length of output: 7810


🌐 Web query:

Docker Compose host-gateway extra_hosts host.docker.internal container network reach host container published port

💡 Result:

To access the host machine or services running on it from a container managed by Docker Compose, use the extra_hosts directive with the special host-gateway value. This configuration maps the hostname host.docker.internal to the host's internal IP address [1][2][3]. Configuration Add the following to your compose.yaml file under the desired service: services: your-service: #... other configuration extra_hosts: - "host.docker.internal:host-gateway" How it works 1. DNS Mapping: The extra_hosts directive adds an entry to the container's /etc/hosts file (e.g., 172.17.0.1 host.docker.internal), ensuring the hostname resolves correctly [4][2][3]. 2. host-gateway: On Linux, host-gateway dynamically resolves to the host's IP address on the default bridge network [1][3]. On Docker Desktop (macOS and Windows), host.docker.internal is already provided, making this configuration harmless and recommended for cross-platform compatibility [1][5][3]. 3. Connectivity: Once configured, applications inside the container can reach services running on the host by connecting to http://host.docker.internal:<port> [4][5]. Important Notes - Host Firewall: Ensure that the service on your host machine is configured to listen on the correct interface (e.g., 0.0.0.0 or the Docker bridge IP, not just 127.0.0.1) and that your host firewall (e.g., ufw, iptables) allows incoming connections from the Docker bridge network [1][6]. - Build Time: Historically, host-gateway does not resolve during the image build process (build: section of Compose). If you need to reach the host during a docker build, you must use build-time arguments or specific buildx flags instead [3].

Citations:


🌐 Web query:

Docker host-gateway host.docker.internal can container reach service outside Docker network Linux firewall docker0

💡 Result:

On Linux, you can enable the use of host.docker.internal to reach the host machine by adding the flag --add-host=host.docker.internal:host-gateway to your docker run command [1][2][3], or by adding extra_hosts: - "host.docker.internal:host-gateway" to your service definition in a docker-compose.yml file [4][2][5]. This feature is available in Docker Engine 20.10 and later [2][3]. Even when this is configured, there are two common reasons why a container might fail to reach a service running on the host: 1. Service Binding: The service on the host must be configured to listen on the host's IP address (e.g., the IP of the docker0 interface, often 172.17.0.1) or on all interfaces (0.0.0.0) [1][2]. If the service is bound only to the local loopback interface (127.0.0.1), it will not accept connections from the Docker network, as the container's loopback address is distinct from the host's loopback address [4][6]. 2. Firewall Configuration: Linux host firewalls (such as UFW or iptables) often restrict traffic between the Docker bridge (e.g., docker0) and the host [1][4]. Because traffic from a container enters the host via the bridge interface, the firewall may block these incoming connections if rules are not explicitly set to permit them [1]. Troubleshooting often involves temporarily disabling the firewall to verify connectivity [1] or adjusting net.bridge.bridge-nf-call-iptables sysctl settings if traffic is being dropped by bridge-level rules [4]. Alternatively, for situations where you need full access to the host's network namespace, you can run the container with the host networking driver using the --network host flag, which allows the container to share the host's network stack directly (including access to services bound to localhost) [5][7].

Citations:


🌐 Web query:

Docker Compose host-gateway port forwarding 127.0.0.1 published localhost inside container

💡 Result:

To access a service running on your host machine from inside a Docker Compose container, you must navigate the fact that localhost inside a container refers to the container itself, not the host machine [1][2][3]. The standard, recommended approach for enabling cross-platform host access is using the host-gateway feature [4][5]. Configuration in Docker Compose You can enable this by adding an extra_hosts entry to your service definition in your docker-compose.yml file [6][4]: services: app: image: myapp:latest extra_hosts: - "host.docker.internal:host-gateway" How it works 1. The host-gateway value is a special instruction that tells Docker to resolve the hostname (host.docker.internal) to the host machine's internal IP address on the default bridge network [7][8]. 2. When this entry is present, the container's /etc/hosts file is updated to map host.docker.internal to that IP [4][5][2]. 3. Applications inside the container can then connect to services on the host using the address http://host.docker.internal:[PORT] instead of localhost [9][10][1]. Important requirements * Host Service Binding: For the container to successfully connect, the service on the host must be listening on all interfaces (0.0.0.0) or specifically on the IP address assigned to the Docker bridge network [9][11]. If the host service is strictly bound to 127.0.0.1, it may reject connections coming from the container's IP [9]. * Compatibility: This method works on Linux (Docker Engine 20.10+) and is automatically available on Docker Desktop (Mac and Windows), where host.docker.internal is provided by default [6][10][11]. Including the extra_hosts configuration is considered a best practice for portability, as it is harmless on systems that already provide it and necessary on Linux [8][1]. * Alternative (Host Networking): If you prefer to bypass Docker networking entirely, you can use network_mode: host [12][1][2]. This allows the container to share the host's network namespace, meaning localhost inside the container will then correctly refer to the host's localhost [10][2]. Note that this has significant implications for network isolation and is typically reserved for specialized use cases [12][2].

Citations:


🌐 Web query:

Docker Compose port publish 127.0.0.1:3000:3000 host-gateway container port

💡 Result:

To publish a container port only to the host's loopback interface (localhost) using Docker Compose, you must specify the IP address in the ports mapping configuration [1][2][3]. Use the following syntax in your docker-compose.yml file: services: your-service: ports: - "127.0.0.1:3000:3000" In this configuration, the first 3000 is the port on your host machine, and the second 3000 is the port inside the container [1][4]. By prefixing the host port with 127.0.0.1, you restrict access so that only the host machine can connect to the container service; it will not be reachable from other machines on your network [2][5]. If you additionally need your container to connect back to the host machine (e.g., to access a database or service running directly on your host), you can use the extra_hosts configuration with the special host-gateway value [6][7][8]. This resolves a hostname to the host's IP address: services: your-service: extra_hosts: - "host.docker.internal:host-gateway" This adds an entry to the container's /etc/hosts file (typically 172.17.0.1 host.docker.internal), allowing your application to reach services on the host by connecting to host.docker.internal [8][9]. It is important to note that if you omit the 127.0.0.1 prefix (e.g., using only "3000:3000"), Docker binds to all interfaces (0.0.0.0), which exposes the service to your local network and potentially the internet if your host has a public IP address [1][2][3]. Explicitly pinning 127.0.0.1 is a recommended security best practice for local development [2][10].

Citations:


Use server:9464 for the server in this Compose stack.

docs/guides/observability/docker-compose.yml does not publish port 9464, so Prometheus must scrape the server container directly. host.docker.internal:9464 only works for a guardian server running directly on the host, not for the containerized topology described here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/guides/observability/prometheus/prometheus.yml` at line 14, Update the
Prometheus scrape target in the targets configuration to use server:9464 instead
of host.docker.internal:9464, matching the containerized Compose topology.

# 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
Expand Down
3 changes: 3 additions & 0 deletions examples/operator-cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
operator-key.bin
package-lock.json
126 changes: 126 additions & 0 deletions examples/operator-cli/list-accounts.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +4 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the key file name in the header.

Lines 5 and 7 specify operator-key.txt. The CLI and examples/operator-cli/.gitignore use operator-key.bin. A user who creates the documented file will cause an unexpected new key generation.

Proposed fix
- * to ./operator-key.txt, prints the operators.json entry, and exits.
+ * to ./operator-key.bin, prints the operators.json entry, and exits.
...
- * Subsequent runs: reads the key from ./operator-key.txt and authenticates.
+ * Subsequent runs: reads the key from ./operator-key.bin and authenticates.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* 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.
* First run (no key file): generates a Falcon keypair, writes the secret key
* to ./operator-key.bin, prints the operators.json entry, and exits.
*
* Subsequent runs: reads the key from ./operator-key.bin and authenticates.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/operator-cli/list-accounts.ts` around lines 4 - 7, Update the header
documentation for the first-run and subsequent-run flows in the operator CLI
example to consistently reference operator-key.bin, matching the filename used
by the CLI and its .gitignore configuration.

*
* 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<string, string> = {};
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());
Comment on lines +57 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict permissions on the generated secret key.

writeFileSync uses a default mode that depends on the process umask. A permissive umask can make operator-key.bin readable by other local users. This key can authenticate as the operator.

Proposed fix
-    writeFileSync(KEY_FILE, secretKey.serialize());
+    writeFileSync(KEY_FILE, secretKey.serialize(), { mode: 0o600 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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());
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(), { mode: 0o600 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/operator-cli/list-accounts.ts` around lines 57 - 60, Update the
key-file creation in the first-run branch of the surrounding account-listing
flow to explicitly set restrictive owner-only permissions when writing the
serialized secret key. Ensure operator-key.bin is not readable or writable by
other users, while preserving the existing key generation and persistence
behavior.

// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not grant accounts:pause to this read-only CLI.

Line 65 generates an operator entry with mutation permission, but this CLI only calls listAccounts. Remove accounts:pause unless this example also implements an account pause command.

Proposed fix
-    const entry = JSON.stringify({ public_key: pubKeyHex, permissions: ['dashboard:read', 'accounts:pause'] }, null, 2);
+    const entry = JSON.stringify({ public_key: pubKeyHex, permissions: ['dashboard:read'] }, null, 2);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const entry = JSON.stringify({ public_key: pubKeyHex, permissions: ['dashboard:read', 'accounts:pause'] }, null, 2);
const entry = JSON.stringify({ public_key: pubKeyHex, permissions: ['dashboard:read'] }, null, 2);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/operator-cli/list-accounts.ts` at line 65, Update the operator entry
generated by the listAccounts CLI to grant only read access; remove the
accounts:pause permission from the permissions array in the JSON.stringify call
unless this CLI also adds a pause command.


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)`);
Comment on lines +102 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not label the first page as the total.

nextCursor means another page exists, but the CLI only renders the first 50 accounts. Either follow the cursor until completion or label this output as a partial page.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/operator-cli/list-accounts.ts` around lines 102 - 105, Update the
account listing flow around client.listAccounts and totalLabel so it does not
present the first page count as the total. Either iterate through every
nextCursor and aggregate all accounts before logging, or clearly label the
current count as a partial page when pagination remains.

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);
});
19 changes: 19 additions & 0 deletions examples/operator-cli/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
18 changes: 18 additions & 0 deletions examples/operator-cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading