---
title: web-app example
description: A web app with sign-in, private notes in PostgreSQL, and AI questions and answers.
slug: /examples/web-app
order: 1
visibility: public
redirects_from: [/examples/web-app-example]
---

<!-- Generated by scripts/build_scaffold_examples.mjs from the scaffold templates. -->

# web-app example

A web app with sign-in, private notes in PostgreSQL, and AI questions and answers.

These files come from the same source as the scaffold command. They are the starter files before the command adds library packages, application settings, and installed dependencies.

## With a shell

Run this in a new project's root:

```text
turnzero scaffold --template web-app
```

Follow the command's next steps. It writes the application in `app/`, takes the required library packages, installs dependencies, and runs the tests. [The turnzero command](/concepts/turnzero-command#starting-a-new-service) explains setup and options.

## From a chat

Follow [Build an app from a chat](/getting-started/build-an-app-from-a-chat). Adapt this example if it fits the app the person requested.

The scaffold can bind its manifest to an application with `--application <application id>`. From a chat, name the application explicitly in `submit_manifest` and `deploy_files`. An uploaded manifest may omit `application`; if present, it must match that application.

Keep the application paths shown in the table. The download URLs end in `.txt` so a browser reads the code instead of running it; that suffix is not part of the application filename. Each file is also reproduced below for tools reading this page through MCP.

The template manifest starts with an empty `packages` list. For each `@turnzero/` dependency, read its library entry and add its current `name` and `version` to that list. Keep the `file:lib/…` dependencies, and omit those library folders from `deploy_files` so Cloud fills them. Do not send placeholder versions or hashes.

## Files

Paths are relative to the application root—the folder containing `package.json`. `deploy_files` uses these paths without an `app/` prefix.

| Application path | Read source |
| --- | --- |
| `.gitignore` | [Text](files/gitignore.txt) |
| `AGENTS.md` | [Text](files/AGENTS.md.txt) |
| `client/app.js` | [Text](files/client/app.js.txt) |
| `client/index.html` | [Text](files/client/index.html.txt) |
| `client/styles.css` | [Text](files/client/styles.css.txt) |
| `manifest.json` | [Text](files/manifest.json.txt) |
| `migrations/0001_notes.sql` | [Text](files/migrations/0001_notes.sql.txt) |
| `package.json` | [Text](files/package.json.txt) |
| `src/ai.mjs` | [Text](files/src/ai.mjs.txt) |
| `src/db.mjs` | [Text](files/src/db.mjs.txt) |
| `src/main.mjs` | [Text](files/src/main.mjs.txt) |
| `src/server.mjs` | [Text](files/src/server.mjs.txt) |
| `src/session.mjs` | [Text](files/src/session.mjs.txt) |
| `test/app.test.mjs` | [Text](files/test/app.test.mjs.txt) |

## File contents

### .gitignore

```text
node_modules/
.env
.env.*
```

### AGENTS.md

```text
# This service runs on Turn Zero Cloud

The service in this folder is hosted on Turn Zero Cloud. It started from the platform's `web-app` template: a Node.js service with a web page, sign-in for its users, each user's own rows in the platform's database, and an AI model under the plan's allowance. This file describes the template the service started from: keep it current as the service changes.

## Where you build

- The page: `client/`, plain HTML, CSS, and script, which the platform serves at the application's address.
- The routes: `src/server.mjs`. The service started with routes under `/api/` that each read the signed-in user, and keep and list that user's own rows.
- The AI model: `src/ai.mjs`, which calls it under the plan's allowance, with no key of your own.
- The tables: `migrations/`, one file for each change, applied in order at each start.

## Where the rules are

Turn Zero Cloud serves its rules to a coding tool through its MCP server, `turnzero-cloud`, and they change with the platform, so none is repeated here.

- Where the server is not connected, connect it first. The platform's page "Connect your AI tool" says how.
- Before you change or deploy this service, read the server's `deploy` skill, and before you change its sign-in or its AI call, the server's `add-sign-in` or `use-ai-allowance` skill.

## What you run here

- The tests: `npm test`, in this folder.
- A local run and a deploy: the `deploy` skill gives each step and the line to run, in this order: `submit_manifest`, the local run, `deploy`.
- Sign-in is on the deployed application's own address, also during a local run, since the page a local run serves has no sign-in routes. A local run's API routes take a session as a Bearer.
```

### client/app.js

```javascript
// client/app.js: the page's script. It calls the backend on the same
// address, so the browser sends the sign-in cookie with each request.

// The rows' name, and the route that adds and lists the signed-in user's rows.
const PLURAL = 'notes';
const ROUTE = '/api/notes';

// The fields of one row, each with its kind: the form's inputs and the list's columns.
const FIELDS = [{ name: 'title', kind: 'text' }];

// The input each kind of field takes.
const INPUT_TYPES = { text: 'text', integer: 'number', boolean: 'checkbox', timestamp: 'datetime-local' };

// Why the AI model gave no answer, by the error the route answers with 503.
const UNANSWERED = {
  allowance_drawn: "This month's AI allowance is drawn, so the model gives no answer until it resets.",
  allowance_unavailable: 'The platform holds no key for the AI allowance yet, so the model gives no answer now.',
  upstream_quota: 'The AI model is out of capacity now: ask again in a while.',
};

const byId = (id) => document.getElementById(id);

// A field's value as the route takes it: a string, a number, true or false, or an ISO time.
function valueOf(field, input) {
  if (field.kind === 'boolean') return input.checked;
  if (input.value === '') return field.kind === 'text' ? '' : null;
  if (field.kind === 'integer') return Number(input.value);
  if (field.kind === 'timestamp') return new Date(input.value).toISOString();
  return input.value;
}

// The signed-in user, from the platform's own route: null where no one is signed in.
async function signedInUser() {
  const response = await fetch('/__account/me');
  if (response.status === 200) return (await response.json()).user;
  if (response.status === 404) {
    byId('who').textContent = "This address serves no sign-in. Sign-in is on the application's deployed address; a local run's API routes take a session as a Bearer.";
    return undefined;
  }
  return null;
}

async function showRows() {
  const response = await fetch(ROUTE);
  const list = byId('rows');
  list.replaceChildren();
  if (response.status !== 200) return;
  for (const row of await response.json()) {
    const item = document.createElement('li');
    item.textContent = FIELDS.map((field) => (FIELDS.length === 1 ? String(row[field.name]) : `${field.name}: ${row[field.name]}`)).join(', ');
    list.append(item);
  }
}

function buildForm() {
  const inputs = FIELDS.map((field) => {
    const label = document.createElement('label');
    label.textContent = field.name;
    const input = document.createElement('input');
    input.type = INPUT_TYPES[field.kind];
    input.name = field.name;
    if (field.kind !== 'boolean') input.required = true;
    label.append(input);
    byId('fields').append(label);
    return input;
  });
  byId('add').addEventListener('submit', async (event) => {
    event.preventDefault();
    const row = Object.fromEntries(FIELDS.map((field, index) => [field.name, valueOf(field, inputs[index])]));
    const response = await fetch(ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(row) });
    const error = byId('add-error');
    error.hidden = response.status === 201;
    if (response.status !== 201) {
      error.textContent = `The row was not added (status ${response.status}).`;
      return;
    }
    byId('add').reset();
    await showRows();
  });
}

function buildAsk() {
  byId('ask').addEventListener('submit', async (event) => {
    event.preventDefault();
    const answer = byId('answer');
    answer.textContent = 'Asking.';
    const response = await fetch('/api/ask', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ question: byId('question').value }) });
    if (response.status === 200) answer.textContent = (await response.json()).answer;
    else if (response.status === 503) answer.textContent = UNANSWERED[(await response.json()).error] ?? 'The AI model gave no answer now.';
    else answer.textContent = `The question was not answered (status ${response.status}).`;
  });
}

async function start() {
  const user = await signedInUser();
  if (user === undefined) return;
  if (user === null) {
    byId('who').textContent = 'No one is signed in.';
    byId('sign-in').hidden = false;
    return;
  }
  const address = user.addresses?.find((held) => held.verified)?.email;
  byId('who').textContent = `Signed in as ${address ?? user.id}.`;
  byId('sign-out').hidden = false;
  byId('rows-title').textContent = `Your ${PLURAL}`;
  byId('signed-in').hidden = false;
  buildForm();
  buildAsk();
  await showRows();
}

start();
```

### client/index.html

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Web app</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <header>
    <h1>Web app</h1>
    <p id="who">Reading who is signed in.</p>
    <nav>
      <a id="sign-in" href="/__account/signin" hidden>Sign in</a>
      <a id="sign-out" href="/__account/signout" hidden>Sign out</a>
    </nav>
  </header>
  <main id="signed-in" hidden>
    <section>
      <h2 id="rows-title">Your rows</h2>
      <form id="add">
        <div id="fields"></div>
        <button type="submit">Add</button>
      </form>
      <p id="add-error" class="error" hidden></p>
      <ul id="rows"></ul>
    </section>
    <section>
      <h2>Ask the AI model</h2>
      <form id="ask">
        <textarea id="question" rows="3" required></textarea>
        <button type="submit">Ask</button>
      </form>
      <p id="answer"></p>
    </section>
  </main>
  <script src="/app.js"></script>
</body>
</html>
```

### client/styles.css

```css
/* client/styles.css */
:root {
  color-scheme: light dark;
  font-family: system-ui, sans-serif;
  line-height: 1.5;
}

body {
  max-width: 40rem;
  margin: 0 auto;
  padding: 1rem;
}

header nav a {
  margin-right: 1rem;
}

form {
  display: grid;
  gap: 0.5rem;
  margin: 1rem 0;
}

label {
  display: grid;
  gap: 0.25rem;
}

input,
textarea,
button {
  font: inherit;
  padding: 0.4rem;
}

button {
  justify-self: start;
}

ul {
  padding-left: 1.2rem;
}

.error {
  color: #b00020;
}
```

### manifest.json

```json
{
  "manifest_version": 1,
  "services": [{ "kind": "database" }, { "kind": "accounts" }],
  "health": "/health",
  "region": "usa",
  "egress": [],
  "audience": { "kind": "public" },
  "packages": []
}
```

### migrations/0001_notes.sql

```sql
-- migrations/0001_notes.sql: each row is kept for the user who added it, its owner.
CREATE TABLE "notes" (
  id bigserial PRIMARY KEY,
  owner text NOT NULL,
  title text NOT NULL CHECK (title <> '')
);
CREATE INDEX ON "notes" (owner, id);
```

### package.json

```json
{
  "name": "web-app",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/main.mjs",
    "test": "node --test"
  },
  "dependencies": {
    "@turnzero/account": "file:lib/account",
    "@turnzero/ai_allowance": "file:lib/ai_allowance",
    "@turnzero/database": "file:lib/database",
    "pg": "^8.22.0"
  },
  "devDependencies": {
    "@electric-sql/pglite": "0.5.8"
  }
}
```

### src/ai.mjs

```javascript
// src/ai.mjs: an answer from an AI model under the plan's AI allowance, with
// no key of your own, as the guide Call an AI model under the allowance shows.
import { AllowanceRefusal, AllowanceUpstreamError, generateText } from '@turnzero/ai_allowance';

// The live transport, which only the process entry opens: the platform's
// origin and this copy's credential, from the settings it receives. A local
// run receives the development credential, so the same transport works there.
// Where the credential is not set, it sends nothing and says which line writes it.
export function liveTransport() {
  const origin = process.env.TURNZERO_CLOUD_GATEWAY_URL ?? process.env.TURNZERO_CLOUD_API;
  return (path, init = {}) => {
    const credential = process.env.TURNZERO_CLOUD_APP_TOKEN;
    if (!credential) throw new Error('TURNZERO_CLOUD_APP_TOKEN is not set, so no request was sent. Run the provision line again, then start the app again.');
    return fetch(origin + path, { ...init, headers: { ...init.headers, authorization: `Bearer ${credential}` } });
  };
}

// The asker over any transport: the live one, or a test double's. It answers
// { answer } for one question, or { unanswered } naming why the model gave
// none: allowance_drawn, the month's units are drawn (the refusal
// allowance_exhausted); allowance_unavailable, the platform's key is not in
// place; or upstream_quota, the provider is out of capacity.
export function createAsker(transport) {
  return async (question) => {
    try {
      const answer = await generateText(transport, {
        system: 'Answer the question in a few sentences.',
        messages: [{ role: 'user', content: question }],
        maxOutputTokens: 400,
      });
      const { used, quota } = answer.allowance;
      if (typeof used === 'number' && typeof quota === 'number') console.log(`AI allowance: ${quota - used} of ${quota} units left before this call`);
      return { answer: answer.text };
    } catch (error) {
      // Choose the fallback by the refusal's name, never by its message.
      const refusal = error instanceof AllowanceRefusal ? error.refusal : null;
      if (refusal === 'allowance_exhausted') return { unanswered: 'allowance_drawn' };
      if (refusal === 'allowance_unavailable') return { unanswered: 'allowance_unavailable' };
      const providerFull = error instanceof AllowanceUpstreamError && error.quota;
      if (providerFull) return { unanswered: 'upstream_quota' };
      throw error;
    }
  };
}
```

### src/db.mjs

```javascript
// src/db.mjs
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { bootWalk, createFailoverPool, migrationsFrom } from '@turnzero/database';

const MIGRATIONS = fileURLToPath(new URL('../migrations/', import.meta.url));
const HEALTH_READ_MS = 2_000;

// The live pool, which only the process entry opens: the package's failover
// pool over a pg pool. Its maximum is the plan's connection_limit, which the
// platform injects and the turnzero command's provision writes; one
// connection where the setting is absent.
export function openLivePool() {
  const url = process.env.APP_DATABASE_URL;
  if (!url) throw new Error('APP_DATABASE_URL is not set');
  const limit = process.env.APP_DATABASE_CONNECTION_LIMIT ?? '';
  const max = /^[1-9][0-9]*$/.test(limit) ? Number(limit) : 1;
  return createFailoverPool({
    // settings is { max, connectionTimeoutMillis }: the maximum and the five-second connect limit.
    connect: (settings) => new pg.Pool({ connectionString: url, ...settings }),
    max,
    onConnectionLoss: (error) => console.warn('database session ended:', error.message),
  });
}

// The migrations through the package's boot walk: a lost connection retried
// every second for up to 120 seconds, and any other failure ends the start.
export function migrate(pool) {
  return bootWalk(pool, migrationsFrom(MIGRATIONS), {
    onRetry: (error, attempt) => {
      if (attempt === 1) console.warn('database unreachable at start; retrying:', error.message);
    },
  });
}

// The data layer over any pool with the pg.Pool shape: the live one, or a test double's.
// Each row belongs to the signed-in user who added it, its owner, and is listed to that user alone.
export function createDataLayer(pool) {
  return {
    // One read over the connection, bounded so a probe never waits on a lost database.
    async reachable() {
      let timer;
      const late = new Promise((resolve) => { timer = setTimeout(resolve, HEALTH_READ_MS, 'late'); });
      try {
        return (await Promise.race([pool.query('SELECT 1'), late])) !== 'late';
      } catch {
        return false;
      } finally {
        clearTimeout(timer);
      }
    },
    async addNote(owner, title) {
      const { rows } = await pool.query('INSERT INTO "notes" (owner, title) VALUES ($1, $2) RETURNING id, title', [owner, title]);
      return rows[0];
    },
    async listNotes(owner) {
      const { rows } = await pool.query('SELECT id, title FROM "notes" WHERE owner = $1 ORDER BY id', [owner]);
      return rows;
    },
  };
}
```

### src/main.mjs

```javascript
// src/main.mjs, the process entry the start script runs
import { createAsker, liveTransport } from './ai.mjs';
import { createDataLayer, migrate, openLivePool } from './db.mjs';
import { createUserReader, openLiveAccounts } from './session.mjs';
import { createAppServer } from './server.mjs';

const pool = openLivePool();
const applied = await migrate(pool);
console.log(applied.length > 0 ? `migrations applied at this start: ${applied.join(', ')}` : 'migrations: none pending at this start');
const port = Number(process.env.PORT ?? 8080);
const server = createAppServer({ db: createDataLayer(pool), userIdOf: createUserReader(openLiveAccounts()), ask: createAsker(liveTransport()) });
server.listen(port, () => console.log(`listening on ${port}`));
```

### src/server.mjs

```javascript
// src/server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

// The web client's three files, by the path a browser asks for them. On the
// platform, the deploy serves them from client/ ahead of these routes. A
// local run has nothing in front of it, so this server answers them itself.
const CLIENT_FILES = Object.freeze({
  '/': ['index.html', 'text/html; charset=utf-8'],
  '/index.html': ['index.html', 'text/html; charset=utf-8'],
  '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
  '/styles.css': ['styles.css', 'text/css; charset=utf-8'],
});

const readBody = async (request) => {
  // One decoder for the whole body: a character whose bytes arrive in two chunks is read whole.
  request.setEncoding('utf8');
  let text = '';
  for await (const chunk of request) text += chunk;
  return text;
};

// The server over its three parts: db, the data layer; userIdOf, which reads
// the signed-in user's id from a request, or null; and ask, which answers a
// question under the AI allowance, or names why the model gave no answer.
export function createAppServer({ db, userIdOf, ask }) {
  return createServer(async (request, response) => {
    // The deployed runtime's abort signal: end the response when it fires.
    request.signal?.addEventListener('abort', () => response.destroy(), { once: true });
    const send = (status, body) => {
      if (response.destroyed) return;
      response.writeHead(status, { 'content-type': 'application/json' });
      response.end(JSON.stringify(body));
    };
    try {
      // A target that does not parse as a URL names no route, so it is answered 404 below.
      let pathname = null;
      try {
        pathname = new URL(request.url, 'http://localhost').pathname;
      } catch {}
      if (request.method === 'GET' && pathname === '/health') {
        return (await db.reachable()) ? send(200, { ok: true }) : send(503, { ok: false });
      }
      if (request.method === 'GET' && Object.hasOwn(CLIENT_FILES, pathname)) {
        const [file, type] = CLIENT_FILES[pathname];
        try {
          const body = await readFile(new URL(`../client/${file}`, import.meta.url));
          if (!response.destroyed) response.writeHead(200, { 'content-type': type }).end(body);
          return;
        } catch (error) {
          if (error?.code === 'ENOENT') return send(404, { error: 'not_found' });
          throw error;
        }
      }
      // Every route under /api/ answers the signed-in user alone.
      if (pathname === '/api/notes' || pathname === '/api/ask') {
        const user = await userIdOf(request);
        if (user === null) return send(401, { error: 'sign_in_required' });
        if (request.method === 'GET' && pathname === '/api/notes') return send(200, await db.listNotes(user));
        if (request.method === 'POST' && pathname === '/api/notes') {
          const posted = JSON.parse(await readBody(request));
          return send(201, await db.addNote(user, String(posted?.title ?? '')));
        }
        if (request.method === 'POST' && pathname === '/api/ask') {
          let question = '';
          try {
            question = String(JSON.parse(await readBody(request))?.question ?? '').trim();
          } catch {}
          if (question === '') return send(400, { error: 'invalid_question' });
          // 503 names why the model gave no answer: allowance_drawn,
          // allowance_unavailable, or upstream_quota (src/ai.mjs).
          const asked = await ask(question);
          return 'answer' in asked ? send(200, { answer: asked.answer }) : send(503, { error: asked.unanswered });
        }
      }
      send(404, { error: 'not_found' });
    } catch (error) {
      const refused = error instanceof SyntaxError ? 'invalid_json' : error?.code === '23514' ? 'invalid_note' : null;
      if (refused === null) console.error(error);
      send(refused === null ? 500 : 400, { error: refused ?? 'internal' });
    }
  });
}
```

### src/session.mjs

```javascript
// src/session.mjs: the signed-in user who sent a request, as the guide Add
// sign-in to your app shows it.
import { makeVerifyClient } from '@turnzero/account';

// The cookie a signed-in browser sends with each request to the application's address.
const SESSION_COOKIE = '__Host-turnzero_cloud_realm';

