Skip to content

feat: add operator CLI example and fix local two-stack observability setup - #385

Draft
stevep0z wants to merge 2 commits into
OpenZeppelin:mainfrom
stevep0z:example/operator-cli
Draft

feat: add operator CLI example and fix local two-stack observability setup#385
stevep0z wants to merge 2 commits into
OpenZeppelin:mainfrom
stevep0z:example/operator-cli

Conversation

@stevep0z

@stevep0z stevep0z commented Aug 10, 2026

Copy link
Copy Markdown

Adds a minimal Node.js CLI script under examples/operator-cli/ that demonstrates the full operator authentication flow and account listing using @openzeppelin/guardian-operator-client.

On first run it generates a Falcon keypair and prints the operators.json entry. On subsequent runs it authenticates via the challenge-response flow and lists all accounts on the target Guardian instance. The GUARDIAN_URL env var points it at any instance.

Also fixes three config issues that prevented the local two-stack setup (dashboard + observability running simultaneously) from working correctly:

  • docs/guides/miden-dashboard/docker-compose.yml: expose metrics port and add GUARDIAN_METRICS_* env vars so Prometheus has something to scrape
  • docs/guides/observability/docker-compose.yml: move Grafana to port 3002 to avoid clash with the dashboard on 3001, add extra_hosts so Prometheus can reach Guardian by hostname
  • docs/guides/observability/prometheus/prometheus.yml: change scrape target from server:9464 to host.docker.internal:9464 to match the separate-network topology

Summary by CodeRabbit

  • New Features

    • Added an operator CLI example that securely authenticates with Guardian and lists account statuses and pause reasons.
    • The CLI generates and stores an operator keypair on first use and reuses it for subsequent sessions.
    • Added development metrics configuration for local monitoring with a 15-second refresh interval.
  • Documentation

    • Updated observability examples to connect Prometheus to host metrics and use the revised Grafana port.

@stevep0z
stevep0z marked this pull request as draft August 10, 2026 06:15
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The PR configures authenticated server metrics for Prometheus and adds a TypeScript Guardian operator CLI that persists a Falcon key, authenticates, and lists account statuses.

Changes

Metrics observability setup

Layer / File(s) Summary
Metrics endpoint and host scraping
docs/guides/miden-dashboard/docker-compose.yml, docs/guides/observability/docker-compose.yml, docs/guides/observability/prometheus/prometheus.yml
The server exposes authenticated metrics on port 9464. Prometheus targets host.docker.internal:9464. Grafana uses host port 3002.

Guardian operator CLI

Layer / File(s) Summary
CLI package and key bootstrap
examples/operator-cli/package.json, examples/operator-cli/tsconfig.json, examples/operator-cli/.gitignore, examples/operator-cli/list-accounts.ts
The package defines TypeScript tooling and ignores local key files. The CLI generates and saves a Falcon key on first run.
Session authentication and account listing
examples/operator-cli/list-accounts.ts
The CLI preserves cookies, signs and verifies a Guardian challenge, retrieves up to 50 accounts, prints account details, and exits with status 1 on errors.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OperatorCLI
  participant MidenSDK
  participant Guardian
  OperatorCLI->>Guardian: Request authentication challenge
  Guardian-->>OperatorCLI: Return challenge
  OperatorCLI->>MidenSDK: Deserialize Falcon key and sign challenge
  MidenSDK-->>OperatorCLI: Return signature
  OperatorCLI->>Guardian: Verify operator signature with session cookies
  Guardian-->>OperatorCLI: Establish authenticated session
  OperatorCLI->>Guardian: Request up to 50 accounts
  Guardian-->>OperatorCLI: Return account statuses and pause reasons
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: zeljkox

Poem

I hop through metrics, fresh and bright,
A Falcon key tucked safe from sight.
Guardian grants the session door,
Accounts pause and statuses soar.
Rabbit reviews with paws held high! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes both the new operator CLI example and the local observability setup fixes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/guides/observability/docker-compose.yml`:
- 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.

In `@docs/guides/observability/prometheus/prometheus.yml`:
- 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.

In `@examples/operator-cli/list-accounts.ts`:
- Around line 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.
- Around line 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.
- Around line 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.
- 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.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c64213a-b09f-455d-995c-7d1803c3c94f

📥 Commits

Reviewing files that changed from the base of the PR and between 6041380 and b10044a.

📒 Files selected for processing (7)
  • docs/guides/miden-dashboard/docker-compose.yml
  • docs/guides/observability/docker-compose.yml
  • docs/guides/observability/prometheus/prometheus.yml
  • examples/operator-cli/.gitignore
  • examples/operator-cli/list-accounts.ts
  • examples/operator-cli/package.json
  • examples/operator-cli/tsconfig.json

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.

# 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.

Comment on lines +4 to +7
* 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.

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.

Comment on lines +57 to +60
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());

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.

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.

Comment on lines +102 to +105
const { items, nextCursor } = await client.listAccounts({ limit: 50 });
const totalLabel = `${items.length}${nextCursor ? '+' : ''}`;

console.log(`Accounts on ${GUARDIAN_URL} (${totalLabel} total)`);

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.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants