feat(mana-ai): encrypted resolver + tick uses Mission Grant to decrypt scoped inputs

Phase 2 of Mission Key-Grant. The tick loop now honours a mission's
grant by unwrapping the MDK and passing it + the record allowlist into
the resolvers. Encrypted modules (notes, tasks, calendar, journal,
kontext) resolve server-side instead of returning null.

- crypto/decrypt-value.ts: mirror of webapp AES-GCM wire format
  (enc:1:<iv>.<ct>) — read-only, server never wraps
- db/resolvers/encrypted.ts: factory + 5 concrete resolvers. Scope-
  violation bumps a metric + writes a structured audit row, decrypt
  failures same. Zero-decrypt (no grant, or record absent) = silent
  null, no audit noise.
- db/audit.ts: best-effort append to mana_ai.decrypt_audit; write
  failures never cascade into tick failures.
- cron/tick.ts: buildResolverContext unwraps grant per mission; MDK
  reference only lives for the scope of planOneMission.
- ResolverContext plumbed through resolveServerInputs; existing goals
  resolver unchanged semantically.
- Metrics: mana_ai_decrypts_total{table}, mana_ai_grant_skips_total
  {reason}, mana_ai_grant_scope_violations_total{table} (alert > 0).

Missions without a grant still run exactly as before — plaintext
resolvers fire, encrypted ones short-circuit to null. No behaviour
regression for existing users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 13:42:31 +02:00
parent 9a3025fed8
commit a6d51afbc9
13 changed files with 818 additions and 21 deletions

View file

@ -30,10 +30,35 @@ What works end-to-end:
also blackbox-probed and surfaces on **status.mana.how** under
"Internal" as "Mana AI Runner".
All roadmap items shipped. Future polish (not blockers):
All v0.3 roadmap items shipped. Future polish (not blockers):
- Multi-instance deploy with advisory locks on snapshot refresh (today single-process)
- Read-only `/internal/missions/:userId` endpoint for ops inspection
## Status: v0.4 (Mission Key-Grants, in Arbeit)
Opt-in Mechanismus zum Entschluesseln der encrypted Input-Tabellen (notes, tasks, events, journal, kontext) serverseitig. Plan: [`docs/plans/ai-mission-key-grant.md`](../../docs/plans/ai-mission-key-grant.md). Architektur: [`docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md` §21](../../docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md).
Was steht (Phase 0-2, Backend):
- [x] RSA-OAEP-2048 keypair slots — `MANA_AI_PRIVATE_KEY_PEM` (ai) / `MANA_AI_PUBLIC_KEY_PEM` (auth). Ohne Env-Var laeuft der Service unveraendert; Grants werden dann einfach uebersprungen.
- [x] Canonical HKDF in `@mana/shared-ai` (`missions/grant.ts`). Scope-Binding (tables + recordIds) via `info`-String → Scope-Change = neuer Key = existierender Grant automatisch invalidiert.
- [x] `POST /api/v1/me/ai-mission-grant` auf mana-auth — leitet MDK ab, RSA-wrapped, lehnt Zero-Knowledge-User ab, TTL-clamped [1h, 30d].
- [x] `mana_ai.decrypt_audit` Tabelle + RLS (`user_scope` via `app.current_user_id`). Append-only.
- [x] `crypto/unwrap-grant.ts` — Private-Key-Import, Grant-Entwrapping mit structured reasons (`not-configured` / `expired` / `wrap-rejected` / `malformed`).
- [x] `crypto/decrypt-value.ts` — Mirror des webapp AES-GCM wire format (`enc:1:<iv>.<ct>`).
- [x] Encrypted Resolver (`db/resolvers/encrypted.ts`) fuer notes / tasks / calendar / journal / kontext. Checkt recordId-Allowlist, replayt Record, entschluesselt `enc:1:`-Felder, schreibt Audit-Row pro Record.
- [x] Tick-Loop-Integration (`cron/tick.ts`) — unwrappt Grant pro Mission, baut `ResolverContext` mit `mdk + allowlist`, Key lebt nur waehrend `planOneMission`.
- [x] Metriken: `mana_ai_decrypts_total{table}`, `mana_ai_grant_scope_violations_total{table}` (Alert > 0!), `mana_ai_grant_skips_total{reason}`.
Was offen ist (Phase 3, Frontend):
- [ ] Webapp `MissionGrantDialog` + Consent-Flow im `/companion/missions`-Editor.
- [ ] Revoke-Button + "Mission → Datenzugriff" Audit-Tab in `/companion/workbench`.
- [ ] Scope-Change-UX: neue Records → Re-Consent-Prompt.
- [ ] `GET /internal/audit?missionId=` Endpoint (read-only) fuer die UI.
- [ ] Feature-Flag `PUBLIC_AI_MISSION_GRANTS=false` default + Rollout (till → beta → alpha).
- [ ] Produktions-Keypair generieren + in Mac-Mini Secrets ablegen.
## Port: 3066
## Tech Stack