// The live client, which only the process entry opens. A deployed copy
// receives all three settings. A local run has no TURNZERO_CLOUD_REALM_KEYS,
// so the client is built without keys there and verifies through the
// platform's route. Where the credential is not set, the start stops and
// says which line writes it.
export function openLiveAccounts() {
  const keys = process.env.TURNZERO_CLOUD_REALM_KEYS;
  if (!process.env.TURNZERO_CLOUD_APP_TOKEN) throw new Error('TURNZERO_CLOUD_APP_TOKEN is not set, so no session can be verified. Run the provision line again, then start the app again.');
  return makeVerifyClient({
    baseUrl: process.env.TURNZERO_CLOUD_API,
    credential: process.env.TURNZERO_CLOUD_APP_TOKEN,
    ...(keys ? { realmKeys: keys } : {}),
  });
}

// The session token: the cookie's value from a browser, or the bearer from a
// native app or a local run.
export function sessionOf(request) {
  const auth = request.headers.authorization ?? '';
  if (auth.startsWith('Bearer ')) return auth.slice('Bearer '.length);
  for (const part of (request.headers.cookie ?? '').split(';')) {
    const at = part.indexOf('=');
    if (at > 0 && part.slice(0, at).trim() === SESSION_COOKIE) return part.slice(at + 1).trim();
  }
  return null;
}

// The reader of a request's user: the signed-in user's id, or null with no
// live session. The token is verified by the Account package's client, with
// the x-turnzero-cloud-session header the serving router sets on each
// request it forwards.
export function createUserReader(accounts) {
  return async (request) => {
    const verdict = await accounts.verifySession(sessionOf(request), {
      sessionHeader: request.headers['x-turnzero-cloud-session'] ?? null,
    });
    return 'user' in verdict ? verdict.user.id : null;
  };
}
```

### test/app.test.mjs

```javascript
// test/app.test.mjs
import assert from 'node:assert/strict';
import { after, before, beforeEach, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { PGlite } from '@electric-sql/pglite';
import { createAccountDouble } from '@turnzero/account/testing';
import { createAiAllowanceDouble } from '@turnzero/ai_allowance/testing';
import { createDatabaseDouble, migrationsFrom } from '@turnzero/database/testing';
import { createAsker } from '../src/ai.mjs';
import { createDataLayer } from '../src/db.mjs';
import { createAppServer } from '../src/server.mjs';
import { createUserReader } from '../src/session.mjs';

const MIGRATIONS = fileURLToPath(new URL('../migrations/', import.meta.url));

// The packages' three test doubles for the file: a PostgreSQL engine inside
// this process, with the migrations applied; the realm's sign-in; and the AI
// allowance. The server runs over them on a port of this machine.
let double;
let accounts;
let ai;
let db;
let server;
let base;
// Two signed-in users, and the headers a browser of each sends.
let sam;
let kim;
const signedIn = (user) => ({ cookie: `__Host-turnzero_cloud_realm=${user.session}`, 'x-turnzero-cloud-session': user.header });

before(async () => {
  double = await createDatabaseDouble({ engine: new PGlite(), files: migrationsFrom(MIGRATIONS) });
  accounts = await createAccountDouble();
  ai = createAiAllowanceDouble();
  db = createDataLayer(double.pool);
  server = createAppServer({ db, userIdOf: createUserReader(accounts.client()), ask: createAsker(ai.transport) });
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  base = `http://127.0.0.1:${server.address().port}`;
  sam = accounts.signIn({ id: 'user-sam' });
  kim = accounts.signIn({ id: 'user-kim' });
});

// Every table emptied and the allowance's answers cleared between tests, so each test starts from empty.
beforeEach(() => {
  ai.reset();
  return double.reset();
});

after(async () => {
  await new Promise((resolve) => server.close(resolve));
  await double.close();
});

test('the migration applies', () => {
  assert.deepEqual(double.applied, ['0001_notes.sql']);
});

test('the data layer adds a row for its owner and lists it to that owner alone', async () => {
  assert.deepEqual(await db.addNote('user-sam', 'first'), { id: '1', title: 'first' });
  assert.deepEqual(await db.listNotes('user-sam'), [{ id: '1', title: 'first' }]);
  assert.deepEqual(await db.listNotes('user-kim'), []);
});

test('GET /health reads the database and returns 200', async () => {
  const response = await fetch(`${base}/health`);
  assert.equal(response.status, 200);
  assert.deepEqual(await response.json(), { ok: true });
});

test('a local run serves the page, its script, and its stylesheet from client/', async () => {
  for (const [path, type] of [['/', 'text/html'], ['/app.js', 'text/javascript'], ['/styles.css', 'text/css']]) {
    const response = await fetch(`${base}${path}`);
    assert.equal(response.status, 200, path);
    assert.ok(response.headers.get('content-type').startsWith(type), path);
    assert.ok((await response.text()).length > 0, path);
  }
});

test('the API answers 401 to a request with no session, and reads a session sent as a Bearer', async () => {
  for (const path of ['/api/notes', '/api/ask']) {
    const response = await fetch(`${base}${path}`, { method: path === '/api/ask' ? 'POST' : 'GET' });
    assert.equal(response.status, 401, path);
    assert.deepEqual(await response.json(), { error: 'sign_in_required' });
  }
  const bearer = await fetch(`${base}/api/notes`, { headers: { authorization: `Bearer ${sam.session}`, 'x-turnzero-cloud-session': sam.header } });
  assert.equal(bearer.status, 200);
});

test('POST /api/notes keeps a title with characters outside ASCII for the signed-in user, and GET /api/notes returns it to that user alone', async () => {
  const title = 'café ☕ 東京';
  const response = await fetch(`${base}/api/notes`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...signedIn(sam) },
    body: JSON.stringify({ title }),
  });
  assert.equal(response.status, 201);
  assert.equal((await response.json()).title, title);
  const listed = await fetch(`${base}/api/notes`, { headers: signedIn(sam) });
  assert.equal(listed.status, 200);
  assert.deepEqual((await listed.json()).map((row) => row.title), [title]);
  assert.deepEqual(await (await fetch(`${base}/api/notes`, { headers: signedIn(kim) })).json(), []);
});

