mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
feat(cli): remote mode — drive a remote OmniRoute with scoped access tokens (#4256)
Remote mode: drive a remote OmniRoute via scoped CLI access tokens (read⊂write⊂admin). Includes the check:db-rules allowlist fix (535a5b673). Live VPS end-to-end (password→token→remote command) is a documented follow-up. Integrated into release/v3.8.29.
This commit is contained in:
committed by
GitHub
parent
a1a9f373bc
commit
a83409e8a1
16
README.md
16
README.md
@@ -408,6 +408,21 @@ omniroute setup # guided first-run wizard
|
||||
omniroute doctor # diagnose providers, ports, native deps
|
||||
```
|
||||
|
||||
### 🛰️ Remote mode — run the CLI here, OmniRoute on a VPS
|
||||
|
||||
OmniRoute on a server? Drive it from your laptop with the **same CLI**. Log in once
|
||||
with a scoped access token; every command then targets the remote.
|
||||
|
||||
```bash
|
||||
omniroute connect 192.168.0.15 # password → scoped token, saved as a context
|
||||
omniroute models list # ← runs against the REMOTE server
|
||||
omniroute configure codex # ← picks a remote model, writes a local Codex profile
|
||||
omniroute tokens create --name ci --scope read # mint narrower tokens for other machines
|
||||
```
|
||||
|
||||
Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopback-only.
|
||||
<sub>📖 [Remote Mode](docs/guides/REMOTE-MODE.md)</sub>
|
||||
|
||||
<div align="center">
|
||||
|
||||
`providers` · `oauth` · `keys` · `combo` · `nodes` · `models` · `cache` · `compression` · `cost` · `usage` · `quota` · `health` · `resilience` · `telemetry` · `logs` · `audit` · `mcp` · `a2a` · `cloud` · `memory` · `skills` · `eval` · `tunnel` · `backup` · `sync` · `webhooks` · `policy` · `pricing` · `translator` · `simulate` …
|
||||
@@ -845,6 +860,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
|
||||
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens |
|
||||
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
|
||||
|
||||
### 🔧 Operations & Deployment
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
|
||||
import { resolveActiveContext } from "./contexts.mjs";
|
||||
|
||||
export const RETRY_DEFAULTS = Object.freeze({
|
||||
maxAttempts: 3,
|
||||
@@ -28,14 +26,12 @@ export function getBaseUrl(opts = {}) {
|
||||
const envUrl = process.env.OMNIROUTE_BASE_URL;
|
||||
if (envUrl) return stripTrailingSlash(envUrl);
|
||||
|
||||
// Resolve from the active context (canonical store + legacy profile fallback).
|
||||
// This is what makes "remote mode" work: `omniroute contexts use <remote>`
|
||||
// routes every command at the remote server's baseUrl.
|
||||
try {
|
||||
const configPath = join(resolveDataDir(), "config.json");
|
||||
if (existsSync(configPath)) {
|
||||
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
|
||||
const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile];
|
||||
if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl);
|
||||
if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl);
|
||||
}
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
if (ctx?.baseUrl) return stripTrailingSlash(ctx.baseUrl);
|
||||
} catch {
|
||||
// Config read failures are not fatal — fall through to default.
|
||||
}
|
||||
@@ -56,15 +52,26 @@ function resolveUrl(path, opts) {
|
||||
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function buildHeaders(opts) {
|
||||
export async function buildHeaders(opts) {
|
||||
const headers = new Headers(opts.headers || {});
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
|
||||
if (apiKey && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${apiKey}`);
|
||||
// Auth precedence: explicit opts/env → active context. Within a context the
|
||||
// scoped accessToken wins over the legacy apiKey. This routes the active
|
||||
// context's credential to the (possibly remote) server automatically.
|
||||
let auth = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
|
||||
if (!auth) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
auth = ctx?.accessToken || ctx?.apiKey || null;
|
||||
} catch {
|
||||
// No context credential available — continue unauthenticated.
|
||||
}
|
||||
}
|
||||
if (auth && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${auth}`);
|
||||
}
|
||||
// Inject machine-id derived CLI token; env var override for testing.
|
||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||
|
||||
180
bin/cli/commands/configure.mjs
Normal file
180
bin/cli/commands/configure.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute configure <cli>` — interactive provider+model picker that writes a
|
||||
* local CLI config pointed at the ACTIVE OmniRoute context (local or remote).
|
||||
*
|
||||
* The model catalog comes from the active context's GET /v1/models, so when you
|
||||
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
|
||||
* live models and the profile is written on THIS machine.
|
||||
*
|
||||
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
|
||||
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
|
||||
*/
|
||||
|
||||
const SUPPORTED = ["codex"];
|
||||
|
||||
/** Derive a short, filesystem-safe profile name from a model id. */
|
||||
export function profileNameFromModel(modelId) {
|
||||
const afterProvider = String(modelId).includes("/")
|
||||
? String(modelId).split("/").slice(1).join("/")
|
||||
: String(modelId);
|
||||
return afterProvider.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "model";
|
||||
}
|
||||
|
||||
/** Provider id for a catalog entry: explicit owned_by, else the id prefix. */
|
||||
function providerOf(entry) {
|
||||
if (entry && typeof entry.owned_by === "string" && entry.owned_by) return entry.owned_by;
|
||||
const id = typeof entry === "string" ? entry : entry?.id || "";
|
||||
return id.includes("/") ? id.split("/")[0] : "(none)";
|
||||
}
|
||||
|
||||
function contextWindowOf(entry) {
|
||||
for (const c of [entry?.context_length, entry?.max_context_window_tokens]) {
|
||||
if (typeof c === "number" && Number.isFinite(c) && c > 0) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchModels(globalOpts) {
|
||||
const res = await apiFetch("/v1/models", { ...globalOpts, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const b = await res.json();
|
||||
msg = b?.error?.message || b?.error || msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`Could not fetch models: ${msg}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data || body.models || [];
|
||||
return list.filter((m) => (typeof m === "string" ? m : m?.id));
|
||||
}
|
||||
|
||||
function buildCodexProfile(modelId, ctx) {
|
||||
const lines = [
|
||||
`# codex --profile ${profileNameFromModel(modelId)}`,
|
||||
`# ${modelId} — generated by 'omniroute configure codex'`,
|
||||
`model = "${modelId}"`,
|
||||
`model_provider = "omniroute"`,
|
||||
];
|
||||
if (ctx && ctx > 0) {
|
||||
const compact = Math.floor(ctx * 0.85);
|
||||
lines.push(`model_context_window = ${ctx}`);
|
||||
lines.push(`model_auto_compact_token_limit = ${compact}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
async function configureCodex(modelId, ctxWindow, opts) {
|
||||
const codexHome = opts.codexHome || path.join(os.homedir(), ".codex");
|
||||
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
|
||||
const profile = opts.name || profileNameFromModel(modelId);
|
||||
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
||||
if (existsSync(filePath)) {
|
||||
copyFileSync(filePath, `${filePath}.bak`);
|
||||
}
|
||||
writeFileSync(filePath, buildCodexProfile(modelId, ctxWindow), "utf8");
|
||||
printSuccess(`Wrote ${filePath}`);
|
||||
printInfo(`Use it: codex --profile ${profile}`);
|
||||
printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block");
|
||||
printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md).");
|
||||
}
|
||||
|
||||
export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
const target = String(cli || "").toLowerCase();
|
||||
if (!SUPPORTED.includes(target)) {
|
||||
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
|
||||
return 2;
|
||||
}
|
||||
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
|
||||
|
||||
let models;
|
||||
try {
|
||||
models = await fetchModels(globalOpts);
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
return 1;
|
||||
}
|
||||
if (!models.length) {
|
||||
printError("The server returned no models.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Resolve model: explicit flags or interactive pick.
|
||||
let chosenId = opts.model;
|
||||
if (chosenId && opts.provider && !chosenId.includes("/")) {
|
||||
chosenId = `${opts.provider}/${chosenId}`;
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
|
||||
const providers = [...new Set(models.map(providerOf))].sort();
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
printHeading("Configure Codex CLI");
|
||||
let providerList = providers;
|
||||
if (opts.provider) {
|
||||
providerList = providers.filter((p) => p === opts.provider);
|
||||
} else {
|
||||
printInfo(`Providers: ${providers.join(", ")}`);
|
||||
const p = await prompt.ask("Provider");
|
||||
if (p) providerList = providers.filter((x) => x === p);
|
||||
}
|
||||
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
|
||||
const candidates = inProvider.length ? inProvider : ids;
|
||||
printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`);
|
||||
chosenId = await prompt.ask("Model id");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
printError("No model selected.");
|
||||
return 2;
|
||||
}
|
||||
const entry = byId(models, chosenId);
|
||||
if (!entry) {
|
||||
printError(`Model '${chosenId}' is not in the catalog.`);
|
||||
return 2;
|
||||
}
|
||||
const ctxWindow = contextWindowOf(entry);
|
||||
|
||||
if (target === "codex") {
|
||||
await configureCodex(chosenId, ctxWindow, opts);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function byId(models, id) {
|
||||
for (const m of models) {
|
||||
const mid = typeof m === "string" ? m : m.id;
|
||||
if (mid === id) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerConfigure(program) {
|
||||
program
|
||||
.command("configure <cli>")
|
||||
.description(
|
||||
t("configure.description") ||
|
||||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
|
||||
)
|
||||
.option("--provider <id>", "Provider id (skips the interactive provider prompt)")
|
||||
.option("--model <id>", "Model id (skips the interactive model prompt)")
|
||||
.option("--name <name>", "Profile name to write (default: derived from model)")
|
||||
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
|
||||
.action(async (cli, opts, cmd) => {
|
||||
const code = await runConfigureCommand(cli, opts, cmd);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
132
bin/cli/commands/connect.mjs
Normal file
132
bin/cli/commands/connect.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { loadContexts, saveContexts } from "../contexts.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute connect <host>` — remote mode.
|
||||
*
|
||||
* Logs into a remote OmniRoute server and saves the result as the active context
|
||||
* so every subsequent command targets that server. Two flows:
|
||||
* - password: prompts for the management password → POST /api/cli/connect →
|
||||
* server mints a scoped access token (default scope: admin).
|
||||
* - token: `--key <oma_...>` validates via GET /api/cli/whoami and saves it.
|
||||
*/
|
||||
|
||||
/** Normalize a host/URL into a server root baseUrl (no trailing path). */
|
||||
export function normalizeBaseUrl(host, port) {
|
||||
let value = String(host || "").trim();
|
||||
if (!value) return "";
|
||||
const hadScheme = /^https?:\/\//i.test(value);
|
||||
if (!hadScheme) value = `http://${value}`;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
// Only apply the default port to a bare host; a full URL is taken as-is.
|
||||
if (!hadScheme && !u.port && port) u.port = String(port);
|
||||
return u.origin;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive a clean context name from a host (strip scheme/port). */
|
||||
export function hostLabel(host) {
|
||||
let value = String(host || "").trim().replace(/^https?:\/\//i, "");
|
||||
value = value.split("/")[0].split(":")[0];
|
||||
return value || "remote";
|
||||
}
|
||||
|
||||
async function readErrorMessage(res) {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.error?.message || body?.error || `HTTP ${res.status}`;
|
||||
} catch {
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConnectCommand(host, opts = {}) {
|
||||
const baseUrl = normalizeBaseUrl(host, opts.port || "20128");
|
||||
if (!baseUrl) {
|
||||
printError("A host is required, e.g. omniroute connect 192.168.0.15");
|
||||
return 2;
|
||||
}
|
||||
const name = opts.name || hostLabel(host);
|
||||
|
||||
let accessToken;
|
||||
let scope;
|
||||
|
||||
if (opts.key) {
|
||||
// Validate the pasted token against the remote.
|
||||
const res = await apiFetch("/api/cli/whoami", {
|
||||
baseUrl,
|
||||
apiKey: opts.key,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Token rejected by ${baseUrl}: ${await readErrorMessage(res)}`);
|
||||
return res.exitCode || 1;
|
||||
}
|
||||
const body = await res.json();
|
||||
accessToken = opts.key;
|
||||
scope = body.scope || "unknown";
|
||||
} else {
|
||||
const prompt = createPrompt();
|
||||
let password;
|
||||
try {
|
||||
password = await prompt.askSecret(`Management password for ${baseUrl}`);
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
if (!password) {
|
||||
printError("Password is required (or use --key <token>).");
|
||||
return 2;
|
||||
}
|
||||
const res = await apiFetch("/api/cli/connect", {
|
||||
baseUrl,
|
||||
method: "POST",
|
||||
body: { password, name, scope: opts.scope },
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Connect failed (${res.status}): ${await readErrorMessage(res)}`);
|
||||
return res.exitCode || 1;
|
||||
}
|
||||
const body = await res.json();
|
||||
accessToken = body.token;
|
||||
scope = body.scope;
|
||||
}
|
||||
|
||||
const cfg = loadContexts();
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl,
|
||||
accessToken,
|
||||
scope,
|
||||
description: `Remote OmniRoute (${host})`,
|
||||
};
|
||||
cfg.currentContext = name;
|
||||
saveContexts(cfg);
|
||||
|
||||
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
|
||||
printInfo("All commands now target this server.");
|
||||
printInfo("Switch back to local with: omniroute contexts use default");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerConnect(program) {
|
||||
program
|
||||
.command("connect <host>")
|
||||
.description(
|
||||
t("connect.description") || "Connect to a remote OmniRoute server and enter remote mode"
|
||||
)
|
||||
.option("--port <port>", "Server port when the host has none", "20128")
|
||||
.option("--key <token>", "Use a pre-generated scoped access token (skips the password prompt)")
|
||||
.option("--name <name>", "Context name to save (default: derived from host)")
|
||||
.option("--scope <scope>", "Requested scope for the password flow (read|write|admin)")
|
||||
.action(async (host, opts) => {
|
||||
const code = await runConnectCommand(host, opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { t } from "../i18n.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { loadContexts, saveContexts, configPath } from "../contexts.mjs";
|
||||
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
|
||||
function authLabel(c) {
|
||||
if (c?.accessToken) return "token";
|
||||
if (c?.apiKey) return "key";
|
||||
return "✗";
|
||||
}
|
||||
|
||||
async function confirm(msg) {
|
||||
const readline = await import("node:readline");
|
||||
@@ -31,7 +38,8 @@ export function registerContexts(program) {
|
||||
active: name === (cfg.currentContext || "default") ? "●" : "",
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
auth: c.apiKey ? "✓" : "✗",
|
||||
auth: authLabel(c),
|
||||
scope: c.scope || "",
|
||||
description: c.description || "",
|
||||
}));
|
||||
emit(rows, globalOpts, [
|
||||
@@ -39,6 +47,7 @@ export function registerContexts(program) {
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "baseUrl", header: "Base URL" },
|
||||
{ key: "auth", header: "Auth" },
|
||||
{ key: "scope", header: "Scope" },
|
||||
{ key: "description", header: "Description" },
|
||||
]);
|
||||
});
|
||||
@@ -47,8 +56,11 @@ export function registerContexts(program) {
|
||||
.command("add <name>")
|
||||
.description("Add a new context")
|
||||
.requiredOption("--url <u>", "Base URL")
|
||||
.option("--api-key <k>", "API key")
|
||||
.option("--api-key <k>", "Legacy inference API key")
|
||||
.option("--api-key-stdin", "Read API key from stdin")
|
||||
.option("--access-token <t>", "Scoped CLI access token (preferred over --api-key)")
|
||||
.option("--access-token-stdin", "Read access token from stdin")
|
||||
.option("--scope <s>", "Token scope hint for display (read|write|admin)")
|
||||
.option("--description <d>", "Context description")
|
||||
.action(async (name, opts) => {
|
||||
const cfg = loadContexts();
|
||||
@@ -57,15 +69,20 @@ export function registerContexts(program) {
|
||||
process.exit(2);
|
||||
}
|
||||
let apiKey = opts.apiKey || null;
|
||||
if (opts.apiKeyStdin) {
|
||||
let accessToken = opts.accessToken || null;
|
||||
if (opts.apiKeyStdin || opts.accessTokenStdin) {
|
||||
const chunks = [];
|
||||
for await (const c of process.stdin) chunks.push(c);
|
||||
apiKey = chunks.join("").trim() || null;
|
||||
const value = chunks.join("").trim() || null;
|
||||
if (opts.accessTokenStdin) accessToken = value;
|
||||
else apiKey = value;
|
||||
}
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: opts.url,
|
||||
accessToken: accessToken || undefined,
|
||||
apiKey,
|
||||
scope: opts.scope || undefined,
|
||||
description: opts.description || undefined,
|
||||
};
|
||||
saveContexts(cfg);
|
||||
@@ -88,10 +105,27 @@ export function registerContexts(program) {
|
||||
|
||||
ctx
|
||||
.command("current")
|
||||
.description("Show current active context name")
|
||||
.action(() => {
|
||||
.description("Show the active context (server, auth, scope)")
|
||||
.option("--name-only", "Print just the context name (legacy behavior)")
|
||||
.action((opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const cfg = loadContexts();
|
||||
process.stdout.write(`${cfg.currentContext || "default"}\n`);
|
||||
const name = cfg.currentContext || cfg.activeProfile || "default";
|
||||
if (opts.nameOnly) {
|
||||
process.stdout.write(`${name}\n`);
|
||||
return;
|
||||
}
|
||||
const c = resolveActiveContext(name);
|
||||
emit(
|
||||
{
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
auth: authLabel(c),
|
||||
scope: c.scope || "",
|
||||
description: c.description || "",
|
||||
},
|
||||
globalOpts
|
||||
);
|
||||
});
|
||||
|
||||
ctx
|
||||
@@ -108,7 +142,9 @@ export function registerContexts(program) {
|
||||
const display = {
|
||||
name,
|
||||
baseUrl: c.baseUrl,
|
||||
accessToken: maskKey(c.accessToken),
|
||||
apiKey: maskKey(c.apiKey),
|
||||
scope: c.scope,
|
||||
description: c.description,
|
||||
};
|
||||
emit(display, globalOpts);
|
||||
@@ -172,6 +208,7 @@ export function registerContexts(program) {
|
||||
if (opts.noSecrets) {
|
||||
for (const c of Object.values(out.contexts || {})) {
|
||||
c.apiKey = null;
|
||||
delete c.accessToken;
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
@@ -209,7 +246,9 @@ export function registerContexts(program) {
|
||||
const c = raw && typeof raw === "object" ? /** @type {Record<string,unknown>} */ (raw) : {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128",
|
||||
accessToken: typeof c.accessToken === "string" ? c.accessToken : undefined,
|
||||
apiKey: typeof c.apiKey === "string" ? c.apiKey : null,
|
||||
scope: typeof c.scope === "string" ? c.scope : undefined,
|
||||
description: typeof c.description === "string" ? c.description : undefined,
|
||||
};
|
||||
count++;
|
||||
|
||||
@@ -56,6 +56,9 @@ import { registerTray } from "./tray.mjs";
|
||||
import { registerAutostart } from "./autostart.mjs";
|
||||
import { registerRepl } from "./repl.mjs";
|
||||
import { registerLaunch } from "./launch.mjs";
|
||||
import { registerConnect } from "./connect.mjs";
|
||||
import { registerTokens } from "./tokens.mjs";
|
||||
import { registerConfigure } from "./configure.mjs";
|
||||
import { registerApiCommands } from "../api-commands/registry.mjs";
|
||||
import { registerPlugin } from "./plugin.mjs";
|
||||
|
||||
@@ -119,6 +122,9 @@ export function registerCommands(program) {
|
||||
registerAutostart(program);
|
||||
registerRepl(program);
|
||||
registerLaunch(program);
|
||||
registerConnect(program);
|
||||
registerTokens(program);
|
||||
registerConfigure(program);
|
||||
registerApiCommands(program);
|
||||
registerPlugin(program);
|
||||
}
|
||||
|
||||
118
bin/cli/commands/tokens.mjs
Normal file
118
bin/cli/commands/tokens.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { printSuccess, printError, printInfo } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute tokens` — manage scoped CLI access tokens on the active (usually
|
||||
* remote) server. Requires an `admin` credential — the commands hit
|
||||
* /api/cli/tokens which is admin-only. Uses the active context's auth via
|
||||
* apiFetch automatically.
|
||||
*/
|
||||
|
||||
async function readErrorMessage(res) {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.error?.message || body?.error || `HTTP ${res.status}`;
|
||||
} catch {
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTokens(program) {
|
||||
const tokens = program
|
||||
.command("tokens")
|
||||
.description(t("tokens.description") || "Manage scoped CLI access tokens (remote mode)");
|
||||
|
||||
tokens
|
||||
.command("create")
|
||||
.description("Create a new access token (requires admin scope)")
|
||||
.requiredOption("--name <name>", "Human-readable token name")
|
||||
.option("--scope <scope>", "Scope: read | write | admin", "read")
|
||||
.option("--expires <days>", "Expire after N days (default: never)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const body = { name: opts.name, scope: opts.scope };
|
||||
if (opts.expires) {
|
||||
const days = Number(opts.expires);
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
printError("--expires must be a positive number of days.");
|
||||
process.exit(2);
|
||||
}
|
||||
body.expiresInDays = days;
|
||||
}
|
||||
const res = await apiFetch("/api/cli/tokens", {
|
||||
...globalOpts,
|
||||
method: "POST",
|
||||
body,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Could not create token: ${await readErrorMessage(res)}`);
|
||||
process.exit(res.exitCode || 1);
|
||||
}
|
||||
const b = await res.json();
|
||||
printSuccess(`Token '${b.name}' created (scope: ${b.scope}).`);
|
||||
printInfo("Copy it now — it will NOT be shown again:");
|
||||
process.stdout.write(`${b.token}\n`);
|
||||
});
|
||||
|
||||
tokens
|
||||
.command("list")
|
||||
.description("List access tokens (masked)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const res = await apiFetch("/api/cli/tokens", { ...globalOpts, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
printError(`Could not list tokens: ${await readErrorMessage(res)}`);
|
||||
process.exit(res.exitCode || 1);
|
||||
}
|
||||
const b = await res.json();
|
||||
const rows = (b.tokens || []).map((tk) => ({
|
||||
id: tk.id,
|
||||
name: tk.name,
|
||||
scope: tk.scope,
|
||||
prefix: tk.tokenPrefix,
|
||||
created: tk.createdAt,
|
||||
lastUsed: tk.lastUsedAt || "",
|
||||
expires: tk.expiresAt || "",
|
||||
status: tk.revokedAt ? "revoked" : "active",
|
||||
}));
|
||||
emit(rows, globalOpts, [
|
||||
{ key: "id", header: "ID" },
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "scope", header: "Scope" },
|
||||
{ key: "prefix", header: "Prefix" },
|
||||
{ key: "status", header: "Status" },
|
||||
{ key: "lastUsed", header: "Last Used" },
|
||||
{ key: "expires", header: "Expires" },
|
||||
]);
|
||||
});
|
||||
|
||||
tokens
|
||||
.command("revoke <idOrPrefix>")
|
||||
.description("Revoke an access token by id or display prefix")
|
||||
.action(async (idOrPrefix, opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const res = await apiFetch(`/api/cli/tokens/${encodeURIComponent(idOrPrefix)}`, {
|
||||
...globalOpts,
|
||||
method: "DELETE",
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Could not revoke token: ${await readErrorMessage(res)}`);
|
||||
process.exit(res.exitCode || 1);
|
||||
}
|
||||
printSuccess(`Revoked ${idOrPrefix}.`);
|
||||
});
|
||||
|
||||
tokens
|
||||
.command("scopes")
|
||||
.description("Explain the three access-token scopes")
|
||||
.action(() => {
|
||||
printInfo("Access-token scopes (admin ⊃ write ⊃ read):");
|
||||
process.stdout.write(" read list/inspect only (models, status, logs, usage)\n");
|
||||
process.stdout.write(" write read + configure/apply (setup-codex, keys add, config set)\n");
|
||||
process.stdout.write(" admin write + manage (tokens, providers add, services, policy)\n");
|
||||
});
|
||||
}
|
||||
@@ -36,8 +36,25 @@ export function saveContexts(cfg) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active context for a CLI invocation.
|
||||
*
|
||||
* Canonical schema is `{ currentContext, contexts }` (written by
|
||||
* `omniroute contexts ...`). For backward compatibility we also read the legacy
|
||||
* `{ activeProfile, profiles }` shape and a bare top-level `baseUrl` — older
|
||||
* configs and `api.mjs::getBaseUrl` used those before remote-mode unified the
|
||||
* store. `overrideName` (from `--context`/`OMNIROUTE_CONTEXT`) wins when set.
|
||||
*
|
||||
* A context may carry `{ baseUrl, accessToken?, apiKey?, scope?, description? }`.
|
||||
* `accessToken` is the scoped CLI access token (preferred); `apiKey` is the
|
||||
* legacy inference key kept for back-compat.
|
||||
*/
|
||||
export function resolveActiveContext(overrideName) {
|
||||
const cfg = loadContexts();
|
||||
const name = overrideName || cfg.currentContext || "default";
|
||||
return cfg.contexts?.[name] || cfg.contexts?.default || { baseUrl: "http://localhost:20128" };
|
||||
const contexts = cfg.contexts || cfg.profiles || {};
|
||||
const name = overrideName || cfg.currentContext || cfg.activeProfile || "default";
|
||||
const found = contexts[name] || contexts.default;
|
||||
if (found) return found;
|
||||
if (cfg.baseUrl) return { baseUrl: cfg.baseUrl };
|
||||
return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` };
|
||||
}
|
||||
|
||||
@@ -1262,5 +1262,14 @@
|
||||
"token": "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.",
|
||||
"notFound": "The 'claude' CLI was not found in PATH."
|
||||
},
|
||||
"connect": {
|
||||
"description": "Connect to a remote OmniRoute server and enter remote mode"
|
||||
},
|
||||
"tokens": {
|
||||
"description": "Manage scoped CLI access tokens (remote mode)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "Pick a provider+model from the active server and write a local CLI config"
|
||||
}
|
||||
}
|
||||
|
||||
173
docs/guides/REMOTE-MODE.md
Normal file
173
docs/guides/REMOTE-MODE.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: "Remote Mode — Drive a remote OmniRoute from your laptop"
|
||||
version: 3.8.29
|
||||
lastUpdated: 2026-06-19
|
||||
---
|
||||
|
||||
# Remote Mode
|
||||
|
||||
Run the `omniroute` CLI on your laptop while OmniRoute itself runs somewhere else
|
||||
(a VPS, a home server, another machine on your Tailnet). You log in once with
|
||||
`omniroute connect`, and from then on **every** CLI command targets that remote
|
||||
server — same commands, same output, just executed against the remote.
|
||||
|
||||
There is no second tool to install: remote mode is the regular `omniroute` CLI
|
||||
plus scoped **access tokens**.
|
||||
|
||||
```bash
|
||||
npm install -g omniroute # the normal CLI
|
||||
omniroute connect 192.168.0.15 # log in (password → scoped token)
|
||||
omniroute models list # ← now lists the REMOTE server's models
|
||||
omniroute configure codex # ← writes a local Codex profile from the remote catalog
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
your laptop remote OmniRoute (VPS)
|
||||
┌────────────────────┐ ┌───────────────────────────────┐
|
||||
│ omniroute CLI │ POST /api/cli/connect (password → token) │
|
||||
│ context: vps │ ───────────────► │ mints a scoped access token │
|
||||
│ baseUrl, token │ Authorization: Bearer oma_live_… │
|
||||
│ │ ───────────────► │ every management route, scope- │
|
||||
│ writes configs │ ◄─────────────── │ checked per the token's scope │
|
||||
│ LOCALLY │ └───────────────────────────────┘
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
- **Contexts** store one server each (`~/.omniroute/config.json`, `chmod 600`).
|
||||
`omniroute contexts use <name>` switches the active server; `default` is local.
|
||||
- **Access tokens** (`oma_live_…`) authorize management commands. They are
|
||||
distinct from inference API keys (`sk-…`, used for `/v1/chat/completions`).
|
||||
- Only the SHA-256 hash of a token is stored server-side. The plaintext is shown
|
||||
**once**, at creation.
|
||||
|
||||
---
|
||||
|
||||
## Connecting
|
||||
|
||||
### With the management password (bootstrap)
|
||||
|
||||
```bash
|
||||
omniroute connect 192.168.0.15
|
||||
# Management password for http://192.168.0.15:20128: ********
|
||||
# ✔ Connected to http://192.168.0.15:20128 — context '192.168.0.15' (scope: admin)
|
||||
```
|
||||
|
||||
The password flow mints an **admin** token by default (you hold the password, so
|
||||
you already have full control). Downscope with `--scope`:
|
||||
|
||||
```bash
|
||||
omniroute connect 192.168.0.15 --scope write
|
||||
```
|
||||
|
||||
Options: `--port <p>` (when the host has none), `--name <ctx>` (context name),
|
||||
`--scope read|write|admin`. A full URL is honoured as-is:
|
||||
`omniroute connect https://omni.example.com`.
|
||||
|
||||
### With a pre-generated token
|
||||
|
||||
Generate a scoped token in the dashboard (or with `omniroute tokens create`) and
|
||||
paste it — no password needed:
|
||||
|
||||
```bash
|
||||
omniroute connect 192.168.0.15 --key oma_live_xxxxxxxx
|
||||
```
|
||||
|
||||
The CLI validates it via `GET /api/cli/whoami` and saves it as the active context.
|
||||
|
||||
---
|
||||
|
||||
## Scopes
|
||||
|
||||
Three levels, hierarchical (`admin ⊃ write ⊃ read`):
|
||||
|
||||
| Scope | Can do |
|
||||
|-------|--------|
|
||||
| `read` | list/inspect — `models list`, `providers status`, `logs`, `usage`, `cost` |
|
||||
| `write` | read **+** configure/apply — `setup-codex`, `keys add`, `config set`, combos |
|
||||
| `admin` | write **+** manage — `tokens` CRUD, add providers, services, policy, oauth |
|
||||
|
||||
The server infers the scope each route requires from the HTTP method
|
||||
(`GET`→read, mutations→write) plus an admin allowlist for sensitive surfaces
|
||||
(`/api/cli/tokens`, `/api/providers` mutations, `/api/oauth`, `/api/services`, …).
|
||||
A token with insufficient scope gets `403` with a clear message.
|
||||
|
||||
> Routes that spawn processes (`/api/services/*`, `/api/mcp/*`, …) stay
|
||||
> **loopback-only** — a remote token can never reach them, regardless of scope.
|
||||
|
||||
---
|
||||
|
||||
## Managing tokens
|
||||
|
||||
```bash
|
||||
omniroute tokens create --name "laptop" --scope write [--expires 30]
|
||||
# ↳ prints the secret ONCE — copy it now
|
||||
omniroute tokens list # masked: id, name, scope, prefix, status, expiry
|
||||
omniroute tokens revoke <id|prefix> # revoke immediately
|
||||
omniroute tokens scopes # explain the three scopes
|
||||
```
|
||||
|
||||
`tokens` commands require an **admin** credential. You can also manage tokens in
|
||||
the dashboard under **Settings → Access Tokens** (create, revoke, copy-once).
|
||||
|
||||
---
|
||||
|
||||
## Configuring a coding CLI from the remote catalog
|
||||
|
||||
`omniroute configure` reads the **active server's** live model catalog and writes
|
||||
a config on **your** machine.
|
||||
|
||||
```bash
|
||||
omniroute configure codex
|
||||
# Providers: glm, kmc, ollamacloud, opencode-go, …
|
||||
# Provider: glm
|
||||
# Model id: glm/glm-5.2
|
||||
# ✔ Wrote ~/.codex/glm52.config.toml
|
||||
# Use it: codex --profile glm52
|
||||
|
||||
# non-interactive
|
||||
omniroute configure codex --provider glm --model glm/glm-5.2 --name glm52
|
||||
```
|
||||
|
||||
The written profile references the inference key by env var
|
||||
(`OMNIROUTE_API_KEY`) — the secret is never written to disk. For the one-time
|
||||
base Codex setup (the `[model_providers.omniroute]` block), see
|
||||
[CODEX-CLI-CONFIGURATION.md](./CODEX-CLI-CONFIGURATION.md).
|
||||
|
||||
---
|
||||
|
||||
## Switching back to local
|
||||
|
||||
```bash
|
||||
omniroute contexts use default # back to localhost
|
||||
omniroute context current # show active server, auth, scope
|
||||
omniroute contexts list # all contexts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- Token plaintext is shown once; only the SHA-256 hash is persisted (same as API keys).
|
||||
- `omniroute connect` reuses the login brute-force lockout + audit logging.
|
||||
- Prefer HTTPS or a Tailnet for the transport; a bare host defaults to `http://`
|
||||
for LAN/Tailscale convenience — pass a full `https://…` URL for TLS.
|
||||
- The local context file is `~/.omniroute/config.json` (`chmod 600`); tokens are
|
||||
never printed in logs (masked to a prefix).
|
||||
|
||||
---
|
||||
|
||||
## API endpoints (reference)
|
||||
|
||||
| Method | Route | Auth | Scope |
|
||||
|--------|-------|------|-------|
|
||||
| POST | `/api/cli/connect` | management password | — (public, password-gated) |
|
||||
| GET | `/api/cli/whoami` | access token | read |
|
||||
| GET | `/api/cli/tokens` | access token | admin |
|
||||
| POST | `/api/cli/tokens` | access token | admin |
|
||||
| DELETE | `/api/cli/tokens/:id` | access token | admin |
|
||||
|
||||
See [openapi.yaml](../reference/openapi.yaml) for full schemas.
|
||||
@@ -2707,6 +2707,92 @@ paths:
|
||||
"200":
|
||||
description: Translation history entries
|
||||
|
||||
# ─── CLI Remote Mode ───────────────────────────────────────────
|
||||
|
||||
/api/cli/connect:
|
||||
post:
|
||||
tags: [CLI Remote Mode]
|
||||
summary: Exchange the management password for a scoped CLI access token
|
||||
description: >
|
||||
Remote-mode bootstrap. Public (password-gated) route: verifies the
|
||||
management password with brute-force lockout, then mints an `oma_`
|
||||
access token. The plaintext token is returned once.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [password]
|
||||
properties:
|
||||
password: { type: string }
|
||||
name: { type: string }
|
||||
scope: { type: string, enum: [read, write, admin] }
|
||||
expiresInDays: { type: integer, minimum: 1, maximum: 3650 }
|
||||
responses:
|
||||
"200":
|
||||
description: Token minted (token returned once)
|
||||
"401":
|
||||
description: Invalid password
|
||||
"429":
|
||||
description: Too many failed attempts
|
||||
|
||||
/api/cli/whoami:
|
||||
get:
|
||||
tags: [CLI Remote Mode]
|
||||
summary: Report the current credential (scope, name, expiry)
|
||||
responses:
|
||||
"200":
|
||||
description: Authenticated; access-token details when applicable
|
||||
"401":
|
||||
description: Authentication required
|
||||
|
||||
/api/cli/tokens:
|
||||
get:
|
||||
tags: [CLI Remote Mode]
|
||||
summary: List access tokens (masked) — admin scope
|
||||
responses:
|
||||
"200":
|
||||
description: Masked token list
|
||||
"403":
|
||||
description: Insufficient scope
|
||||
post:
|
||||
tags: [CLI Remote Mode]
|
||||
summary: Create a scoped access token — admin scope
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name: { type: string }
|
||||
scope: { type: string, enum: [read, write, admin] }
|
||||
expiresInDays: { type: integer, minimum: 1, maximum: 3650 }
|
||||
responses:
|
||||
"200":
|
||||
description: Token created (token returned once)
|
||||
"403":
|
||||
description: Insufficient scope
|
||||
|
||||
/api/cli/tokens/{id}:
|
||||
delete:
|
||||
tags: [CLI Remote Mode]
|
||||
summary: Revoke an access token by id or display prefix — admin scope
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Token revoked
|
||||
"403":
|
||||
description: Insufficient scope
|
||||
"404":
|
||||
description: Token not found or already revoked
|
||||
|
||||
# ─── CLI Tools ─────────────────────────────────────────────────
|
||||
|
||||
/api/cli-tools/backups:
|
||||
|
||||
@@ -39,6 +39,7 @@ const HANDLERS_DIR = path.join(cwd, "open-sse/handlers");
|
||||
// sem investigação — pode ser reserva de schema ou F2 pendente
|
||||
export const INTENTIONALLY_INTERNAL = new Set([
|
||||
"_rowTypes", // type-only: 5 importers internos em db/ (AgentBridge/Inspector row types)
|
||||
"accessTokens", // intentionally-internal: 4 rotas /api/cli/* (connect, whoami, tokens, tokens/[id]) + server/authz/accessTokenAuth.ts via import direto "@/lib/db/accessTokens" (Rule #2)
|
||||
"cleanup", // intentionally-internal: 3 API routes (purge-quota-snapshots, purge-call-logs, purge-detailed-logs)
|
||||
"cliToolState", // intentionally-internal: 14+ API routes em /api/cli-tools/*-settings
|
||||
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import AccessTokensTab from "../components/AccessTokensTab";
|
||||
|
||||
export default function SettingsAccessTokensPage() {
|
||||
return <AccessTokensTab />;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, Input, Select, Badge, Spinner, ConfirmModal } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AccessTokenRow {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: "read" | "write" | "admin";
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
const SCOPE_VARIANT: Record<string, "info" | "warning" | "error" | "default"> = {
|
||||
read: "info",
|
||||
write: "warning",
|
||||
admin: "error",
|
||||
};
|
||||
|
||||
export default function AccessTokensTab() {
|
||||
const t = useTranslations("settings");
|
||||
// Graceful fallback so the tab renders in every locale before keys are translated.
|
||||
const L = (key: string, fallback: string) =>
|
||||
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
|
||||
|
||||
const [tokens, setTokens] = useState<AccessTokenRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [scope, setScope] = useState("read");
|
||||
const [expires, setExpires] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newSecret, setNewSecret] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [revokeTarget, setRevokeTarget] = useState<AccessTokenRow | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/cli/tokens");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setTokens(Array.isArray(data.tokens) ? data.tokens : []);
|
||||
} catch {
|
||||
setError(L("accessTokensLoadError", "Could not load access tokens."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const createToken = async () => {
|
||||
if (!name.trim()) return;
|
||||
setCreating(true);
|
||||
setError("");
|
||||
try {
|
||||
const body: Record<string, unknown> = { name: name.trim(), scope };
|
||||
const days = Number(expires);
|
||||
if (expires && Number.isFinite(days) && days > 0) body.expiresInDays = days;
|
||||
const res = await fetch("/api/cli/tokens", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error?.message || data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setNewSecret(data.token);
|
||||
setCopied(false);
|
||||
setName("");
|
||||
setExpires("");
|
||||
setScope("read");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : L("accessTokensCreateError", "Could not create token."));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!revokeTarget) return;
|
||||
const target = revokeTarget;
|
||||
setRevokeTarget(null);
|
||||
try {
|
||||
const res = await fetch(`/api/cli/tokens/${encodeURIComponent(target.id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await load();
|
||||
} catch {
|
||||
setError(L("accessTokensRevokeError", "Could not revoke token."));
|
||||
}
|
||||
};
|
||||
|
||||
const copySecret = async () => {
|
||||
if (!newSecret) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(newSecret);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
/* clipboard unavailable — user can select manually */
|
||||
}
|
||||
};
|
||||
|
||||
const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "—");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="p-5">
|
||||
<h2 className="text-lg font-semibold text-text">
|
||||
{L("accessTokensTitle", "Access Tokens")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{L(
|
||||
"accessTokensDescription",
|
||||
"Scoped tokens that let the omniroute CLI manage this server remotely. Distinct from inference API keys. The secret is shown once."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Create */}
|
||||
<Card>
|
||||
<div className="p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-text">
|
||||
{L("accessTokensCreateHeading", "Create a token")}
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
<Input
|
||||
placeholder={L("accessTokensNamePlaceholder", "Name (e.g. laptop)")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={scope}
|
||||
onChange={(e) => setScope(e.target.value)}
|
||||
options={[
|
||||
{ value: "read", label: L("accessTokensScopeRead", "read — list/inspect") },
|
||||
{ value: "write", label: L("accessTokensScopeWrite", "write — configure") },
|
||||
{ value: "admin", label: L("accessTokensScopeAdmin", "admin — manage") },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={L("accessTokensExpiresPlaceholder", "Expires (days, optional)")}
|
||||
value={expires}
|
||||
onChange={(e) => setExpires(e.target.value)}
|
||||
/>
|
||||
<Button onClick={createToken} disabled={creating || !name.trim()}>
|
||||
{creating ? L("accessTokensCreating", "Creating…") : L("accessTokensCreate", "Create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{newSecret && (
|
||||
<div className="rounded-control border border-primary/40 bg-primary/5 p-4">
|
||||
<p className="text-sm font-medium text-text">
|
||||
{L("accessTokensCopyNow", "Copy this token now — it will not be shown again:")}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="flex-1 break-all rounded bg-surface px-3 py-2 font-mono text-xs text-text">
|
||||
{newSecret}
|
||||
</code>
|
||||
<Button variant="secondary" onClick={copySecret}>
|
||||
{copied ? L("accessTokensCopied", "Copied") : L("accessTokensCopy", "Copy")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setNewSecret(null)}>
|
||||
{L("accessTokensDismiss", "Dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-control border border-red-500/40 bg-red-500/5 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<Card>
|
||||
<div className="p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold text-text">
|
||||
{L("accessTokensExisting", "Existing tokens")}
|
||||
</h3>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-text-muted">
|
||||
{L("accessTokensEmpty", "No access tokens yet.")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-text-muted">
|
||||
<th className="py-2 pr-4 font-medium">{L("accessTokensColName", "Name")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{L("accessTokensColScope", "Scope")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{L("accessTokensColPrefix", "Prefix")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{L("accessTokensColStatus", "Status")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{L("accessTokensColLastUsed", "Last used")}</th>
|
||||
<th className="py-2 pr-4 font-medium">{L("accessTokensColExpires", "Expires")}</th>
|
||||
<th className="py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((tk) => {
|
||||
const revoked = Boolean(tk.revokedAt);
|
||||
return (
|
||||
<tr key={tk.id} className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 text-text">{tk.name}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant={SCOPE_VARIANT[tk.scope] || "default"}>{tk.scope}</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-text-muted">
|
||||
{tk.tokenPrefix}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant={revoked ? "default" : "success"}>
|
||||
{revoked
|
||||
? L("accessTokensStatusRevoked", "revoked")
|
||||
: L("accessTokensStatusActive", "active")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-text-muted">{fmt(tk.lastUsedAt)}</td>
|
||||
<td className="py-2 pr-4 text-text-muted">{fmt(tk.expiresAt)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{!revoked && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setRevokeTarget(tk)}>
|
||||
{L("accessTokensRevoke", "Revoke")}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={Boolean(revokeTarget)}
|
||||
onClose={() => setRevokeTarget(null)}
|
||||
onConfirm={confirmRevoke}
|
||||
title={L("accessTokensRevokeTitle", "Revoke access token")}
|
||||
message={L(
|
||||
"accessTokensRevokeConfirm",
|
||||
"This immediately invalidates the token. Any machine using it loses access."
|
||||
)}
|
||||
confirmText={L("accessTokensRevoke", "Revoke")}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/app/api/cli/connect/route.ts
Normal file
156
src/app/api/cli/connect/route.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import {
|
||||
ensurePersistentManagementPasswordHash,
|
||||
getStoredManagementPassword,
|
||||
verifyManagementPassword,
|
||||
} from "@/lib/auth/managementPassword";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard";
|
||||
import { createAccessToken } from "@/lib/db/accessTokens";
|
||||
import { ACCESS_SCOPES } from "@/lib/accessTokens/scopes";
|
||||
|
||||
/**
|
||||
* POST /api/cli/connect — remote-mode bootstrap.
|
||||
*
|
||||
* Exchange the management password for a scoped CLI access token. Public route
|
||||
* (no token exists yet) that does its OWN password verification + brute-force
|
||||
* lockout, mirroring /api/auth/login — but mints an `oma_` access token instead
|
||||
* of a dashboard JWT cookie. The plaintext token is returned exactly once.
|
||||
*
|
||||
* Default scope is `admin`: the password holder is the owner and can already do
|
||||
* anything; the first token should be able to mint narrower tokens for other
|
||||
* machines. Pass `scope` to downscope (e.g. a read-only CI token).
|
||||
*/
|
||||
|
||||
const connectSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
scope: z.enum(ACCESS_SCOPES).optional(),
|
||||
expiresInDays: z.number().int().positive().max(3650).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auditContext = getAuditRequestContext(request);
|
||||
|
||||
try {
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(connectSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { password, name, scope, expiresInDays } = validation.data;
|
||||
|
||||
const settings = await getSettings();
|
||||
const bruteForceEnabled = settings.bruteForceProtection !== false;
|
||||
const clientIp = auditContext.ipAddress || null;
|
||||
|
||||
const guardCheck = checkLoginGuard(clientIp, { enabled: bruteForceEnabled });
|
||||
if (!guardCheck.allowed) {
|
||||
logAuditEvent({
|
||||
action: "cli.connect.locked",
|
||||
actor: "anonymous",
|
||||
target: "cli-access-token",
|
||||
resourceType: "auth_session",
|
||||
status: "failed",
|
||||
ipAddress: clientIp || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: { retryAfterSeconds: guardCheck.retryAfterSeconds || 0 },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Too many failed attempts. Try again later." },
|
||||
{
|
||||
status: 429,
|
||||
headers: guardCheck.retryAfterSeconds
|
||||
? { "Retry-After": String(guardCheck.retryAfterSeconds) }
|
||||
: {},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const passwordState = await ensurePersistentManagementPasswordHash({
|
||||
settings,
|
||||
source: "cli.connect",
|
||||
});
|
||||
const storedHash = getStoredManagementPassword(passwordState.settings);
|
||||
if (!storedHash) {
|
||||
return NextResponse.json(
|
||||
{ error: "No password configured. Complete onboarding first.", needsSetup: true },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = await verifyManagementPassword(password, storedHash);
|
||||
if (!isValid) {
|
||||
const failureDecision = recordLoginFailure(clientIp, { enabled: bruteForceEnabled });
|
||||
logAuditEvent({
|
||||
action: "cli.connect.failed",
|
||||
actor: "anonymous",
|
||||
target: "cli-access-token",
|
||||
resourceType: "auth_session",
|
||||
status: "failed",
|
||||
ipAddress: clientIp || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: { reason: "invalid_password", lockedOut: failureDecision.allowed === false },
|
||||
});
|
||||
if (!failureDecision.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many failed attempts. Try again later." },
|
||||
{
|
||||
status: 429,
|
||||
headers: failureDecision.retryAfterSeconds
|
||||
? { "Retry-After": String(failureDecision.retryAfterSeconds) }
|
||||
: {},
|
||||
}
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: "Invalid password" }, { status: 401 });
|
||||
}
|
||||
|
||||
clearLoginAttempts(clientIp);
|
||||
|
||||
const tokenScope = scope ?? "admin";
|
||||
const tokenName = (name ?? "remote-cli").trim() || "remote-cli";
|
||||
const expiresAt =
|
||||
typeof expiresInDays === "number"
|
||||
? new Date(Date.now() + expiresInDays * 86_400_000).toISOString()
|
||||
: null;
|
||||
|
||||
const { record, secret } = createAccessToken({
|
||||
name: tokenName,
|
||||
scope: tokenScope,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
logAuditEvent({
|
||||
action: "cli.connect.success",
|
||||
actor: "admin",
|
||||
target: "cli-access-token",
|
||||
resourceType: "auth_session",
|
||||
status: "success",
|
||||
ipAddress: clientIp || undefined,
|
||||
requestId: auditContext.requestId,
|
||||
metadata: { tokenId: record.id, scope: tokenScope },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
token: secret,
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
scope: record.scope,
|
||||
expiresAt: record.expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CLI] connect failed:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
23
src/app/api/cli/tokens/[id]/route.ts
Normal file
23
src/app/api/cli/tokens/[id]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { revokeAccessToken } from "@/lib/db/accessTokens";
|
||||
|
||||
/**
|
||||
* DELETE /api/cli/tokens/:id — revoke an access token (by id or display prefix).
|
||||
* Admin-only (same enforcement as the collection route). Idempotent: revoking
|
||||
* an unknown/already-revoked token returns 404.
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id } = await params;
|
||||
const revoked = revokeAccessToken(id);
|
||||
if (!revoked) {
|
||||
return NextResponse.json({ error: "Token not found or already revoked" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ success: true, id });
|
||||
}
|
||||
60
src/app/api/cli/tokens/route.ts
Normal file
60
src/app/api/cli/tokens/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { createAccessToken, listAccessTokens } from "@/lib/db/accessTokens";
|
||||
import { ACCESS_SCOPES } from "@/lib/accessTokens/scopes";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
/**
|
||||
* /api/cli/tokens — manage scoped CLI access tokens. Admin-only: the path is in
|
||||
* ADMIN_SCOPE_PREFIXES, so the central pipeline + requireManagementAuth both
|
||||
* require an `admin` credential (a read/write token gets 403 before here).
|
||||
*/
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
return NextResponse.json({ tokens: listAccessTokens() });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
scope: z.enum(ACCESS_SCOPES).optional(),
|
||||
expiresInDays: z.number().int().positive().max(3650).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(createSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { name, scope, expiresInDays } = validation.data;
|
||||
const expiresAt =
|
||||
typeof expiresInDays === "number"
|
||||
? new Date(Date.now() + expiresInDays * 86_400_000).toISOString()
|
||||
: null;
|
||||
|
||||
const { record, secret } = createAccessToken({ name, scope: scope ?? "read", expiresAt });
|
||||
|
||||
// `token` (the plaintext secret) is returned ONCE here and never again.
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
token: secret,
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
scope: record.scope,
|
||||
tokenPrefix: record.tokenPrefix,
|
||||
createdAt: record.createdAt,
|
||||
expiresAt: record.expiresAt,
|
||||
});
|
||||
}
|
||||
38
src/app/api/cli/whoami/route.ts
Normal file
38
src/app/api/cli/whoami/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { extractBearer, ACCESS_TOKEN_PREFIX } from "@/server/authz/accessTokenAuth";
|
||||
import { verifyAccessToken, getAccessToken } from "@/lib/db/accessTokens";
|
||||
|
||||
/**
|
||||
* GET /api/cli/whoami — report the current credential to the CLI.
|
||||
*
|
||||
* Requires a valid management credential (read scope is enough — it's a GET).
|
||||
* When the caller used a scoped CLI access token, returns its name/scope/expiry
|
||||
* so `omniroute connect --key` / `context current` can confirm what they hold.
|
||||
* Other credentials (dashboard session, manage-scope API key, loopback CLI
|
||||
* token) report `viaAccessToken: false`.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const bearer = extractBearer(request);
|
||||
if (bearer && bearer.startsWith(ACCESS_TOKEN_PREFIX)) {
|
||||
const verified = verifyAccessToken(bearer);
|
||||
if (verified) {
|
||||
const record = getAccessToken(verified.id);
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
viaAccessToken: true,
|
||||
id: verified.id,
|
||||
name: verified.name,
|
||||
scope: verified.scope,
|
||||
createdAt: record?.createdAt ?? null,
|
||||
lastUsedAt: record?.lastUsedAt ?? null,
|
||||
expiresAt: record?.expiresAt ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ authenticated: true, viaAccessToken: false, scope: null });
|
||||
}
|
||||
@@ -995,6 +995,7 @@
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsAi": "AI Settings",
|
||||
"settingsSecurity": "Security",
|
||||
"settingsAccessTokens": "Access Tokens",
|
||||
"settingsFeatureFlags": "Feature Flags",
|
||||
"settingsAuthz": "Authz",
|
||||
"settingsRouting": "Routing",
|
||||
@@ -1078,6 +1079,7 @@
|
||||
"settingsResilienceSubtitle": "Retries and breakers",
|
||||
"settingsAdvancedSubtitle": "Power user options",
|
||||
"settingsSecuritySubtitle": "Auth and encryption",
|
||||
"settingsAccessTokensSubtitle": "Scoped CLI tokens for remote mode",
|
||||
"settingsFeatureFlagsSubtitle": "Toggle system capabilities",
|
||||
"settingsSidebar": "Sidebar",
|
||||
"settingsSidebarSubtitle": "Customize sidebar layout",
|
||||
|
||||
@@ -6999,6 +6999,7 @@
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsAi": "AI Settings",
|
||||
"settingsSecurity": "Security",
|
||||
"settingsAccessTokens": "Tokens de Acesso",
|
||||
"settingsFeatureFlags": "Sinalizadores de recursos",
|
||||
"settingsAuthz": "Autorização",
|
||||
"settingsRouting": "Routing",
|
||||
@@ -7077,6 +7078,7 @@
|
||||
"settingsResilienceSubtitle": "Retries e circuit breakers",
|
||||
"settingsAdvancedSubtitle": "Opções avançadas",
|
||||
"settingsSecuritySubtitle": "Auth e criptografia",
|
||||
"settingsAccessTokensSubtitle": "Tokens de CLI com escopo para modo remoto",
|
||||
"settingsFeatureFlagsSubtitle": "Alternar recursos do sistema",
|
||||
"settingsSidebar": "Sidebar",
|
||||
"settingsSidebarSubtitle": "Customize sidebar layout",
|
||||
|
||||
51
src/lib/accessTokens/scopes.ts
Normal file
51
src/lib/accessTokens/scopes.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* CLI access-token scopes — the 3-level hierarchy used by remote mode.
|
||||
*
|
||||
* These tokens authorize the `omniroute` CLI (and dashboard) to run *management*
|
||||
* commands against a (possibly remote) OmniRoute server. They are distinct from
|
||||
* inference API keys (`api_keys`), which authorize `/v1/chat/completions` traffic.
|
||||
*
|
||||
* Hierarchy (admin ⊃ write ⊃ read):
|
||||
* - read : list/inspect only (models list, providers status, logs, usage, cost)
|
||||
* - write : read + configure/apply (setup-codex, keys add, config set, combo edit)
|
||||
* - admin : write + sensitive management (tokens create/revoke, providers add,
|
||||
* services install/start, policy, oauth)
|
||||
*
|
||||
* Loopback-only routes that spawn processes (`isLocalOnlyPath`) are NEVER reachable
|
||||
* by a remote token regardless of scope — that enforcement happens before auth.
|
||||
*/
|
||||
|
||||
export const ACCESS_SCOPES = ["read", "write", "admin"] as const;
|
||||
|
||||
export type AccessScope = (typeof ACCESS_SCOPES)[number];
|
||||
|
||||
/** Numeric rank for hierarchy comparisons. Higher = more privileged. */
|
||||
const SCOPE_RANK: Record<AccessScope, number> = {
|
||||
read: 1,
|
||||
write: 2,
|
||||
admin: 3,
|
||||
};
|
||||
|
||||
/** Type guard: is `value` one of the three valid scopes? */
|
||||
export function isAccessScope(value: unknown): value is AccessScope {
|
||||
return typeof value === "string" && (ACCESS_SCOPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a token holding `have` is allowed to perform an action that requires
|
||||
* `need`. Hierarchy is inclusive: an `admin` token satisfies `write` and `read`;
|
||||
* a `write` token satisfies `read`. Unknown scopes never satisfy anything.
|
||||
*/
|
||||
export function scopeSatisfies(have: unknown, need: AccessScope): boolean {
|
||||
if (!isAccessScope(have)) return false;
|
||||
return SCOPE_RANK[have] >= SCOPE_RANK[need];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an arbitrary input into a valid scope, falling back to the safest
|
||||
* default (`read`) when the value is missing or invalid. Used when reading a
|
||||
* stored/declared scope that must never silently widen privileges.
|
||||
*/
|
||||
export function normalizeScope(value: unknown, fallback: AccessScope = "read"): AccessScope {
|
||||
return isAccessScope(value) ? value : fallback;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
import { getApiKeyMetadata } from "@/lib/db/apiKeys";
|
||||
import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth";
|
||||
import { evaluateAccessTokenAuth } from "@/server/authz/accessTokenAuth";
|
||||
import {
|
||||
MANAGE_SCOPE,
|
||||
hasManageScope as hasManageScopeShared,
|
||||
@@ -34,6 +35,37 @@ export async function requireManagementAuth(request: Request): Promise<Response
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scoped CLI access token (remote mode). Intercepted BEFORE the API-key branch:
|
||||
// these `oma_` tokens are management/CLI credentials, not inference API keys,
|
||||
// and would otherwise be rejected by isValidApiKey. Same shared evaluation the
|
||||
// central managementPolicy uses (no drift). Dashboard JWT, the loopback CLI
|
||||
// token, and manage-scope API keys remain full-access above/below.
|
||||
const accessVerdict = evaluateAccessTokenAuth(request);
|
||||
switch (accessVerdict.kind) {
|
||||
case "ok":
|
||||
return null;
|
||||
case "error":
|
||||
return createErrorResponse({
|
||||
status: 503,
|
||||
message: "Service temporarily unavailable",
|
||||
type: "server_error",
|
||||
});
|
||||
case "invalid":
|
||||
return createErrorResponse({
|
||||
status: 401,
|
||||
message: "Invalid or expired access token",
|
||||
type: "invalid_request",
|
||||
});
|
||||
case "insufficient":
|
||||
return createErrorResponse({
|
||||
status: 403,
|
||||
message: `Access token scope '${accessVerdict.have}' is insufficient; '${accessVerdict.need}' required.`,
|
||||
type: "invalid_request",
|
||||
});
|
||||
case "absent":
|
||||
break; // no oma_ token → fall through to API-key auth
|
||||
}
|
||||
|
||||
// Management auth never honours a URL-borne credential (header-only) — a token
|
||||
// in the path/query must not authenticate a management route. See #3300 follow-up.
|
||||
const apiKey = extractApiKey(request, { allowUrl: false });
|
||||
|
||||
183
src/lib/db/accessTokens.ts
Normal file
183
src/lib/db/accessTokens.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { createHash, randomBytes, randomUUID } from "crypto";
|
||||
import { getDbInstance } from "./core";
|
||||
import { type AccessScope, normalizeScope } from "../accessTokens/scopes";
|
||||
|
||||
/**
|
||||
* CLI access tokens — scoped credentials for remote-mode management commands.
|
||||
* Distinct from `api_keys` (inference). Only the SHA-256 hash is persisted; the
|
||||
* plaintext secret is returned exactly once, at creation.
|
||||
*
|
||||
* Token format: `oma_live_<base64url(32 bytes)>`. The first chars are stored as
|
||||
* `token_prefix` so tokens can be listed/identified without revealing the secret.
|
||||
*/
|
||||
|
||||
const TOKEN_RANDOM_BYTES = 32;
|
||||
const TOKEN_SECRET_PREFIX = "oma_live_";
|
||||
/** How many leading chars of the secret are kept for display (prefix). */
|
||||
const DISPLAY_PREFIX_LEN = TOKEN_SECRET_PREFIX.length + 6;
|
||||
|
||||
export interface AccessTokenRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: AccessScope;
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
/** Result of validating a presented token on a request. */
|
||||
export interface VerifiedAccessToken {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: AccessScope;
|
||||
}
|
||||
|
||||
interface AccessTokenRow {
|
||||
id: string;
|
||||
token_hash: string;
|
||||
token_prefix: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
|
||||
function rowToRecord(row: AccessTokenRow): AccessTokenRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
scope: normalizeScope(row.scope),
|
||||
tokenPrefix: row.token_prefix,
|
||||
createdAt: row.created_at,
|
||||
lastUsedAt: row.last_used_at,
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a token secret for storage/lookup.
|
||||
*
|
||||
* SHA-256 is intentional: these are high-entropy random secrets compared by exact
|
||||
* hash match for per-request validation, NOT user passwords. bcrypt/scrypt would
|
||||
* add ~100ms per request for no security gain. Mirrors `api_keys` hashing.
|
||||
* lgtm[js/insufficient-password-hash]
|
||||
*/
|
||||
export function hashAccessToken(secret: string): string {
|
||||
return createHash("sha256").update(secret).digest("hex"); // nosemgrep: insufficient-password-hash
|
||||
}
|
||||
|
||||
/** True when an ISO timestamp is in the past (treats invalid dates as not-expired). */
|
||||
function isExpired(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false;
|
||||
const ts = new Date(expiresAt).getTime();
|
||||
return Number.isFinite(ts) && ts <= Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new access token. Returns the persisted record plus the plaintext
|
||||
* secret — the ONLY time the secret is available. Caller must show it once and
|
||||
* never store it server-side.
|
||||
*/
|
||||
export function createAccessToken(input: {
|
||||
name: string;
|
||||
scope?: AccessScope | string;
|
||||
expiresAt?: string | null;
|
||||
}): { record: AccessTokenRecord; secret: string } {
|
||||
const name = (input.name ?? "").trim();
|
||||
if (!name) throw new Error("Access token name is required");
|
||||
|
||||
const db = getDbInstance();
|
||||
const scope = normalizeScope(input.scope, "read");
|
||||
const secret = `${TOKEN_SECRET_PREFIX}${randomBytes(TOKEN_RANDOM_BYTES).toString("base64url")}`;
|
||||
const id = `tok_${randomUUID()}`;
|
||||
const tokenHash = hashAccessToken(secret);
|
||||
const tokenPrefix = secret.slice(0, DISPLAY_PREFIX_LEN);
|
||||
const createdAt = new Date().toISOString();
|
||||
const expiresAt = input.expiresAt ?? null;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO cli_access_tokens
|
||||
(id, token_hash, token_prefix, name, scope, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(id, tokenHash, tokenPrefix, name, scope, createdAt, expiresAt);
|
||||
|
||||
return {
|
||||
secret,
|
||||
record: {
|
||||
id,
|
||||
name,
|
||||
scope,
|
||||
tokenPrefix,
|
||||
createdAt,
|
||||
lastUsedAt: null,
|
||||
expiresAt,
|
||||
revokedAt: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a presented secret. Returns the token's identity + scope, or null when
|
||||
* the secret is unknown, revoked, or expired. Touches `last_used_at` on success.
|
||||
*/
|
||||
export function verifyAccessToken(secret: string | null | undefined): VerifiedAccessToken | null {
|
||||
if (!secret || typeof secret !== "string") return null;
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT * FROM cli_access_tokens WHERE token_hash = ?")
|
||||
.get(hashAccessToken(secret)) as AccessTokenRow | undefined;
|
||||
if (!row) return null;
|
||||
if (row.revoked_at) return null;
|
||||
if (isExpired(row.expires_at)) return null;
|
||||
|
||||
// Best-effort usage stamp; never block validation on the write.
|
||||
try {
|
||||
db.prepare("UPDATE cli_access_tokens SET last_used_at = ? WHERE id = ?").run(
|
||||
new Date().toISOString(),
|
||||
row.id
|
||||
);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
return { id: row.id, name: row.name, scope: normalizeScope(row.scope) };
|
||||
}
|
||||
|
||||
/** List all tokens (masked — never includes the secret or its hash). */
|
||||
export function listAccessTokens(): AccessTokenRecord[] {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT * FROM cli_access_tokens ORDER BY created_at DESC")
|
||||
.all() as AccessTokenRow[];
|
||||
return rows.map(rowToRecord);
|
||||
}
|
||||
|
||||
/** Fetch one token's masked record by id, or null. */
|
||||
export function getAccessToken(id: string): AccessTokenRecord | null {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM cli_access_tokens WHERE id = ?").get(id) as
|
||||
| AccessTokenRow
|
||||
| undefined;
|
||||
return row ? rowToRecord(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token by id or by its display prefix. Idempotent: revoking an
|
||||
* already-revoked token is a no-op. Returns true when a row was newly revoked.
|
||||
*/
|
||||
export function revokeAccessToken(idOrPrefix: string): boolean {
|
||||
if (!idOrPrefix) return false;
|
||||
const db = getDbInstance();
|
||||
const res = db
|
||||
.prepare(
|
||||
`UPDATE cli_access_tokens SET revoked_at = ?
|
||||
WHERE (id = ? OR token_prefix = ?) AND revoked_at IS NULL`
|
||||
)
|
||||
.run(new Date().toISOString(), idOrPrefix, idOrPrefix);
|
||||
return res.changes > 0;
|
||||
}
|
||||
18
src/lib/db/migrations/100_cli_access_tokens.sql
Normal file
18
src/lib/db/migrations/100_cli_access_tokens.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- CLI access tokens — scoped credentials for remote-mode management commands.
|
||||
-- Distinct from `api_keys` (inference traffic): these authorize the `omniroute`
|
||||
-- CLI / dashboard to run management operations against a (possibly remote) server.
|
||||
-- Only the SHA-256 hash of the secret is stored; the plaintext is shown once at
|
||||
-- creation. Scope is one of: 'read' | 'write' | 'admin' (admin ⊃ write ⊃ read).
|
||||
CREATE TABLE IF NOT EXISTS cli_access_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'read',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cli_access_tokens_hash ON cli_access_tokens(token_hash);
|
||||
62
src/server/authz/accessScopes.ts
Normal file
62
src/server/authz/accessScopes.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type AccessScope } from "@/lib/accessTokens/scopes";
|
||||
|
||||
/**
|
||||
* Required-scope inference for remote CLI access tokens.
|
||||
*
|
||||
* Policy (owner-confirmed 2026-06-19): infer from HTTP method —
|
||||
* - GET/HEAD/OPTIONS → read
|
||||
* - POST/PUT/PATCH/DELETE → write
|
||||
* with two admin overrides:
|
||||
* - ADMIN_SCOPE_PREFIXES: admin for ANY method (inherently sensitive surfaces).
|
||||
* - ADMIN_MUTATION_PREFIXES: admin only when mutating; GET/HEAD stay read so a
|
||||
* `read` token can still inspect status under these prefixes.
|
||||
*
|
||||
* This covers every existing management route with zero per-route edits. A NEW
|
||||
* mutating route nasce exigindo `write` por padrão (não `admin`) — quando uma
|
||||
* rota nova for sensível, adicione seu prefixo a uma das listas abaixo.
|
||||
*
|
||||
* Note: this only governs the access-token credential path. Dashboard JWT, the
|
||||
* loopback CLI machine-id token, and manage-scope API keys remain full-access.
|
||||
* Loopback-only routes that spawn processes are blocked before auth regardless.
|
||||
*/
|
||||
|
||||
/** Sensitive management surfaces — require `admin` for ALL methods. */
|
||||
export const ADMIN_SCOPE_PREFIXES: readonly string[] = [
|
||||
"/api/cli/tokens", // access-token management (create/list/revoke)
|
||||
"/api/oauth", // OAuth authorization flows
|
||||
"/api/auth", // login / logout / session
|
||||
"/api/policy", // policy engine
|
||||
"/api/services", // embedded-service lifecycle (also loopback-blocked)
|
||||
"/api/mcp", // MCP process surface (also loopback-blocked)
|
||||
];
|
||||
|
||||
/** Require `admin` only for mutating methods; GET/HEAD under these stay `read`. */
|
||||
export const ADMIN_MUTATION_PREFIXES: readonly string[] = [
|
||||
"/api/providers", // POST add provider / rotate key = admin; GET status = read
|
||||
"/api/cli-tools/apply", // writes config onto the host filesystem
|
||||
];
|
||||
|
||||
const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
function matchesPrefix(path: string, prefixes: readonly string[]): boolean {
|
||||
// Exact match or a true path-segment boundary (`pre/...`). A bare
|
||||
// `startsWith(pre)` would over-match lookalikes (e.g. "/api/auth" vs
|
||||
// "/api/authz-inventory"), so it is intentionally NOT used.
|
||||
return prefixes.some((pre) => path === pre || path.startsWith(pre + "/"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the minimum access-token scope required to call `method path`.
|
||||
* Pure + deterministic — safe to unit test exhaustively.
|
||||
*/
|
||||
export function inferRequiredScope(method: string, path: string): AccessScope {
|
||||
const m = (method || "GET").toUpperCase();
|
||||
const p = path || "/";
|
||||
|
||||
if (matchesPrefix(p, ADMIN_SCOPE_PREFIXES)) return "admin";
|
||||
|
||||
const isMutation = !READ_METHODS.has(m);
|
||||
if (isMutation && matchesPrefix(p, ADMIN_MUTATION_PREFIXES)) return "admin";
|
||||
|
||||
return isMutation ? "write" : "read";
|
||||
}
|
||||
67
src/server/authz/accessTokenAuth.ts
Normal file
67
src/server/authz/accessTokenAuth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { verifyAccessToken } from "@/lib/db/accessTokens";
|
||||
import { scopeSatisfies, type AccessScope } from "@/lib/accessTokens/scopes";
|
||||
import { inferRequiredScope } from "@/server/authz/accessScopes";
|
||||
|
||||
/**
|
||||
* Shared evaluation of a scoped CLI access token (`oma_...`) for remote mode.
|
||||
*
|
||||
* Used by BOTH the central authz pipeline (`managementPolicy` — the authoritative
|
||||
* gate wired through `src/proxy.ts`) and the route-level `requireManagementAuth`
|
||||
* (defense-in-depth + lets `/api/cli/whoami` learn its own scope). Keeping the
|
||||
* logic here means the two gates can never drift.
|
||||
*
|
||||
* Returns a neutral verdict the caller maps to its own response shape
|
||||
* (`allow()/reject()` in the policy, `null`/Response in requireManagementAuth).
|
||||
*/
|
||||
|
||||
/** Prefix that distinguishes a CLI access token from an inference API key. */
|
||||
export const ACCESS_TOKEN_PREFIX = "oma_";
|
||||
|
||||
export type AccessTokenVerdict =
|
||||
| { kind: "absent" } // no oma_ bearer present → caller continues other auth paths
|
||||
| { kind: "error" } // auth backend (DB) threw → 503, not an auth failure
|
||||
| { kind: "invalid" } // oma_ present but unknown/expired/revoked → 401
|
||||
| { kind: "insufficient"; have: AccessScope; need: AccessScope } // valid but scope too low → 403
|
||||
| { kind: "ok"; scope: AccessScope; id: string; name: string }; // authorized → allow
|
||||
|
||||
/** Read a Bearer token from the Authorization header (header-only; never URL). */
|
||||
export function extractBearer(request: Request): string | null {
|
||||
const header = request.headers.get("authorization") || "";
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
function safePathname(url: string): string {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the request's access-token credential and required scope.
|
||||
* Pure w.r.t. the request (only side effect is `verifyAccessToken` stamping
|
||||
* `last_used_at`). Never throws — DB failures surface as `{ kind: "error" }`.
|
||||
*/
|
||||
export function evaluateAccessTokenAuth(request: Request): AccessTokenVerdict {
|
||||
const bearer = extractBearer(request);
|
||||
if (!bearer || !bearer.startsWith(ACCESS_TOKEN_PREFIX)) {
|
||||
return { kind: "absent" };
|
||||
}
|
||||
|
||||
let verified: ReturnType<typeof verifyAccessToken>;
|
||||
try {
|
||||
verified = verifyAccessToken(bearer);
|
||||
} catch {
|
||||
return { kind: "error" };
|
||||
}
|
||||
if (!verified) return { kind: "invalid" };
|
||||
|
||||
const need = inferRequiredScope(request.method, safePathname(request.url));
|
||||
if (!scopeSatisfies(verified.scope, need)) {
|
||||
return { kind: "insufficient", have: verified.scope, need };
|
||||
}
|
||||
|
||||
return { kind: "ok", scope: verified.scope, id: verified.id, name: verified.name };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { allow, reject } from "../context";
|
||||
import { extractApiKey, isValidApiKey } from "../../../sse/services/auth";
|
||||
import { getApiKeyMetadata } from "../../../lib/db/apiKeys";
|
||||
import { hasManageScope } from "../../../lib/api/requireManagementAuth";
|
||||
import { evaluateAccessTokenAuth } from "../accessTokenAuth";
|
||||
import { CLI_TOKEN_HEADER, PEER_IP_HEADER } from "../headers";
|
||||
import { resolveStampedPeer } from "../peerStamp";
|
||||
import {
|
||||
@@ -200,6 +201,32 @@ export const managementPolicy: RoutePolicy = {
|
||||
// unhealthy, which is a 503, not a 403 — masking it as an auth failure
|
||||
// would tell callers their credentials are wrong when the real problem
|
||||
// is that the server cannot validate any credential right now.
|
||||
// Scoped CLI access token (remote mode). Evaluated BEFORE the API-key branch
|
||||
// because `oma_` tokens are management credentials, not inference API keys.
|
||||
// Shared with `requireManagementAuth` (no drift). Scope enforced per the
|
||||
// method+admin-allowlist policy (inferRequiredScope).
|
||||
const accessVerdict = evaluateAccessTokenAuth(ctx.request as unknown as Request);
|
||||
switch (accessVerdict.kind) {
|
||||
case "ok":
|
||||
return allow({
|
||||
kind: "management_key",
|
||||
id: accessVerdict.id,
|
||||
label: `access-token:${accessVerdict.scope}`,
|
||||
});
|
||||
case "error":
|
||||
return reject(503, "AUTH_BACKEND_UNAVAILABLE", "Service temporarily unavailable");
|
||||
case "invalid":
|
||||
return reject(401, "AUTH_001", "Invalid or expired access token");
|
||||
case "insufficient":
|
||||
return reject(
|
||||
403,
|
||||
"AUTH_SCOPE",
|
||||
`Access token scope '${accessVerdict.have}' is insufficient; '${accessVerdict.need}' required.`
|
||||
);
|
||||
case "absent":
|
||||
break; // no oma_ token → fall through to API-key auth
|
||||
}
|
||||
|
||||
// Management auth is header-only — a URL-borne token must not authenticate
|
||||
// a management route. See #3300 follow-up.
|
||||
const apiKey = extractApiKey(ctx.request as unknown as Request, { allowUrl: false });
|
||||
|
||||
@@ -10,6 +10,10 @@ const PUBLIC_API_ROUTE_PREFIXES = [
|
||||
// Public, ticket-gated Codex device-flow completion (validate + persist).
|
||||
// The handler enforces its own single-use ticket check; no dashboard auth.
|
||||
"/api/codex/connect/",
|
||||
// Remote-mode bootstrap: exchange the management password for a scoped CLI
|
||||
// access token. The handler enforces its own password check + lockout — there
|
||||
// is no token yet at this point, so it cannot require management auth.
|
||||
"/api/cli/connect",
|
||||
];
|
||||
|
||||
const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
|
||||
|
||||
@@ -92,6 +92,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"settings-resilience",
|
||||
"settings-advanced",
|
||||
"settings-security",
|
||||
"settings-access-tokens",
|
||||
"settings-feature-flags",
|
||||
"settings-sidebar",
|
||||
// Help
|
||||
@@ -787,6 +788,14 @@ const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
subtitleKey: "settingsSecuritySubtitle",
|
||||
icon: "shield",
|
||||
},
|
||||
{
|
||||
id: "settings-access-tokens",
|
||||
href: "/dashboard/settings/access-tokens",
|
||||
i18nKey: "settingsAccessTokens",
|
||||
labelFallback: "Access Tokens",
|
||||
subtitleKey: "settingsAccessTokensSubtitle",
|
||||
icon: "key",
|
||||
},
|
||||
{
|
||||
id: "settings-feature-flags",
|
||||
href: "/dashboard/settings/feature-flags",
|
||||
@@ -999,6 +1008,7 @@ const ADMIN_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
|
||||
"settings-routing",
|
||||
"settings-resilience",
|
||||
"settings-security",
|
||||
"settings-access-tokens",
|
||||
"settings-feature-flags",
|
||||
"settings-sidebar",
|
||||
"docs",
|
||||
|
||||
48
tests/unit/access-scopes-infer.test.ts
Normal file
48
tests/unit/access-scopes-infer.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { inferRequiredScope } from "../../src/server/authz/accessScopes.ts";
|
||||
|
||||
test("read methods default to read", () => {
|
||||
assert.equal(inferRequiredScope("GET", "/api/v1/models"), "read");
|
||||
assert.equal(inferRequiredScope("HEAD", "/api/health"), "read");
|
||||
assert.equal(inferRequiredScope("OPTIONS", "/api/anything"), "read");
|
||||
});
|
||||
|
||||
test("mutating methods default to write", () => {
|
||||
assert.equal(inferRequiredScope("POST", "/api/keys"), "write");
|
||||
assert.equal(inferRequiredScope("PUT", "/api/config"), "write");
|
||||
assert.equal(inferRequiredScope("PATCH", "/api/combo/x"), "write");
|
||||
assert.equal(inferRequiredScope("DELETE", "/api/keys/abc"), "write");
|
||||
});
|
||||
|
||||
test("admin-prefix routes require admin for ANY method", () => {
|
||||
assert.equal(inferRequiredScope("GET", "/api/cli/tokens"), "admin");
|
||||
assert.equal(inferRequiredScope("POST", "/api/cli/tokens"), "admin");
|
||||
assert.equal(inferRequiredScope("DELETE", "/api/cli/tokens/tok_1"), "admin");
|
||||
assert.equal(inferRequiredScope("GET", "/api/oauth/start"), "admin");
|
||||
assert.equal(inferRequiredScope("POST", "/api/auth/login"), "admin");
|
||||
assert.equal(inferRequiredScope("POST", "/api/policy"), "admin");
|
||||
assert.equal(inferRequiredScope("POST", "/api/services/foo/start"), "admin");
|
||||
});
|
||||
|
||||
test("admin-mutation prefixes: GET stays read, mutations become admin", () => {
|
||||
// providers: status is read, but creating/rotating is admin
|
||||
assert.equal(inferRequiredScope("GET", "/api/providers/status"), "read");
|
||||
assert.equal(inferRequiredScope("GET", "/api/providers"), "read");
|
||||
assert.equal(inferRequiredScope("POST", "/api/providers"), "admin");
|
||||
assert.equal(inferRequiredScope("DELETE", "/api/providers/openai"), "admin");
|
||||
// cli-tools/apply writes to the host fs
|
||||
assert.equal(inferRequiredScope("POST", "/api/cli-tools/apply"), "admin");
|
||||
});
|
||||
|
||||
test("a brand-new mutating route is write by default (not admin)", () => {
|
||||
assert.equal(inferRequiredScope("POST", "/api/some-future-route"), "write");
|
||||
assert.equal(inferRequiredScope("GET", "/api/some-future-route"), "read");
|
||||
});
|
||||
|
||||
test("prefix matching does not over-match unrelated paths", () => {
|
||||
// "/api/authz-inventory" must NOT be caught by the "/api/auth" admin prefix
|
||||
assert.equal(inferRequiredScope("GET", "/api/authz-inventory"), "read");
|
||||
// "/api/services" itself and its children are admin, but a lookalike is not
|
||||
assert.equal(inferRequiredScope("GET", "/api/services-catalog"), "read");
|
||||
});
|
||||
58
tests/unit/access-token-scopes.test.ts
Normal file
58
tests/unit/access-token-scopes.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
ACCESS_SCOPES,
|
||||
isAccessScope,
|
||||
scopeSatisfies,
|
||||
normalizeScope,
|
||||
} from "../../src/lib/accessTokens/scopes.ts";
|
||||
|
||||
test("ACCESS_SCOPES are exactly read/write/admin", () => {
|
||||
assert.deepEqual([...ACCESS_SCOPES], ["read", "write", "admin"]);
|
||||
});
|
||||
|
||||
test("isAccessScope accepts valid scopes and rejects anything else", () => {
|
||||
assert.equal(isAccessScope("read"), true);
|
||||
assert.equal(isAccessScope("write"), true);
|
||||
assert.equal(isAccessScope("admin"), true);
|
||||
assert.equal(isAccessScope("superuser"), false);
|
||||
assert.equal(isAccessScope(""), false);
|
||||
assert.equal(isAccessScope(null), false);
|
||||
assert.equal(isAccessScope(undefined), false);
|
||||
assert.equal(isAccessScope(3), false);
|
||||
});
|
||||
|
||||
test("scopeSatisfies enforces the admin ⊃ write ⊃ read hierarchy", () => {
|
||||
// admin satisfies everything
|
||||
assert.equal(scopeSatisfies("admin", "read"), true);
|
||||
assert.equal(scopeSatisfies("admin", "write"), true);
|
||||
assert.equal(scopeSatisfies("admin", "admin"), true);
|
||||
// write satisfies read+write, not admin
|
||||
assert.equal(scopeSatisfies("write", "read"), true);
|
||||
assert.equal(scopeSatisfies("write", "write"), true);
|
||||
assert.equal(scopeSatisfies("write", "admin"), false);
|
||||
// read satisfies only read
|
||||
assert.equal(scopeSatisfies("read", "read"), true);
|
||||
assert.equal(scopeSatisfies("read", "write"), false);
|
||||
assert.equal(scopeSatisfies("read", "admin"), false);
|
||||
});
|
||||
|
||||
test("scopeSatisfies returns false for invalid `have` scopes (fail closed)", () => {
|
||||
assert.equal(scopeSatisfies("bogus", "read"), false);
|
||||
assert.equal(scopeSatisfies(null, "read"), false);
|
||||
assert.equal(scopeSatisfies(undefined, "read"), false);
|
||||
assert.equal(scopeSatisfies("", "read"), false);
|
||||
});
|
||||
|
||||
test("normalizeScope falls back to read by default for invalid input", () => {
|
||||
assert.equal(normalizeScope("write"), "write");
|
||||
assert.equal(normalizeScope("admin"), "admin");
|
||||
assert.equal(normalizeScope("bogus"), "read");
|
||||
assert.equal(normalizeScope(undefined), "read");
|
||||
assert.equal(normalizeScope(null), "read");
|
||||
});
|
||||
|
||||
test("normalizeScope honors a custom fallback", () => {
|
||||
assert.equal(normalizeScope("bogus", "write"), "write");
|
||||
assert.equal(normalizeScope(undefined, "admin"), "admin");
|
||||
});
|
||||
104
tests/unit/access-tokens-db.test.ts
Normal file
104
tests/unit/access-tokens-db.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// DB-backed access-token store. Uses an isolated DATA_DIR + closes the handle in
|
||||
// test.after (CLAUDE.md "Database Handles in Tests" — otherwise Node's runner hangs).
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-access-tokens-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const at = await import("../../src/lib/db/accessTokens.ts");
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
core.resetDbInstance();
|
||||
} catch {}
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test("createAccessToken returns a secret prefixed oma_live_ and a masked record", () => {
|
||||
const { record, secret } = at.createAccessToken({ name: "laptop", scope: "write" });
|
||||
assert.match(secret, /^oma_live_/);
|
||||
assert.equal(record.name, "laptop");
|
||||
assert.equal(record.scope, "write");
|
||||
assert.ok(record.id.startsWith("tok_"));
|
||||
assert.ok(secret.startsWith(record.tokenPrefix), "prefix must be a prefix of the secret");
|
||||
assert.equal(record.revokedAt, null);
|
||||
});
|
||||
|
||||
test("createAccessToken defaults to the safest scope (read) for invalid input", () => {
|
||||
const { record } = at.createAccessToken({ name: "x", scope: "bogus" });
|
||||
assert.equal(record.scope, "read");
|
||||
});
|
||||
|
||||
test("createAccessToken rejects an empty name", () => {
|
||||
assert.throws(() => at.createAccessToken({ name: " ", scope: "read" }), /name is required/);
|
||||
});
|
||||
|
||||
test("verifyAccessToken returns identity+scope for a valid secret, null for wrong", () => {
|
||||
const { secret } = at.createAccessToken({ name: "verify-me", scope: "admin" });
|
||||
const v = at.verifyAccessToken(secret);
|
||||
assert.ok(v);
|
||||
assert.equal(v?.scope, "admin");
|
||||
assert.equal(v?.name, "verify-me");
|
||||
assert.equal(at.verifyAccessToken("oma_live_wrong"), null);
|
||||
assert.equal(at.verifyAccessToken(""), null);
|
||||
assert.equal(at.verifyAccessToken(null), null);
|
||||
});
|
||||
|
||||
test("only the hash is stored — the plaintext secret never lands in the DB", () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "secrecy", scope: "read" });
|
||||
const db = core.getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT token_hash, token_prefix FROM cli_access_tokens WHERE id = ?")
|
||||
.get(record.id) as { token_hash: string; token_prefix: string };
|
||||
assert.notEqual(row.token_hash, secret, "must store hash, not plaintext");
|
||||
assert.equal(row.token_hash, at.hashAccessToken(secret));
|
||||
assert.equal(row.token_hash.length, 64, "sha-256 hex");
|
||||
});
|
||||
|
||||
test("verifyAccessToken stamps last_used_at", () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "touch", scope: "read" });
|
||||
assert.equal(at.getAccessToken(record.id)?.lastUsedAt, null);
|
||||
at.verifyAccessToken(secret);
|
||||
assert.notEqual(at.getAccessToken(record.id)?.lastUsedAt, null);
|
||||
});
|
||||
|
||||
test("revoked tokens fail verification", () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "to-revoke", scope: "write" });
|
||||
assert.ok(at.verifyAccessToken(secret));
|
||||
assert.equal(at.revokeAccessToken(record.id), true);
|
||||
assert.equal(at.verifyAccessToken(secret), null);
|
||||
// idempotent: revoking again is a no-op
|
||||
assert.equal(at.revokeAccessToken(record.id), false);
|
||||
});
|
||||
|
||||
test("revokeAccessToken works by display prefix too", () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "by-prefix", scope: "read" });
|
||||
assert.equal(at.revokeAccessToken(record.tokenPrefix), true);
|
||||
assert.equal(at.verifyAccessToken(secret), null);
|
||||
});
|
||||
|
||||
test("expired tokens fail verification", () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
const { secret } = at.createAccessToken({ name: "expired", scope: "admin", expiresAt: past });
|
||||
assert.equal(at.verifyAccessToken(secret), null);
|
||||
});
|
||||
|
||||
test("listAccessTokens returns masked records (no secret/hash field)", () => {
|
||||
at.createAccessToken({ name: "listed", scope: "read" });
|
||||
const list = at.listAccessTokens();
|
||||
assert.ok(list.length >= 1);
|
||||
for (const rec of list) {
|
||||
assert.ok("tokenPrefix" in rec);
|
||||
assert.ok(!("secret" in rec));
|
||||
assert.ok(!("tokenHash" in rec));
|
||||
assert.ok(!("token_hash" in rec));
|
||||
}
|
||||
});
|
||||
35
tests/unit/cli-connect-helpers.test.ts
Normal file
35
tests/unit/cli-connect-helpers.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeBaseUrl, hostLabel } from "../../bin/cli/commands/connect.mjs";
|
||||
import { profileNameFromModel } from "../../bin/cli/commands/configure.mjs";
|
||||
|
||||
test("normalizeBaseUrl: bare host gets http:// and the default port", () => {
|
||||
assert.equal(normalizeBaseUrl("192.168.0.15", "20128"), "http://192.168.0.15:20128");
|
||||
});
|
||||
|
||||
test("normalizeBaseUrl: host with explicit port keeps it", () => {
|
||||
assert.equal(normalizeBaseUrl("192.168.0.15:9000", "20128"), "http://192.168.0.15:9000");
|
||||
});
|
||||
|
||||
test("normalizeBaseUrl: full https URL is preserved as origin", () => {
|
||||
assert.equal(normalizeBaseUrl("https://omni.example.com", "20128"), "https://omni.example.com");
|
||||
assert.equal(normalizeBaseUrl("http://host:1234/path", "20128"), "http://host:1234");
|
||||
});
|
||||
|
||||
test("normalizeBaseUrl: empty input returns empty string", () => {
|
||||
assert.equal(normalizeBaseUrl("", "20128"), "");
|
||||
});
|
||||
|
||||
test("hostLabel strips scheme and port", () => {
|
||||
assert.equal(hostLabel("https://omni.example.com:20128"), "omni.example.com");
|
||||
assert.equal(hostLabel("192.168.0.15:20128"), "192.168.0.15");
|
||||
assert.equal(hostLabel("http://10.0.0.1"), "10.0.0.1");
|
||||
});
|
||||
|
||||
test("profileNameFromModel strips the provider prefix and non-alphanumerics", () => {
|
||||
assert.equal(profileNameFromModel("glm/glm-5.2"), "glm52");
|
||||
assert.equal(profileNameFromModel("kmc/kimi-k2.7"), "kimik27");
|
||||
assert.equal(profileNameFromModel("ollamacloud/gpt-oss:20b"), "gptoss20b");
|
||||
assert.equal(profileNameFromModel("cx/gpt-5.5"), "gpt55");
|
||||
assert.equal(profileNameFromModel("bare-model"), "baremodel");
|
||||
});
|
||||
178
tests/unit/cli-remote-mode.test.ts
Normal file
178
tests/unit/cli-remote-mode.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// Remote-mode core: the CLI must resolve BOTH baseUrl and auth from the active
|
||||
// context (canonical `contexts`/`currentContext` schema, with legacy
|
||||
// `profiles`/`activeProfile` fallback). Before this work, getBaseUrl read only
|
||||
// the legacy `profiles` schema and buildHeaders never read the context's
|
||||
// credential at all — so `omniroute contexts use <remote>` silently failed to
|
||||
// route auth to the remote server.
|
||||
|
||||
let tmpDir: string;
|
||||
let origDataDir: string | undefined;
|
||||
let origBaseUrl: string | undefined;
|
||||
let origApiKey: string | undefined;
|
||||
let origContext: string | undefined;
|
||||
|
||||
function writeConfig(cfg: unknown): void {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
writeFileSync(join(tmpDir, "config.json"), JSON.stringify(cfg, null, 2));
|
||||
}
|
||||
|
||||
test.before(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "omniroute-remote-test-"));
|
||||
origDataDir = process.env.DATA_DIR;
|
||||
origBaseUrl = process.env.OMNIROUTE_BASE_URL;
|
||||
origApiKey = process.env.OMNIROUTE_API_KEY;
|
||||
origContext = process.env.OMNIROUTE_CONTEXT;
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
delete process.env.OMNIROUTE_BASE_URL;
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
delete process.env.OMNIROUTE_CONTEXT;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (origDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = origDataDir;
|
||||
if (origBaseUrl === undefined) delete process.env.OMNIROUTE_BASE_URL;
|
||||
else process.env.OMNIROUTE_BASE_URL = origBaseUrl;
|
||||
if (origApiKey === undefined) delete process.env.OMNIROUTE_API_KEY;
|
||||
else process.env.OMNIROUTE_API_KEY = origApiKey;
|
||||
if (origContext === undefined) delete process.env.OMNIROUTE_CONTEXT;
|
||||
else process.env.OMNIROUTE_CONTEXT = origContext;
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// ── getBaseUrl ────────────────────────────────────────────────────────────────
|
||||
|
||||
test("getBaseUrl reads baseUrl from the active context (canonical schema)", async () => {
|
||||
writeConfig({
|
||||
version: 1,
|
||||
currentContext: "vps",
|
||||
contexts: {
|
||||
default: { baseUrl: "http://localhost:20128", apiKey: null },
|
||||
vps: { baseUrl: "https://vps.example.com:20128", accessToken: "oma_live_x", scope: "write" },
|
||||
},
|
||||
});
|
||||
const { getBaseUrl } = await import("../../bin/cli/api.mjs");
|
||||
assert.equal(getBaseUrl(), "https://vps.example.com:20128");
|
||||
});
|
||||
|
||||
test("getBaseUrl honors the --context override", async () => {
|
||||
writeConfig({
|
||||
version: 1,
|
||||
currentContext: "default",
|
||||
contexts: {
|
||||
default: { baseUrl: "http://localhost:20128", apiKey: null },
|
||||
staging: { baseUrl: "http://staging:20128", apiKey: null },
|
||||
},
|
||||
});
|
||||
const { getBaseUrl } = await import("../../bin/cli/api.mjs");
|
||||
assert.equal(getBaseUrl({ context: "staging" }), "http://staging:20128");
|
||||
});
|
||||
|
||||
test("getBaseUrl is backward-compatible with the legacy profiles schema", async () => {
|
||||
writeConfig({
|
||||
version: 1,
|
||||
activeProfile: "old",
|
||||
profiles: { old: { baseUrl: "http://legacy:20128" } },
|
||||
});
|
||||
const { getBaseUrl } = await import("../../bin/cli/api.mjs");
|
||||
assert.equal(getBaseUrl(), "http://legacy:20128");
|
||||
});
|
||||
|
||||
test("getBaseUrl: opts.baseUrl wins over the active context", async () => {
|
||||
writeConfig({
|
||||
version: 1,
|
||||
currentContext: "vps",
|
||||
contexts: { vps: { baseUrl: "https://vps.example.com" } },
|
||||
});
|
||||
const { getBaseUrl } = await import("../../bin/cli/api.mjs");
|
||||
assert.equal(getBaseUrl({ baseUrl: "http://override:1234" }), "http://override:1234");
|
||||
});
|
||||
|
||||
// ── buildHeaders (auth resolution) ─────────────────────────────────────────────
|
||||
|
||||
test("buildHeaders injects Bearer from the active context accessToken", async () => {
|
||||
writeConfig({
|
||||
version: 1,
|
||||
currentContext: "vps",
|
||||
contexts: { vps: { baseUrl: "https://vps.example.com", accessToken: "oma_live_secret" } },
|
||||
});
|
||||
const { buildHeaders } = await import("../../bin/cli/api.mjs");
|
||||
const headers = await buildHeaders({ cliToken: "" });
|
||||
assert.equal(headers.get("authorization"), "Bearer oma_live_secret");
|
||||
});
|
||||
|
||||
test("buildHeaders prefers accessToken over apiKey in the same context", async () => {
|
||||
writeConfig({
|
||||
version: 1,
|
||||
currentContext: "vps",
|
||||
contexts: {
|
||||
vps: { baseUrl: "https://vps.example.com", accessToken: "oma_token", apiKey: "sk-legacy" },
|
||||
},
|
||||
});
|
||||
const { buildHeaders } = await import("../../bin/cli/api.mjs");
|
||||
const headers = await buildHeaders({ cliToken: "" });
|
||||
assert.equal(headers.get("authorization"), "Bearer oma_token");
|
||||
});
|
||||
|
||||
test("buildHeaders falls back to the context apiKey when no accessToken", async () => {
|
||||
writeConfig({
|
||||
version: 1,
|
||||
currentContext: "vps",
|
||||
contexts: { vps: { baseUrl: "https://vps.example.com", apiKey: "sk-ctx" } },
|
||||
});
|
||||
const { buildHeaders } = await import("../../bin/cli/api.mjs");
|
||||
const headers = await buildHeaders({ cliToken: "" });
|
||||
assert.equal(headers.get("authorization"), "Bearer sk-ctx");
|
||||
});
|
||||
|
||||
test("buildHeaders: explicit opts.apiKey wins over the context credential", async () => {
|
||||
writeConfig({
|
||||
version: 1,
|
||||
currentContext: "vps",
|
||||
contexts: { vps: { baseUrl: "https://vps.example.com", accessToken: "oma_token" } },
|
||||
});
|
||||
const { buildHeaders } = await import("../../bin/cli/api.mjs");
|
||||
const headers = await buildHeaders({ cliToken: "", apiKey: "sk-explicit" });
|
||||
assert.equal(headers.get("authorization"), "Bearer sk-explicit");
|
||||
});
|
||||
|
||||
// ── context current command ─────────────────────────────────────────────────────
|
||||
|
||||
test("commands/contexts.mjs registers a `current` subcommand", async () => {
|
||||
const { registerContexts } = await import("../../bin/cli/commands/contexts.mjs");
|
||||
// Minimal fake commander program to capture subcommand registration.
|
||||
const sub: string[] = [];
|
||||
const fakeCtx: any = {
|
||||
command(name: string) {
|
||||
sub.push(name.split(" ")[0]);
|
||||
return this;
|
||||
},
|
||||
description() {
|
||||
return this;
|
||||
},
|
||||
requiredOption() {
|
||||
return this;
|
||||
},
|
||||
option() {
|
||||
return this;
|
||||
},
|
||||
action() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
const fakeProgram: any = {
|
||||
command() {
|
||||
return fakeCtx;
|
||||
},
|
||||
};
|
||||
registerContexts(fakeProgram);
|
||||
assert.ok(sub.includes("current"), `expected a 'current' subcommand, got: ${sub.join(", ")}`);
|
||||
});
|
||||
84
tests/unit/require-management-auth-access-token.test.ts
Normal file
84
tests/unit/require-management-auth-access-token.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Integration: the management auth gate must accept scoped CLI access tokens
|
||||
// (`oma_...`) and enforce the method+admin-allowlist scope policy. Other
|
||||
// credential paths (dashboard JWT, loopback CLI token, manage-scope API key)
|
||||
// are unaffected. Isolated DATA_DIR + DB handle closed in test.after.
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mgmt-auth-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
// Force isAuthRequired() === true deterministically (config'd password present).
|
||||
process.env.INITIAL_PASSWORD = "test-pass";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const at = await import("../../src/lib/db/accessTokens.ts");
|
||||
const { requireManagementAuth } = await import("../../src/lib/api/requireManagementAuth.ts");
|
||||
|
||||
const BASE = "http://localhost:20128";
|
||||
|
||||
function req(method: string, pathname: string, token?: string): Request {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
return new Request(`${BASE}${pathname}`, { method, headers });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
core.resetDbInstance();
|
||||
} catch {}
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
} catch {}
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
});
|
||||
|
||||
test("read token: allowed on GET, rejected (403) on a write route", async () => {
|
||||
const { secret } = at.createAccessToken({ name: "read-tok", scope: "read" });
|
||||
assert.equal(await requireManagementAuth(req("GET", "/api/v1/models", secret)), null);
|
||||
|
||||
const denied = await requireManagementAuth(req("POST", "/api/keys", secret));
|
||||
assert.ok(denied, "expected a rejection Response");
|
||||
assert.equal(denied?.status, 403);
|
||||
});
|
||||
|
||||
test("write token: allowed on write route, rejected (403) on admin route", async () => {
|
||||
const { secret } = at.createAccessToken({ name: "write-tok", scope: "write" });
|
||||
assert.equal(await requireManagementAuth(req("POST", "/api/keys", secret)), null);
|
||||
assert.equal(await requireManagementAuth(req("GET", "/api/v1/models", secret)), null);
|
||||
|
||||
const denied = await requireManagementAuth(req("POST", "/api/cli/tokens", secret));
|
||||
assert.equal(denied?.status, 403);
|
||||
});
|
||||
|
||||
test("admin token: allowed on admin route", async () => {
|
||||
const { secret } = at.createAccessToken({ name: "admin-tok", scope: "admin" });
|
||||
assert.equal(await requireManagementAuth(req("POST", "/api/cli/tokens", secret)), null);
|
||||
assert.equal(await requireManagementAuth(req("POST", "/api/providers", secret)), null);
|
||||
});
|
||||
|
||||
test("invalid/expired access token is rejected with 401", async () => {
|
||||
const bad = await requireManagementAuth(req("GET", "/api/v1/models", "oma_live_not_a_real_token"));
|
||||
assert.equal(bad?.status, 401);
|
||||
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
const { secret } = at.createAccessToken({ name: "exp", scope: "admin", expiresAt: past });
|
||||
const expired = await requireManagementAuth(req("GET", "/api/v1/models", secret));
|
||||
assert.equal(expired?.status, 401);
|
||||
});
|
||||
|
||||
test("revoked access token is rejected", async () => {
|
||||
const { secret, record } = at.createAccessToken({ name: "rev", scope: "admin" });
|
||||
assert.equal(await requireManagementAuth(req("GET", "/api/v1/models", secret)), null);
|
||||
at.revokeAccessToken(record.id);
|
||||
const denied = await requireManagementAuth(req("GET", "/api/v1/models", secret));
|
||||
assert.equal(denied?.status, 401);
|
||||
});
|
||||
|
||||
test("no credential at all → 401 (auth still required)", async () => {
|
||||
const denied = await requireManagementAuth(req("GET", "/api/v1/models"));
|
||||
assert.equal(denied?.status, 401);
|
||||
});
|
||||
Reference in New Issue
Block a user