mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
9a3025fed8
commit
a6d51afbc9
13 changed files with 818 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
127
services/mana-ai/src/crypto/decrypt-value.test.ts
Normal file
127
services/mana-ai/src/crypto/decrypt-value.test.ts
Normal 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;
|
||||
}
|
||||
95
services/mana-ai/src/crypto/decrypt-value.ts
Normal file
95
services/mana-ai/src/crypto/decrypt-value.ts
Normal 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;
|
||||
}
|
||||
69
services/mana-ai/src/db/audit.ts
Normal file
69
services/mana-ai/src/db/audit.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
188
services/mana-ai/src/db/resolvers/encrypted.test.ts
Normal file
188
services/mana-ai/src/db/resolvers/encrypted.test.ts
Normal 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);
|
||||
}
|
||||
190
services/mana-ai/src/db/resolvers/encrypted.ts
Normal file
190
services/mana-ai/src/db/resolvers/encrypted.ts
Normal 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),
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue