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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ entry. See `CONTRIBUTING.md` § Releases & changelog.
primitives, and `middleware/src` imports no channel plugin), because
"where does this code live" is a property no runtime assertion can express
and the cheapest one to regress.
- **Admin UI** (`web-ui/app/admin/api-keys/`): create/list/revoke against
`/api/public/v1/admin/keys`. A created key's plaintext token is shown
exactly once, right after creation, and creation is blocked while that
one-time reveal is still on screen — the create button and every form
field stay disabled until the operator explicitly dismisses it, so a
second key can never silently overwrite the first one's only-ever-shown
token in React state before it's copied. Revoking is a two-step
confirm-then-revoke per row, with independent busy/confirm state per key
(concurrent revokes on different rows don't clobber each other), and the
list reload is guarded against out-of-order responses (a slower in-flight
fetch can't stomp a newer one's result). Errors map known backend codes
(`not_found`, `operator_auth.unavailable`, `auth.missing`/`auth.invalid`,
`invalid_request`) to translated messages rather than surfacing the raw
response body.

### Added — public API channel: chat over HTTP with per-key auth (#438)

Expand Down
26 changes: 26 additions & 0 deletions docs/middleware-agent-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,14 @@ vom eigenen Server aus aufruft, ohne menschliche Session.
- **`publicPaths.ts` bleibt unverändert eng:** weiterhin nur
`/api/public/v1/chat`. Wer `requireApiKey` auf eine neue Route mountet,
braucht dort einen eigenen, möglichst engen Eintrag.
- **Admin-UI (`web-ui/app/admin/api-keys/`)** — separate, auf diesem Branch
gestackte Web-UI-PR. `ApiKeysPanel.tsx` deckt create/list/revoke gegen
genau die drei Routen oben ab, keine neuen Backend-Endpunkte. `scopes: []`
wird nie gesendet — die Checkbox für `chat:write` muss angehakt bleiben,
sonst bleibt der Create-Button deaktiviert, statt den Footgun aus dem
`CreateKeyRequestSchema`-Kommentar oben zu reproduzieren. Details zum
Reveal-/Revoke-/Fehler-Verhalten stehen direkt nach der Testliste unten,
um Doppelung zu vermeiden.

Tests: `test/auth/requireApiKey.test.ts` (Auth/Scope/Rate-Limit/Audit der
Middleware), `test/auth/apiKeyScopes.test.ts` (Scope-Modell inkl.
Expand All @@ -975,6 +983,24 @@ Kernel kein Channel-Plugin importiert). Die bestehenden `test/channelApi/`-
Suites laufen inhaltlich unverändert weiter, nur die Importpfade der
verschobenen Module zeigen jetzt auf `packages/harness-api-key-auth/`.

**Admin-UI** (`web-ui/app/admin/api-keys/`, Issue #438/#439): Create/List/
Revoke gegen `/api/public/v1/admin/keys` (`ApiKeysPanel.tsx`). Das
Klartext-Token wird — wie beim Webhook-Secret-Reveal — genau einmal direkt
nach dem Create angezeigt, nie erneut aus einem Reload rekonstruiert (die
Listen-Response enthält nie ein Token-Feld). Solange dieser Reveal auf dem
Schirm ist, ist das Erstellen eines weiteren Keys blockiert (Formular
disabled) — sonst würde ein zweiter Create das erste, noch nicht kopierte
Token in React-State kommentarlos überschreiben. Revoke ist zweistufig
(Arm → Confirm) mit Busy-/Confirm-State **pro Key-Id** (Set statt einem
einzelnen globalen Id-String), damit ein gleichzeitiges Revoke auf einer
anderen Zeile den Confirm-/Busy-Zustand dieser Zeile nicht zurücksetzt; der
Listen-Reload trägt eine Sequenznummer, damit ein langsamer, überholter
Fetch nicht das Ergebnis eines neueren überschreibt. Fehler werden über
bekannte Backend-Codes (`not_found`, `operator_auth.unavailable`,
`auth.missing`/`auth.invalid`, `invalid_request`) auf übersetzte
Catalog-Strings gemappt statt den rohen Response-Body anzuzeigen (web-ui
i18n Hard Rule, `web-ui/CLAUDE.md`).

---

## 4. Migration Managed Agents → Lokal
Expand Down
54 changes: 54 additions & 0 deletions web-ui/app/_lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4525,3 +4525,57 @@ export async function listWebhookSubscriptionDeliveries(
): Promise<{ deliveries: ConductorWebhookOutboundDelivery[] }> {
return getJson(`${WEBHOOKS_BASE}/subscriptions/${encodeURIComponent(id)}/deliveries`);
}

// -----------------------------------------------------------------------------
// Public API keys (issues #438/#439) — /api/public/v1/admin/keys.
//
// This router lives in @omadia/channel-api, not under /v1/operator/* like the
// rest of this file's admin surfaces — it is mounted at API_PREFIX
// `/api/public/v1`, gated by the same operator-session cookie via its own
// `operatorAuth` middleware (see adminKeysRouter.ts). getJson/postJson still
// apply here unchanged: same cookie, same 401-bounces-to-/login behavior.
// -----------------------------------------------------------------------------

const API_KEYS_BASE = '/public/v1/admin/keys';

export interface ApiKeyPublicView {
id: string;
label?: string;
rateLimitPerMinute: number;
scopes: string[];
/** Epoch ms. */
createdAt: number;
/** Epoch ms. Present iff the key has been revoked. */
revokedAt?: number;
}

export interface CreateApiKeyInput {
label?: string;
rateLimitPerMinute?: number;
/**
* Omit this field entirely to accept the backend's legacy default
* (`['chat:write']`). An explicitly empty array is REJECTED by the
* backend with 400 — it reads `[]` as a deliberate "grant nothing"
* request, never as "use the default". Callers must never pass `[]`.
*/
scopes?: string[];
}

export interface CreateApiKeyResult {
key: ApiKeyPublicView;
/** Plaintext — present only in this one response. Never returned again by
* any other endpoint; do not persist it beyond the reveal-once UI. */
token: string;
}

export async function listApiKeys(): Promise<{ keys: ApiKeyPublicView[] }> {
return getJson(API_KEYS_BASE);
}

export async function createApiKey(input: CreateApiKeyInput): Promise<CreateApiKeyResult> {
return postJson(API_KEYS_BASE, input);
}

export async function revokeApiKey(id: string): Promise<{ key: ApiKeyPublicView }> {
return postJson(`${API_KEYS_BASE}/${encodeURIComponent(id)}/revoke`, {});
}
Loading
Loading