From 74bbfda2122152259a2e3564f28a4df3e138951f Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 13:53:11 +0200 Subject: [PATCH] feat(ai): Mission Grant consent UI + Workbench audit tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — user-facing side of the Mission Key-Grant rollout. Users can now opt into server-side execution, revoke it, and inspect every decrypt the runner has performed. Webapp: - MissionGrantDialog explains the scope (record count, tables, TTL, audit visibility, revocation) and calls requestMissionGrant. Error paths render distinctly for ZK, not-configured, missing vault. - Mission detail shows a Server-Zugriff box with status pill (aktiv/abgelaufen/nicht erteilt), Neu-erteilen + Zurückziehen buttons. Only renders for missions with at least one encrypted- table input. - store.ts: setMissionGrant / revokeMissionGrant helpers, Proxy- stripped like the rest of the store's writes. - Workbench adds a Timeline/Datenzugriff tab switch. Audit tab queries the new GET /api/v1/me/ai-audit endpoint, renders decrypt events with color-coded status pills (ok/failed/scope-violation) and stable reason strings. - getManaAiUrl() added to api/config for the audit fetch. mana-ai: - GET /api/v1/me/ai-audit (JWT-gated via shared-hono authMiddleware) backed by readDecryptAudit() — withUser + RLS double-gate so a user can only read their own rows. - Limit capped at 1000, newest-first. Missions without a grant continue to work exactly as before; the grant UI is purely additive. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mana/apps/web/src/lib/api/config.ts | 14 + .../components/ai/MissionGrantDialog.svelte | 314 ++++++++++++++++++ .../apps/web/src/lib/data/ai/audit/queries.ts | 45 +++ .../lib/data/ai/missions/grant-client.test.ts | 69 ++++ .../src/lib/data/ai/missions/grant-client.ts | 102 ++++++ .../web/src/lib/data/ai/missions/types.ts | 2 + .../lib/modules/ai-missions/ListView.svelte | 123 +++++++ .../lib/modules/ai-workbench/ListView.svelte | 194 ++++++++++- services/mana-ai/src/db/audit-read.ts | 77 +++++ services/mana-ai/src/index.ts | 20 ++ 10 files changed, 950 insertions(+), 10 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte create mode 100644 apps/mana/apps/web/src/lib/data/ai/audit/queries.ts create mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/grant-client.test.ts create mode 100644 apps/mana/apps/web/src/lib/data/ai/missions/grant-client.ts create mode 100644 services/mana-ai/src/db/audit-read.ts diff --git a/apps/mana/apps/web/src/lib/api/config.ts b/apps/mana/apps/web/src/lib/api/config.ts index 9e4edf751..2e30ba4df 100644 --- a/apps/mana/apps/web/src/lib/api/config.ts +++ b/apps/mana/apps/web/src/lib/api/config.ts @@ -60,6 +60,20 @@ export function getManaCreditsUrl(): string { return process.env.PUBLIC_MANA_CREDITS_URL || 'http://localhost:3061'; } +/** + * Get the mana-ai service URL. + * Hosts the background Mission Runner, decrypt-audit read endpoint, + * Prometheus metrics. + */ +export function getManaAiUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_AI_URL__?: string }) + .__PUBLIC_MANA_AI_URL__; + return injected || 'http://localhost:3066'; + } + return process.env.PUBLIC_MANA_AI_URL || 'http://localhost:3066'; +} + /** * Get the mana-mail service URL. * Hosts mail threads, send, labels, accounts. diff --git a/apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte b/apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte new file mode 100644 index 000000000..02a019baf --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/ai/MissionGrantDialog.svelte @@ -0,0 +1,314 @@ + + + +{#if open} +
e.key === 'Escape' && handleClose()} + role="presentation" + > + + +
+{/if} + + open && e.key === 'Escape' && handleClose()} /> + + diff --git a/apps/mana/apps/web/src/lib/data/ai/audit/queries.ts b/apps/mana/apps/web/src/lib/data/ai/audit/queries.ts new file mode 100644 index 000000000..7ef230bcf --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/audit/queries.ts @@ -0,0 +1,45 @@ +/** + * Queries the mana-ai audit endpoint for server-side decrypt events on + * the user's missions. Powers the "Datenzugriff" tab in the AI Workbench. + * + * The endpoint (GET /api/v1/me/ai-audit) is JWT-gated on mana-ai; rows + * are RLS-scoped so the user only ever sees their own regardless of the + * client parameters. + */ + +import { getManaAiUrl } from '$lib/api/config'; +import { authStore } from '$lib/stores/auth.svelte'; + +export interface AuditRow { + id: string; + missionId: string; + iterationId: string | null; + tableName: string; + recordId: string; + status: 'ok' | 'failed' | 'scope-violation'; + reason: string | null; + ts: string; +} + +export interface FetchAuditParams { + missionId?: string; + limit?: number; +} + +export async function fetchDecryptAudit(params: FetchAuditParams = {}): Promise { + const token = await authStore.getValidToken(); + if (!token) throw new Error('fetchDecryptAudit: no auth token'); + + const url = new URL(`${getManaAiUrl()}/api/v1/me/ai-audit`); + if (params.missionId) url.searchParams.set('missionId', params.missionId); + if (params.limit) url.searchParams.set('limit', String(params.limit)); + + const res = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + throw new Error(`fetchDecryptAudit failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()) as { rows: AuditRow[] }; + return body.rows; +} diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/grant-client.test.ts b/apps/mana/apps/web/src/lib/data/ai/missions/grant-client.test.ts new file mode 100644 index 000000000..d8b6697cc --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/grant-client.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + requestMissionGrant, + ZeroKnowledgeGrantError, + GrantNotConfiguredError, + VaultNotInitialisedError, +} from './grant-client'; + +function mockFetch(status: number, body: unknown): typeof fetch { + return vi.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + statusText: 'stub', + json: async () => body, + })) as unknown as typeof fetch; +} + +const opts = { + authUrl: 'https://auth.test', + getToken: () => 'tok', +}; + +const input = { + missionId: 'm1', + tables: ['notes'], + recordIds: ['notes:n1'], +}; + +describe('requestMissionGrant', () => { + it('returns the grant on 200', async () => { + const grant = { + wrappedKey: 'wk', + derivation: { version: 'v1', missionId: 'm1', tables: ['notes'], recordIds: ['notes:n1'] }, + issuedAt: '2026-04-15T00:00:00.000Z', + expiresAt: '2026-04-22T00:00:00.000Z', + }; + globalThis.fetch = mockFetch(200, grant); + expect(await requestMissionGrant(opts, input)).toEqual(grant); + }); + + it('throws ZeroKnowledgeGrantError on 409 ZK_ACTIVE', async () => { + globalThis.fetch = mockFetch(409, { code: 'ZK_ACTIVE', error: 'zk' }); + await expect(requestMissionGrant(opts, input)).rejects.toBeInstanceOf(ZeroKnowledgeGrantError); + }); + + it('throws GrantNotConfiguredError on 503 GRANT_NOT_CONFIGURED', async () => { + globalThis.fetch = mockFetch(503, { code: 'GRANT_NOT_CONFIGURED' }); + await expect(requestMissionGrant(opts, input)).rejects.toBeInstanceOf(GrantNotConfiguredError); + }); + + it('throws VaultNotInitialisedError on 404 VAULT_NOT_INITIALISED', async () => { + globalThis.fetch = mockFetch(404, { code: 'VAULT_NOT_INITIALISED' }); + await expect(requestMissionGrant(opts, input)).rejects.toBeInstanceOf(VaultNotInitialisedError); + }); + + it('throws a generic Error with status for unrecognised failures', async () => { + globalThis.fetch = mockFetch(400, { code: 'BAD_REQUEST', error: 'missionId required' }); + await expect(requestMissionGrant(opts, input)).rejects.toThrow( + /requestMissionGrant failed: 400 missionId required/ + ); + }); + + it('refuses to call without a token', async () => { + globalThis.fetch = mockFetch(200, {}); + await expect(requestMissionGrant({ ...opts, getToken: () => null }, input)).rejects.toThrow( + /no auth token/ + ); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/grant-client.ts b/apps/mana/apps/web/src/lib/data/ai/missions/grant-client.ts new file mode 100644 index 000000000..ba03f1203 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/ai/missions/grant-client.ts @@ -0,0 +1,102 @@ +/** + * Client for `POST /api/v1/me/ai-mission-grant`. + * + * Issues a Mission Key-Grant so `mana-ai` can decrypt the referenced + * records without the user's browser tab being open. Flow + architecture: + * `docs/plans/ai-mission-key-grant.md` and COMPANION_BRAIN_ARCHITECTURE + * §21. + * + * The client is deliberately stateless — each call issues a fresh grant, + * which the caller attaches to the Mission record via the existing sync + * pipeline. Revocation is just `Mission.grant = null`; there is no + * server-side grant store to delete from. + * + * Failure modes surface as typed errors so the Consent UI can render + * different messages / fallbacks: + * - ZeroKnowledgeGrantError → "grants unavailable in ZK mode, using + * foreground runner" + * - GrantNotConfiguredError → "server hasn't loaded the runner key + * yet — try again later or run locally" + * - VaultNotInitialisedError → shouldn't happen after a successful + * login; if it does, force re-init + * - Generic Error → network / 5xx / validation; UI shows retry + */ + +import type { MissionGrant } from '@mana/shared-ai'; + +export interface GrantClientOptions { + authUrl: string; + getToken: () => Promise | string | null; +} + +export interface RequestGrantInput { + missionId: string; + tables: string[]; + /** Record IDs qualified with their table: `"${table}:${id}"`. The + * mana-ai resolver enforces this shape as the allowlist key. */ + recordIds: string[]; + /** Optional TTL in ms. Server clamps to [1h, 30d]; omitting uses the + * server default (7d). */ + ttlMs?: number; +} + +export class ZeroKnowledgeGrantError extends Error { + constructor() { + super('Mission grants are unavailable in zero-knowledge mode.'); + this.name = 'ZeroKnowledgeGrantError'; + } +} + +export class GrantNotConfiguredError extends Error { + constructor() { + super('Mission grants are not configured on this server.'); + this.name = 'GrantNotConfiguredError'; + } +} + +export class VaultNotInitialisedError extends Error { + constructor() { + super('Encryption vault has not been initialised for this user.'); + this.name = 'VaultNotInitialisedError'; + } +} + +/** + * Issues a fresh grant. Returns the `MissionGrant` blob the caller + * writes onto the Mission record. Throws on all error paths — never + * returns an incomplete or invalid grant. + */ +export async function requestMissionGrant( + opts: GrantClientOptions, + input: RequestGrantInput +): Promise { + const token = await opts.getToken(); + if (!token) throw new Error('requestMissionGrant: no auth token'); + + const res = await fetch(`${opts.authUrl}/api/v1/me/ai-mission-grant`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + + if (res.ok) { + return (await res.json()) as MissionGrant; + } + + // Drain the error body once so we can branch on `code`. + const body = (await res.json().catch(() => null)) as { code?: string; error?: string } | null; + + switch (body?.code) { + case 'ZK_ACTIVE': + throw new ZeroKnowledgeGrantError(); + case 'GRANT_NOT_CONFIGURED': + throw new GrantNotConfiguredError(); + case 'VAULT_NOT_INITIALISED': + throw new VaultNotInitialisedError(); + default: + throw new Error(`requestMissionGrant failed: ${res.status} ${body?.error ?? res.statusText}`); + } +} diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/types.ts b/apps/mana/apps/web/src/lib/data/ai/missions/types.ts index 38aa93cf2..ebdc89218 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/types.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/types.ts @@ -15,6 +15,8 @@ export type { MissionInputRef, MissionIteration, MissionState, + MissionGrant, + GrantDerivation, PlanStep, } from '@mana/shared-ai'; diff --git a/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte index e62123348..1fa229b7c 100644 --- a/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/ai-missions/ListView.svelte @@ -13,10 +13,12 @@ completeMission, deleteMission, addIterationFeedback, + revokeMissionGrant, } from '$lib/data/ai/missions/store'; import { runMission } from '$lib/data/ai/missions/runner'; import { productionDeps } from '$lib/data/ai/missions/setup'; import MissionInputPicker from '$lib/components/ai/MissionInputPicker.svelte'; + import MissionGrantDialog from '$lib/components/ai/MissionGrantDialog.svelte'; import type { Mission, MissionCadence, MissionInputRef } from '$lib/data/ai/missions/types'; const missions = $derived(useMissions()); @@ -93,6 +95,27 @@ feedbackDraft = ''; } + // ── Key-Grant (server-side execution) ────────────────── + const ENCRYPTED_SERVER_TABLES = new Set([ + 'notes', + 'tasks', + 'events', + 'journalEntries', + 'kontextDoc', + ]); + function hasEncryptedInputs(m: Mission): boolean { + return m.inputs.some((i) => ENCRYPTED_SERVER_TABLES.has(i.table)); + } + function grantStatus(m: Mission): 'none' | 'active' | 'expired' { + if (!m.grant) return 'none'; + return Date.parse(m.grant.expiresAt) < Date.now() ? 'expired' : 'active'; + } + function formatGrantExpiry(m: Mission): string { + if (!m.grant) return '—'; + return formatRelative(m.grant.expiresAt); + } + let grantDialogOpen = $state(false); + function describeCadence(c: MissionCadence): string { switch (c.kind) { case 'manual': @@ -282,6 +305,41 @@ {/if} + {#if hasEncryptedInputs(selected)} +
+
+ 🔑 Server-Zugriff + {#if grantStatus(selected) === 'active'} + aktiv · läuft ab {formatGrantExpiry(selected)} + {:else if grantStatus(selected) === 'expired'} + abgelaufen + {:else} + nicht erteilt + {/if} +
+

+ {#if grantStatus(selected) === 'active'} + Läuft autonom; Zugriff kann jederzeit zurückgezogen werden. + {:else} + Ohne Zugriff läuft die Mission nur bei offenem Tab. + {/if} +

+
+ {#if selected.grant} + + {/if} + +
+
+ + {/if} +

Iterationen

{#if selected.iterations.length === 0}

Noch keine Iteration gelaufen.

@@ -581,4 +639,69 @@ background: hsl(var(--color-surface)); cursor: pointer; } + .grant-box { + padding: 0.75rem 0.875rem; + border: 1px solid hsl(var(--color-border)); + border-radius: 0.5rem; + background: color-mix(in oklab, hsl(var(--color-primary)) 4%, hsl(var(--color-surface))); + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .grant-head { + display: flex; + justify-content: space-between; + align-items: center; + } + .grant-title { + font-weight: 600; + font-size: 0.8125rem; + } + .grant-pill { + font-size: 0.6875rem; + padding: 0.125rem 0.5rem; + border-radius: 999px; + } + .grant-pill-ok { + background: #d7f7e3; + color: #1b7a3a; + } + .grant-pill-warn { + background: #fde7c8; + color: #8a4f00; + } + .grant-pill-muted { + background: hsl(var(--color-surface)); + color: hsl(var(--color-muted-foreground)); + border: 1px solid hsl(var(--color-border)); + } + .grant-note { + margin: 0; + font-size: 0.75rem; + color: hsl(var(--color-muted-foreground)); + } + .grant-actions { + display: flex; + gap: 0.375rem; + justify-content: flex-end; + flex-wrap: wrap; + } + .grant-actions .btn-ghost, + .grant-actions .btn-primary { + padding: 0.25rem 0.625rem; + border-radius: 0.375rem; + font: inherit; + font-size: 0.75rem; + cursor: pointer; + } + .grant-actions .btn-ghost { + border: 1px solid hsl(var(--color-border)); + background: hsl(var(--color-surface)); + color: hsl(var(--color-foreground)); + } + .grant-actions .btn-primary { + border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 45%, transparent); + background: color-mix(in oklab, hsl(var(--color-primary)) 18%, hsl(var(--color-surface))); + color: hsl(var(--color-primary)); + } diff --git a/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte b/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte index b82f0716e..9b18b0bb5 100644 --- a/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/ai-workbench/ListView.svelte @@ -7,6 +7,7 @@ import { useAiTimeline, bucketByIteration } from '$lib/data/ai/timeline/queries'; import { useMissions } from '$lib/data/ai/missions/queries'; import { revertIteration } from '$lib/data/ai/revert/revert-iteration'; + import { fetchDecryptAudit, type AuditRow } from '$lib/data/ai/audit/queries'; import type { DomainEvent } from '$lib/data/events/types'; let moduleFilter = $state(null); @@ -39,6 +40,45 @@ return new Date(iso).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' }); } + // ── Tab switcher: timeline ↔ decrypt audit ───────────── + let tab = $state<'timeline' | 'audit'>('timeline'); + let auditRows = $state([]); + let auditLoading = $state(false); + let auditError = $state(null); + + async function loadAudit() { + auditLoading = true; + auditError = null; + try { + auditRows = await fetchDecryptAudit({ + missionId: missionFilter ?? undefined, + limit: 500, + }); + } catch (err) { + auditError = err instanceof Error ? err.message : String(err); + auditRows = []; + } finally { + auditLoading = false; + } + } + + // Reload audit when user switches to audit tab OR changes mission filter + // while on it. No effect while on timeline — no need to poll. + $effect(() => { + if (tab === 'audit') { + void loadAudit(); + } + }); + + function formatAuditTs(iso: string): string { + return new Date(iso).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + } + let revertingKey = $state(null); async function handleRevert(key: string, missionId: string, iterationId: string) { if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return; @@ -59,16 +99,41 @@
+
+ + +
+
- + {#if tab === 'timeline'} + + {/if}