Skip to content

Commit d454d20

Browse files
author
OneFineStarstuff
committed
feat(privacy,safety,telemetry): add consent ledger API (hash-chained), safety pipeline (pre/steer/post), telemetry placeholder; wire into SSE stream and chat UI
1 parent 8e903d8 commit d454d20

6 files changed

Lines changed: 101 additions & 4 deletions

File tree

next-app/app/api/chat/stream/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ function* fakeStream(text: string) {
88
}
99
}
1010

11+
import { preFilter, steerPrompt, postModerate } from '@/lib/safety/pipeline';
12+
1113
function streamForMessage(message: string) {
1214
const ctrl = new AbortController();
1315
const stream = new ReadableStream<Uint8Array>({
1416
async start(controller) {
1517
try {
16-
const reply = `Echo: ${message}`;
17-
const meta = { layer: 'surface', model: 'mock', version: '0.0.1', latencyMs: 42 };
18+
const pre = preFilter(message);
19+
const safePrompt = steerPrompt(message);
20+
const reply = `Echo: ${safePrompt}`;
21+
const post = postModerate(reply);
22+
const meta = { layer: 'surface', model: 'mock', version: '0.0.1', latencyMs: 42, pre, post };
1823
controller.enqueue(encode(`event: meta\ndata: ${JSON.stringify(meta)}\n\n`));
1924
for (const chunk of fakeStream(reply)) {
2025
await new Promise(r => setTimeout(r, 10));

next-app/app/api/consent/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NextRequest } from 'next/server';
2+
import { appendConsentEvent, exportConsent } from '@/lib/privacy/consentLedger';
3+
4+
export const runtime = 'nodejs';
5+
6+
export async function POST(req: NextRequest) {
7+
const { userId = 'demo', sessionId, action } = await req.json();
8+
if (!['persist_on','persist_off','export'].includes(action)) return new Response('bad action', { status: 400 });
9+
const ev = await appendConsentEvent({ userId, sessionId, action, ts: new Date().toISOString() as any });
10+
return Response.json(ev);
11+
}
12+
13+
export async function GET(req: NextRequest) {
14+
const { searchParams } = new URL(req.url);
15+
const userId = searchParams.get('userId') ?? 'demo';
16+
const data = await exportConsent(userId);
17+
return Response.json(data);
18+
}

next-app/app/chat/page.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export default function ChatPage() {
4949

5050
return (
5151
<div className="space-y-4">
52-
<h1 className="text-2xl font-semibold">Chat</h1>
52+
<h1 className="text-2xl font-semibold">Chat <span className="text-xs align-middle text-slate-500">(ephemeral by default)</span></h1>
5353
<div className="rounded border bg-white p-3">
5454
<div className="space-y-3" role="log" aria-live="polite">
5555
{messages.map((m, i) => (
@@ -63,10 +63,11 @@ export default function ChatPage() {
6363
</div>
6464
))}
6565
</div>
66-
<div className="mt-3 flex gap-2">
66+
<div className="mt-3 flex flex-wrap items-center gap-2">
6767
<input value={input} onChange={e=>setInput(e.target.value)} className="flex-1 rounded border px-3 py-2" placeholder="Type a message..." />
6868
<button onClick={send} disabled={streaming} className="rounded bg-amber-600 px-4 py-2 text-white disabled:opacity-50">Send</button>
6969
{fallback && <span className="text-xs text-slate-500">Fallback in use</span>}
70+
<a href="/api/consent?userId=demo" target="_blank" className="text-xs text-amber-700 underline">Export consent ledger</a>
7071
</div>
7172
</div>
7273
</div>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import crypto from 'crypto';
2+
import fs from 'fs/promises';
3+
import path from 'path';
4+
5+
export type ConsentAction = 'persist_on' | 'persist_off' | 'export';
6+
export type ConsentEvent = { userId: string; sessionId?: string; action: ConsentAction; ts: string; prevHash?: string; hash?: string };
7+
8+
const DATA_DIR = path.join(process.cwd(), 'next-app', '.data', 'consent');
9+
10+
export async function appendConsentEvent(e: Omit<ConsentEvent, 'hash' | 'prevHash'>) {
11+
await fs.mkdir(DATA_DIR, { recursive: true });
12+
const chainFile = path.join(DATA_DIR, `${e.userId}.jsonl`);
13+
let prevHash: string | undefined;
14+
try {
15+
const last = await tailLastLine(chainFile);
16+
if (last) prevHash = JSON.parse(last).hash;
17+
} catch {}
18+
const event: ConsentEvent = { ...e, prevHash, ts: e.ts ?? new Date().toISOString() };
19+
event.hash = hashEvent(event);
20+
await fs.appendFile(chainFile, JSON.stringify(event) + '\n', 'utf8');
21+
return event;
22+
}
23+
24+
export function hashEvent(e: ConsentEvent) {
25+
const s = `${e.userId}|${e.sessionId ?? ''}|${e.action}|${e.ts}|${e.prevHash ?? ''}`;
26+
return crypto.createHash('sha256').update(s).digest('hex');
27+
}
28+
29+
export async function exportConsent(userId: string) {
30+
const chainFile = path.join(DATA_DIR, `${userId}.jsonl`);
31+
try {
32+
const raw = await fs.readFile(chainFile, 'utf8');
33+
const events = raw.trim().split('\n').map((l) => JSON.parse(l) as ConsentEvent);
34+
return { events, root: events.at(-1)?.hash };
35+
} catch (e: any) {
36+
if (e.code === 'ENOENT') return { events: [], root: undefined };
37+
throw e;
38+
}
39+
}
40+
41+
async function tailLastLine(file: string): Promise<string | null> {
42+
try {
43+
const data = await fs.readFile(file, 'utf8');
44+
const lines = data.trim().split('\n');
45+
return lines.length ? lines[lines.length - 1] : null;
46+
} catch (e: any) {
47+
if (e.code === 'ENOENT') return null;
48+
throw e;
49+
}
50+
}

next-app/lib/safety/pipeline.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type ModerationAction = 'allow' | 'block' | 'revise';
2+
export type ModerationEvent = { stage: 'pre' | 'post'; action: ModerationAction; reason?: string };
3+
4+
const SENSITIVE = /(ssn|password|credit\s*card|cvv)/i;
5+
6+
export function preFilter(input: string): ModerationEvent {
7+
if (SENSITIVE.test(input)) return { stage: 'pre', action: 'revise', reason: 'redact_sensitive' };
8+
return { stage: 'pre', action: 'allow' };
9+
}
10+
11+
export function steerPrompt(input: string): string {
12+
return `Policy: Be safe and helpful. Avoid unsafe advice.\n${input}`;
13+
}
14+
15+
export function postModerate(output: string): ModerationEvent {
16+
if (/violent|illegal/i.test(output)) return { stage: 'post', action: 'block', reason: 'unsafe_content' };
17+
return { stage: 'post', action: 'allow' };
18+
}

next-app/lib/telemetry/record.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type ProviderMeta = { provider?: string; model?: string; layer?: string; version?: string; tokensIn?: number; tokensOut?: number; latencyMs?: number; tools?: any[] };
2+
export async function recordProviderInvocation(sessionId: string | undefined, meta: ProviderMeta) {
3+
// Placeholder: in MVP, just log to console; integrate with OTel/PostHog later
4+
console.log('provider_invocation', { sessionId, ...meta });
5+
}

0 commit comments

Comments
 (0)