# Build an owned-note JSON API

> Keep HTTP validation, field conversion and authorization at their own boundaries. Use the starter’s real note routes as the example.

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

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

## Run the starter first

Create the Notes starter, configure its new database, apply migrations and run development. Sign in through the generated UI before trying the browser examples below. AI is optional and not needed for note CRUD.

This guide modifies application code generated by `@db3.ai/create`. The backend framework does not silently register these endpoints for every model.

- [Create and run the app](https://db3.ai/docs/starter-app.md)
- [Cookie authentication walkthrough](https://db3.ai/docs/guide-auth.md)

<a id="model"></a>

## Let the fields own conversion

The model has logical `owner`, `title` and `body` fields. `owner` maps to `owner_id`; timestamps map to their SQL columns. Normal route code uses logical names and `toJSON()`.

`requestFillable` allows only title/body. The server assigns the authenticated owner independently. HTTP validation rejects extra submitted properties instead of silently accepting a requested owner. A later model/schema change must go through committed migrations.

### server/models/Note.ts

```typescript
import { ActiveRecord, type FieldBuilder } from '@db3.ai/app/db';

/** A private note. Its owner is assigned by authenticated server code, never request data. */
export class Note extends ActiveRecord {
	static override table = 'notes';
	static override requestFillable = ['title', 'body'];

	/** Defines field validation, logical names and database storage in one place. */
	static override fields(field: FieldBuilder) {
		return {
			id: field.ulid(),
			owner: field.string({ column: 'owner_id', required: true, length: 26, maxLength: 26, index: true }),
			title: field.string({ required: true, maxLength: 120 }),
			body: field.text({ required: true, maxLength: 20_000 }),
			createdAt: field.timestamp({ column: 'created_at', auto: 'create' }),
			updatedAt: field.timestamp({ column: 'updated_at', auto: 'update' }),
		};
	}

	declare id: string | null;
	declare owner: string | null;
	declare title: string | null;
	declare body: string | null;
}
```

<a id="routes"></a>

## Keep the route contract explicit

`GET /api/notes` returns at most 100 owned notes, newest first with ID as a tie-breaker. It is a bounded list, not a complete pagination API. `POST /api/notes` creates one and returns `201` with `{ note }`.

`GET /api/notes/:id` loads one owned note. `PATCH /api/notes/:id` changes supplied title/body fields and preserves omitted fields. An empty patch is rejected. `DELETE /api/notes/:id` deletes the owned record and returns `{ ok: true }`.

Every individual lookup includes both ID and owner. A foreign or missing record returns the same `404`. Authentication alone does not authorize access to an arbitrary ULID.

### 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="run"></a>

## Try it from the signed-in browser

Open the browser console on your local starter app and run this snippet. It creates and edits only a new local example note. Same-origin browser fetch includes the session cookie and supplies Origin for writes. The expected responses are `201` then `200`; the title changes while body and owner remain the same.

Do not use this snippet on a live application with real customer data. Delete the example note through the same owned API when finished.

### Local starter browser console

```javascript
const created = await fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'API example', body: 'A private note created through the API.' }) });
console.log(created.status);
const { note } = await created.json();
const edited = await fetch(`/api/notes/${note.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Updated API example' }) });
console.log(edited.status, await edited.json());
// When finished with this local example:
await fetch(`/api/notes/${note.id}`, { method: 'DELETE' });
```

<a id="errors"></a>

## Return useful, safe errors

Malformed IDs, extra fields, blank content and excessive lengths return `400`. Missing authentication is `401`; rejected Origin is `403`; absent/foreign notes are `404`. Model values are serialized through the fields, not hand-remapped.

The route’s error handler hides SQL errors, stack traces, credentials and provider payloads. The HTTP schema enforces the current note constraints before saving; when adding model rules, decide how those validation errors map into your public response contract rather than letting them become a generic `500`.

<a id="extend"></a>

## Add a feature in the right places

For an extra persisted field: update the model, request allowlist where appropriate, request/response contract and UI. Generate and inspect its migration, apply it to a disposable app, then test valid and invalid input.

For a different access policy, change scoped model queries and tests together. Do not thread optional database parameters through route helpers or bypass field conversion with raw SQL just to add a normal filter.

- [Model a feature](https://db3.ai/docs/guide-model-data.md)
- [Migration workflow](https://db3.ai/docs/migrations.md)
- [Query scope and escape hatches](https://db3.ai/docs/queries.md)

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

## Test persistence and denial

The exact generated test below verifies the owned edit, preservation of omitted fields, foreign reads/edits, invalid patches without changed data, deletion and missing-row response. It also covers login and the optional simulated AI boundary.

Set `TEST_DB_*` for a disposable SQL account. The test must not point at the application database. A workspace source import is not a substitute for exercising this generated consumer.

### 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 the generated checks

Run from the generated app root. The test suite uses real framework services, then `check` and `build` validate the client/server application types and browser bundle.

### From the generated app root

```bash
npm test
npm run check
npm run build
```

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

## Coverage and next steps

Current source/tests cover owned note CRUD, logical fields, strict input, bounded listing, private errors and session/origin policy. Still to add as separate features: pagination cursors, optimistic edit conflicts, shared project membership and an OpenAPI endpoint specification.

This page is source-backed; release readiness depends on the integrated starter trial and does not follow from prose alone.

- [Complete model API](https://db3.ai/docs/active-record-api.md)
- [Private file endpoints](https://db3.ai/docs/guide-files.md)
- [Move expensive work into a queue](https://db3.ai/docs/guide-background.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.
- [Trace login from browser to database](https://db3.ai/docs/guide-auth.md): Use the starter’s existing login, then understand the session boundary before adding another provider.
- [Keep conversion in the field](https://db3.ai/docs/fields.md): Define a reusable value once, from input and validation through storage and public output.
- [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.
