Trace login from browser to database

Use the starter’s existing login, then understand the session boundary before adding another provider.

On this pageSource-backed Markdown

Start with the generated app

Follow Build your first app, including the unpublished tarball preview while the packages are not on npm. Use a new application database, apply migrations, then run npm run dev. Open http://localhost:5173.

Registration, login and notes do not require an OpenAI key. Keep OPENAI_API_KEY empty for this walkthrough. This is the starter’s application code, not automatically installed Auth routes.

Create an account and sign back in

Register a local test account with a name, email and a 12–128 character password. Save a note, reload and confirm it remains. Sign out; private data should no longer be available. Sign in with a wrong password, then the correct one.

Use the browser Network panel to inspect /api/register, /api/me, /api/login and /api/logout. Success responses do not contain a bearer token. Do not paste cookies or credentials into bug reports.

Enable the provider at boot

The application enables password authentication in config.auth.providers. It can also accept a configured Google client ID. Auth owns account/provider/token records; the generated HTTP adapter chooses the session transport and public routes.

server/app.ts
ts
import { App, type AppOptions } from '@db3.ai/app';
import type { StarterConfig } from './config';

/** Creates the framework root. Test database injection belongs only at this bootstrap boundary. */
export function createApplication(config: StarterConfig, options: Pick<AppOptions, 'db'> = {}) {
	return new App({
		...options,
		config: { auth: { providers: {
			password: true,
			...(config.auth.googleClientId ? { google: { clientIds: [config.auth.googleClientId] } } : {}),
		} } },
	});
}

Keep the session server-controlled

Registration/login issue a seven-day bearer session, then put its opaque value in a host-only db3_session cookie with HttpOnly and SameSite=Lax. Production adds Secure. /api/me selects only ID, name and email.

Every request enters the framework request context. Protected routes call the application helper requireUser(), which authenticates the cookie first. Logout revokes the current durable token and clears the cookie; clearing browser state alone would not revoke access.

server/http/session.ts
ts
import type { App } from '@db3.ai/app';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { HttpError } from './errors';

/** Cookie name is host-only; no domain-wide sharing between unrelated applications. */
export const sessionCookie = 'db3_session';

/** Sets the opaque session token in a host-only, HttpOnly cookie. */
export function setSession(reply: FastifyReply, token: string, secure: boolean) {
	reply.setCookie(sessionCookie, token, { path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 7 });
}

/** Authenticates the current cookie or rejects before any private record lookup. */
export async function requireUser(application: App, request: FastifyRequest) {
	const token = request.cookies[sessionCookie];
	const user = token ? await application.auth.authenticateToken(token) : null;
	if (!user) throw new HttpError(401, 'Please sign in.');
	return user;
}

Validate the origin and input

All non-GET/HEAD/OPTIONS requests must carry an Origin exactly matching APP_ORIGIN; foreign or missing origins receive 403. This includes registration/login, not only requests with an existing session.

Fastify rejects extra body properties and invalid lengths. The sign-in/registration endpoints have process-local limits of ten requests per fifteen minutes per rate-limit key. Duplicate registration returns 409; bad credentials return a generic 401.

Use one consistent browser hostname and port. localhost and 127.0.0.1 are different origins. Non-browser clients must explicitly send the configured Origin for this starter API; change the API design deliberately if adding other clients.

server/http/createServer.ts
ts
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit';
import type { App } from '@db3.ai/app';
import { AuthIdentityExistsError } from '@db3.ai/app/auth';
import { OpenAIText, TextGenerationError } from '@db3.ai/app/ai';
import type { StarterConfig } from '../config';
import { Note } from '../models/Note';
import { AiAllowance } from '../ai/AiAllowance';
import { summariseNote } from '../ai/summariseNote';
import { requireUser, sessionCookie, setSession } from './session';
import { HttpError } from './errors';