View file

@ -37,7 +37,10 @@ import {
snapshotsNewTotal,
snapshotsUpdatedTotal,
snapshotRowsAppliedTotal,
grantSkipsTotal,
} from '../metrics';
import { unwrapMissionGrant } from '../crypto/unwrap-grant';
import type { ResolverContext } from '../db/resolvers/types';
import type { Config } from '../config';
export interface TickStats {
@ -161,10 +164,15 @@ async function planOneMission(
sql: Sql
): Promise<AiPlanOutput | null> {
const mission = serverMissionToSharedMission(m);
// Resolvers skip silently for modules they don't handle (notes / kontext
// etc. are encrypted — server can't project them). The Planner then sees
// only plaintext-safe context (today: goals), plus concept + objective.
const resolvedInputs = await resolveServerInputs(sql, m.inputs, m.userId);
// Resolve the mission's Key-Grant (if any) once per tick. An absent
// grant is NOT an error — plaintext missions (goals-only) run fine
// without one; encrypted-input missions degrade to "null inputs" and
// the foreground runner takes over. A present-but-expired / -malformed
// grant bumps a metric and otherwise behaves the same. The MDK never
// leaves this function's scope; after planning finishes the CryptoKey
// reference goes out of scope and gets GC'd.
const context = await buildResolverContext(m);
const resolvedInputs = await resolveServerInputs(sql, m.inputs, m.userId, context);
const input: AiPlanInput = {
mission,
resolvedInputs,
@ -183,6 +191,32 @@ async function planOneMission(
return parsed.value;
}
/**
* Build the per-mission ResolverContext. Extracted so the tick flow
* stays readable and so unit tests can drive it directly.
*
* For a mission without a grant, the context has no MDK and no
* allowlist encrypted resolvers return null for their refs, plaintext
* resolvers run unchanged. For a mission WITH a grant, we try to unwrap
* and build an allowlist; failures bump a metric but never throw.
*/
async function buildResolverContext(m: ServerMission): Promise<ResolverContext> {
if (!m.grant) return { missionId: m.id };
const unwrap = await unwrapMissionGrant(m.grant);
if (!unwrap.ok) {
grantSkipsTotal.inc({ reason: unwrap.reason });
console.warn(`[mana-ai tick] mission=${m.id} grant unwrap skipped: reason=${unwrap.reason}`);
return { missionId: m.id };
}
return {
missionId: m.id,
mdk: unwrap.mdk,
allowlist: new Set(m.grant.derivation.recordIds),
};
}
/**
* Projection shared-ai Mission shape. The projection leaves a few
* fields as `unknown` because the server doesn't need to interpret them

View file

@ -0,0 +1,127 @@
/**
* Round-trip test between the webapp's wrap format and mana-ai's unwrap.
*
* The webapp's wire format is documented in
* `apps/mana/apps/web/src/lib/data/crypto/aes.ts`. We re-implement the
* wrap side here (server-side we never wrap, but the test needs to
* produce the wire format somehow) and assert the unwrap result matches
* the original value.
*/
import { describe, it, expect } from 'bun:test';
import { isEncrypted, unwrapValue, decryptRecordFields } from './decrypt-value';
const ENC_PREFIX = 'enc:1:';
async function webappWrap(value: unknown, key: CryptoKey): Promise<unknown> {
if (value === null || value === undefined) return value;
const json = JSON.stringify(value);
const plaintext = new TextEncoder().encode(json);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
key,
toBufferSource(plaintext)
);
return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct));
}
async function freshKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
describe('isEncrypted', () => {
it('recognises the prefix', () => {
expect(isEncrypted('enc:1:abc.def')).toBe(true);
expect(isEncrypted('plain string')).toBe(false);
expect(isEncrypted(null)).toBe(false);
expect(isEncrypted(undefined)).toBe(false);
expect(isEncrypted(42)).toBe(false);
});
});
describe('unwrapValue', () => {
it('round-trips strings', async () => {
const key = await freshKey();
const blob = await webappWrap('hello world', key);
expect(await unwrapValue(blob, key)).toBe('hello world');
});
it('round-trips arrays and objects (same JSON envelope as webapp)', async () => {
const key = await freshKey();
const arr = ['a', 1, { nested: true }];
const blobArr = await webappWrap(arr, key);
expect(await unwrapValue(blobArr, key)).toEqual(arr);
const obj = { title: 'x', tags: ['y', 'z'] };
const blobObj = await webappWrap(obj, key);
expect(await unwrapValue(blobObj, key)).toEqual(obj);
});
it('passes through null, undefined, non-strings', async () => {
const key = await freshKey();
expect(await unwrapValue(null, key)).toBe(null);
expect(await unwrapValue(undefined, key)).toBe(undefined);
expect(await unwrapValue(42, key)).toBe(42);
expect(await unwrapValue('plain', key)).toBe('plain');
});
it('throws on tampered ciphertext', async () => {
const key = await freshKey();
const blob = (await webappWrap('secret', key)) as string;
// Flip a byte in the ciphertext part (after the dot).
const dot = blob.indexOf('.');
const tampered = blob.slice(0, dot + 1) + 'A' + blob.slice(dot + 2);
await expect(unwrapValue(tampered, key)).rejects.toThrow();
});
it('throws on malformed prefix', async () => {
const key = await freshKey();
await expect(unwrapValue('enc:1:nodot', key)).rejects.toThrow(/malformed/);
});
it('throws on wrong key', async () => {
const k1 = await freshKey();
const k2 = await freshKey();
const blob = await webappWrap('secret', k1);
await expect(unwrapValue(blob, k2)).rejects.toThrow();
});
});
describe('decryptRecordFields', () => {
it('decrypts only enc: fields and reports which', async () => {
const key = await freshKey();
const record = {
id: 'r1',
title: await webappWrap('Private Title', key),
content: await webappWrap({ markdown: 'secret' }, key),
createdAt: '2026-04-15',
userId: 'u1',
};
const { record: out, decryptedFields } = await decryptRecordFields(record, key);
expect(out.title).toBe('Private Title');
expect(out.content).toEqual({ markdown: 'secret' });
expect(out.id).toBe('r1');
expect(out.createdAt).toBe('2026-04-15');
expect(decryptedFields.sort()).toEqual(['content', 'title']);
});
it('is a no-op for records with no encrypted fields', async () => {
const key = await freshKey();
const record = { id: 'r1', value: 42, period: 'week' };
const { decryptedFields } = await decryptRecordFields(record, key);
expect(decryptedFields).toEqual([]);
});
});
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}

View file

@ -0,0 +1,95 @@
/**
* AES-GCM unwrap server-side mirror of the webapp's `aes.ts#unwrapValue`.
*
* Same wire format:
* `enc:1:${base64(iv)}.${base64(ct)}`
*
* The webapp's pipeline JSON-stringifies every value before encryption, so
* we JSON.parse after decryption and the type round-trips. Non-prefixed
* values pass through unchanged, matching the webapp's "safe to apply
* unconditionally" semantics.
*
* Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed
* blobs, wrong key. The resolver catches and converts into audit rows.
*
* The companion `wrapValue` is intentionally NOT implemented here
* mana-ai never writes encrypted fields; it only reads them. Keeping the
* surface read-only makes it obvious in review that this service cannot
* corrupt user data cryptographically.
*/
const ENC_PREFIX = 'enc:1:';
const IV_LENGTH = 12;
export function isEncrypted(value: unknown): boolean {
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
}
/**
* Decrypts a wire-format blob back to its original JSON value. For non-
* encrypted inputs (null, non-strings, strings without the prefix) it
* returns the value as-is. That way a resolver can apply it over every
* field of a mixed-encryption record without a per-field check.
*/
export async function unwrapValue(blob: unknown, key: CryptoKey): Promise<unknown> {
if (!isEncrypted(blob)) return blob;
const body = (blob as string).slice(ENC_PREFIX.length);
const dotIndex = body.indexOf('.');
if (dotIndex === -1) {
throw new Error('mana-ai/crypto: malformed encrypted blob (missing iv/ct separator)');
}
const iv = base64ToBytes(body.slice(0, dotIndex));
const ct = base64ToBytes(body.slice(dotIndex + 1));
if (iv.length !== IV_LENGTH) {
throw new Error(`mana-ai/crypto: expected ${IV_LENGTH}-byte IV, got ${iv.length}`);
}
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
key,
toBufferSource(ct)
);
return JSON.parse(new TextDecoder().decode(plaintext));
}
/**
* Decrypts every enc:1:-prefixed string field on a record in place. Non-
* prefixed fields are left alone. Returns the same record reference for
* caller convenience.
*
* Counts how many fields were decrypted so the caller can decide whether
* to write an audit row (zero-decrypt records don't need one no secret
* was touched). Failures bubble up; the caller is responsible for the
* try/catch and audit bookkeeping.
*/
export async function decryptRecordFields(
record: Record<string, unknown>,
key: CryptoKey
): Promise<{ record: Record<string, unknown>; decryptedFields: string[] }> {
const decryptedFields: string[] = [];
for (const [k, v] of Object.entries(record)) {
if (!isEncrypted(v)) continue;
record[k] = await unwrapValue(v, key);
decryptedFields.push(k);
}
return { record, decryptedFields };
}
// ─── Helpers ─────────────────────────────────────────────────
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}

View file

@ -0,0 +1,69 @@
/**
* Decrypt-audit writer appends one row per server-side decrypt attempt
* to `mana_ai.decrypt_audit`.
*
* Invariants:
* - **Append-only.** Never mutate or delete. Expire old rows via a
* separate retention job if at all.
* - **Write before decrypt**, not after. If the decrypt itself throws,
* we still want the attempt on record so forensics can tell
* "mana-ai tried to read record X". The writer is called with the
* *outcome* though pattern is: try decrypt; in success/failure
* branch, call `writeDecryptAudit` with the right status. On an
* unexpected throw (network, DB), the audit row is lost acceptable
* because the decrypt didn't complete either.
* - **Best-effort.** A write failure is logged but never escalates; we
* must not break Mission ticks because the audit table is unhappy.
*
* Row volume: with ~50 active Missions × 5 inputs × 1 tick/minute, worst
* case is ~360k rows/day. Fine for Postgres but suggests a retention job
* at ~90 days.
*/
import type { Sql } from './connection';
import { withUser } from './connection';
export type DecryptAuditStatus = 'ok' | 'failed' | 'scope-violation';
export interface DecryptAuditEntry {
missionId: string;
iterationId?: string;
tableName: string;
recordId: string;
status: DecryptAuditStatus;
/** Short machine-readable reason on non-ok rows: `wrap-rejected`,
* `ciphertext-tampered`, `scope-record-not-allowlisted`, etc. */
reason?: string;
}
export async function writeDecryptAudit(
sql: Sql,
userId: string,
entry: DecryptAuditEntry
): Promise<void> {
try {
await withUser(sql, userId, async (tx) => {
await tx`
INSERT INTO mana_ai.decrypt_audit (
user_id, mission_id, iteration_id, table_name, record_id, status, reason
) VALUES (
${userId},
${entry.missionId},
${entry.iterationId ?? null},
${entry.tableName},
${entry.recordId},
${entry.status},
${entry.reason ?? null}
)
`;
});
} catch (err) {
// Audit failures must never cascade into tick failures. Log loud
// enough that an operator notices and can investigate.
console.error(
'[mana-ai audit] failed to write decrypt_audit row:',
err instanceof Error ? err.message : String(err),
entry
);
}
}

View file

@ -10,6 +10,7 @@
* its deadline.
*/
import type { MissionGrant } from '@mana/shared-ai';
import type { Sql } from './connection';
/**
@ -27,6 +28,9 @@ export interface ServerMission {
inputs: { module: string; table: string; id: string }[];
cadence: unknown; // opaque — the browser Runner owns cadence math
iterations: unknown[]; // opaque — server just reads count
/** Present iff the mission has a Key-Grant attached enables
* decryption of encrypted-table inputs during this tick. */
grant?: MissionGrant;
}
interface ChangeRow {
@ -75,6 +79,7 @@ export async function listDueMissions(sql: Sql, now: string): Promise<ServerMiss
inputs: Array.isArray(record.inputs) ? (record.inputs as ServerMission['inputs']) : [],
cadence: record.cadence,
iterations: Array.isArray(record.iterations) ? record.iterations : [],
grant: (record.grant ?? undefined) as MissionGrant | undefined,
}));
}
@ -141,6 +146,7 @@ export function mergeAndFilter(
inputs: Array.isArray(record.inputs) ? (record.inputs as ServerMission['inputs']) : [],
cadence: record.cadence,
iterations: Array.isArray(record.iterations) ? record.iterations : [],
grant: (record.grant ?? undefined) as MissionGrant | undefined,
});
}
return missions;

View file

@ -0,0 +1,188 @@
/**
* Encrypted resolver unit tests with a stubbed Sql driver.
*
* We don't spin up Postgres; instead we mount a fake `sql` tag that
* returns canned rows for `replayRecord` and collects INSERT calls for
* the audit writer. This exercises the allowlist check, the decrypt
* path, and the audit bookkeeping without needing a real DB.
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { createEncryptedResolver } from './encrypted';
import type { Sql } from '../connection';
const ENC_PREFIX = 'enc:1:';
async function freshKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
/** Webapp-side wrap; we emulate so tests can produce ciphertext the
* resolver then unwraps. */
async function wrap(value: unknown, key: CryptoKey): Promise<string> {
const json = JSON.stringify(value);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv.buffer.slice(0) as ArrayBuffer },
key,
new TextEncoder().encode(json)
);
return ENC_PREFIX + b64(iv) + '.' + b64(new Uint8Array(ct));
}
interface Capture {
queries: Array<{ text: string; values: unknown[] }>;
rows: Record<string, unknown[]>;
}
/** Build a stub Sql tag. Matches the calls made by replayRecord + audit
* writer. Responses are keyed by whether the query is SELECT (pulls
* from capture.rows) or INSERT/SET (no-op + captured for assertions).
* Template literal quirks: postgres.js calls the tag with
* (strings: string[], ...values: unknown[]). */
function stubSql(capture: Capture): Sql {
const tag = ((strings: TemplateStringsArray, ...values: unknown[]) => {
const text = strings.join('?');
capture.queries.push({ text, values });
const promise: Promise<unknown> = Promise.resolve(
text.includes('FROM sync_changes') ? (capture.rows.replay ?? []) : []
);
// postgres.js query objects also support .begin etc.; we only
// need the promise interface for these tests.
return promise;
}) as unknown as Sql;
// begin = transactional callback; we inline-run it against the same
// tag so the same query log is captured.
(tag as unknown as { begin: (fn: (tx: Sql) => Promise<unknown>) => Promise<unknown> }).begin =
async (fn) => fn(tag);
return tag;
}
beforeEach(() => {
// nothing global; fresh capture per test
});
describe('encrypted resolver', () => {
it('returns null when the mission has no grant (no mdk in context)', async () => {
const resolver = createEncryptedResolver({
module: 'notes',
appId: 'notes',
label: 'Notiz',
formatContent: (r) => String(r.content ?? ''),
});
const capture: Capture = { queries: [], rows: {} };
const out = await resolver(
stubSql(capture),
{ module: 'notes', table: 'notes', id: 'n1' },
'user-1',
{ missionId: 'm' } // no mdk, no allowlist
);
expect(out).toBe(null);
// No DB work at all — we bailed before replay.
expect(capture.queries).toEqual([]);
});
it('writes scope-violation audit when record is not on the allowlist', async () => {
const key = await freshKey();
const resolver = createEncryptedResolver({
module: 'notes',
appId: 'notes',
label: 'Notiz',
formatContent: (r) => String(r.content ?? ''),
});
const capture: Capture = { queries: [], rows: {} };
const out = await resolver(
stubSql(capture),
{ module: 'notes', table: 'notes', id: 'n-other' },
'user-1',
{ missionId: 'm1', mdk: key, allowlist: new Set(['notes:n1']) }
);
expect(out).toBe(null);
// Audit insert recorded.
const auditInsert = capture.queries.find((q) =>
q.text.includes('INSERT INTO mana_ai.decrypt_audit')
);
expect(auditInsert).toBeDefined();
expect(auditInsert!.values).toContain('scope-violation');
expect(auditInsert!.values).toContain('record-not-in-grant-allowlist');
});
it('decrypts allowlisted records and writes ok audit', async () => {
const key = await freshKey();
const encTitle = await wrap('Private Titel', key);
const encContent = await wrap('geheimer inhalt', key);
const capture: Capture = {
queries: [],
rows: {
replay: [
{
op: 'insert',
data: { title: encTitle, content: encContent, createdAt: '2026-04-15' },
field_timestamps: null,
created_at: new Date(0),
},
],
},
};
const resolver = createEncryptedResolver({
module: 'notes',
appId: 'notes',
label: 'Notiz',
formatContent: (r) => String(r.content ?? ''),
});
const out = await resolver(
stubSql(capture),
{ module: 'notes', table: 'notes', id: 'n1' },
'user-1',
{ missionId: 'm1', mdk: key, allowlist: new Set(['notes:n1']) }
);
expect(out).not.toBe(null);
expect(out!.title).toBe('Private Titel');
expect(out!.content).toBe('geheimer inhalt');
const audit = capture.queries.find((q) => q.text.includes('INSERT INTO mana_ai.decrypt_audit'));
expect(audit!.values).toContain('ok');
});
it('writes failed audit when decrypt throws (wrong key)', async () => {
const wrapperKey = await freshKey();
const runnerKey = await freshKey(); // different → ciphertext won't decrypt
const enc = await wrap('x', wrapperKey);
const capture: Capture = {
queries: [],
rows: {
replay: [
{
op: 'insert',
data: { title: enc },
field_timestamps: null,
created_at: new Date(0),
},
],
},
};
const resolver = createEncryptedResolver({
module: 'notes',
appId: 'notes',
label: 'Notiz',
formatContent: (r) => String(r.title ?? ''),
});
const out = await resolver(
stubSql(capture),
{ module: 'notes', table: 'notes', id: 'n1' },
'user-1',
{ missionId: 'm1', mdk: runnerKey, allowlist: new Set(['notes:n1']) }
);
expect(out).toBe(null);
const audit = capture.queries.find((q) => q.text.includes('INSERT INTO mana_ai.decrypt_audit'));
expect(audit!.values).toContain('failed');
});
});
function b64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}

View file

@ -0,0 +1,190 @@
/**
* Encrypted-table resolver factory.
*
* One resolver per encrypted module (notes, tasks, events, journal,
* kontext, ). The factory parametrises the module/table name, the
* mini-formatter for the Planner's context string, and the set of
* encrypted fields we try to expose post-decrypt.
*
* Flow per resolve:
* 1. Check context has an MDK + allowlist (mission has a valid grant).
* Missing return null; the tick loop already flagged the mission
* as grant-missing and the planner runs without this input.
* 2. Check `${table}:${recordId}` is on the allowlist. Record not
* allowlisted write `scope-violation` audit row, bump metric,
* return null. This is belt+braces: the scope-bound HKDF would
* have produced a different key anyway, so decrypt would fail
* cryptographically. But we catch it earlier with a clear signal.
* 3. Replay the record with LWW. Record missing / deleted null.
* 4. Decrypt all `enc:1:`-prefixed fields in place with the MDK.
* Crypto failure `failed` audit row, return null.
* 5. Write `ok` audit row, format the Planner context, return.
*/
import type { MissionInputRef, ResolvedInput } from '@mana/shared-ai';
import { decryptRecordFields } from '../../crypto/decrypt-value';
import { writeDecryptAudit } from '../audit';
import { replayRecord } from './record-replay';
import type { ResolverContext, ServerInputResolver } from './types';
import { decryptsTotal, grantScopeViolationsTotal } from '../../metrics';
export interface EncryptedResolverConfig {
/** Module name registered with the resolver registry. */
readonly module: string;
/** Dexie/app_id where the records live in `sync_changes` (usually
* the same as `module`, but decoupled because some modules sync
* under a different app id e.g. `aiMissions` lives under `ai`). */
readonly appId: string;
/** Human label used in the Planner's context title. */
readonly label: string;
/** Extracts a short content string from the (decrypted) record for
* the Planner. Typical impl: pluck `title`/`content`, truncate. */
readonly formatContent: (record: Record<string, unknown>) => string;
/** Extracts a title line for the Planner; falls back to the record id
* if unset. */
readonly formatTitle?: (record: Record<string, unknown>) => string;
}
export function createEncryptedResolver(cfg: EncryptedResolverConfig): ServerInputResolver {
return async function encryptedResolver(sql, ref, userId, ctx) {
if (!ctx.mdk || !ctx.allowlist) {
// No grant → silently yield null. The Planner runs without
// this input; the foreground runner picks up the slack when
// the user next opens a tab. No audit row: no decrypt attempt.
return null;
}
const scopeKey = `${ref.table}:${ref.id}`;
if (!ctx.allowlist.has(scopeKey)) {
grantScopeViolationsTotal.inc({ table: ref.table });
await writeDecryptAudit(sql, userId, {
missionId: ctx.missionId,
tableName: ref.table,
recordId: ref.id,
status: 'scope-violation',
reason: 'record-not-in-grant-allowlist',
});
return null;
}
const record = (await replayRecord(sql, userId, cfg.appId, ref.table, ref.id)) as Record<
string,
unknown
> | null;
if (!record) return null;
try {
const { decryptedFields } = await decryptRecordFields(record, ctx.mdk);
if (decryptedFields.length > 0) {
decryptsTotal.inc({ table: ref.table }, decryptedFields.length);
}
} catch (err) {
await writeDecryptAudit(sql, userId, {
missionId: ctx.missionId,
tableName: ref.table,
recordId: ref.id,
status: 'failed',
reason: errorReason(err),
});
return null;
}
await writeDecryptAudit(sql, userId, {
missionId: ctx.missionId,
tableName: ref.table,
recordId: ref.id,
status: 'ok',
});
return toResolvedInput(cfg, ref, record);
};
}
function toResolvedInput(
cfg: EncryptedResolverConfig,
ref: MissionInputRef,
record: Record<string, unknown>
): ResolvedInput {
const title =
(cfg.formatTitle ? cfg.formatTitle(record) : undefined) ||
(typeof record.title === 'string' ? record.title : cfg.label);
return {
id: ref.id,
module: ref.module,
table: ref.table,
title,
content: cfg.formatContent(record),
};
}
function errorReason(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('malformed')) return 'ciphertext-malformed';
// AES-GCM auth-tag failures surface as DOMException/OperationError.
// Return a stable short string rather than the DOM message (which
// varies across Bun versions).
if (/OperationError|decrypt/i.test(msg)) return 'ciphertext-tampered-or-wrong-key';
return 'decrypt-failed';
}
// ─── Built-in configs for the five encrypted modules ────────
/** Truncate long text content so the Planner prompt stays tight. */
function truncate(s: string, max = 500): string {
if (s.length <= max) return s;
return s.slice(0, max) + '…';
}
export const notesResolver = createEncryptedResolver({
module: 'notes',
appId: 'notes',
label: 'Notiz',
formatContent: (r) =>
truncate(typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? '')),
});
export const tasksResolver = createEncryptedResolver({
module: 'tasks',
appId: 'todo',
label: 'Task',
formatContent: (r) => {
const title = typeof r.title === 'string' ? r.title : '(ohne Titel)';
const desc = typeof r.description === 'string' ? r.description : '';
const status = typeof r.status === 'string' ? r.status : '';
const due = typeof r.dueDate === 'string' ? r.dueDate : '';
const parts = [status && `[${status}]`, title, desc && `${desc}`, due && `(faellig: ${due})`]
.filter(Boolean)
.join(' ');
return truncate(parts);
},
});
export const eventsResolver = createEncryptedResolver({
module: 'calendar',
appId: 'calendar',
label: 'Termin',
formatContent: (r) => {
const title = typeof r.title === 'string' ? r.title : '(ohne Titel)';
const start = typeof r.startDate === 'string' ? r.startDate : '';
const location = typeof r.location === 'string' ? r.location : '';
return truncate(
[title, start && `@ ${start}`, location && `in ${location}`].filter(Boolean).join(' ')
);
},
});
export const journalResolver = createEncryptedResolver({
module: 'journal',
appId: 'journal',
label: 'Journal-Eintrag',
formatContent: (r) => truncate(typeof r.content === 'string' ? r.content : ''),
});
export const kontextResolver = createEncryptedResolver({
module: 'kontext',
appId: 'kontext',
label: 'Kontext',
formatTitle: () => 'Mana-Kontext',
formatContent: (r) =>
truncate(typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? ''), 1500),
});