test('POST /api/notes refuses an empty title, and GET /api/notes returns no row', async () => {
  const response = await fetch(`${base}/api/notes`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...signedIn(sam) },
    body: JSON.stringify({ title: '' }),
  });
  assert.equal(response.status, 400);
  assert.deepEqual(await response.json(), { error: 'invalid_note' });
  assert.deepEqual(await (await fetch(`${base}/api/notes`, { headers: signedIn(sam) })).json(), []);
});

test('POST /api/notes answers 400 invalid_json for a body that is not JSON, and GET /api/notes returns no row', async () => {
  const response = await fetch(`${base}/api/notes`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...signedIn(sam) },
    body: '{',
  });
  assert.equal(response.status, 400);
  assert.deepEqual(await response.json(), { error: 'invalid_json' });
  assert.deepEqual(await (await fetch(`${base}/api/notes`, { headers: signedIn(sam) })).json(), []);
});

test('POST /api/ask answers under the AI allowance, and answers 503 naming why the model gave no answer: the allowance drawn, no key in place, or the provider out of capacity', async () => {
  ai.prime({ text: 'Lunch is at noon.', usage: { input: 40, output: 12 } });
  const ask = (question) =>
    fetch(`${base}/api/ask`, { method: 'POST', headers: { 'content-type': 'application/json', ...signedIn(sam) }, body: JSON.stringify({ question }) });
  const answered = await ask('When is lunch?');
  assert.equal(answered.status, 200);
  assert.deepEqual(await answered.json(), { answer: 'Lunch is at noon.' });
  const unanswered = async (error) => {
    const response = await ask('When is lunch?');
    assert.equal(response.status, 503, error);
    assert.deepEqual(await response.json(), { error });
  };
  ai.refuseNext('allowance_exhausted');
  await unanswered('allowance_drawn');
  ai.refuseNext('allowance_unavailable');
  await unanswered('allowance_unavailable');
  ai.prime({ status: 429, json: { error: { status: 'RESOURCE_EXHAUSTED' } } });
  await unanswered('upstream_quota');
  const empty = await ask('');
  assert.equal(empty.status, 400);
});
```

