From 6b04ea0db1012ebb9a95289702aa89fc4dea1387 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Thu, 28 May 2026 15:46:35 -0700 Subject: [PATCH 1/3] docs: expand admin interface page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit the admin-interface doc against the live runtime/CLI and fill in the missing details users need to expose and call admin methods safely: - Security: token format/rotation, reserved ADMIN_TOKEN env var, and the X-Miren-Access trust model. - Auditing: admin calls land in the app's log stream. - JSON-RPC shape: object vs positional params, 30s call timeout, and HTTP status semantics. - Introspection: $-prefixed methods ($methods, $type) are reserved. - CLI: --func-help, --params-file (incl. stdin), mixed input styles, type-aware parsing, kebab→snake flag normalization. Move the per-language samples (Python, Node.js, Bun) into a single "More Implementation Examples" section below CLI usage, with Other Languages linking down to it. Switches the Go and Node.js auth checks to constant-time comparison. --- docs/docs/admin-interface.md | 307 ++++++++++++++++++++++++++++++++--- 1 file changed, 281 insertions(+), 26 deletions(-) diff --git a/docs/docs/admin-interface.md b/docs/docs/admin-interface.md index 50d99323c..abf99ba09 100644 --- a/docs/docs/admin-interface.md +++ b/docs/docs/admin-interface.md @@ -8,10 +8,6 @@ import CliCommand from '@site/src/components/CliCommand'; # Admin Interface -:::info Labs Feature -The admin interface is a [labs feature](/labs) and is disabled by default. Enable it with `--labs adminapi` or `MIREN_LABS=adminapi` when starting the server. -::: - The admin interface allows you to expose custom administrative functions in your application that can be called from the CLI or other tooling. This is useful for user management, cache clearing, database operations, and other maintenance tasks. ## How It Works @@ -49,9 +45,15 @@ Your web service must expose: ### Security -Admin calls are authenticated using a bearer token that Miren generates for your app. Your app receives this token via the `ADMIN_TOKEN` environment variable. +Admin calls are authenticated using a bearer token that Miren generates for your app. Your app receives this token via the `ADMIN_TOKEN` environment variable and must validate it on every request. + +#### The admin token -**You must validate this token on every request:** +- **Format**: 32 random bytes, a random bearer token +- **Per-version**: a fresh token is generated for every new app version at build time, so each deploy rotates the token automatically. +- **Reserved env var**: `ADMIN_TOKEN` is injected by the runtime. It is appended after your own env vars so it cannot be overridden from `miren.toml`, the CLI, or build-time env. + +**Validate the token on every request, for example in Go:** ```go func authMiddleware(token string, next http.Handler) http.Handler { @@ -62,7 +64,10 @@ func authMiddleware(token string, next http.Handler) http.Handler { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if strings.TrimPrefix(auth, "Bearer ") != token { + if subtle.ConstantTimeCompare( + []byte(strings.TrimPrefix(auth, "Bearer ")), + []byte(token), + ) != 1 { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -72,11 +77,24 @@ func authMiddleware(token string, next http.Handler) http.Handler { } ``` -The admin endpoint also receives an `X-Miren-Access` header: -- `internal` — Request is from Miren's admin system (trusted) -- `public` — Request is from an external client (Miren strips any client-provided value) +#### Network reachability and `X-Miren-Access` + +Miren's admin proxy talks to your app over its **internal** httpingress path, never via the public route. The proxy sets one header you can rely on: + +- `X-Miren-Access: internal` — request originated from `miren admin` and was routed through Miren's internal admin path. +- `X-Miren-Access: public` — request arrived through the public ingress. Miren overwrites any client-supplied value here before forwarding, so the header cannot be spoofed by external callers. + +For the most defensive setup, be sure to check `X-Miren-Access: internal` as well as the bearer token. + +### Auditing + +Every admin call is appended to the **app's log stream** as an out-of-band entry (`source=admin`, `method=`) including the method name, params payload size, status (`ok` / `error=...`), and duration in milliseconds. You can review the audit trail with: + +```bash +miren logs +``` -You can use this header for additional access control if your endpoint is accidentally exposed to the internet. +This is server-side bookkeeping — your handler doesn't need to log calls itself to get an audit record. ### JSON-RPC 2.0 Format @@ -91,6 +109,16 @@ Requests follow the standard JSON-RPC 2.0 format: } ``` +`params` may be a JSON **object** (named arguments, recommended) or a JSON **array** (positional arguments). When the CLI describes a method that uses positional arguments via `$methods`, it labels the entries `arg0`, `arg1`, etc. + +#### Call timeout + +Each admin call has a fixed **30-second** timeout enforced by the runtime. Handlers should return promptly — if work takes longer, enqueue it on a background worker and return a job handle the caller can poll on a subsequent admin method. + +#### HTTP status semantics + +The runtime expects a `200 OK` response carrying a JSON-RPC envelope (success *or* error). A non-200 HTTP status is surfaced to the caller as a generic `admin endpoint returned status N` message with no structured detail. Prefer returning a JSON-RPC error object over a raw HTTP error code so the CLI can render a useful message and error code. + Successful responses: ```json @@ -130,6 +158,10 @@ Error responses: When you run `miren admin --list`, Miren sends a JSON-RPC request with the reserved method name `$methods` to your admin endpoint. If your app handles this method, it should return an array of objects describing the available admin methods. This is optional — if your app doesn't handle `$methods`, the `--list` command will report an error, but regular method calls still work. +:::note Reserved method names +Names beginning with `$` are reserved for the runtime. The CLI currently uses `$methods` for discovery and filters both `$methods` and `$type` out of `--list` output, so don't expose business logic under those names. +::: + The `$methods` request has no params: ```json @@ -186,6 +218,7 @@ package main import ( "context" + "crypto/subtle" "log" "net/http" "os" @@ -252,7 +285,10 @@ func authMiddleware(token string, next http.Handler) http.Handler { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if strings.TrimPrefix(auth, "Bearer ") != token { + if subtle.ConstantTimeCompare( + []byte(strings.TrimPrefix(auth, "Bearer ")), + []byte(token), + ) != 1 { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -336,8 +372,93 @@ can expose admin methods. The key requirements are: - Handle JSON-RPC requests and return proper responses - Optionally implement `$methods` for introspection +See [More Implementation Examples](#more-implementation-examples) at the bottom of this page for ready-to-use Python, Node.js, and Bun snippets. + +## Calling Admin Methods + +Once your app exposes the admin interface, use the CLI to call methods. All commands target the active version of the app named with `--app` / `-a` (or inferred from the current directory). + +### Discovery and help + + + +```miren +# List every method the app advertises via $methods +miren admin --list -a myapp + +# Show parameter signature for one method (--func-help or -h ) +miren admin -a myapp get-user -h +miren admin -a myapp --func-help get-user +``` + + + +### Passing parameters + +You can pass parameters three ways, and mix them freely in one call. The CLI rejects the call if the same key shows up via more than one channel. + + + +```miren +# 1. Bare key=value pairs +miren admin -a myapp get-user user_id=user-1 + +# 2. Long flags: --key=value or --key value +miren admin -a myapp list-users --limit 50 --offset 0 + +# 3. A JSON params object from a file (use - for stdin) +miren admin -a myapp update-config -f settings.json +cat settings.json | miren admin -a myapp update-config -f - + +# Mixed: file supplies defaults, flag overrides one field +miren admin -a myapp update-config -f settings.json --debug=true +``` + + + +#### Type-aware parsing + +When the app advertises a parameter type via `$methods`, the CLI coerces the supplied string into that type before sending the JSON-RPC request: + +| Declared type | Accepted CLI input | +|---------------|--------------------| +| `string` | any value, passed through | +| `number`, `integer`, `int`, `float` | numeric literal (`42`, `3.14`, `-5`) | +| `boolean`, `bool` | `true` / `false` / `1` / `0` / `yes` / `no` | +| `object` | JSON object literal (`'{"k":"v"}'`) | +| `array` | JSON array literal (`'[1,2,3]'`) | + +If the app does not advertise types (or you pass `--no-validate`), the CLI tries to parse each value as JSON and falls back to a string. + +#### Kebab-case flag names + +If your method declares a snake_case parameter like `user_id`, you can write the equivalent kebab-case flag (`--user-id`) on the CLI and it will be normalized automatically. Keys that genuinely contain hyphens are left alone. + +### Output format + +The CLI chooses an output format based on context: + +- **TTY**: human-friendly pretty rendering (tables for uniform arrays, key/value lists otherwise). +- **Non-TTY** (pipes, scripts): syntax-highlighted JSON. + +Override with `--json` to force JSON or `--pretty` to force the rendered form. + +### Skipping validation + +If your app does not implement `$methods`, the CLI silently skips validation. To suppress validation explicitly — for example to call an undeclared diagnostic method — pass `--no-validate`: + +```bash +miren admin -a myapp --no-validate debug-internal +``` + +See [Admin Commands](/command/admin) for the full CLI flag reference. + +## More Implementation Examples + ### Python Example +Using Flask: + ```python from flask import Flask, request, jsonify import os @@ -382,29 +503,163 @@ def admin_endpoint(): }) ``` -## Calling Admin Methods +### Node.js Example -Once your app exposes the admin interface, use the CLI to call methods: +Using Express: - +```javascript +const express = require('express'); +const crypto = require('crypto'); -```miren -# List available methods -miren admin --list +const app = express(); +app.use(express.json()); -# Call a method -miren admin get-user user_id=user-1 +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || ''; -# Call with complex parameters -miren admin update-config settings='{"debug": true}' +function tokenMatches(supplied, expected) { + const a = Buffer.from(supplied); + const b = Buffer.from(expected); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} -# Output as JSON (for scripting) -miren admin get-stats --json | jq '.total' +app.post('/.well-known/miren/admin', (req, res) => { + const auth = req.get('authorization') || ''; + if (ADMIN_TOKEN) { + if (!auth.startsWith('Bearer ') || !tokenMatches(auth.slice(7), ADMIN_TOKEN)) { + return res.status(401).send('Unauthorized'); + } + } + + const { method, params = {}, id } = req.body; + + if (method === '$methods') { + return res.json({ + jsonrpc: '2.0', + id, + result: [ + { + name: 'get-stats', + description: 'Get app statistics', + category: 'maintenance', + }, + { + name: 'get-user', + description: 'Get a specific user by ID', + category: 'users', + params: { user_id: 'string' }, + }, + ], + }); + } + + if (method === 'get-stats') { + return res.json({ + jsonrpc: '2.0', + id, + result: { users: 42, requests: 1000 }, + }); + } + + if (method === 'get-user') { + if (!params.user_id) { + return res.json({ + jsonrpc: '2.0', + id, + error: { code: -32602, message: 'user_id is required' }, + }); + } + return res.json({ + jsonrpc: '2.0', + id, + result: { id: params.user_id, name: 'Alice' }, + }); + } + + return res.json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' }, + }); +}); + +const port = process.env.PORT || 8080; +app.listen(port, () => console.log(`listening on :${port}`)); ``` - +### Bun Example + +Bun's built-in HTTP server needs no dependencies: + +```typescript +import { timingSafeEqual } from 'node:crypto'; -See [Admin Commands](/command/admin) for full CLI documentation. +const ADMIN_TOKEN = process.env.ADMIN_TOKEN ?? ''; + +function tokenMatches(supplied: string, expected: string): boolean { + const a = Buffer.from(supplied); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +type RpcRequest = { + jsonrpc: '2.0'; + method: string; + params?: Record | unknown[]; + id: number | string | null; +}; + +function rpc(id: RpcRequest['id'], body: object): Response { + return Response.json({ jsonrpc: '2.0', id, ...body }); +} + +Bun.serve({ + port: Number(process.env.PORT ?? 8080), + async fetch(req) { + const url = new URL(req.url); + if (req.method !== 'POST' || url.pathname !== '/.well-known/miren/admin') { + return new Response('Not Found', { status: 404 }); + } + + if (ADMIN_TOKEN) { + const auth = req.headers.get('authorization') ?? ''; + if (!auth.startsWith('Bearer ') || !tokenMatches(auth.slice(7), ADMIN_TOKEN)) { + return new Response('Unauthorized', { status: 401 }); + } + } + + const { method, params = {}, id } = (await req.json()) as RpcRequest; + const p = params as Record; + + switch (method) { + case '$methods': + return rpc(id, { + result: [ + { name: 'get-stats', description: 'Get app statistics', category: 'maintenance' }, + { + name: 'get-user', + description: 'Get a specific user by ID', + category: 'users', + params: { user_id: 'string' }, + }, + ], + }); + + case 'get-stats': + return rpc(id, { result: { users: 42, requests: 1000 } }); + + case 'get-user': + if (!p.user_id) { + return rpc(id, { error: { code: -32602, message: 'user_id is required' } }); + } + return rpc(id, { result: { id: p.user_id, name: 'Alice' } }); + + default: + return rpc(id, { error: { code: -32601, message: 'Method not found' } }); + } + }, +}); +``` ## Next Steps From c5bad015b74fc3cdfdcbad6bb1dbd8ed4e111693 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Tue, 9 Jun 2026 14:29:57 -0700 Subject: [PATCH 2/3] docs: clarify admin path is hard-blocked on the public route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit feedback on #831: - Fix "httpingress" jargon → "internal HTTP ingress". - Spell out that the ingress returns 404 for any public request to /.well-known/miren/admin before it reaches the app (httpingress.go:488), so the X-Miren-Access check is defense-in-depth rather than the sole guard. --- docs/docs/admin-interface.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/docs/admin-interface.md b/docs/docs/admin-interface.md index abf99ba09..892a93b38 100644 --- a/docs/docs/admin-interface.md +++ b/docs/docs/admin-interface.md @@ -79,12 +79,14 @@ func authMiddleware(token string, next http.Handler) http.Handler { #### Network reachability and `X-Miren-Access` -Miren's admin proxy talks to your app over its **internal** httpingress path, never via the public route. The proxy sets one header you can rely on: +Miren's admin proxy talks to your app over its **internal** HTTP ingress path, never via the public route. Any public request to `/.well-known/miren/admin` is rejected with `404 Not Found` by the ingress before it can reach your app, so in practice your handler only ever sees requests carrying the `X-Miren-Access: internal` header. + +For reference, the two values the runtime uses are: - `X-Miren-Access: internal` — request originated from `miren admin` and was routed through Miren's internal admin path. - `X-Miren-Access: public` — request arrived through the public ingress. Miren overwrites any client-supplied value here before forwarding, so the header cannot be spoofed by external callers. -For the most defensive setup, be sure to check `X-Miren-Access: internal` as well as the bearer token. +The bearer token is the primary guard; checking for `X-Miren-Access: internal` is a defense-in-depth signal in case the well-known path is ever exposed through a custom route. ### Auditing From 965d2f2ec07ed12661fe73251f534f9143fd568b Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Tue, 9 Jun 2026 14:37:10 -0700 Subject: [PATCH 3/3] docs: use hmac.compare_digest in admin Python example Match the constant-time comparison the page recommends and that the Go, Node.js, and Bun examples already use. hmac.compare_digest has been in the stdlib since Python 3.3. --- docs/docs/admin-interface.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/docs/admin-interface.md b/docs/docs/admin-interface.md index 892a93b38..ed097a8e8 100644 --- a/docs/docs/admin-interface.md +++ b/docs/docs/admin-interface.md @@ -463,6 +463,7 @@ Using Flask: ```python from flask import Flask, request, jsonify +import hmac import os app = Flask(__name__) @@ -472,7 +473,7 @@ ADMIN_TOKEN = os.environ.get('ADMIN_TOKEN', '') def admin_endpoint(): # Validate token auth = request.headers.get('Authorization', '') - if ADMIN_TOKEN and auth != f'Bearer {ADMIN_TOKEN}': + if ADMIN_TOKEN and not hmac.compare_digest(auth, f'Bearer {ADMIN_TOKEN}'): return 'Unauthorized', 401 data = request.json