Skip to content
Merged
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
6 changes: 3 additions & 3 deletions api/auth/verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Expand All @@ -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 });
Expand Down
4 changes: 2 additions & 2 deletions api/learning.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions docs/product/surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion functions/api/[[path]].js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
}
Expand Down
3 changes: 3 additions & 0 deletions shared/api/learning-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
110 changes: 110 additions & 0 deletions shared/api/pages-record-sync.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading