# Trace login from browser to database

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

- Package: `@db3.ai/create`
- Canonical page: [https://db3.ai/docs/guide-auth](https://db3.ai/docs/guide-auth)
- Markdown: [https://db3.ai/docs/guide-auth.md](https://db3.ai/docs/guide-auth.md)
- Framework source of truth: `packages/create/template/README.md`

<a id="setup"></a>

## 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.

- [Build your first app](https://db3.ai/docs/starter-app.md)

<a id="run"></a>

## 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.

<a id="boot"></a>

## 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

```typescript
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] } } : {}),
		} } },
	});
}
```

<a id="session"></a>

## 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

```typescript
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;
}
```

<a id="boundary"></a>

## 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

```typescript
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;
}
```

<a id="providers"></a>

## 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.

- [Auth providers and account linking](https://db3.ai/docs/auth.md#providers)
- [Full Auth API](https://db3.ai/docs/auth-api.md)
- [Mail previews](https://db3.ai/docs/mail.md)

<a id="testing"></a>

## 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

```typescript
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 = 'ada@example.test') {
	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('grace@example.test');
		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: 'ada@example.test', 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: 'ada@example.test', 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: 'ada@example.test', 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: 'ada@example.test', 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('grace@example.test');
		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);
	});
});
```

<a id="run-tests"></a>

## 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
```

<a id="coverage"></a>

## 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.

- [Build an owned-note API](https://db3.ai/docs/guide-api.md)
- [Auth service lab](https://db3.ai/docs/auth.md)

## Behavioural verification
- Behaviour test: `packages/create/template/tests/app.test.ts`

## Related documentation
- [Build your first app](https://db3.ai/docs/starter-app.md): Create an account, save a private note and summarise it with AI. Start with working application code you can change.
- [Auth](https://db3.ai/docs/auth.md): Give an account one or more login methods. Issue bearer sessions, reset passwords and revoke access without mixing identity with credentials.
- [Build an owned-note JSON API](https://db3.ai/docs/guide-api.md): Keep HTTP validation, field conversion and authorization at their own boundaries. Use the starter’s real note routes as the example.
- [Test an application workflow across services](https://db3.ai/docs/guide-testing.md): Use private file access to test authentication, persistence, validation and cleanup together.

## Framework-owned source: `packages/create/template/README.md`

This is the exact source document captured by the documentation build. Use it for detailed API and workflow guidance, subject to the public package exports and behavioural evidence identified above.

````markdown
# Your DB3 app

Sign in, save a private note, then summarise it with AI. This is your application
code. Change the models, routes and screens to build your own features.

## Run locally

Use Node.js 24+, npm and MariaDB. Docker is optional. The creator writes `.env`
with an empty `OPENAI_API_KEY`; you do not need AI credentials to run this app.

If you used the creator's default interactive setup, it installs dependencies,
applies committed migrations and starts development once you configure `.env`.
For a generated-only project (`--no-install`), complete database setup below, then:

```sh
npm install
npm run db:migrate
npm run dev
```

Open [localhost:5173](http://localhost:5173). Create an account with a password
of at least 12 characters, save a note and reload the page. The note survives
because it is stored in MariaDB. Sign out and sign back in to see it again.
A second account cannot read, delete or summarise the first account's notes.

The Vite frontend runs on 5173 and proxies `/api` to Fastify on 3001. If you change
ports, update `PORT`, `APP_ORIGIN` and `vite.config.ts` together. Use exactly the
origin configured in `.env`; `localhost` and `127.0.0.1` are different origins.

## MariaDB on your machine

On macOS with Homebrew:

```sh
brew install mariadb
brew services start mariadb
mariadb
```

Use MariaDB's [installation instructions](https://mariadb.com/docs/server/server-management/install-and-upgrade-mariadb)
for other platforms. From an administrator SQL session, create a database and
an app-specific TCP account. Replace the example password before running:

```sql
CREATE DATABASE db3_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'db3_app'@'127.0.0.1' IDENTIFIED BY 'replace-with-your-local-password';
GRANT ALL PRIVILEGES ON db3_app.* TO 'db3_app'@'127.0.0.1';
```

Set `DB_PASSWORD` in `.env` to your chosen password. `DB_HOST=127.0.0.1` uses
TCP, not the database's Unix socket. Do not point the starter at an existing
application database. Remove any inherited `DATABASE_URL` from your shell,
because it takes precedence over the individual `DB_*` settings.

## Optional Docker database

The creator's `--docker` option generates a random database password and starts
MariaDB with Docker Compose. The Node app still runs locally. Docker is not a
requirement for development.

Install Docker with Compose support. The creator also recognises the standalone
`docker-compose` command used by some Homebrew setups; use that spelling in the
commands below if your installation does not expose `docker compose`.
The engine must run locally: a remote Docker context publishes the database port
on the remote host, not on this computer. The creator refuses remote endpoints.

To use Docker after generating with `--no-install`, set `DB_PORT=33067` and a
non-empty `DB_PASSWORD` in `.env`, then run:

```sh
docker compose up -d --wait db
npm install
npm run db:migrate
npm run dev
```

`docker compose stop` stops the database while retaining its volume. Do not use
`docker compose down -v` unless you intend to delete its data. Changing a password
in `.env` does not reset credentials in an existing database volume.

## Add your own AI key

Put your own OpenAI API key in the **server's** `.env`:

```dotenv
OPENAI_API_KEY=your-own-key
OPENAI_MODEL=gpt-4.1-mini
```

Restart `npm run dev`, sign in and click **Summarise with AI** on a saved note.
The key belongs to the developer running this app, not every end user. No key is
bundled with DB3 or copied from another application. Do not commit it, put it in
Vue code, or use a `VITE_` environment variable for it.

Only the chosen note is sent to OpenAI. The summary is shown separately and is
not saved over the original. Provider charges apply. Review the output; it can
be wrong. Choose a Responses-compatible model available to your OpenAI project.

The route limits input to 20,000 characters plus the title and output to 400
tokens. The framework client has a 30-second timeout and no automatic retry.
The demo allows one active request per user, four overall and ten attempts per
user per minute. These limits are in memory, reset on restart and are **not a
spending cap**. Set provider limits before sharing an AI-enabled app publicly.
`store: false` disables stored Responses, not all provider data retention.

Missing key: notes still work, and the button explains setup. Wrong key, quota,
network or model failure: a safe error appears without exposing provider data.
A timeout can still incur cost. Do not automatically retry chargeable requests.

## Where to build

```text
server/config.ts                 Server configuration; public values are explicit
server/app.ts                    Framework services and password/Google provider config
server/models/Note.ts            Field definitions, validation and storage
server/http/createServer.ts      Auth, note ownership and HTTP routes
server/ai/summariseNote.ts        Application-owned prompt and authorization
server/database/                 CLI and model registry
database/migrations/             Committed migrations
database/schema.snapshot.json    Model schema baseline
src/App.vue                      Landing, login and the protected notebook
tests/app.test.ts                Real Auth/SQL tests; simulated external AI
```

The example uses `@db3.ai/app/ai`, `/auth` and `/db` public imports. To add a
nullable subtitle, add this entry to the object returned by `Note.fields()`:

```ts
subtitle: field.string({ required: false, maxLength: 160 }),
```

Fields use `required: false`, not `nullable: true`. Add `declare subtitle: string
| null;` to the class if you want typed property access. To accept it from the
browser, also add `subtitle` to `requestFillable`, the note HTTP body schema and
the form. Optional schema-only fields do not require those UI changes.

Then run:

```sh
npm run db:make:migration -- add_note_field
# Review the generated migration and snapshot before applying.
npm run db:migrate
npm run db:check
```

Finish applying the migration before checking the changed app, then reload the
browser. During development, the server may reload your model before its new
column exists and temporarily return a request error. If that happens, complete
`db:migrate`, confirm `db:check` passes and reload; server hot reload does not
apply database migrations.

`db:make:migration` first runs `npm run check`. Invalid field options must fail
type checking before the generator writes a migration. Generation is not a
substitute for reviewing data loss, backfills and existing records.

Commit the migration and snapshot together. Do not edit an applied migration.
Server startup never installs or changes the schema.

## Google and other sign-in methods

`server/app.ts` shows where Google client IDs configure the built-in provider.
The shipped browser flow is password registration/login only. Setting
`GOOGLE_AUTH_CLIENT_ID` alone does **not** add a working Google button. The next
recipe is wiring Google Identity Services to an origin-checked route that calls
`application.auth.issueTokenForProvider('google', { credential })` and issues
the same session cookie. Follow the [Auth guide](https://db3.ai/docs/auth).
Other social providers need an explicit provider driver; they are not implied.

## Test your app

```sh
npm run check
npm run build
npm test
```

Tests use real MariaDB and committed migrations, with uniquely named
`db3_app_test_*` databases that are removed afterwards. Set `TEST_DB_HOST`,
`TEST_DB_PORT`, `TEST_DB_USER` and `TEST_DB_PASSWORD` in `.env` to a dedicated
test account with CREATE/DROP permissions for that namespace. For example:

```sql
CREATE USER 'db3_test'@'127.0.0.1' IDENTIFIED BY 'replace-with-your-test-password';
GRANT ALL PRIVILEGES ON `db3\_app\_test\_%`.* TO 'db3_test'@'127.0.0.1';
```

The Docker app account has privileges only on its app database, deliberately.
Create a separate test account using an admin session, or use a separate local
test server. Missing test infrastructure fails; tests do not silently skip.
All AI tests use a dummy key and a simulated HTTP response. They do not use your
real API key or incur AI charges. The summary shown in tests is a fixture, not
evidence that a live provider was called.

## Before public deployment

Build with `npm run build`, apply migrations in a release step, and run
`NODE_ENV=production npm start` behind HTTPS. Set `APP_ORIGIN` to the exact HTTPS
origin and configure `HOST`/`PORT` for your reverse proxy. Production uses secure,
HttpOnly, SameSite cookies; writes require the exact Origin header. It serves
the built frontend and API from the same server.

This first starter does not include password-reset screens, verified-email
onboarding, organisations, billing, durable AI accounting, shared rate limiting,
or a production deployment recipe. Review registration abuse controls, CSP,
backups, monitoring and secret management before public use. Do not describe the
demo as a complete production SaaS.

The framework is MIT licensed. DOM Studio is a separate dependency with its own
licence, not relicensed by this starter. See [DOM Studio](https://getdom.studio).
````

## Guidance for AI tools
Use the documented public import `@db3.ai/create` and its exported types. Prefer the source-backed examples and behavioural outcomes above over invented APIs or source-relative internal imports.
