mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
4b29f6d293
commit
74bbfda212
10 changed files with 950 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
45
apps/mana/apps/web/src/lib/data/ai/audit/queries.ts
Normal file
45
apps/mana/apps/web/src/lib/data/ai/audit/queries.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
102
apps/mana/apps/web/src/lib/data/ai/missions/grant-client.ts
Normal file
102
apps/mana/apps/web/src/lib/data/ai/missions/grant-client.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ export type {
|
|||
MissionInputRef,
|
||||
MissionIteration,
|
||||
MissionState,
|
||||
MissionGrant,
|
||||
GrantDerivation,
|
||||
PlanStep,
|
||||
} from '@mana/shared-ai';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
77
services/mana-ai/src/db/audit-read.ts
Normal file
77
services/mana-ai/src/db/audit-read.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue