feat(ai): Mission Grant consent UI + Workbench audit tab

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 13:53:11 +02:00
parent 4b29f6d293
commit 74bbfda212
10 changed files with 950 additions and 10 deletions

View file

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

View file

@ -0,0 +1,314 @@
<!--
MissionGrantDialog
==================
Consent dialog shown when a mission (a) has at least one encrypted-table
input AND (b) runs autonomously (i.e. anything other than manual
cadence). Explains the scope, lets the user pick "yes, issue key-grant"
or "no, foreground only", and calls through to `requestMissionGrant` +
`setMissionGrant` on approval.
Surfaces the three error paths distinctly:
- ZK_ACTIVE → "not available in zero-knowledge mode"
- NOT_CONFIGURED→ "server can't issue grants right now"
- network / 5xx → retry button
The dialog is intentionally no-framework: plain <dialog> + flat state,
driven by a `bind:open` prop so the caller controls visibility without
a store. Matches the pattern used by the other confirm-style dialogs
in `$lib/components`.
-->
<script lang="ts">
import type { Mission } from '$lib/data/ai/missions/types';
import {
requestMissionGrant,
ZeroKnowledgeGrantError,
GrantNotConfiguredError,
VaultNotInitialisedError,
} from '$lib/data/ai/missions/grant-client';
import { setMissionGrant } from '$lib/data/ai/missions/store';
import { getManaAuthUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
/**
* Tables the webapp's encryption registry flags as encrypted AND the
* mana-ai server-side resolver knows how to handle. Keep this list in
* sync with `services/mana-ai/src/db/resolvers/index.ts`. A mission
* referencing any of these tables triggers the dialog.
*/
const ENCRYPTED_SERVER_TABLES = new Set([
'notes',
'tasks',
'events',
'journalEntries',
'kontextDoc',
]);
interface Props {
/** Mission to issue the grant for. Required — the dialog reads
* inputs / id / title off it. */
mission: Mission;
/** Bound visibility. Caller sets to true to open; dialog flips
* back to false on close / approve / decline. */
open: boolean;
/** Optional callback fired after a successful grant is persisted
* on the mission record. */
onGranted?: () => void;
/** Optional callback when the user explicitly declines server-side
* execution. The mission stays foreground-only. */
onDeclined?: () => void;
}
let { mission, open = $bindable(false), onGranted, onDeclined }: Props = $props();
type Status =
| { kind: 'idle' }
| { kind: 'submitting' }
| { kind: 'error'; message: string; code?: 'zk' | 'not-configured' | 'vault' | 'other' };
let status = $state<Status>({ kind: 'idle' });
const encryptedInputs = $derived(
mission.inputs.filter((i) => ENCRYPTED_SERVER_TABLES.has(i.table))
);
const tables = $derived([...new Set(encryptedInputs.map((i) => i.table))]);
const recordIds = $derived(encryptedInputs.map((i) => `${i.table}:${i.id}`));
async function handleApprove() {
status = { kind: 'submitting' };
try {
const grant = await requestMissionGrant(
{
authUrl: getManaAuthUrl(),
getToken: () => authStore.getValidToken(),
},
{
missionId: mission.id,
tables,
recordIds,
}
);
await setMissionGrant(mission.id, grant);
status = { kind: 'idle' };
open = false;
onGranted?.();
} catch (err) {
if (err instanceof ZeroKnowledgeGrantError) {
status = {
kind: 'error',
code: 'zk',
message:
'Im Zero-Knowledge-Modus kann der Server keinen Schlüssel erhalten. Die Mission läuft weiterhin nur bei offenem Tab.',
};
return;
}
if (err instanceof GrantNotConfiguredError) {
status = {
kind: 'error',
code: 'not-configured',
message:
'Der Server unterstützt Mission-Grants momentan nicht. Versuche es später erneut oder nutze den Vordergrund-Runner.',
};
return;
}
if (err instanceof VaultNotInitialisedError) {
status = {
kind: 'error',
code: 'vault',
message:
'Dein Verschlüsselungs-Vault ist nicht initialisiert. Melde dich einmal ab und wieder an.',
};
return;
}
status = {
kind: 'error',
code: 'other',
message: err instanceof Error ? err.message : String(err),
};
}
}
function handleDecline() {
status = { kind: 'idle' };
open = false;
onDeclined?.();
}
function handleClose() {
if (status.kind === 'submitting') return;
open = false;
}
</script>
{#if open}
<div
class="scrim-wrap"
onkeydown={(e) => e.key === 'Escape' && handleClose()}
role="presentation"
>
<button type="button" class="scrim" aria-label="Dialog schliessen" onclick={handleClose}
></button>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="grant-dialog-title">
<h2 id="grant-dialog-title">🔑 Server-Zugriff für diese Mission?</h2>
<p class="lede">
Damit <strong>{mission.title}</strong> auch ohne offenen Browser-Tab laufen kann, braucht der
AI-Runner einmalig einen eng-gescopten Entschlüsselungs-Schlüssel.
</p>
<section class="scope">
<h3>Was sieht der Server?</h3>
<ul>
<li>
<strong>{encryptedInputs.length}</strong> verschlüsselte Record(s) aus:
<code>{tables.join(', ')}</code>
</li>
<li>Nur diese Records — kein Zugriff auf andere Tabellen oder andere Einträge.</li>
<li>
Schlüssel ist <strong>7 Tage</strong> gültig und wird bei jedem erfolgreichen Lauf erneuert.
</li>
<li>Jeder Zugriff wird geloggt — einsehbar in "Workbench → Datenzugriff".</li>
<li>Du kannst den Zugriff jederzeit mit einem Klick zurückziehen.</li>
</ul>
</section>
{#if status.kind === 'error'}
<div class="error" role="alert">
<strong>Konnte Grant nicht erstellen.</strong>
<p>{status.message}</p>
</div>
{/if}
<footer class="actions">
<button
type="button"
class="btn-ghost"
onclick={handleDecline}
disabled={status.kind === 'submitting'}
>
Nur bei offenem Tab ausführen
</button>
<button
type="button"
class="btn-primary"
onclick={handleApprove}
disabled={status.kind === 'submitting'}
>
{status.kind === 'submitting' ? 'Erstelle Schlüssel…' : 'Verstanden, Zugriff erteilen'}
</button>
</footer>
</div>
</div>
{/if}
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && handleClose()} />
<style>
.scrim-wrap {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.scrim {
position: absolute;
inset: 0;
border: none;
background: rgba(0, 0, 0, 0.45);
cursor: pointer;
}
.panel {
position: relative;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
padding: 1.25rem 1.5rem;
max-width: 32rem;
width: 100%;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.0625rem;
}
.lede {
margin: 0 0 1rem;
font-size: 0.9375rem;
line-height: 1.4;
}
.scope {
margin: 0 0 1rem;
padding: 0.75rem 1rem;
background: color-mix(in oklab, hsl(var(--color-primary)) 6%, hsl(var(--color-surface)));
border-radius: 0.5rem;
border: 1px solid color-mix(in oklab, hsl(var(--color-primary)) 25%, transparent);
}
.scope h3 {
margin: 0 0 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.scope ul {
margin: 0;
padding-left: 1.125rem;
font-size: 0.8125rem;
line-height: 1.5;
}
.scope li + li {
margin-top: 0.125rem;
}
.scope code {
background: hsl(var(--color-surface));
padding: 0.0625rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
}
.error {
margin: 0 0 1rem;
padding: 0.625rem 0.75rem;
background: color-mix(in oklab, hsl(var(--color-error)) 10%, transparent);
color: hsl(var(--color-error));
border: 1px solid color-mix(in oklab, hsl(var(--color-error)) 35%, transparent);
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.error p {
margin: 0.25rem 0 0;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-ghost,
.btn-primary {
padding: 0.5rem 0.875rem;
border-radius: 0.375rem;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.btn-ghost {
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
}
.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));
}
.btn-ghost:disabled,
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View file

@ -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<AuditRow[]> {
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;
}

View file

@ -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/
);
});
});

View file

@ -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> | 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<MissionGrant> {
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}`);
}
}

View file

@ -15,6 +15,8 @@ export type {
MissionInputRef,
MissionIteration,
MissionState,
MissionGrant,
GrantDerivation,
PlanStep,
} from '@mana/shared-ai';

View file

@ -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 @@
</details>
{/if}
{#if hasEncryptedInputs(selected)}
<section class="grant-box">
<div class="grant-head">
<span class="grant-title">🔑 Server-Zugriff</span>
{#if grantStatus(selected) === 'active'}
<span class="grant-pill grant-pill-ok"
>aktiv · läuft ab {formatGrantExpiry(selected)}</span
>
{:else if grantStatus(selected) === 'expired'}
<span class="grant-pill grant-pill-warn">abgelaufen</span>
{:else}
<span class="grant-pill grant-pill-muted">nicht erteilt</span>
{/if}
</div>
<p class="grant-note">
{#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}
</p>
<div class="grant-actions">
{#if selected.grant}
<button type="button" class="btn-ghost" onclick={() => revokeMissionGrant(selected.id)}>
Zugriff zurückziehen
</button>
{/if}
<button type="button" class="btn-primary" onclick={() => (grantDialogOpen = true)}>
{selected.grant ? 'Neu erteilen' : 'Zugriff erteilen'}
</button>
</div>
</section>
<MissionGrantDialog mission={selected} bind:open={grantDialogOpen} />
{/if}
<h3 class="section-title">Iterationen</h3>
{#if selected.iterations.length === 0}
<p class="empty">Noch keine Iteration gelaufen.</p>
@ -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));
}
</style>

View file

@ -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<string | null>(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<AuditRow[]>([]);
let auditLoading = $state(false);
let auditError = $state<string | null>(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<string | null>(null);
async function handleRevert(key: string, missionId: string, iterationId: string) {
if (!confirm('Alle AI-Writes dieser Iteration zurücknehmen?')) return;
@ -59,16 +99,41 @@
</script>
<div class="wb">
<div class="tabs" role="tablist">
<button
type="button"
role="tab"
class="tab"
class:tab-active={tab === 'timeline'}
aria-selected={tab === 'timeline'}
onclick={() => (tab = 'timeline')}
>
Timeline
</button>
<button
type="button"
role="tab"
class="tab"
class:tab-active={tab === 'audit'}
aria-selected={tab === 'audit'}
onclick={() => (tab = 'audit')}
>
Datenzugriff
</button>
</div>
<div class="filters">
<label>
<span class="lbl">Modul</span>
<select bind:value={moduleFilter}>
<option value={null}>alle</option>
{#each allModules as m}
<option value={m}>{m}</option>
{/each}
</select>
</label>
{#if tab === 'timeline'}
<label>
<span class="lbl">Modul</span>
<select bind:value={moduleFilter}>
<option value={null}>alle</option>
{#each allModules as m}
<option value={m}>{m}</option>
{/each}
</select>
</label>
{/if}
<label>
<span class="lbl">Mission</span>
<select bind:value={missionFilter}>
@ -80,7 +145,43 @@
</label>
</div>
{#if buckets.length === 0}
{#if tab === 'audit'}
{#if auditLoading}
<p class="empty">Lade Audit…</p>
{:else if auditError}
<p class="empty error">Fehler: {auditError}</p>
{:else if auditRows.length === 0}
<p class="empty">
Keine serverseitigen Entschlüsselungen. Der mana-ai Runner hat für diese Mission noch keine
Records gelesen — entweder ist kein Key-Grant erteilt, oder die Mission nutzt nur plaintext
Inputs (goals).
</p>
{:else}
<table class="audit-table">
<thead>
<tr>
<th>Zeit</th>
<th>Mission</th>
<th>Record</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each auditRows as r (r.id)}
<tr class="audit-{r.status}">
<td class="audit-ts">{formatAuditTs(r.ts)}</td>
<td>{missionTitleById.get(r.missionId) ?? r.missionId}</td>
<td><code>{r.tableName}:{r.recordId}</code></td>
<td>
<span class="audit-pill audit-pill-{r.status}">{r.status}</span>
{#if r.reason}<span class="audit-reason">{r.reason}</span>{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{:else if buckets.length === 0}
<p class="empty">
Noch keine AI-Aktivität. Sobald eine Mission läuft und Proposals approved werden, erscheinen
hier die Änderungen.
@ -136,6 +237,79 @@
gap: 0.75rem;
padding: 0.75rem 1rem 1.25rem;
}
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid hsl(var(--color-border));
}
.tab {
border: none;
background: transparent;
padding: 0.375rem 0.75rem;
font: inherit;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tab-active {
color: hsl(var(--color-foreground));
border-bottom-color: hsl(var(--color-primary));
font-weight: 600;
}
.audit-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.audit-table th,
.audit-table td {
text-align: left;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.audit-table th {
font-weight: 600;
font-size: 0.6875rem;
text-transform: uppercase;
color: hsl(var(--color-muted-foreground));
}
.audit-table code {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.audit-ts {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.audit-pill {
display: inline-block;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
}
.audit-pill-ok {
background: #d7f7e3;
color: #1b7a3a;
}
.audit-pill-failed {
background: #fde7c8;
color: #8a4f00;
}
.audit-pill-scope-violation {
background: #f7d7d7;
color: #8a1b1b;
}
.audit-reason {
margin-left: 0.25rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.error {
color: hsl(var(--color-error));
}
.filters {
display: flex;
gap: 0.75rem;

View file

@ -0,0 +1,77 @@
/**
* Read side of `mana_ai.decrypt_audit`. Returns rows for one user +
* (optionally) one mission, newest first.
*
* RLS-scoped via `withUser` even if the caller forgets the WHERE
* clause, the policy wouldn't expose another user's rows. The explicit
* `user_id = $1` clause is belt + braces.
*/
import type { Sql } from './connection';
import { withUser } from './connection';
export interface DecryptAuditRow {
id: string;
missionId: string;
iterationId: string | null;
tableName: string;
recordId: string;
status: 'ok' | 'failed' | 'scope-violation';
reason: string | null;
ts: string;
}
export interface ReadDecryptAuditParams {
missionId?: string;
/** Caller-controlled upper bound. Hard-capped at 1000 so a runaway
* client can't OOM us. */
limit?: number;
}
export async function readDecryptAudit(
sql: Sql,
userId: string,
params: ReadDecryptAuditParams = {}
): Promise<DecryptAuditRow[]> {
const limit = Math.max(1, Math.min(1000, params.limit ?? 200));
return withUser(sql, userId, async (tx) => {
const rows = params.missionId
? await tx<AuditRowRaw[]>`
SELECT id, mission_id, iteration_id, table_name, record_id, status, reason, ts
FROM mana_ai.decrypt_audit
WHERE user_id = ${userId} AND mission_id = ${params.missionId}
ORDER BY ts DESC
LIMIT ${limit}
`
: await tx<AuditRowRaw[]>`
SELECT id, mission_id, iteration_id, table_name, record_id, status, reason, ts
FROM mana_ai.decrypt_audit
WHERE user_id = ${userId}
ORDER BY ts DESC
LIMIT ${limit}
`;
return rows.map((r) => ({
id: String(r.id),
missionId: r.mission_id,
iterationId: r.iteration_id,
tableName: r.table_name,
recordId: r.record_id,
status: r.status as DecryptAuditRow['status'],
reason: r.reason,
ts: r.ts instanceof Date ? r.ts.toISOString() : String(r.ts),
}));
});
}
interface AuditRowRaw {
id: number | string;
mission_id: string;
iteration_id: string | null;
table_name: string;
record_id: string;
status: string;
reason: string | null;
ts: Date | string;
}

View file

@ -11,6 +11,7 @@
*/
import { Hono } from 'hono';
import { authMiddleware } from '@mana/shared-hono';
import { loadConfig } from './config';
import { closeSql, getSql } from './db/connection';
import { migrate } from './db/migrate';
@ -18,6 +19,7 @@ import { runTickOnce, startTick, stopTick, isTickRunning } from './cron/tick';
import { serviceAuth } from './middleware/service-auth';
import { register, httpRequestsTotal, httpRequestDuration } from './metrics';
import { configureMissionGrantKey } from './crypto/unwrap-grant';
import { readDecryptAudit } from './db/audit-read';
const config = loadConfig();
@ -67,6 +69,24 @@ app.post('/internal/tick', async (c) => {
return c.json(stats);
});
// ─── User-facing audit read ─────────────────────────────────
// JWT-gated. Powers the "Mission → Datenzugriff" tab in the webapp
// Workbench. RLS + withUser ensure the caller only ever sees their own
// rows even if they try to swap in another userId client-side.
app.use('/api/v1/me/ai-audit', authMiddleware());
app.get('/api/v1/me/ai-audit', async (c) => {
const userId = (c.get as (key: string) => string)('userId');
const missionId = c.req.query('missionId') ?? undefined;
const limitRaw = c.req.query('limit');
const limit = limitRaw ? parseInt(limitRaw, 10) : undefined;
const rows = await readDecryptAudit(getSql(config.syncDatabaseUrl), userId, {
missionId,
limit: Number.isFinite(limit) ? limit : undefined,
});
return c.json({ rows });
});
const stopScheduledTick = startTick(config);
const server = Bun.serve({