Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ thin: it proxies OpenAI requests with the server-held API key and writes study l
than dropping them (see `anonymizeUserUsage`).
- **Logging** (`src/logging.ts`): structured JSONL to `backend/logs/<username>.jsonl`,
same shape/location the Python backend used. `validateUsername` is the
path-traversal guard. Log-viewer endpoints (`/api/logs_poll`, `/api/download_logs`)
path-traversal guard. `GET /api/me/data[?since=<epoch ms>]` is the user's own
export (their log events + `llm_usage` rows as one JSON download); it refuses tool
callers (`clientId` set), and the client computes `since` so "today" is local. Log-viewer endpoints (`/api/logs_poll`, `/api/download_logs`)
are gated by `LOG_SECRET`.
- **Frontend serving** (`src/static.ts`): in the production single-container image
the backend also serves the built frontend (`frontend/dist`, copied to `./public`
Expand Down
57 changes: 57 additions & 0 deletions backend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../app.js';
import { closeDb } from '../db.js';
import { recordUsage } from '../usage.js';
import type { Auth } from '../auth.js';

const app = createApp();
Expand Down Expand Up @@ -256,6 +258,61 @@ describe('DELETE /api/me/activity', () => {
});
});

describe('GET /api/me/data', () => {
beforeEach(async () => {
vi.stubEnv('DATA_DIR', await mkdtemp(path.join(tmpdir(), 'wt-export-')));
});
afterEach(() => {
closeDb();
});

async function writeEvent(authApp: ReturnType<typeof createApp>, event: string) {
await authApp.request('/api/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event }),
});
}

it("exports the user's own events and usage rows, filtered by since", async () => {
const { app: authApp } = makeAuthApp({ id: 'usr-exp', loggingConsent: 'usage' });
const { app: otherApp } = makeAuthApp({ id: 'usr-other', loggingConsent: 'usage' });
await writeEvent(authApp, 'mine');
await writeEvent(otherApp, 'theirs');
recordUsage({
userId: 'usr-exp', clientId: null, provider: 'openai', endpoint: 'responses',
model: 'm', inputTokens: 1, cachedInputTokens: 0, outputTokens: 1,
reasoningTokens: 0, status: 200, streamed: false, durationMs: 1, ttftMs: null,
});

const all = await authApp.request('/api/me/data');
expect(all.status).toBe(200);
expect(all.headers.get('Content-Disposition')).toMatch(/attachment/);
type Export = { since: string | null; events: Array<{ event: string }>; usage: unknown[] };
const body = (await all.json()) as Export;
expect(body.since).toBeNull();
expect(body.events.map((e) => e.event)).toEqual(['mine']);
expect(body.usage).toHaveLength(1);

const future = await authApp.request(`/api/me/data?since=${Date.now() + 60_000}`);
const empty = (await future.json()) as Export;
expect(empty.events).toEqual([]);
expect(empty.usage).toEqual([]);
});

it('rejects a malformed since', async () => {
const { app: authApp } = makeAuthApp({ id: 'usr-exp' });
const res = await authApp.request('/api/me/data?since=yesterday');
expect(res.status).toBe(400);
});

it('401s without a session', async () => {
const { app: authApp } = makeAuthApp(null);
const res = await authApp.request('/api/me/data');
expect(res.status).toBe(401);
});
});

describe('POST /api/openai/chat/completions', () => {
it('injects the API key and forwards the body', async () => {
vi.stubEnv("OPENAI_API_KEY", 'sk-test-123');
Expand Down
1 change: 1 addition & 0 deletions backend/src/__tests__/oauth-security.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ describe('Logging into Mindmap with Thoughtful: security assurances', () => {
['/api/log', asMindmap('POST', {})],
['/api/me/consent', asMindmap('POST', { loggingConsent: 'usage' })],
['/api/me/activity', asMindmap('DELETE')],
['/api/me/data', asMindmap('GET')],
['/api/handoff', asMindmap('POST', { tool_client_id: 'mindmap', scopes: ['openai:chat'] })],
['/api/openai/realtime/session', asMindmap('POST', {})],
];
Expand Down
45 changes: 43 additions & 2 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
acceptedOAuthClientIds,
} from './config.js';
import { eraseLoggedData } from './erasure.js';
import { appendLog, pollLogs, zipLogs } from './logging.js';
import { appendLog, pollLogs, readUserLogs, zipLogs } from './logging.js';
import {
attributeRequest,
openaiProxy,
Expand All @@ -37,7 +37,7 @@ import {
revokeToolToken,
type ToolScope,
} from './toolGrants.js';
import { summarizeUsage } from './usage.js';
import { summarizeUsage, userUsageRows } from './usage.js';
import { allowlistFields, isUserAllowed } from './userAllowlist.js';
import { oauthLoginHandler } from './routes/oauth-login.js';

Expand Down Expand Up @@ -443,6 +443,47 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
return c.json({ message: 'Your logged activity has been erased.' });
});

// Self-service export: everything we've logged about the authenticated user —
// their study-log events and their content-free LLM usage rows — as one JSON
// download. `since` (epoch ms, optional) limits it to recent records; the client
// computes it so "today" means the person's own local midnight, not the server's.
//
// First-party only: a tool acting for the user (tool grant token, or a device
// client naming itself via X-Client-Id) was granted AI calls, not a read of the
// user's whole history.
app.get('/api/me/data', async (c) => {
const user = await resolveUser(c);
if (!user || user.clientId !== null) {
return c.json({ detail: 'Unauthorized' }, 401);
}

const rawSince = c.req.query('since');
let since: number | undefined;
if (rawSince !== undefined && rawSince !== '') {
since = Number(rawSince);
if (!Number.isFinite(since) || since < 0) {
return c.json({ detail: 'since must be epoch milliseconds.' }, 400);
}
}

const exportedAt = new Date();
const body = {
exported_at: exportedAt.toISOString(),
since: since === undefined ? null : new Date(since).toISOString(),
user_id: user.id,
events: await readUserLogs(user.id, since),
usage: userUsageRows(user.id, since),
};
const stamp = exportedAt.toISOString().slice(0, 10);
return new Response(JSON.stringify(body, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="thoughtful-data-${stamp}.json"`,
'Cache-Control': 'no-store',
},
});
});

// --- Tool launcher: handoff grant flow (see toolGrants.ts) -------------------

// Mint a launch grant for a sidebar-launched tool. Authenticated: the signed-in
Expand Down
34 changes: 34 additions & 0 deletions backend/src/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,37 @@ export async function zipLogs(): Promise<Uint8Array> {
}
return zipSync(archive);
}

/**
* One user's study-log entries, oldest first, optionally only those at or after
* `sinceMs` (epoch ms). Backs the self-service "download my data" export. Missing
* file → []. Unparseable lines are skipped rather than failing the whole export.
*
* Entries keep the shape they were written in (`timestamp` is epoch *seconds*),
* so an export reads the same as the files the analysis scripts consume.
*/
export async function readUserLogs(
userId: string,
sinceMs?: number,
): Promise<LogEntry[]> {
let content: string;
try {
content = await readFile(logFilePath(userId), 'utf8');
} catch (e) {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw e;
}
const entries: LogEntry[] = [];
for (const line of content.split('\n')) {
if (!line.trim()) continue;
let entry: LogEntry;
try {
entry = JSON.parse(line) as LogEntry;
} catch {
continue;
}
if (sinceMs !== undefined && entry.timestamp * 1000 < sinceMs) continue;
entries.push(entry);
}
return entries;
}
21 changes: 21 additions & 0 deletions backend/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,27 @@ export function anonymizeUserUsage(userId: string): void {
.run(DELETED_USER_ID, userId);
}

/**
* One user's raw usage rows, oldest first, optionally only those at or after
* `sinceMs`. Backs the self-service "download my data" export: these rows are
* data we hold about the person even though they sit outside the consent tiers.
*/
export function userUsageRows(
userId: string,
sinceMs?: number,
): Array<Record<string, unknown>> {
return db()
.prepare(
`SELECT ts, client_id, provider, endpoint, model, input_tokens,
cached_input_tokens, output_tokens, reasoning_tokens, status,
streamed, duration_ms, ttft_ms
FROM llm_usage
WHERE user_id = ? AND ts >= ?
ORDER BY ts, id`,
)
.all(userId, sinceMs ?? 0) as Array<Record<string, unknown>>;
}

export interface UsageSummaryRow {
userId: string;
email: string | null;
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/privacypolicy.html
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ <h2 id="your-rights-and-choices">Your Rights and Choices</h2>
<li><strong>Change what is logged</strong> at any time by changing your logging level in the Add-in’s account settings.</li>
<li><strong>Erase your logged activity</strong> — from the account settings you can erase everything we have recorded about how you use the Add-in: your logged events and your analytics profile. You keep your account and can carry on using the Add-in; this is how you withdraw from the research study without leaving. Because your account is still open, the content-free AI usage records described above continue from that point on — we are still billed for the requests you make.</li>
<li><strong>Delete your account</strong> — this erases your logged activity (exactly as above) and then removes your account itself. Your AI usage records are stripped of your identity rather than erased: the token counts remain, so that our books still reconcile with what the AI provider billed us, but they are no longer linked to you or to each other — and they never contained any of your content.</li>
<li><strong>Access</strong> information about what we hold and how it is used by contacting us.</li>
<li><strong>Download your data</strong> — from the account settings you can download, as a file, your logged events and your AI usage records, for today, the past seven days, or all time. Your analytics profile is not included; for that, or for any other question about what we hold and how it is used, contact us.</li>
</ul>
<h2 id="changes-to-this-policy">Changes to This Policy</h2>
<p>We may update this privacy policy to reflect changes in our practices or for other operational, legal, or regulatory reasons. We will notify users of any material changes through the Add-in or via email.</p>
Expand Down
87 changes: 86 additions & 1 deletion frontend/src/account/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Lets a signed-in user exercise the "Your Rights and Choices" actions from the
* privacy policy, which are deliberately three *different* things:
* - change their logging-consent level → POST /api/me/consent
* - download what we've logged (access) → GET /api/me/data
* - erase their logged activity (withdrawal) → DELETE /api/me/activity
* - delete their account (departure) → POST /api/auth/delete-user
*
Expand All @@ -14,7 +15,12 @@
*/
import { useState } from 'react';
import { Button } from 'reshaped';
import { changeConsent, deleteAccount, eraseActivity } from '@/api/account';
import {
changeConsent,
deleteAccount,
downloadMyData,
eraseActivity,
} from '@/api/account';
import { clearToken, loadToken } from '@/api/authTokenStore';
import { ConsentLevelChooser } from '@/components/ConsentLevelChooser';
import type { ConsentLevel } from '@/consent';
Expand All @@ -23,6 +29,40 @@ import { DeviceCodePanel } from './DeviceCodePanel';
import classes from './styles.module.css';

type ConsentStatus = 'idle' | 'saving' | 'saved' | 'error';
type ExportRange = 'today' | 'week' | 'all';
type ExportStatus = 'idle' | 'working' | 'error';

const EXPORT_LABELS: Record<ExportRange, string> = {
today: 'Today',
week: 'Past 7 days',
all: 'Everything',
};

/**
* Start of the export window in epoch ms, or undefined for everything. "Today"
* is the viewer's local midnight — computed here, not on the server, whose clock
* is in some other time zone. "Past 7 days" is a rolling 7×24h window.
*/
function exportSince(range: ExportRange, now = new Date()): number | undefined {
if (range === 'all') return undefined;
if (range === 'week') return now.getTime() - 7 * 24 * 60 * 60 * 1000;
const midnight = new Date(now);
midnight.setHours(0, 0, 0, 0);
return midnight.getTime();
}

/** Hand a blob to the browser as a file download. */
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Revoke on the next tick: some browsers start the download asynchronously.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
type EraseStatus = 'idle' | 'confirm' | 'working' | 'done' | 'error';
type DeleteStatus =
'idle' | 'confirm' | 'working' | 'needs-reauth' | 'done' | 'error';
Expand All @@ -33,6 +73,8 @@ export function AccountPage() {
const [level, setLevel] = useState<ConsentLevel>(session.loggingConsent);
const [consentStatus, setConsentStatus] = useState<ConsentStatus>('idle');

const [exportStatus, setExportStatus] = useState<ExportStatus>('idle');

const [eraseStatus, setEraseStatus] = useState<EraseStatus>('idle');
const [eraseError, setEraseError] = useState<string | null>(null);

Expand All @@ -54,6 +96,20 @@ export function AccountPage() {
}
};

// --- download my data (access) --------------------------------------------
const onExport = async (range: ExportRange) => {
setExportStatus('working');
try {
const token = await session.getAccessToken();
const blob = await downloadMyData(token, exportSince(range));
const day = new Date().toISOString().slice(0, 10);
saveBlob(blob, `thoughtful-data-${range}-${day}.json`);
setExportStatus('idle');
} catch {
setExportStatus('error');
}
};

// --- erase activity (withdrawal) ------------------------------------------
const onErase = async () => {
setEraseStatus('working');
Expand Down Expand Up @@ -136,6 +192,35 @@ export function AccountPage() {
</p>
</section>

{/* Download — access */}
<section className={classes.section}>
<h2 className={classes.h2}>Download my data</h2>
<p className={classes.sectionIntro}>
A JSON file of the events we&rsquo;ve logged about you and
the content-free AI usage records we keep for billing.
</p>
<div className={classes.actionRow}>
{(Object.keys(EXPORT_LABELS) as ExportRange[]).map(
(range) => (
<button
key={range}
type="button"
className={classes.linkButton}
disabled={exportStatus === 'working'}
onClick={() => void onExport(range)}
>
{EXPORT_LABELS[range]}
</button>
),
)}
</div>
<p className={classes.statusLine}>
{exportStatus === 'working' && 'Preparing download…'}
{exportStatus === 'error' &&
'Could not download your data. Please try again.'}
</p>
</section>

{/* Erase activity — withdrawal */}
<section className={classes.section}>
<h2 className={classes.h2}>Erase my logged activity</h2>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/account/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,20 @@
color: #2563eb;
font-weight: 600;
}

/* Action that reads as a link (the data-download choices). */
.linkButton {
padding: 0;
border: 0;
background: none;
color: #2563eb;
font: inherit;
font-weight: 600;
text-decoration: underline;
cursor: pointer;
}

.linkButton:disabled {
color: #a1a1aa;
cursor: default;
}
Loading
Loading