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.
On this page
Source-backed MarkdownRun 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.
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.
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;
}
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.
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;
}
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.
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' });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.
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.
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.
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 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.
npm test
npm run check
npm run buildCoverage 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.