const email = { type: 'string', format: 'email', maxLength: 255 };
const password = { type: 'string', minLength: 12, maxLength: 128 };
const credentials = { type: 'object', additionalProperties: false, required: ['email', 'password'], properties: { email, password } };
const noteBody = { type: 'object', additionalProperties: false, required: ['title', 'body'], properties: { title: { type: 'string', minLength: 1, maxLength: 120, pattern: '\\S' }, body: { type: 'string', minLength: 1, maxLength: 20_000, pattern: '\\S' } } };
const noteParams = { type: 'object', required: ['id'], properties: { id: { type: 'string', pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' } } };
const notePatch = { ...noteBody, required: [], minProperties: 1 };

/**
 * Composes real Auth/ActiveRecord routes and the optional server-only AI client.
 * External HTTP may be replaced in tests; framework services are never mocked.
 */
export async function createServer(application: App, config: StarterConfig, options: { fetch?: typeof globalThis.fetch } = {}) {
	const server = Fastify({ logger: false, bodyLimit: 96 * 1024, ajv: { customOptions: { removeAdditional: false } } });
	const ai = config.ai.apiKey ? new OpenAIText({ ...config.ai, fetch: options.fetch }) : null;
	const allowance = new AiAllowance();
	await server.register(cookie);
	await server.register(rateLimit, { max: 120, timeWindow: '1 minute', cache: 10_000 });
	server.addHook('onRequest', (request, reply, done) => {
		reply.header('Cache-Control', 'no-store').header('X-Content-Type-Options', 'nosniff').header('X-Frame-Options', 'DENY').header('Referrer-Policy', 'same-origin');
		// Strict Origin verification covers login CSRF as well as authenticated writes.
		if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method) && request.headers.origin !== config.origin) {
			reply.code(403).send({ message: 'Request origin is not allowed.' });
			return;
		}
		application.requestContext.run(done);
	});
	server.setErrorHandler((error, _request, reply) => {
		if (error instanceof HttpError) return reply.code(error.statusCode).send({ message: error.message });
		if (error instanceof AuthIdentityExistsError) return reply.code(409).send({ message: 'An account with that email already exists. Try signing in.' });
		if (error instanceof TextGenerationError) return reply.code(error.code === 'rate_limit' ? 429 : 502).send({ message: 'AI could not complete this request. Check the server API key, model and provider limits before trying again.', code: error.code });
		if (error && typeof error === 'object' && 'validation' in error) return reply.code(400).send({ message: 'Check the supplied fields and their length limits.' });
		if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 429) return reply.code(429).send({ message: 'Too many requests. Try again shortly.' });
		// Do not serialize SQL errors, request bodies, credentials or provider payloads.
		return reply.code(500).send({ message: 'The request could not be completed.' });
	});
	server.get('/api/config', async () => ({ name: config.name, aiEnabled: Boolean(ai), googleClientId: config.auth.googleClientId }));
	server.get('/api/me', async request => {
		const token = request.cookies[sessionCookie];
		const user = token ? await application.auth.authenticateToken(token) : null;
		return { user: user ? { id: user.id, name: user.name, email: user.email } : null };
	});
	server.post<{ Body: { name: string; email: string; password: string } }>('/api/register', {
		config: { rateLimit: { max: 10, timeWindow: '15 minutes' } },
		schema: { body: { ...credentials, required: ['name', 'email', 'password'], properties: { ...credentials.properties, name: { type: 'string', minLength: 1, maxLength: 120, pattern: '\\S' } } } },
	}, async (request, reply) => {
		const input = { ...request.body, name: request.body.name.trim(), email: request.body.email.trim().toLowerCase() };
		const issued = await application.auth.registerWithPassword(input, { expiresInMs: 7 * 24 * 60 * 60 * 1000 });
		setSession(reply, issued.token, config.production);
		return reply.code(201).send({ ok: true });
	});
	server.post<{ Body: { email: string; password: string } }>('/api/login', {
		config: { rateLimit: { max: 10, timeWindow: '15 minutes' } }, schema: { body: credentials },
	}, async (request, reply) => {
		const issued = await application.auth.issueTokenForProvider('password', { ...request.body, email: request.body.email.trim().toLowerCase() }, { expiresInMs: 7 * 24 * 60 * 60 * 1000 });
		if (!issued) throw new HttpError(401, 'Email or password is incorrect.');
		setSession(reply, issued.token, config.production);
		return { ok: true };
	});
	server.post('/api/logout', async (request, reply) => {
		await requireUser(application, request);
		await application.auth.revokeCurrentToken();
		reply.clearCookie(sessionCookie, { path: '/', httpOnly: true, sameSite: 'lax', secure: config.production });
		return { ok: true };
	});
	server.get('/api/notes', async request => {
		const user = await requireUser(application, request);
		return { notes: (await Note.where('owner', user.id).orderBy('createdAt', 'desc').orderBy('id', 'desc').limit(100).all()).map(note => note.toJSON()) };
	});
	server.post<{ Body: { title: string; body: string } }>('/api/notes', { schema: { body: noteBody } }, async (request, reply) => {
		const user = await requireUser(application, request);
		const note = new Note();
		note.setFromRequest(request.body);
		note.assign({ owner: user.id });
		await note.save();
		return reply.code(201).send({ note: note.toJSON() });
	});
	server.get<{ Params: { id: string } }>('/api/notes/:id', { schema: { params: noteParams } }, async request => {
		const user = await requireUser(application, request);
		const note = await Note.where({ id: request.params.id, owner: user.id }).first();
		if (!note) throw new HttpError(404, 'Note not found.');
		return { note: note.toJSON() };
	});
	server.patch<{ Params: { id: string }; Body: { title?: string; body?: string } }>('/api/notes/:id', { schema: { params: noteParams, body: notePatch } }, async request => {
		const user = await requireUser(application, request);
		const note = await Note.where({ id: request.params.id, owner: user.id }).first();
		if (!note) throw new HttpError(404, 'Note not found.');
		note.setFromRequest(request.body);
		await note.save();
		return { note: note.toJSON() };
	});
	server.delete<{ Params: { id: string } }>('/api/notes/:id', { schema: { params: noteParams } }, async request => {
		const user = await requireUser(application, request);
		const note = await Note.where({ id: request.params.id, owner: user.id }).first();
		if (!note) throw new HttpError(404, 'Note not found.');
		await note.delete();
		return { ok: true };
	});
	server.post<{ Params: { id: string } }>('/api/notes/:id/summarise', { schema: { params: noteParams } }, async request => {
		const user = await requireUser(application, request);
		return summariseNote(user.id!, request.params.id, ai, allowance);
	});
	return server;
}

Add social login as a separate feature

Setting GOOGLE_AUTH_CLIENT_ID prepares the framework provider and public client ID; it does not add a Google button or a credential-exchange route. Implement those together with the provider guide and the same cookie/validation policy.

Password recovery email, provider linking/removal UI, recent-authentication checks and session management screens are also follow-ups. Do not auto-link accounts just because their email addresses match.

Test the real boundary

The generated tests/app.test.ts is the source below. It creates a disposable database from committed migrations, exercises HTTP routes with real Auth/ActiveRecord and removes it afterwards. Configure its separate TEST_DB_* credentials as shown in the generated README.

The suite keeps the real server; only external AI HTTP responses use a synthetic test key and controlled fetch. No live OpenAI request is needed to run these tests.

tests/app.test.ts
ts
import 'dotenv/config';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createGeneratedTestDatabase, type GeneratedTestDatabase } from '@db3.ai/app/db/test/db';
import { createApplication } from '../server/app';
import { createServer } from '../server/http/createServer';
import { migrations } from '../server/database/migrations';
import type { StarterConfig } from '../server/config';

// Explicit test-only connection. Never inherit the application DATABASE_URL.
delete process.env.DATABASE_URL;
process.env.DB_CONNECTION = 'mariadb';
process.env.DB_HOST = process.env.TEST_DB_HOST || '127.0.0.1';
process.env.DB_PORT = process.env.TEST_DB_PORT || '3306';
process.env.DB_USER = process.env.TEST_DB_USER || 'root';
process.env.DB_PASSWORD = process.env.TEST_DB_PASSWORD || '';
process.env.DB_DATABASE = 'db3_app_test';
process.env.DB_TEST_DATABASE_PREFIX = 'db3_app_test';
const config: StarterConfig = { name: 'Test app', origin: 'http://localhost:5173', port: 3001, host: '127.0.0.1', production: false, auth: { googleClientId: '' }, ai: { apiKey: '', model: 'test-model' } };
let database: GeneratedTestDatabase;
let application: ReturnType<typeof createApplication>;
let server: Awaited<ReturnType<typeof createServer>>;

/** Registers a real user and returns only the browser cookie transport. */
async function register(email = '[email protected]') {
	const response = await server.inject({ method: 'POST', url: '/api/register', headers: { origin: config.origin }, payload: { name: 'Ada', email, password: 'example-password-123' } });
	expect(response.statusCode).toBe(201);
	expect(response.headers['set-cookie']).toContain('HttpOnly');
	expect(response.body).not.toContain('token');
	return String(response.headers['set-cookie']).split(';')[0];
}

/** Saves a real owner-scoped note through the public HTTP API. */
async function save(cookie: string) {
	const response = await server.inject({ method: 'POST', url: '/api/notes', headers: { origin: config.origin, cookie }, payload: { title: 'Launch plan', body: 'Write the guide. Test the starter. Publish the packages.' } });
	expect(response.statusCode).toBe(201);
	return response.json().note.id as string;
}

beforeEach(async () => {
	database = await createGeneratedTestDatabase('starter');
	application = createApplication(config, { db: database.db });
	await migrations(application).migrate();
	server = await createServer(application, config);
});
afterEach(async () => {
	try { if (server) await server.close(); } finally { try { if (application) await application.close(); } finally { if (database) await database.destroy(); } }
});

describe('starter app', () => {
	it('reads and edits only an owned note, preserves omitted fields and rejects invalid changes', async () => {
		const owner = await register();
		const stranger = await register('[email protected]');
		const id = await save(owner);
		const headers = { cookie: owner, origin: config.origin };
		const original = (await server.inject({ url: `/api/notes/${id}`, headers })).json().note;
		for (const method of ['GET', 'PATCH'] as const) {
			const response = await server.inject({ method, url: `/api/notes/${id}`, headers: { cookie: stranger, origin: config.origin }, ...(method === 'PATCH' ? { payload: { title: 'Forbidden' } } : {}) });
			expect(response.statusCode).toBe(404);
		}
		for (const payload of [{}, { title: '   ' }, { owner: 'attacker' }, { title: 'x'.repeat(121) }]) {
			expect((await server.inject({ method: 'PATCH', url: `/api/notes/${id}`, headers, payload })).statusCode).toBe(400);
		}
		expect((await server.inject({ url: `/api/notes/${id}`, headers })).json().note).toEqual(original);
		const edited = await server.inject({ method: 'PATCH', url: `/api/notes/${id}`, headers, payload: { title: ' Revised plan ' } });
		expect(edited.statusCode).toBe(200);
		expect(edited.json().note).toMatchObject({ id, title: 'Revised plan', body: original.body, owner: original.owner });
		expect((await server.inject({ method: 'DELETE', url: `/api/notes/${id}`, headers })).statusCode).toBe(200);
		expect((await server.inject({ url: `/api/notes/${id}`, headers })).statusCode).toBe(404);
	});
	it('rejects a wrong password then recovers with valid credentials', async () => {
		await register();
		const invalid = await server.inject({ method: 'POST', url: '/api/login', headers: { origin: config.origin }, payload: { email: '[email protected]', password: 'incorrect-password' } });
		expect(invalid.statusCode).toBe(401);
		expect(invalid.headers['set-cookie']).toBeUndefined();
		const valid = await server.inject({ method: 'POST', url: '/api/login', headers: { origin: config.origin }, payload: { email: '[email protected]', password: 'example-password-123' } });
		expect(valid.statusCode).toBe(200);
		expect(valid.headers['set-cookie']).toContain('HttpOnly');
	});
	it('registers, saves, reloads, logs out and signs back in without an AI key', async () => {
		const cookie = await register();
		const id = await save(cookie);
		expect((await server.inject({ url: '/api/notes', headers: { cookie } })).json().notes[0].id).toBe(id);
		expect((await server.inject({ url: '/api/config' })).json().aiEnabled).toBe(false);
		const disabled = await server.inject({ method: 'POST', url: `/api/notes/${id}/summarise`, headers: { origin: config.origin, cookie }, payload: {} });
		expect(disabled.statusCode).toBe(503);
		expect(disabled.json().message).toContain('OPENAI_API_KEY');
		expect((await server.inject({ method: 'POST', url: '/api/logout', headers: { origin: config.origin, cookie }, payload: {} })).statusCode).toBe(200);
		expect((await server.inject({ url: '/api/notes', headers: { cookie } })).statusCode).toBe(401);
		const login = await server.inject({ method: 'POST', url: '/api/login', headers: { origin: config.origin }, payload: { email: '[email protected]', password: 'example-password-123' } });
		expect(login.statusCode).toBe(200);
	});
	it('rejects unauthenticated requests, invalid input and foreign origins', async () => {
		expect((await server.inject('/api/notes')).statusCode).toBe(401);
		expect((await server.inject({ method: 'POST', url: '/api/register', headers: { origin: 'https://evil.example' }, payload: {} })).statusCode).toBe(403);
		expect((await server.inject({ method: 'POST', url: '/api/register', headers: { origin: config.origin }, payload: { name: 'Ada', email: '[email protected]', password: 'short' } })).statusCode).toBe(400);
		const cookie = await register();
		expect((await server.inject({ method: 'POST', url: '/api/notes', headers: { origin: config.origin, cookie }, payload: { title: 'X', body: 'Y', owner: 'attacker' } })).statusCode).toBe(400);
	});
	it('summarises only the owner’s saved note and never returns the API key', async () => {
		await server.close();
		const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(Response.json({ object: 'response', id: 'resp_test', model: 'test-model', status: 'completed', output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'A simulated summary.' }] }], usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 } }));
		server = await createServer(application, { ...config, ai: { ...config.ai, apiKey: 'dummy-key-never-live' } }, { fetch });
		const owner = await register();
		const stranger = await register('[email protected]');
		const id = await save(owner);
		expect((await server.inject({ url: '/api/notes', headers: { cookie: stranger } })).json().notes).toEqual([]);
		for (const method of ['POST', 'DELETE'] as const) {
			const denied = await server.inject({ method, url: `/api/notes/${id}${method === 'POST' ? '/summarise' : ''}`, headers: { cookie: stranger, origin: config.origin } });
			expect(denied.statusCode).toBe(404);
		}
		expect(fetch).not.toHaveBeenCalled();
		const result = await server.inject({ method: 'POST', url: `/api/notes/${id}/summarise`, headers: { cookie: owner, origin: config.origin }, payload: {} });
		expect(result.statusCode).toBe(200);
		expect(result.json().text).toBe('A simulated summary.');
		expect(result.body).not.toContain('dummy-key');
		expect((await server.inject('/api/config')).body).not.toContain('dummy-key');
		expect(fetch).toHaveBeenCalledTimes(1);
	});
	it('returns a safe AI error and permits a later retry after provider failure', async () => {
		await server.close();
		const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(async () => Response.json({ error: { message: 'secret provider payload' } }, { status: 429 }));
		server = await createServer(application, { ...config, ai: { ...config.ai, apiKey: 'dummy-key-never-live' } }, { fetch });
		const cookie = await register();
		const id = await save(cookie);
		for (let attempt = 0; attempt < 2; attempt++) {
			const result = await server.inject({ method: 'POST', url: `/api/notes/${id}/summarise`, headers: { cookie, origin: config.origin }, payload: {} });
			expect(result.statusCode).toBe(429);
			expect(result.body).not.toContain('secret provider payload');
		}
		expect(fetch).toHaveBeenCalledTimes(2);
	});
});

Run and extend the tests

Add a second account and keep unauthorized/cross-account checks when changing the UI. Test cookie Secure behavior behind your actual HTTPS deployment and configure a shared rate-limit store before running multiple API processes.

From the generated app root
bash
npm test
npm run check
npm run build

Coverage and production boundaries

The supplied tests cover registration, wrong-password recovery, cookie transport, logout revocation, rejected origins, invalid input and owner isolation. Browser/proxy HTTPS, distributed rate limiting, email recovery and live social login require separate deployment/provider trials.

This page renders the current starter source and tests. Its release status follows the starter’s integrated verification, not the existence of these code blocks.