diff --git a/api/auth/verify.mjs b/api/auth/verify.mjs index 0735f759..36c8c472 100644 --- a/api/auth/verify.mjs +++ b/api/auth/verify.mjs @@ -26,7 +26,7 @@ function extractToken(req) { return null; } -export async function requireAuth(req, res) { +export async function requireAuth(req, res, { allowNonOwner = false } = {}) { if (req._authenticatedUser) return req._authenticatedUser; const token = extractToken(req); if (!token) { @@ -47,7 +47,7 @@ export async function requireAuth(req, res) { return null; } const ownerEmail = process.env.OWNER_EMAIL?.toLowerCase(); - if (ownerEmail && user.email?.toLowerCase() !== ownerEmail) { + if (!allowNonOwner && ownerEmail && user.email?.toLowerCase() !== ownerEmail) { res.status(403).json({ error: 'Forbidden' }); return null; } @@ -60,7 +60,7 @@ export default async function handler(req, res) { return res.status(405).json({ error: 'Method not allowed' }); } - const user = await requireAuth(req, res); + const user = await requireAuth(req, res, { allowNonOwner: true }); if (!user) return; return res.status(200).json({ user }); diff --git a/api/learning.mjs b/api/learning.mjs index 4ad90f8d..ebd52368 100644 --- a/api/learning.mjs +++ b/api/learning.mjs @@ -1,7 +1,7 @@ // Leftover local dispatcher — same Fetch handlers as production. // Production: functions/api/[[path]].js → dispatchLearningAction. import { requireAuth } from './auth/verify.mjs'; -import { AUTH_ACTIONS } from '../shared/api/learning-registry.mjs'; +import { AUTH_ACTIONS, USER_RECORD_ACTIONS } from '../shared/api/learning-registry.mjs'; import { dispatchLearningAction } from '../shared/api/worker-learning.mjs'; import { getDb } from '../shared/db/client.mjs'; @@ -37,7 +37,7 @@ export default async function handler(req, res) { const action = req.query?.action; let user = req._authenticatedUser || null; if (!user && AUTH_ACTIONS.includes(action)) { - user = await requireAuth(req, res); + user = await requireAuth(req, res, { allowNonOwner: USER_RECORD_ACTIONS.includes(action) }); if (!user) return; } diff --git a/docs/product/surfaces.md b/docs/product/surfaces.md index 671735a2..f8b8b87a 100644 --- a/docs/product/surfaces.md +++ b/docs/product/surfaces.md @@ -97,6 +97,11 @@ discarded anyway. The client reads the auth/public split from `shared/api/learning-registry.mjs`, the same list the server enforces, so adding an action gates the client automatically. +Signed-in learners may synchronize their own artifacts, drills and project +records. These three actions bind every read/write to the authenticated user +and reject a mismatched account ID. Other learning actions retain the owner +gate; signing in does not grant access to the owner's library or hosted AI. + Outside an explicitly submitted AI action, the only request a guest makes is one `GET /api/auth/verify` on their first load, which is how the app discovers there is no session. It is not repeated. Role fit can use a learner-configured diff --git a/functions/api/[[path]].js b/functions/api/[[path]].js index f27f3e15..d19194e6 100644 --- a/functions/api/[[path]].js +++ b/functions/api/[[path]].js @@ -1,4 +1,5 @@ import { dispatchLearningAction } from '../../shared/api/worker-learning.mjs'; +import { USER_RECORD_ACTIONS } from '../../shared/api/learning-registry.mjs'; import { handleMcpLearningRequest } from '../../shared/api/mcp-learning.mjs'; import { dispatchWarsRequest } from '../../shared/api/worker-wars.mjs'; import { createD1Client } from '../../shared/db/d1-client.mjs'; @@ -272,7 +273,9 @@ async function handleProgress(request, env) { async function handleLearning(request, env) { await initDatabase(env); const authenticatedUser = await currentUser(request, env); - const user = authenticatedUser?.isOwner ? authenticatedUser : null; + const action = new URL(request.url).searchParams.get('action'); + const user = + authenticatedUser?.isOwner || USER_RECORD_ACTIONS.includes(action) ? authenticatedUser : null; const client = getDb(env); return dispatchLearningAction({ request, client, user, env, json }); } diff --git a/shared/api/learning-registry.mjs b/shared/api/learning-registry.mjs index 7ea63dbf..b8c6f054 100644 --- a/shared/api/learning-registry.mjs +++ b/shared/api/learning-registry.mjs @@ -7,6 +7,9 @@ /** BYOK / heuristic — no user auth required. */ const PUBLIC_ACTIONS = ['gaps', 'critique', 'role-fit', 'understanding', 'tag']; +/** Account-owned records only; no owner library or deployment-funded AI access. */ +export const USER_RECORD_ACTIONS = ['artifacts', 'drills', 'projects']; + /** * Signed-in user required. * diff --git a/shared/api/pages-record-sync.test.mjs b/shared/api/pages-record-sync.test.mjs new file mode 100644 index 00000000..43bbe4a9 --- /dev/null +++ b/shared/api/pages-record-sync.test.mjs @@ -0,0 +1,110 @@ +// @vitest-environment node +import { createHmac } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { onRequest } from '../../functions/api/[[path]].js'; + +const secret = 'synthetic-test-signing-key'; +let database; +let env; + +beforeEach(() => { + database = new DatabaseSync(':memory:'); + for (const migration of ['0001_initial.sql', '0003_record_sync_receipts.sql']) { + database.exec( + readFileSync(new URL(`../../migrations/d1/${migration}`, import.meta.url), 'utf8') + ); + } + database.exec( + "INSERT INTO users (id,google_id,email,name) VALUES ('alice','alice','alice@example.invalid','Alice'), ('bob','bob','bob@example.invalid','Bob')" + ); + env = { + JWT_SECRET: secret, + OWNER_EMAIL: 'owner@example.invalid', + DB: { + prepare(sql) { + const statement = database.prepare(sql); + const bound = (args) => ({ + execute: () => ({ + results: statement.all(...args), + meta: { changes: Number(database.prepare('SELECT changes() AS n').get()?.n) }, + }), + all: async () => bound(args).execute(), + bind: (...values) => bound(values), + }); + return bound([]); + }, + async batch(statements) { + database.exec('BEGIN'); + try { + const results = statements.map((statement) => statement.execute()); + database.exec('COMMIT'); + return results; + } catch (error) { + database.exec('ROLLBACK'); + throw error; + } + }, + }, + }; +}); +afterEach(() => database.close()); + +function request(action, user = 'alice', body = undefined, accountId = user, path = ['learning']) { + const unsigned = [ + { alg: 'HS256', typ: 'JWT' }, + { userId: user, exp: Math.floor(Date.now() / 1000) + 600 }, + ] + .map((value) => Buffer.from(JSON.stringify(value)).toString('base64url')) + .join('.'); + const token = `${unsigned}.${createHmac('sha256', secret).update(unsigned).digest('base64url')}`; + return onRequest({ + request: new Request( + `https://prep.example.invalid/api/${path.join('/')}?action=${action}&accountId=${accountId}`, + { + method: body ? 'POST' : 'GET', + headers: { + ...(user ? { Cookie: `dsa_prep_auth=${token}` } : {}), + 'Content-Type': 'application/json', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + } + ), + env, + params: { path }, + }); +} + +it.each(['artifacts', 'drills', 'projects'])( + 'allows a signed-in non-owner to read their own %s records', + async (action) => { + const response = await request(action); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ [action]: {} }); + } +); + +it('persists non-owner records without exposing them to another account', async () => { + const body = { + accountId: 'alice', + operationId: 'save-1', + drillId: 'synthetic-drill', + status: 'solved', + lastCode: 'Alice private attempt', + }; + expect((await request('drills', 'alice', body)).status).toBe(200); + const own = await (await request('drills')).json(); + expect(own.drills['synthetic-drill'].lastCode).toBe(body.lastCode); + expect(await (await request('drills', 'bob')).json()).toEqual({ drills: {} }); + expect((await request('drills', 'bob', body)).status).toBe(409); + expect((await request('drills', 'bob', undefined, 'alice')).status).toBe(409); +}); + +it('retains owner-only learning and provider access and rejects anonymous record reads', async () => { + expect((await request('concepts')).status).toBe(401); + expect((await request('drills', '')).status).toBe(401); + const response = await request('', 'alice', { messages: [] }, 'alice', ['ai', 'chat']); + expect(response.status).toBe(403); + expect((await request('', 'alice', undefined, 'alice', ['auth', 'verify'])).status).toBe(200); +});