View file

@ -19,7 +19,7 @@ interface GoalRecord {
deletedAt?: string;
}
export const goalsResolver: ServerInputResolver = async (sql, ref, userId) => {
export const goalsResolver: ServerInputResolver = async (sql, ref, userId, _context) => {
const record = (await replayRecord(
sql,
userId,

View file

@ -9,8 +9,15 @@
import type { Sql } from '../connection';
import type { MissionInputRef, ResolvedInput } from '@mana/shared-ai';
import type { ServerInputResolver } from './types';
import type { ResolverContext, ServerInputResolver } from './types';
import { goalsResolver } from './goals';
import {
eventsResolver,
journalResolver,
kontextResolver,
notesResolver,
tasksResolver,
} from './encrypted';
const resolvers = new Map<string, ServerInputResolver>();
@ -22,24 +29,34 @@ export function unregisterServerResolver(moduleName: string): void {
resolvers.delete(moduleName);
}
// Seed with the built-in plaintext resolvers. Encrypted modules (notes,
// kontext, journal, dreams, …) are intentionally NOT registered — the
// server only sees ciphertext for those tables and can't produce useful
// Planner context. Missions referencing them should use the foreground
// runner; see CLAUDE.md → "Privacy constraint" for rationale.
// Plaintext resolvers run for every mission — no grant needed.
registerServerResolver('goals', goalsResolver);
// Encrypted resolvers require a currently-valid Mission Grant. Without
// one they return null per ref; the Planner then runs with fewer inputs
// and the foreground runner picks up the slack on the user's next
// browser visit. Encryption registry source: `apps/mana/apps/web/src/
// lib/data/crypto/registry.ts` — keep the table set here in sync with
// the set flipped to `enabled: true` there.
registerServerResolver('notes', notesResolver);
registerServerResolver('tasks', tasksResolver);
registerServerResolver('todo', tasksResolver);
registerServerResolver('calendar', eventsResolver);
registerServerResolver('journal', journalResolver);
registerServerResolver('kontext', kontextResolver);
export async function resolveServerInputs(
sql: Sql,
refs: readonly MissionInputRef[],
userId: string
userId: string,
context: ResolverContext
): Promise<ResolvedInput[]> {
const results = await Promise.all(
refs.map(async (ref) => {
const resolver = resolvers.get(ref.module);
if (!resolver) return null;
try {
return await resolver(sql, ref, userId);
return await resolver(sql, ref, userId, context);
} catch (err) {
console.error(
`[mana-ai resolver] module=${ref.module} ref=${ref.id} threw:`,
@ -52,4 +69,4 @@ export async function resolveServerInputs(
return results.filter((r): r is ResolvedInput => r !== null);
}
export type { ServerInputResolver } from './types';
export type { ResolverContext, ServerInputResolver } from './types';

View file

@ -22,14 +22,14 @@ describe('resolveServerInputs', () => {
}));
const refs: MissionInputRef[] = [{ module: 'resolver_test_mod', table: 't', id: 'a' }];
const resolved = await resolveServerInputs(stubSql, refs, 'user-1');
const resolved = await resolveServerInputs(stubSql, refs, 'user-1', { missionId: 'm' });
expect(resolved).toHaveLength(1);
expect(resolved[0].content).toBe('content for a');
});
it('skips refs whose module has no registered resolver', async () => {
const refs: MissionInputRef[] = [{ module: 'does-not-exist', table: 't', id: 'x' }];
const resolved = await resolveServerInputs(stubSql, refs, 'u');
const resolved = await resolveServerInputs(stubSql, refs, 'u', { missionId: 'm' });
expect(resolved).toEqual([]);
});
@ -38,7 +38,7 @@ describe('resolveServerInputs', () => {
throw new Error('broken');
});
const refs: MissionInputRef[] = [{ module: 'resolver_test_boom', table: 't', id: 'x' }];
const resolved = await resolveServerInputs(stubSql, refs, 'u');
const resolved = await resolveServerInputs(stubSql, refs, 'u', { missionId: 'm' });
expect(resolved).toEqual([]);
});
@ -54,7 +54,7 @@ describe('resolveServerInputs', () => {
{ module: 'unknown', table: 't', id: 'b' },
{ module: 'resolver_test_mod', table: 't', id: 'c' },
];
const resolved = await resolveServerInputs(stubSql, refs, 'u');
const resolved = await resolveServerInputs(stubSql, refs, 'u', { missionId: 'm' });
expect(resolved).toHaveLength(2);
});
@ -63,7 +63,7 @@ describe('resolveServerInputs', () => {
// checking the empty-refs path doesn't throw and that an unknown
// module still skips — any negative-space test, since we can't
// invoke the goals resolver without a live DB here.
const resolved = await resolveServerInputs(stubSql, [], 'u');
const resolved = await resolveServerInputs(stubSql, [], 'u', { missionId: 'm' });
expect(resolved).toEqual([]);
});
});

View file

@ -21,8 +21,29 @@
import type { Sql } from '../connection';
import type { MissionInputRef, ResolvedInput } from '@mana/shared-ai';
/**
* Per-tick context passed into every resolver. Lets encrypted resolvers
* read the Mission Data Key (MDK) + allowlist without the registry
* having to know which resolvers need it. Plaintext resolvers ignore
* these fields entirely.
*/
export interface ResolverContext {
/** Mission whose input is being resolved — used for audit attribution. */
missionId: string;
/** Unwrapped AES-GCM Mission Data Key, if the mission carries a
* currently-valid grant. Undefined for missions without a grant or
* when the unwrap failed; plaintext resolvers don't care. */
mdk?: CryptoKey;
/** Record-ID allowlist from the grant, in `${table}:${recordId}` form.
* Encrypted resolvers MUST refuse to decrypt anything outside this
* set (cryptographically enforced by the scope-bound HKDF, but
* double-checked at runtime to produce clean audit trails). */
allowlist?: ReadonlySet<string>;
}
export type ServerInputResolver = (
sql: Sql,
ref: MissionInputRef,
userId: string
userId: string,
context: ResolverContext
) => Promise<ResolvedInput | null>;

View file

@ -98,3 +98,28 @@ export const snapshotRowsAppliedTotal = new Counter({
help: 'Sync-changes rows folded into the snapshot cache.',
registers: [register],
});
// ── Mission Key-Grant (Phase 2+) ──────────────────────────
export const decryptsTotal = new Counter({
name: 'mana_ai_decrypts_total',
help: 'Server-side field decrypts performed under a Mission grant.',
labelNames: ['table'] as const,
registers: [register],
});
/** Must remain at 0 in steady state any increment indicates a record
* was requested outside the grant's allowlist. Alert on > 0. */
export const grantScopeViolationsTotal = new Counter({
name: 'mana_ai_grant_scope_violations_total',
help: 'Decrypt attempts rejected because the record was not on the grant allowlist.',
labelNames: ['table'] as const,
registers: [register],
});
export const grantSkipsTotal = new Counter({
name: 'mana_ai_grant_skips_total',
help: 'Missions skipped because their grant was missing, expired, or unwrappable.',
labelNames: ['reason'] as const,
registers: [register],
});