# Dashboard: complete application

> Documentation for Redweb 0.13.3. Install that exact version when following these examples.

# Persistent private dashboard

This recipe combines decorator-first pages, reusable live cards, validated actions, and real SQLite persistence. It is an application example, not an authentication framework or managed database.

## Run it

Requires **Node 22.13 or newer** (native `node:sqlite`, experimental in Node 22) and npm. Other Redweb starters retain their own Node requirements. Use a supported Node release in production.

After installing dependencies, create your account:

```sh
npm run add-user -- alice
npm test
npm run dev
```

The provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.

`npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.

`npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage. It also waits through the actual one-minute login admission window without mocking the clock. The report includes TypeScript-generated decorator accessor functions; inspect that distinction rather than assuming a library coverage figure applies to this recipe. The generated npm configuration enforces this recipe's Node engine requirement before installation.

## Where the behavior lives

- `app.tsx`: composition, login page, protected dashboard, listener and shutdown.
- `cards.tsx`: reusable `Cards` component and account-scoped live subscriptions. Normal TSX expressions update automatically. Forms call typed actions; feedback requires no browser glue.
- `store.ts`: prepared SQL, bounded cards/sessions, owner-filtered operations and synchronous transactions.
- `auth.ts`: asynchronous scrypt, bounded login attempts, hashed session tokens, cookies and sign-out.
- `admin.ts`: explicit local account provisioning.

## Production boundaries

Set `DASHBOARD_DATABASE` to a writable persistent file path (default `data/dashboard.sqlite`). Protect the directory with OS permissions: the database contains password hashes, private card text, and session metadata. It, its WAL/SHM files, and backups must never be served as public assets or committed. Stop the process cleanly before copying the database for a backup, or use a proper SQLite online backup facility; copying only the main file during live WAL writes is not a backup plan.

Set `NODE_ENV=production` and `DASHBOARD_ORIGIN=https://your-domain.example` behind an HTTPS reverse proxy. The origin must have no path or trailing slash. This enables Secure cookies; all session cookies are HttpOnly and SameSite=Strict. Both login/logout forms and socket upgrades require the exact trusted origin. The application does not trust Host or forwarded headers to establish origin or identity. The HTTP listener must not be publicly reachable around your TLS proxy.

Provision accounts on the same persistent volume before serving requests. Passwords use salted scrypt; only hashes of random session tokens are stored. Default sessions last one hour. Up to 32 unexpired sessions and 100 cards per account are supported. Login work is limited to four simultaneous checks and ten attempts per minute per direct peer IP, with at most 1,024 tracked IPs; clients behind one proxy share its bucket. Add appropriate proxy-level abuse controls for an Internet deployment. There is no registration, password reset, MFA, or account recovery; integrate a dedicated identity provider if your product needs those features.

SQL checks the current session and card owner inside each write transaction. Private subscriptions recheck session validity before publishing and close at expiry. Sign-out invalidates credentials before revoking Redweb sessions. An expired or disconnected page may need a reload/sign-in; the recipe does not silently retry actions with uncertain outcomes.

This is a **single-process live-update model**. SQLite transactions are synchronous and kept small; this is not a claim of unlimited concurrency. Do not put multiple app workers behind a load balancer and expect cross-worker notifications or revocation. Add a deliberate shared notification/session-revocation design before scaling horizontally. Static export cannot include protected dashboards.

Build and deploy using the shared instructions above, including the persistent data volume and the environment settings here. Neither `npm run dev` nor a process restart should erase durable cards. Redweb itself does not depend on SQLite.


## Setup and acceptance

```sh
npx --yes redweb@0.13.3 init my-dashboard --template dashboard
cd my-dashboard
npm install --save-exact redweb@0.13.3
npm run add-user -- alice
npm test
npm run dev
```


Requirements: Node.js 18 or newer and npm for the realtime, chat, site, socket and http-ws templates; the dashboard template requires Node.js 22.13+ for native SQLite. Use a currently supported Node.js release in production.

For an unreleased checkout or tarball, first run `npm install --save-exact TARBALL`, replacing `TARBALL` with the absolute path to the same tested Redweb tarball used to generate this app (quote paths containing spaces). This installs the matching package and its published client dependency. Do not substitute an older registry release or `latest`. Published Redweb releases can use the installation command below directly.

```sh
npm install
npm test
npm run dev
```

HTTP starters open at http://localhost:8181; the authenticated dashboard uses http://127.0.0.1:8181/login and requires account provisioning described below. Set the `PORT` environment variable to change the listener.
`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.
`npm run test:coverage` runs the same tests with application coverage mapped back to TypeScript. Reports are written to the ignored `coverage/` directory; this is separate from Redweb library coverage. TypeScript-generated decorator accessors can appear in function counts even when the framework does not call them. The report exposes remaining gaps; it does not certify complete application coverage. Source maps are generated during the build for diagnostics and coverage, but no coverage collector is loaded by `npm start`.

## Development and production

Edit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,
then rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,
HTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible
notice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,
not autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.
The generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.
The refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),
and creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;
custom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.
`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.
Run `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,
then install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.

The standalone entrypoint calls the shared `runApp(createApp)` helper. Importing either module starts no listener and installs no process handlers. On SIGINT/SIGTERM, a listener error, or native listener closure, the helper calls application shutdown once. Repeated signals do not bypass cleanup. The five-second outer deadline covers the whole application, including database/worker cleanup after HTTP closes; customize it with the helper's second argument if necessary. Cleanup must resolve only after resources are released. A failed cleanup sets a failure exit status and retains a deadline for any surviving handles; the helper never resets an existing failure status. If cleanup does not finish in time, the entrypoint terminates the process with a failure status. This cannot preempt synchronous code blocking Node's event loop and does not make in-memory state durable. Factory functions remain responsible for releasing partially constructed resources before throwing.

The shipped lifecycle tests exercise actual processes, HTTP/TCP/WebSocket peers and timers. Linux uses actual OS signals; Windows tests explicitly emit signal events inside the process because killing a Windows child does not exercise graceful POSIX signal delivery. This is not a claim that Windows console/service managers forward the same signals. Deploy with a supervisor that forwards the supported termination signal and allows longer than the configured cleanup deadline.

For public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,
and application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.
Never commit secrets; `.env` is ignored but is not loaded automatically.

`npx --no-install redweb doctor --json` reports configuration problems without changing your files.


## Exact generated files

These files come from the initializer itself. The tests below run real listeners; they are not illustrative pseudocode. The generated manifest uses the package metadata version; the installation step above pins the matching artifact or release.

### package.json

```json
{
  "name": "redweb-app",
  "private": true,
  "version": "0.0.0",
  "scripts": {
    "build": "tsc && node scripts/copy-assets.cjs",
    "start": "node dist/app.js",
    "dev": "nodemon",
    "test": "npm run build && node --test test/app.test.cjs test/run-app.test.cjs",
    "test:coverage": "npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/run-app.test.cjs test/rate-window.test.cjs",
    "add-user": "npm run build && node dist/admin.js"
  },
  "dependencies": {
    "redweb": "^0.13.3",
    "zod": "^4.4.3",
    "express": "^4.22.2"
  },
  "devDependencies": {
    "typescript": "^5.9.3",
    "nodemon": "^3.1.11",
    "ws": "^8.21.3",
    "c8": "^10.1.3",
    "@types/node": "^22.20.1",
    "@types/express": "^4.17.21"
  },
  "nodemonConfig": {
    "env": {
      "REDWEB_DEV_REFRESH": "1"
    },
    "watch": [
      "src",
      "tsconfig.json"
    ],
    "ext": "ts,tsx,css,html,json",
    "exec": "npm run build && npm start || exit 1",
    "delay": 200
  },
  "engines": {
    "node": ">=22.13.0"
  }
}
```

### tsconfig.json

```json
{
  "extends": "redweb/tsconfig.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx"
  ]
}
```

### src/app.tsx

```tsx
import express, { type ErrorRequestHandler } from 'express';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { page, start, type LivePageRequestContext } from 'redweb';
import { DashboardAuth, sessionToken } from './auth';
import { Cards, PrivateCards } from './cards';
import { DashboardStore } from './store';
import { runApp } from './run-app';

export interface DashboardOptions { port?: number; database?: string; origin?: string; sessionLifetimeMs?: number; }

export function databasePath() { return resolve(process.env.DASHBOARD_DATABASE ?? 'data/dashboard.sqlite'); }

export function createApp(options: DashboardOptions = {}) {
    const port = options.port ?? Number(process.env.PORT ?? 8181);
    const configuredOrigin = options.origin ?? process.env.DASHBOARD_ORIGIN;
    if (configuredOrigin && (!/^https?:$/.test(new URL(configuredOrigin).protocol) || new URL(configuredOrigin).origin !== configuredOrigin)) {
        throw new Error('DASHBOARD_ORIGIN must be an exact HTTP(S) origin without a path.');
    }
    if (process.env.NODE_ENV === 'production' && !configuredOrigin?.startsWith('https://')) throw new Error('Production requires an explicit HTTPS DASHBOARD_ORIGIN.');
    const filename = options.database ?? databasePath();
    mkdirSync(dirname(filename), { recursive: true });
    const store = new DashboardStore(filename);
    try {
    const cards = new PrivateCards(store);
    const auth = new DashboardAuth(store, options.sessionLifetimeMs);
    const app = express();
    app.disable('x-powered-by');
    app.use(express.urlencoded({ extended: false, limit: '4kb', parameterLimit: 4 }));
    const invalidBody: ErrorRequestHandler = (_error, _request, response, _next) => {
        if (!response.destroyed) response.status(400).send('Invalid form submission.');
    };
    app.use(invalidBody);
    const origin = () => configuredOrigin ?? `http://127.0.0.1:${(server.server.address() as { port: number }).port}`;

    @page('/login', { live: false, css: 'app.css', head: { title: 'Sign in · Your cards' } })
    class Login {
        render() {
            return <main class="home"><h1>Your private workspace</h1>
                <p>Sign in with the credentials created by your administrator.</p>
                <form method="post" action="/login">
                    <label for="account">Account</label><input id="account" name="account" autocomplete="username" required />
                    <label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required />
                    <button type="submit">Sign in</button>
                </form>
            </main>;
        }
    }

    @page('/', { css: 'app.css', authorize: context => cards.allowed(context), head: { title: 'Your cards' } })
    class Dashboard {
        private readonly workspace = new Cards(cards);
        render(context: LivePageRequestContext) {
            return <main class="home"><header><div><h1>Your cards</h1><p>Signed in as {context.principal}</p></div>
                <form method="post" action="/logout"><button type="submit">Sign out all sessions</button></form>
            </header>{this.workspace}<p>Open another tab to see your changes instantly.</p></main>;
        }
    }

    auth.mount(app, origin, account => server.revoke(account));
    const server = start([Login, Dashboard], {
        server: app, port, bind: configuredOrigin ? '0.0.0.0' : '127.0.0.1', logger: null, templateRoot: __dirname,
        origins: value => value === origin(),
        authenticate: request => request.method === 'GET' && request.url?.split('?')[0] === '/login'
            ? true : store.session(sessionToken(request.headers.cookie))?.account,
    });
    let closing: Promise<void> | undefined;
    const shutdown = () => {
        auth.close();
        if (!closing) {
            closing = server.shutdown().finally(() => store.close());
        }
        return closing;
    };
    server.server.once('error', () => { void shutdown().catch(() => {}); });
    return {
        server: server.server,
        shutdown,
    };
    } catch (error) { store.close(); throw error; }
}

if (require.main === module) {
    const app = runApp(createApp);
    app?.server.once('listening', () => console.log(`Dashboard: ${process.env.DASHBOARD_ORIGIN ?? `http://127.0.0.1:${(app.server.address() as { port: number }).port}`}/login`));
}
```

### src/run-app.ts

```ts
import type { Server } from 'node:http';

interface Application { server: Server; shutdown(): Promise<void>; }

/** Entry-point policy only: importing a recipe never installs process handlers. */
export function runApp<T extends Application>(createApp: () => T, shutdownTimeoutMs = 5000): T | undefined {
    if (!Number.isInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 1 || shutdownTimeoutMs > 2147483647) {
        throw new RangeError('Application shutdown timeout must be a positive timer-safe integer.');
    }
    const fail = (message: string) => {
        console.error(message);
        if (Number(process.exitCode ?? 0) === 0) process.exitCode = 1;
    };
    let app: T;
    try { app = createApp(); }
    catch { fail('Application startup failed.'); return undefined; }

    let closing: Promise<void> | undefined;
    const stop = () => {
        if (!closing) {
            let failed = false;
            const deadline = setTimeout(() => {
                fail('Application cleanup exceeded its deadline; terminating the process.');
                process.exit();
            }, shutdownTimeoutMs);
            closing = Promise.resolve().then(() => app.shutdown()).catch(() => {
                failed = true;
                fail('Application cleanup failed.');
            }).finally(() => {
                // Failed cleanup may leave live handles. Permit natural exit if none
                // remain, but still force a bounded exit when resources were leaked.
                if (failed) { deadline.unref(); return; }
                clearTimeout(deadline);
                process.off('SIGINT', stop);
                process.off('SIGTERM', stop);
                app.server.off('error', onError);
                app.server.off('close', stop);
            });
        }
        return closing;
    };
    const onError = () => { fail('Application listener failed.'); void stop(); };
    // Persistent handlers keep repeated signals from bypassing active cleanup.
    process.on('SIGINT', stop);
    process.on('SIGTERM', stop);
    app.server.on('error', onError);
    // Native close can precede database/worker cleanup: it starts, never ends, shutdown.
    app.server.once('close', stop);
    return app;
}
```

### src/app.css

```css
:root { font-family: system-ui, sans-serif; color: #e8edf5; background: #111827; color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; }
.home { width: min(64rem, 100%); margin: 3rem auto; padding: 0 1.5rem; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
form { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; }
input, button { font: inherit; border: 1px solid #526179; border-radius: .5rem; padding: .75rem; }
input { background: #1f2937; max-width: 100%; }
button { background: #a7f3d0; color: #102c23; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
:focus-visible { outline: 3px solid #60a5fa; outline-offset: 3px; }
.cards { margin-top: 2rem; }
.card-grid { padding: 0; list-style: none; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; }
.card-grid li { border: 1px solid #526179; border-radius: .75rem; padding: 1.25rem; overflow-wrap: anywhere; }
h2 { font-size: 1.25rem; }
[role="alert"] { color: #fca5a5; }
```

### scripts/copy-assets.cjs

```js
const fs = require('node:fs');
const path = require('node:path');

// Keep runtime assets beside the compiled classes. Production needs only dist/ and dependencies.
fs.cpSync('src', 'dist', {
    recursive: true,
    filter: file => fs.statSync(file).isDirectory() || ['.css', '.html'].includes(path.extname(file)),
});
```

### test/network.cjs

```js
const assert = require('node:assert/strict');
const { once } = require('node:events');
const WebSocket = require('ws');
const { createApp } = require('../dist/app.js');

async function listen(t) {
    const app = createApp({ port: 0, bind: '127.0.0.1', logger: null });
    t.after(() => app.shutdown());
    if (!app.server.listening) await once(app.server, 'listening');
    return `http://127.0.0.1:${app.server.address().port}`;
}

async function connect(t, url, origin, headers = {}) {
    const socket = new WebSocket(url, { headers: { ...headers, Origin: origin } });
    const messages = [];
    socket.on('message', raw => messages.push(JSON.parse(raw.toString())));
    t.after(async () => {
        if (socket.readyState === WebSocket.CLOSED) return;
        const closed = once(socket, 'close');
        // Cleanup must not depend on a peer completing the closing handshake.
        // Tests of graceful disconnect explicitly close and await their sockets.
        socket.terminate();
        await closed;
    });
    await once(socket, 'open');
    return {
        socket,
        send: message => socket.send(JSON.stringify(message)),
        async receive(predicate) {
            const deadline = Date.now() + 3000;
            while (Date.now() < deadline) {
                const index = messages.findIndex(predicate);
                if (index !== -1) return messages.splice(index, 1)[0];
                await new Promise(resolve => setTimeout(resolve, 10));
            }
            assert.fail(`Timed out waiting for a socket message; received ${JSON.stringify(messages)}`);
        },
    };
}

async function live(t, origin, headers = {}) {
    const response = await fetch(origin, { headers });
    assert.equal(response.status, 200);
    const document = await response.text();
    const config = JSON.parse(document.match(/id="__redweb_page">([^<]+)</)[1]);
    const connection = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, headers);
    return {
        ...connection,
        document, config,
        patch: predicate => connection.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(predicate)),
        action: (name, args = [], component) => connection.send({
            v: config.version, type: 'redweb:html', payload: { kind: 'action', name, args, component },
        }),
        state: (name, value, component) => connection.receive(message => message.type === 'redweb:state' &&
            message.payload.name === name && message.payload.component === component && value(message.payload.value)),
    };
}

module.exports = { listen, connect, live };
```

### test/app.test.cjs

```js
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { once } = require('node:events');
const { mkdtempSync, rmSync, writeFileSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { join } = require('node:path');
const { DatabaseSync } = require('node:sqlite');
const { spawn, spawnSync } = require('node:child_process');
const net = require('node:net');
const { WebSocketServer, WebSocket } = require('ws');
const { createApp, databasePath } = require('../dist/app');
const { DashboardStore } = require('../dist/store');
const { DashboardAuth, credentials, sessionToken } = require('../dist/auth');
const { PrivateCards } = require('../dist/cards');
const { live, connect } = require('./network.cjs');

const password = 'test-only-correct-password';
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

async function fixture(t, options = {}) {
    const directory = mkdtempSync(join(tmpdir(), 'redweb-private-cards-'));
    const database = join(directory, 'cards.sqlite');
    const store = new DashboardStore(database);
    const secret = await credentials(password);
    store.provision('alice', secret);
    store.provision('bob', secret);
    store.close();
    let app;
    t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });
    async function restart() {
        await app?.shutdown();
        app = createApp({ port: 0, database, ...options });
        if (!app.server.listening) await once(app.server, 'listening');
        return `http://127.0.0.1:${app.server.address().port}`;
    }
    return { database, restart, origin: await restart(), get app() { return app; } };
}

function post(origin, path, values, cookie, suppliedOrigin = origin) {
    return fetch(`${origin}${path}`, {
        method: 'POST', redirect: 'manual',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: suppliedOrigin, ...(cookie ? { Cookie: cookie } : {}) },
        body: new URLSearchParams(values),
    });
}

async function login(origin, account = 'alice') {
    const response = await post(origin, '/login', { account, password });
    assert.equal(response.status, 303);
    const header = response.headers.get('set-cookie');
    assert.match(header, /HttpOnly; SameSite=Strict; Path=\//);
    return header.split(';')[0];
}

async function cardClient(t, origin, cookie) {
    const client = await live(t, origin, { Cookie: cookie });
    const component = client.document.match(/data-rw-component="([^"]+)"/)[1];
    await client.patch(patch => patch.id === 'root');
    const parseCards = html => [...html.matchAll(/data-card-id="([^"]+)"[^>]*><h2>([\s\S]*?)<\/h2>/g)].map(match => ({
        id: match[1], title: match[2].replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&quot;', '"').replaceAll('&#39;', "'").replaceAll('&amp;', '&'),
    }));
    return {
        ...client, component,
        add: title => client.action('add', [{ title }], component),
        remove: id => client.action('remove', [{ id }], component),
        items: predicate => client.patch(patch => predicate(parseCards(patch.html))).then(message => parseCards(message.payload.patches.find(patch => predicate(parseCards(patch.html))).html)),
    };
}

test('private live cards: real HTTP, sockets, isolation, reconnect, sign-out and durable restart', async t => {
    const fixtureApp = await fixture(t);
    let { origin } = fixtureApp;
    assert.equal((await fetch(`${origin}/login`)).status, 200);
    assert.equal((await fetch(origin)).status, 401);
    assert.equal((await post(origin, '/login', { account: 'alice', password }, '', 'https://foreign.example')).status, 403);
    assert.equal((await post(origin, '/login', { account: 'alice', password: 'wrong-password-at-least-16' })).status, 401);
    const alice = await login(origin);
    const alice2 = await login(origin);
    const bob = await login(origin, 'bob');
    const page = await fetch(origin, { headers: { Cookie: alice } });
    assert.match(page.headers.get('cache-control'), /private.*no-store/);
    assert.equal(page.headers.get('etag'), null);
    const first = await cardClient(t, origin, alice);
    const second = await cardClient(t, origin, alice2);
    const other = await cardClient(t, origin, bob);
    first.add('Saved <script>alert(1)</script>');
    const [items] = await Promise.all([first.items(value => value.length === 1), second.items(value => value.length === 1)]);
    assert.equal(items[0].title, 'Saved <script>alert(1)</script>');
    const db = new DashboardStore(fixtureApp.database);
    assert.deepEqual(db.list(sessionToken(bob)), []);
    other.remove(items[0].id);
    await delay(50);
    assert.equal(db.list(sessionToken(alice)).length, 1);
    first.action('add', [{ title: 'forged', account: 'bob' }], first.component);
    const invalid = await first.receive(message => message.type === 'error');
    assert.equal(invalid.error.code, 'ACTION_INVALID_INPUT');
    assert.equal(db.list(sessionToken(alice)).length, 1);
    const closed = once(first.socket, 'close'); first.socket.close(); await closed;
    second.add('While disconnected');
    await second.items(value => value.length === 2);
    const config = first.config;
    const reconnect = await connect(t, `${origin.replace('http:', 'ws:')}${config.socketPath}?pageId=${config.pageId}&redwebVersion=${encodeURIComponent(config.version)}`, origin, { Cookie: alice });
    await reconnect.receive(message => message.type === 'redweb:patch' && message.payload.patches.some(patch => patch.html.includes('While disconnected')));
    const deniedLogout = await post(origin, '/logout', {}, alice, 'https://foreign.example');
    assert.equal(deniedLogout.status, 403);
    const aliceClosed = once(reconnect.socket, 'close');
    const secondClosed = once(second.socket, 'close');
    const loggedOut = await post(origin, '/logout', {}, alice);
    assert.equal(loggedOut.status, 303);
    assert.match(loggedOut.headers.get('set-cookie'), /Max-Age=0/);
    await Promise.all([aliceClosed, secondClosed]);
    assert.equal((await fetch(origin, { headers: { Cookie: alice2 } })).status, 401);
    assert.equal(other.socket.readyState, 1);
    assert.equal(db.session(sessionToken(alice)), undefined);
    db.close();
    origin = await fixtureApp.restart();
    const renewed = await login(origin);
    const restored = await fetch(origin, { headers: { Cookie: renewed } });
    const text = await restored.text();
    assert.match(text, /While disconnected/);
    assert.match(text, /&lt;script&gt;/);
    const client = await cardClient(t, origin, renewed);
    client.remove(items[0].id);
    await client.items(value => value.length === 1);
});

test('session expiry closes idle sockets and rejects later HTTP access', async t => {
    const { origin } = await fixture(t, { sessionLifetimeMs: 1200 });
    const cookie = await login(origin);
    const client = await cardClient(t, origin, cookie);
    const [code] = await once(client.socket, 'close');
    assert.equal(code, 1008);
    assert.equal((await fetch(origin, { headers: { Cookie: cookie } })).status, 401);
});

test('store and authentication units use actual SQLite and scrypt, never substitutes', async t => {
    const previousDatabase = process.env.DASHBOARD_DATABASE;
    const previousPort = process.env.PORT;
    try {
        delete process.env.DASHBOARD_DATABASE;
        delete process.env.PORT;
        assert.equal(databasePath(), require('node:path').resolve('data/dashboard.sqlite'));
        assert.throws(() => createApp({ origin: 'ftp://invalid.example' }), /exact/);
    } finally {
        if (previousDatabase === undefined) delete process.env.DASHBOARD_DATABASE; else process.env.DASHBOARD_DATABASE = previousDatabase;
        if (previousPort === undefined) delete process.env.PORT; else process.env.PORT = previousPort;
    }
    const directory = mkdtempSync(join(tmpdir(), 'redweb-store-'));
    const database = join(directory, 'unit.sqlite');
    const store = new DashboardStore(database);
    t.after(() => { store.close(); rmSync(directory, { recursive: true, force: true }); });
    await assert.rejects(credentials('short'), /16/);
    const secret = await credentials(password);
    assert.throws(() => store.provision('?', secret), /Invalid/);
    store.provision('alice', secret);
    assert.throws(() => store.provision('alice', secret));
    assert.equal(store.credentials('missing'), undefined);
    assert.throws(() => store.issue('alice', 0));
    const auth = new DashboardAuth(store);
    assert.equal(await auth.login('peer', 'unknown', password), undefined);
    assert.equal(await auth.login('peer', {}, password), undefined);
    const token = await auth.login('peer', 'alice', password);
    assert.equal(store.session(token).account, 'alice');
    assert.equal(sessionToken(`redweb_dashboard=${token}`), token);
    assert.equal(sessionToken(`redweb_dashboard=${token}; redweb_dashboard=${token}`), '');
    assert.equal(sessionToken('redweb_dashboard=invalid'), '');
    assert.equal(store.session('invalid'), undefined);
    assert.throws(() => store.list('invalid'), /expired/);
    assert.throws(() => store.add(token, ''), /Invalid/);
    assert.throws(() => store.add(token, '\u0000'), /Invalid/);
    for (let i = 0; i < 100; i++) store.add(token, `Card ${i}`);
    assert.throws(() => store.add(token, 'Over capacity'), /limit/);
    assert.equal(store.list(token).length, 100);
    store.remove(token, store.list(token)[0].id);
    assert.equal(store.list(token).length, 99);
    for (let i = 1; i < 32; i++) store.issue('alice', 10000);
    assert.throws(() => store.issue('alice', 10000), /existing sessions/);
    assert.equal(store.signOut(token), 'alice');
    assert.equal(store.signOut(token), undefined);
    assert.throws(() => store.remove(token, 'anything'), /expired/);
    for (let i = 0; i < 10; i++) await auth.login('limited', '!', password);
    assert.equal(await auth.login('limited', 'alice', password), undefined);
    store.close(); store.close();
    const raw = new DatabaseSync(database);
    raw.exec('PRAGMA user_version = 2'); raw.close();
    assert.throws(() => new DashboardStore(database), /Unsupported/);
});

test('logout fences password checks in flight; close and admission bounds stop new sessions', async t => {
    const store = new DashboardStore(':memory:');
    t.after(() => store.close());
    store.provision('alice', await credentials(password));
    const auth = new DashboardAuth(store);
    const token = store.issue('alice', 5000);
    const pending = auth.login('peer', 'alice', password);
    store.signOut(token);
    await assert.rejects(pending, /Sign-out occurred/);
    const closing = auth.login('peer', 'alice', password);
    auth.close();
    assert.equal(await closing, undefined);
    assert.equal(await auth.login('peer', 'alice', password), undefined);
    assert.throws(() => new DashboardAuth(store, 0));
    const limited = new DashboardAuth(store);
    const concurrent = Array.from({ length: 5 }, (_, index) => limited.login(`peer-${index}`, 'alice', password));
    assert.equal(await concurrent[4], undefined);
    const issued = await Promise.all(concurrent.slice(0, 4));
    assert.ok(issued.every(Boolean));
    for (let index = 0; index < 1024; index++) await limited.login(`invalid-${index}`, null, null);
    assert.equal(await limited.login('new-peer', 'alice', password), undefined);
    const expiring = store.issue('alice', 100);
    await delay(110);
    assert.equal(store.session(expiring), undefined);
    store.issue('alice', 1000); // Prunes expired rows during issuance.
});

test('subscription cleanup is idempotent across replacement groups and failed callbacks', async t => {
    const store = new DashboardStore(':memory:');
    store.provision('alice', await credentials(password));
    const token = store.issue('alice', 5000);
    const cards = new PrivateCards(store);
    const sockets = new WebSocketServer({ port: 0, host: '127.0.0.1' });
    await once(sockets, 'listening');
    const peers = [];
    t.after(async () => {
        for (const peer of peers) peer.terminate();
        for (const peer of sockets.clients) peer.terminate();
        await new Promise(resolve => sockets.close(resolve)); store.close();
    });
    async function context() {
        const accepted = once(sockets, 'connection');
        const client = new WebSocket(`ws://127.0.0.1:${sockets.address().port}`); peers.push(client);
        const opened = once(client, 'open');
        const [socket] = await accepted;
        await opened;
        const controller = new AbortController();
        return { controller, value: { principal: 'alice', signal: controller.signal, socket, request: { get: name => name === 'cookie' ? `redweb_dashboard=${token}` : undefined } } };
    }
    const original = await context();
    const cleanup = cards.subscribe(original.value, () => {});
    original.controller.abort();
    const replacement = await context();
    let updates = 0;
    const release = cards.subscribe(replacement.value, () => updates++);
    cleanup(); cleanup();
    cards.publish(store.add(token, 'Replacement still registered'));
    assert.equal(updates, 2);
    const broken = await context();
    let fail = false;
    cards.subscribe(broken.value, () => { if (fail) throw new Error('Intentional consumer failure'); });
    fail = true;
    cards.publish(store.add(token, 'Failure isolation'));
    assert.equal(updates, 3);
    const failedInitial = await context();
    assert.throws(() => cards.subscribe(failedInitial.value, () => { throw new Error('Initial callback failure'); }), /Initial/);
    const invalid = await context(); invalid.controller.abort();
    assert.throws(() => cards.subscribe(invalid.value, () => {}), /Sign in/);
    store.signOut(token);
    cards.publish('alice');
    assert.equal(updates, 3);
    assert.throws(() => cards.subscribe(replacement.value, () => {}), /Sign in/);
    release(); cards.publish('missing');
});

test('incomplete HTTP uploads cannot keep shutdown or the database alive indefinitely', async t => {
    const directory = mkdtempSync(join(tmpdir(), 'redweb-drain-'));
    const database = join(directory, 'drain.sqlite');
    let app;
    t.after(async () => { await app?.shutdown(); rmSync(directory, { recursive: true, force: true }); });
    assert.throws(() => createApp({ port: 0, database, sessionLifetimeMs: 0 }), /lifetime/);
    assert.throws(() => createApp({ port: 0, database, origin: 'https://example.com/path' }), /exact/);
    assert.throws(() => createApp({ port: 0, database, origin: 'ftp://example.com' }), /exact/);
    app = createApp({ port: 0, database });
    await once(app.server, 'listening');
    const socket = net.connect(app.server.address().port, '127.0.0.1');
    t.after(() => socket.destroy());
    socket.on('error', () => {});
    await once(socket, 'connect');
    socket.write('POST /login HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 1000\r\n\r\naccount=');
    await delay(30);
    const started = Date.now();
    await app.shutdown();
    assert.ok(Date.now() - started < 2000);
    const reopened = new DashboardStore(database); reopened.close();
});

test('SQLite commits survive abrupt process termination rather than only graceful shutdown', async t => {
    const directory = mkdtempSync(join(tmpdir(), 'redweb-crash-'));
    const database = join(directory, 'crash.sqlite');
    const store = new DashboardStore(database);
    store.provision('alice', await credentials(password));
    const token = store.issue('alice', 60000); store.close();
    const child = spawn(process.execPath, ['-e', `
        const { DashboardStore } = require('./dist/store');
        const db = new DashboardStore(process.argv[1]);
        db.add(process.argv[2], 'Committed before crash');
        process.send('committed');
        setInterval(() => {}, 1000);
    `, database, token], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true });
    t.after(async () => {
        if (child.exitCode === null && child.signalCode === null) { const exited = once(child, 'exit'); child.kill('SIGKILL'); await exited; }
        rmSync(directory, { recursive: true, force: true });
    });
    assert.deepEqual(await once(child, 'message'), ['committed', undefined]);
    const exited = once(child, 'exit'); child.kill('SIGKILL'); await exited;
    const recovered = new DashboardStore(database);
    try { assert.equal(recovered.list(token)[0].title, 'Committed before crash'); }
    finally { recovered.close(); }
});

test('production origin/cookies and malformed forms use real HTTP', async t => {
    const { origin } = await fixture(t, { origin: 'https://dashboard.example' });
    const authenticated = await post(origin, '/login', { account: 'alice', password }, '', 'https://dashboard.example');
    assert.equal(authenticated.status, 303);
    assert.match(authenticated.headers.get('set-cookie'), /; Secure/);
    assert.equal((await post(origin, '/login', { account: 'alice', password: 'x'.repeat(5000) })).status, 400);
    assert.equal((await post(origin, '/logout', {}, '', 'https://dashboard.example')).status, 303);
    assert.equal((await post(origin, '/login', {})).status, 403);
});

test('unit: listener-error cleanup observes rejection without hiding it from the application owner', async t => {
    const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cleanup-'));
    const database = join(directory, 'cards.sqlite');
    const app = createApp({ port: 0, database });
    t.after(async () => {
        // This test deliberately makes the returned cleanup promise reject.
        // Await settlement before removing files, including on assertion failure.
        await Promise.allSettled([app.shutdown()]);
        rmSync(directory, { recursive: true, force: true });
    });
    await once(app.server, 'listening');
    const failure = new Error('Injected database cleanup failure');
    const close = DashboardStore.prototype.close;
    // Unit-only fault injection, not a claim of a naturally occurring SQLite
    // failure. Real database/socket cleanup still runs; network ITs use no mocks.
    const injected = t.mock.method(DashboardStore.prototype, 'close', function () {
        close.call(this);
        throw failure;
    });
    app.server.emit('error', new Error('Injected listener failure'));
    const closing = app.shutdown();
    assert.equal(app.shutdown(), closing);
    await assert.rejects(closing, error => error === failure);
    assert.equal(injected.mock.callCount(), 1);
    assert.equal(app.server.listening, false);
    injected.mock.restore();
    const reopened = new DashboardStore(database);
    reopened.close();
});

test('invalid-form middleware leaves an already destroyed native HTTP response untouched', async t => {
    const { origin, app } = await fixture(t);
    const handled = new Promise(resolve => app.server.once('request', (request, response) => resolve({ request, response })));
    const page = await fetch(`${origin}/login`);
    assert.equal(page.status, 200);
    await page.text();
    const { request, response } = await handled;
    // Unit-test the defensive state with genuine Express objects. This is not
    // a claim that an aborted upload naturally reaches this middleware branch.
    const handlers = app.server.listeners('request').flatMap(listener => listener._router?.stack ?? [])
        .filter(layer => layer.handle.name === 'invalidBody');
    assert.equal(handlers.length, 1);
    response.destroy();
    assert.equal(response.destroyed, true);
    const before = { status: response.statusCode, headers: response.getHeaders(), ended: response.writableEnded };
    handlers[0].handle(new Error('Invalid form after disconnect'), request, response, () => assert.fail('must not forward'));
    assert.deepEqual({ status: response.statusCode, headers: response.getHeaders(), ended: response.writableEnded }, before);
});

test('capacity failures and abandoned uploads remain contained over real HTTP', { timeout: 10000 }, async t => {
    const { origin, database } = await fixture(t);
    const store = new DashboardStore(database);
    try { for (let index = 0; index < 32; index++) store.issue('alice', 60000); }
    finally { store.close(); }
    const response = await post(origin, '/login', { account: 'alice', password });
    assert.equal(response.status, 503);
    assert.equal(await response.text(), 'Unable to complete the request. Try again later.');
    const socket = net.connect(Number(new URL(origin).port), '127.0.0.1');
    socket.on('error', () => {});
    t.after(() => socket.destroy());
    await once(socket, 'connect');
    const closed = new Promise(resolve => socket.once('close', resolve));
    socket.end('POST /login HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 100\r\n\r\naccount=alice');
    socket.resume();
    await closed;
    const reset = net.connect(Number(new URL(origin).port), '127.0.0.1');
    reset.on('error', () => {});
    t.after(() => reset.destroy());
    await once(reset, 'connect');
    const resetClosed = new Promise(resolve => reset.once('close', resolve));
    reset.write('POST /login HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Encoding: gzip\r\nContent-Length: 100\r\n\r\n');
    await delay(20);
    reset.resetAndDestroy();
    await resetClosed;
    assert.equal((await fetch(`${origin}/login`)).status, 200);
});

test('real administrator and standalone startup commands expose errors and persist accounts', async t => {
    const directory = mkdtempSync(join(tmpdir(), 'redweb-dashboard-cli-'));
    const database = join(directory, 'cli.sqlite');
    let app;
    let child;
    t.after(async () => {
        if (child && child.exitCode === null && child.signalCode === null) { const exit = once(child, 'exit'); child.kill(); await exit; }
        await app?.shutdown();
        rmSync(directory, { recursive: true, force: true });
    });
    const env = { ...process.env, DASHBOARD_DATABASE: database, PORT: '0', NODE_ENV: 'test' };
    delete env.DASHBOARD_ORIGIN;
    const run = (file, args = [], overrides = {}) => spawnSync(process.execPath, [file, ...args], {
        env: { ...env, ...overrides }, encoding: 'utf8', timeout: 10000, windowsHide: true,
    });
    assert.equal(run('dist/admin.js').status, 1);
    assert.equal(run('dist/admin.js', ['?', 'extra']).status, 1);
    const created = run('dist/admin.js', ['carol']);
    assert.equal(created.status, 0); // Never include stdout (a generated password) in diagnostic output.
    assert.ok(created.stdout.startsWith('Created carol.'));
    assert.equal(run('dist/admin.js', ['carol']).status, 1);
    assert.equal(run('dist/app.js', [], { NODE_ENV: 'production' }).status, 1);
    assert.equal(run('dist/app.js', [], { NODE_ENV: 'production', DASHBOARD_ORIGIN: 'http://example.com' }).status, 1);
    const store = new DashboardStore(database);
    try { assert.ok(store.credentials('carol')); }
    finally { store.close(); }
    app = createApp({ database, port: 0 });
    await once(app.server, 'listening');
    const unavailable = run('dist/app.js', [], { PORT: String(app.server.address().port) });
    assert.equal(unavailable.status, 1);
    assert.match(unavailable.stderr, /Application listener failed/);
    // Windows kill('SIGTERM') terminates immediately without invoking Node handlers.
    // An actual IPC message delivers the signal event there; Unix uses its OS signal.
    const signalControl = join(directory, 'signal.cjs');
    writeFileSync(signalControl, "process.once('message', () => { process.disconnect(); process.emit('SIGTERM'); });");
    for (const configured of [false, true]) {
        const args = [...(process.platform === 'win32' ? ['--require', signalControl] : []), 'dist/app.js'];
        child = spawn(process.execPath, args, { env: { ...env, ...(configured ? { DASHBOARD_ORIGIN: 'https://dashboard.example', NODE_ENV: 'production' } : {}) },
            stdio: ['ignore', 'pipe', 'pipe', ...(process.platform === 'win32' ? ['ipc'] : [])], windowsHide: true });
        let output = '', errors = '';
        child.stdout.on('data', chunk => { output += chunk; });
        child.stderr.on('data', chunk => { errors += chunk; });
        const deadline = Date.now() + 5000;
        while (!output.includes('/login') && Date.now() < deadline && child.exitCode === null) await delay(20);
        assert.match(output, configured ? /Dashboard: https:\/\/dashboard.example\/login/ : /Dashboard: http:\/\/127\.0\.0\.1:\d+\/login/);
        if (!configured) assert.equal((await fetch(output.match(/http:\/\/127\.0\.0\.1:\d+\/login/)[0])).status, 200);
        const exit = once(child, 'exit');
        if (process.platform === 'win32') child.send('stop');
        else child.kill('SIGTERM');
        const [code, signal] = await exit;
        assert.equal(code, 0, errors);
        assert.equal(signal, null);
    }
});
```

### test/run-app.test.cjs

```js
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { spawn } = require('node:child_process');

// Each case uses its own Node process, real HTTP/TCP/WS resources and real timers.
// Windows cannot deliver POSIX signals through child.kill, so only that platform
// explicitly emits the signal event inside the child. Linux uses real OS signals.
const fixture = String.raw`
const assert = require('node:assert/strict');
const http = require('node:http');
const net = require('node:net');
const { once } = require('node:events');
const WebSocket = require('ws');
const mode = process.argv[1];
const signals = ['SIGINT', 'SIGTERM'];
const initial = signals.map(signal => process.listenerCount(signal));
const { runApp } = require('./dist/run-app.js');
assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
require('./dist/app.js');
assert.deepEqual(signals.map(signal => process.listenerCount(signal)), initial);
let cleanups = 0;
process.once('beforeExit', () => console.log(JSON.stringify({ cleanups, signals: signals.map(signal => process.listenerCount(signal)), initial })));
const signal = name => process.platform === 'win32' ? process.emit(name) : process.kill(process.pid, name);
if (mode === 'invalid') {
    for (const value of [0, -1, NaN, Infinity, 1.5, 2147483648]) assert.throws(() => runApp(() => { throw Error('must not execute'); }, value), RangeError);
} else if (mode === 'factory') {
    assert.equal(runApp(() => { throw Error('private startup detail'); }), undefined);
} else {
    if (mode === 'preserve') process.exitCode = '7';
    const server = http.createServer((_request, response) => response.end('ready'));
    const wss = new WebSocket.Server({ server });
    wss.on('error', () => {}); // The HTTP listener error is owned by runApp.
    const peers = new Set();
    server.on('connection', peer => { peers.add(peer); peer.on('close', () => peers.delete(peer)); });
    const close = async () => {
        for (const peer of peers) peer.destroy();
        for (const peer of wss.clients) peer.terminate();
        await new Promise(resolve => wss.close(resolve));
        await new Promise(resolve => server.close(resolve));
    };
    const app = runApp(() => ({ server, shutdown() {
        cleanups++;
        console.log('cleanup-started');
        if (mode === 'throw') { void close(); throw Error('private cleanup detail'); }
        if (mode === 'reject-open') return Promise.reject(Error('private cleanup detail'));
        return close().then(async () => {
            if (mode === 'hung') return new Promise(() => {});
            if (mode === 'reject') throw Error('private cleanup detail');
            if (mode === 'repeat') {
                signal('SIGINT'); signal('SIGTERM');
                server.emit('error', Error('private listener detail'));
            }
            await new Promise(resolve => setTimeout(resolve, 20));
        });
    } }), 200);
    assert.equal(app.server, server);
    (async () => {
        if (mode === 'occupied') {
            const other = http.createServer();
            await new Promise(resolve => other.listen(0, '127.0.0.1', resolve));
            server.once('error', () => other.close());
            server.listen(other.address().port, '127.0.0.1');
            return;
        }
        await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
        const port = server.address().port;
        const response = await fetch('http://127.0.0.1:' + port);
        assert.equal(await response.text(), 'ready');
        const peer = net.connect(port, '127.0.0.1');
        peer.on('error', () => {});
        await once(peer, 'connect');
        peer.write('GET / HTTP/1.1\r\nHost: localhost\r\n');
        const socket = new WebSocket('ws://127.0.0.1:' + port);
        socket.on('error', () => {});
        await once(socket, 'open');
        if (mode === 'native-close') {
            for (const connection of peers) connection.destroy();
            server.close();
            return;
        }
        // A partial HTTP peer otherwise prevents native close; application cleanup
        // begins via the signal and the later native close must not end its timer.
        signal(mode === 'interrupt' ? 'SIGINT' : 'SIGTERM');
    })().catch(error => { console.error(error); process.exit(99); });
}
`;

function execute(mode, t, args = ['-e', fixture, mode], env = process.env) {
    return new Promise((resolve, reject) => {
        const child = spawn(process.execPath, args, { cwd: process.cwd(), env, windowsHide: true });
        let stdout = '', stderr = '';
        let timedOut = false, finished = false;
        const closed = new Promise(resolve => child.once('close', () => { finished = true; resolve(); }));
        const deadline = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 5000);
        t.after(async () => {
            clearTimeout(deadline);
            if (!finished) { child.kill('SIGKILL'); await closed; }
        });
        child.stdout.on('data', data => { stdout += data; });
        child.stderr.on('data', data => { stderr += data; });
        child.once('error', reject);
        child.once('close', (code, signal) => {
            clearTimeout(deadline);
            if (timedOut) reject(new Error(`Lifecycle child timed out: ${mode}\n${stdout}\n${stderr}`));
            else resolve({ code, signal, stdout, stderr });
        });
    });
}

test('the actual application entrypoint exits cleanly when its port is occupied', { timeout: 7000 }, async t => {
    const net = require('node:net');
    const { once } = require('node:events');
    const fs = require('node:fs');
    const path = require('node:path');
    const directory = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'redweb-entrypoint-'));
    const occupied = net.createServer(socket => socket.destroy());
    const loopback = net.createServer(socket => socket.destroy());
    let failure;
    try {
        occupied.listen(0, '0.0.0.0');
        await once(occupied, 'listening');
        // Windows permits distinct wildcard/loopback binds on the same port.
        // Hold both addresses; Unix may already reject the second bind.
        loopback.listen(occupied.address().port, '127.0.0.1');
        try { await once(loopback, 'listening'); }
        catch (error) { assert.equal(error.code, 'EADDRINUSE'); }
        const env = { ...process.env, PORT: String(occupied.address().port), NODE_ENV: 'test', DASHBOARD_DATABASE: path.join(directory, 'test.sqlite') };
        delete env.DASHBOARD_ORIGIN;
        const result = await execute('actual-entrypoint', t, ['dist/app.js'], env);
        assert.equal(result.code, 1, `${result.stdout}\n${result.stderr}`);
        assert.equal(result.signal, null);
        assert.match(result.stderr, /Application listener failed/);
    } catch (error) { failure = error; }
    const cleanup = await Promise.allSettled([
        ...[occupied, loopback].map(server => new Promise((resolve, reject) => server.close(error =>
            error && error.code !== 'ERR_SERVER_NOT_RUNNING' ? reject(error) : resolve()))),
        fs.promises.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
    ]);
    const failures = [...(failure ? [failure] : []), ...cleanup.filter(result => result.status === 'rejected').map(result => result.reason)];
    if (failures.length) throw new AggregateError(failures, 'Entrypoint verification or cleanup failed');
});

for (const mode of ['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'throw', 'reject', 'reject-open', 'hung', 'occupied', 'repeat', 'preserve']) {
    test(`entrypoint cleanup: ${mode}`, { timeout: 7000 }, async t => {
        const result = await execute(mode, t);
        const expected = ['normal', 'interrupt', 'native-close', 'invalid'].includes(mode) ? 0 : mode === 'preserve' ? 7 : 1;
        assert.equal(result.code, expected, `${result.stdout}\n${result.stderr}`);
        assert.equal(result.signal, null);
        assert.doesNotMatch(result.stderr, /private .* detail/);
        const noApp = ['invalid', 'factory'].includes(mode);
        assert.equal((result.stdout.match(/cleanup-started/g) || []).length, noApp ? 0 : 1);
        if (['hung', 'reject-open'].includes(mode)) assert.match(result.stderr, /exceeded its deadline/);
        if (['normal', 'interrupt', 'native-close', 'invalid', 'factory', 'preserve'].includes(mode)) {
            const snapshot = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1));
            assert.deepEqual(snapshot.signals, snapshot.initial);
        }
    });
}
```

### README.md

````md
# Your Redweb application

Requirements: Node.js 18 or newer and npm for the realtime, chat, site, socket and http-ws templates; the dashboard template requires Node.js 22.13+ for native SQLite. Use a currently supported Node.js release in production.

For an unreleased checkout or tarball, first run `npm install --save-exact TARBALL`, replacing `TARBALL` with the absolute path to the same tested Redweb tarball used to generate this app (quote paths containing spaces). This installs the matching package and its published client dependency. Do not substitute an older registry release or `latest`. Published Redweb releases can use the installation command below directly.

```sh
npm install
npm test
npm run dev
```

HTTP starters open at http://localhost:8181; the authenticated dashboard uses http://127.0.0.1:8181/login and requires account provisioning described below. Set the `PORT` environment variable to change the listener.
`npm test` builds and runs real HTTP/WebSocket integration tests on an ephemeral loopback port. No mocks or external service are needed.
`npm run test:coverage` runs the same tests with application coverage mapped back to TypeScript. Reports are written to the ignored `coverage/` directory; this is separate from Redweb library coverage. TypeScript-generated decorator accessors can appear in function counts even when the framework does not call them. The report exposes remaining gaps; it does not certify complete application coverage. Source maps are generated during the build for diagnostics and coverage, but no coverage collector is loaded by `npm start`.

## Development and production

Edit `src/app.tsx`. `npm run dev` watches TypeScript, TSX, CSS, HTML, and the root TypeScript configuration,
then rebuilds and restarts the server. A type error stops startup until you fix it. On direct localhost access,
HTML pages refresh automatically when a new server revision is ready. If edits were detected, a keyboard-accessible
notice keeps the old document until you choose **Reload and discard drafts**. This is a conservative edit guard,
not autosave or browser hot-module replacement: restarts reset in-memory state and old socket sessions.
The generated development command sets `REDWEB_DEV_REFRESH=1`; `development: { refresh: false }` overrides it.
The refresh feature is refused under `NODE_ENV=production`, applies only to served HTML (not raw sockets or static exports),
and creates no local/session-storage copy of form contents. Use direct `localhost`, `127.x.x.x`, or `[::1]` access;
custom hostnames, tunnels and proxy-forwarded origins are not supported by this development helper.
`npm run build` checks types and copies CSS/HTML beside the compiled classes in `dist/`.
Run `npm start` to serve the compiled app. For deployment, build first, ship `dist/`, `package.json`, and the lockfile,
then install runtime dependencies with `npm ci --omit=dev`. The application does not require TypeScript or `src/` at runtime.

The standalone entrypoint calls the shared `runApp(createApp)` helper. Importing either module starts no listener and installs no process handlers. On SIGINT/SIGTERM, a listener error, or native listener closure, the helper calls application shutdown once. Repeated signals do not bypass cleanup. The five-second outer deadline covers the whole application, including database/worker cleanup after HTTP closes; customize it with the helper's second argument if necessary. Cleanup must resolve only after resources are released. A failed cleanup sets a failure exit status and retains a deadline for any surviving handles; the helper never resets an existing failure status. If cleanup does not finish in time, the entrypoint terminates the process with a failure status. This cannot preempt synchronous code blocking Node's event loop and does not make in-memory state durable. Factory functions remain responsible for releasing partially constructed resources before throwing.

The shipped lifecycle tests exercise actual processes, HTTP/TCP/WebSocket peers and timers. Linux uses actual OS signals; Windows tests explicitly emit signal events inside the process because killing a Windows child does not exercise graceful POSIX signal delivery. This is not a claim that Windows console/service managers forward the same signals. Deploy with a supervisor that forwards the supported termination signal and allows longer than the configured cleanup deadline.

For public deployment, configure HTTPS/WSS at your Node server or reverse proxy, authentication, trusted origins,
and application-specific rate limits. These starters are demonstrations, not a hosted identity or database service.
Never commit secrets; `.env` is ignored but is not loaded automatically.

`npx --no-install redweb doctor --json` reports configuration problems without changing your files.

# Persistent private dashboard

This recipe combines decorator-first pages, reusable live cards, validated actions, and real SQLite persistence. It is an application example, not an authentication framework or managed database.

## Run it

Requires **Node 22.13 or newer** (native `node:sqlite`, experimental in Node 22) and npm. Other Redweb starters retain their own Node requirements. Use a supported Node release in production.

After installing dependencies, create your account:

```sh
npm run add-user -- alice
npm test
npm run dev
```

The provisioning command displays a randomly generated password once. Save it securely; there are no default accounts or passwords. Open **http://127.0.0.1:8181/login**, sign in, and add a card. A second signed-in tab updates immediately. Restart the app: your cards and unexpired credentials remain valid. Sign out all sessions to close every connected tab for that account and invalidate all its cookies.

`npm test` provisions temporary test accounts and a real temporary database, then exercises HTTP, WebSockets, isolation, restart, and session expiry. It never modifies your application database. Integration tests use no mocks. A separately labelled unit test injects a cleanup error after closing a real SQLite database to verify rejection handling; it does not simulate a real operating-system failure.

`npm run test:coverage` measures the TypeScript application through source maps, separately from Redweb's own instrumented-library coverage. It also waits through the actual one-minute login admission window without mocking the clock. The report includes TypeScript-generated decorator accessor functions; inspect that distinction rather than assuming a library coverage figure applies to this recipe. The generated npm configuration enforces this recipe's Node engine requirement before installation.

## Where the behavior lives

- `app.tsx`: composition, login page, protected dashboard, listener and shutdown.
- `cards.tsx`: reusable `Cards` component and account-scoped live subscriptions. Normal TSX expressions update automatically. Forms call typed actions; feedback requires no browser glue.
- `store.ts`: prepared SQL, bounded cards/sessions, owner-filtered operations and synchronous transactions.
- `auth.ts`: asynchronous scrypt, bounded login attempts, hashed session tokens, cookies and sign-out.
- `admin.ts`: explicit local account provisioning.

## Production boundaries

Set `DASHBOARD_DATABASE` to a writable persistent file path (default `data/dashboard.sqlite`). Protect the directory with OS permissions: the database contains password hashes, private card text, and session metadata. It, its WAL/SHM files, and backups must never be served as public assets or committed. Stop the process cleanly before copying the database for a backup, or use a proper SQLite online backup facility; copying only the main file during live WAL writes is not a backup plan.

Set `NODE_ENV=production` and `DASHBOARD_ORIGIN=https://your-domain.example` behind an HTTPS reverse proxy. The origin must have no path or trailing slash. This enables Secure cookies; all session cookies are HttpOnly and SameSite=Strict. Both login/logout forms and socket upgrades require the exact trusted origin. The application does not trust Host or forwarded headers to establish origin or identity. The HTTP listener must not be publicly reachable around your TLS proxy.

Provision accounts on the same persistent volume before serving requests. Passwords use salted scrypt; only hashes of random session tokens are stored. Default sessions last one hour. Up to 32 unexpired sessions and 100 cards per account are supported. Login work is limited to four simultaneous checks and ten attempts per minute per direct peer IP, with at most 1,024 tracked IPs; clients behind one proxy share its bucket. Add appropriate proxy-level abuse controls for an Internet deployment. There is no registration, password reset, MFA, or account recovery; integrate a dedicated identity provider if your product needs those features.

SQL checks the current session and card owner inside each write transaction. Private subscriptions recheck session validity before publishing and close at expiry. Sign-out invalidates credentials before revoking Redweb sessions. An expired or disconnected page may need a reload/sign-in; the recipe does not silently retry actions with uncertain outcomes.

This is a **single-process live-update model**. SQLite transactions are synchronous and kept small; this is not a claim of unlimited concurrency. Do not put multiple app workers behind a load balancer and expect cross-worker notifications or revocation. Add a deliberate shared notification/session-revocation design before scaling horizontally. Static export cannot include protected dashboards.

Build and deploy using the shared instructions above, including the persistent data volume and the environment settings here. Neither `npm run dev` nor a process restart should erase durable cards. Redweb itself does not depend on SQLite.
````

### .gitignore

```text
node_modules/
dist/
coverage/
.env
data/
*.sqlite
*.sqlite-wal
*.sqlite-shm
```

### .npmrc

```text
engine-strict=true
```

### test/rate-window.test.cjs

```js
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { DashboardStore } = require('../dist/store');
const { DashboardAuth, credentials } = require('../dist/auth');

test('login admission reopens after the real one-minute window, without clock mocks', { timeout: 65000 }, async t => {
    const store = new DashboardStore(':memory:');
    const auth = new DashboardAuth(store);
    t.after(() => { auth.close(); store.close(); });
    const password = 'test-only-login-window-password';
    store.provision('alice', await credentials(password));
    for (let attempt = 0; attempt < 10; attempt++) assert.equal(await auth.login('same-peer', 'invalid', password), undefined);
    assert.equal(await auth.login('same-peer', 'alice', password), undefined);
    await new Promise(resolve => setTimeout(resolve, 60010));
    const token = await auth.login('same-peer', 'alice', password);
    assert.equal(store.session(token).account, 'alice');
});
```

### src/store.ts

```ts
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';

export interface Card { id: string; title: string; }
export interface Session { account: string; expires: number; }
export interface Credentials { salt: string; hash: string; }
export const MAX_CARDS = 100;
export const USERNAME = /^[a-z][a-z0-9_-]{2,31}$/;
const digest = (token: string) => createHash('sha256').update(token).digest('hex');

/** Recipe-local persistence. Every private query derives its owner from a live session. */
export class DashboardStore {
    private readonly db: DatabaseSync;
    private closed = false;

    constructor(filename: string) {
        this.db = new DatabaseSync(filename);
        try {
            this.db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA busy_timeout = 1000;');
            const version = this.db.prepare('PRAGMA user_version').get()!.user_version;
            if (version !== 0 && version !== 1) throw new Error('Unsupported dashboard database version.');
            this.db.exec(`
                BEGIN IMMEDIATE;
                CREATE TABLE IF NOT EXISTS accounts (
                    id TEXT PRIMARY KEY, salt TEXT NOT NULL, hash TEXT NOT NULL, epoch INTEGER NOT NULL DEFAULT 0
                ) STRICT;
                CREATE TABLE IF NOT EXISTS sessions (
                    token TEXT PRIMARY KEY, account TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
                    expires INTEGER NOT NULL
                ) STRICT;
                CREATE INDEX IF NOT EXISTS sessions_owner ON sessions(account);
                CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires);
                CREATE TABLE IF NOT EXISTS cards (
                    id TEXT PRIMARY KEY, account TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
                    title TEXT NOT NULL CHECK(length(title) BETWEEN 1 AND 80)
                ) STRICT;
                CREATE INDEX IF NOT EXISTS cards_owner ON cards(account);
                PRAGMA user_version = 1;
                COMMIT;
            `);
        } catch (error) { this.db.close(); throw error; }
    }

    provision(account: string, credentials: Credentials) {
        if (!USERNAME.test(account) || !/^[a-f0-9]{32}$/.test(credentials.salt) || !/^[a-f0-9]{128}$/.test(credentials.hash)) {
            throw new TypeError('Invalid account credentials.');
        }
        this.db.prepare('INSERT INTO accounts(id, salt, hash) VALUES (?, ?, ?)').run(account, credentials.salt, credentials.hash);
    }

    credentials(account: string): (Credentials & { epoch: number }) | undefined {
        return this.db.prepare('SELECT salt, hash, epoch FROM accounts WHERE id = ?').get(account) as unknown as (Credentials & { epoch: number }) | undefined;
    }

    issue(account: string, ttlMs: number, expectedEpoch?: number): string {
        if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Session lifetime must be 100ms–24h.');
        return this.transaction(() => {
            if (expectedEpoch !== undefined && this.credentials(account)?.epoch !== expectedEpoch) throw new Error('Sign-out occurred during sign-in. Try again.');
            this.db.prepare('DELETE FROM sessions WHERE expires <= ?').run(Date.now());
            const count = this.db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE account = ?').get(account)!.count as number;
            if (count >= 32) throw new Error('Sign out existing sessions before signing in again.');
            const token = randomBytes(32).toString('base64url');
            this.db.prepare('INSERT INTO sessions(token, account, expires) VALUES (?, ?, ?)').run(digest(token), account, Date.now() + ttlMs);
            return token;
        });
    }

    session(token: string): Session | undefined {
        if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return undefined;
        return this.db.prepare('SELECT account, expires FROM sessions WHERE token = ? AND expires > ?').get(digest(token), Date.now()) as unknown as Session | undefined;
    }

    list(token: string): Card[] {
        const { account } = this.requireSession(token);
        return this.db.prepare('SELECT id, title FROM cards WHERE account = ? ORDER BY rowid').all(account) as unknown as Card[];
    }

    add(token: string, title: string): string {
        if (typeof title !== 'string' || !title.trim() || title.length > 80 || /[\p{Cc}\p{Cf}]/u.test(title)) throw new TypeError('Invalid card title.');
        return this.transaction(() => {
            const { account } = this.requireSession(token);
            const count = this.db.prepare('SELECT COUNT(*) AS count FROM cards WHERE account = ?').get(account)!.count as number;
            if (count >= MAX_CARDS) throw new Error('Card limit reached.');
            this.db.prepare('INSERT INTO cards(id, account, title) VALUES (?, ?, ?)').run(randomUUID(), account, title.trim());
            return account;
        });
    }

    remove(token: string, id: string): string {
        return this.transaction(() => {
            const { account } = this.requireSession(token);
            this.db.prepare('DELETE FROM cards WHERE id = ? AND account = ?').run(id, account);
            return account;
        });
    }

    signOut(token: string): string | undefined {
        return this.transaction(() => {
            const session = this.session(token);
            if (!session) return undefined;
            this.db.prepare('DELETE FROM sessions WHERE account = ?').run(session.account);
            this.db.prepare('UPDATE accounts SET epoch = epoch + 1 WHERE id = ?').run(session.account);
            return session.account;
        });
    }

    close() { if (!this.closed) { this.closed = true; this.db.close(); } }

    private requireSession(token: string): Session {
        const session = this.session(token);
        if (!session) throw new Error('Session expired. Sign in again.');
        return session;
    }

    private transaction<T>(operation: () => T): T {
        this.db.exec('BEGIN IMMEDIATE');
        try { const result = operation(); this.db.exec('COMMIT'); return result; }
        catch (error) { this.db.exec('ROLLBACK'); throw error; }
    }
}
```

### src/auth.ts

```ts
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
import type { Application, Request, Response } from 'express';
import { DashboardStore, USERNAME, type Credentials } from './store';

const COOKIE = 'redweb_dashboard';
const DUMMY: Credentials = { salt: '00'.repeat(16), hash: '00'.repeat(64) };

export function sessionToken(cookie: string | undefined): string {
    const matches = (cookie ?? '').split(';').map(part => part.trim()).filter(part => part.startsWith(`${COOKIE}=`));
    const token = matches.length === 1 ? matches[0].slice(COOKIE.length + 1) : '';
    return /^[A-Za-z0-9_-]{43}$/.test(token) ? token : '';
}

const passwordHash = promisify(scrypt);

export async function credentials(password: string): Promise<Credentials> {
    if (typeof password !== 'string' || password.length < 16 || password.length > 128) throw new TypeError('Use a password of 16–128 characters.');
    const salt = randomBytes(16).toString('hex');
    return { salt, hash: (await passwordHash(password, salt, 64) as Buffer).toString('hex') };
}

/** Bounded asynchronous password work; neither proxy headers nor browser input establish identity. */
export class DashboardAuth {
    private active = 0;
    private closed = false;
    private readonly attempts = new Map<string, { count: number; expires: number }>();

    constructor(private readonly store: DashboardStore, private readonly ttlMs = 3600000) {
        if (!Number.isInteger(ttlMs) || ttlMs < 100 || ttlMs > 86400000) throw new RangeError('Invalid session lifetime.');
    }

    async login(ip: string, account: unknown, password: unknown): Promise<string | undefined> {
        if (this.closed) return undefined;
        const now = Date.now();
        for (const [key, entry] of this.attempts) if (entry.expires <= now) this.attempts.delete(key);
        let attempt = this.attempts.get(ip);
        if (!attempt) {
            if (this.attempts.size >= 1024) return undefined;
            attempt = { count: 0, expires: now + 60000 };
            this.attempts.set(ip, attempt);
        }
        if (++attempt.count > 10 || this.active >= 4) return undefined;
        if (typeof account !== 'string' || !USERNAME.test(account) || typeof password !== 'string' || password.length < 16 || password.length > 128) return undefined;
        this.active++;
        try {
            const stored = this.store.credentials(account);
            const expected = stored ?? DUMMY;
            const actual = await passwordHash(password, expected.salt, 64) as Buffer;
            if (this.closed) return undefined;
            if (!timingSafeEqual(actual, Buffer.from(expected.hash, 'hex')) || !stored) return undefined;
            return this.store.issue(account, this.ttlMs, stored.epoch);
        } finally { this.active--; }
    }

    close() { this.closed = true; this.attempts.clear(); }

    mount(app: Application, origin: () => string, revoke: (account: string) => Promise<unknown>) {
        const cookie = (token: string) => `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${token ? Math.ceil(this.ttlMs / 1000) : 0}${origin().startsWith('https:') ? '; Secure' : ''}`;
        const post = (route: string, handler: (request: Request, response: Response) => Promise<void>) => {
            app.post(route, (request, response) => {
                response.set('Cache-Control', 'private, no-store');
                if (request.get('origin') !== origin()) { response.status(403).send('This form must be submitted from this site.'); return; }
                void handler(request, response).catch(() => response.status(503).send('Unable to complete the request. Try again later.'));
            });
        };
        post('/login', async (request, response) => {
            const token = await this.login(String(request.socket.remoteAddress), request.body?.account, request.body?.password);
            if (!token) { response.status(401).send('Unable to sign in. Check your credentials or try again later.'); return; }
            response.setHeader('Set-Cookie', cookie(token));
            response.redirect(303, '/');
        });
        post('/logout', async (request, response) => {
            const account = this.store.signOut(sessionToken(request.headers.cookie));
            response.setHeader('Set-Cookie', cookie(''));
            if (account) await revoke(account);
            response.redirect(303, '/login');
        });
    }
}
```

### src/cards.tsx

```tsx
import { action, component, state, type ActionInput, type LivePageConnectionContext, type LivePageRequestContext } from 'redweb';
import { z } from 'zod';
import { sessionToken } from './auth';
import { DashboardStore, MAX_CARDS, type Card } from './store';

const addInput = z.object({ title: z.string().trim().min(1).max(80).regex(/^[^\p{Cc}\p{Cf}]+$/u) }).strict();
const removeInput = z.object({ id: z.string().uuid() }).strict();
const tokenOf = (context: LivePageRequestContext) => sessionToken(context.request.get('cookie'));

interface Subscriber { token: string; update(): void; close(): void; }

/** Single-process notifications; SQLite remains the source of truth on every connection. */
export class PrivateCards {
    private readonly accounts = new Map<string, Set<Subscriber>>();
    constructor(readonly store: DashboardStore) {}

    allowed(context: LivePageRequestContext) {
        const session = this.store.session(tokenOf(context));
        return !!session && session.account === context.principal && !context.signal.aborted;
    }

    subscribe(context: LivePageConnectionContext, update: (cards: Card[]) => void): () => void {
        const token = tokenOf(context);
        const session = this.store.session(token);
        if (!session || !this.allowed(context)) throw new Error('Sign in again.');
        let group = this.accounts.get(session.account);
        if (!group) this.accounts.set(session.account, group = new Set());
        let closed = false;
        const subscriber: Subscriber = {
            token, update: () => update(this.store.list(token)),
            close: () => { unsubscribe(); context.socket.close(1008, 'Sign in again.'); },
        };
        const unsubscribe = () => {
            if (closed) return;
            closed = true;
            clearTimeout(expiry);
            context.signal.removeEventListener('abort', unsubscribe);
            group.delete(subscriber);
            if (!group.size && this.accounts.get(session.account) === group) this.accounts.delete(session.account);
        };
        const expiry = setTimeout(subscriber.close, Math.max(1, session.expires - Date.now()));
        expiry.unref();
        group.add(subscriber);
        context.signal.addEventListener('abort', unsubscribe, { once: true });
        try { subscriber.update(); }
        catch (error) { unsubscribe(); throw error; }
        return unsubscribe;
    }

    publish(account: string) {
        for (const subscriber of this.accounts.get(account) ?? []) {
            try {
                if (this.store.session(subscriber.token)?.account === account) subscriber.update();
                else subscriber.close();
            } catch { subscriber.close(); }
        }
    }
}

@component()
export class Cards {
    @state() items: Card[] = [];
    private unsubscribe?: () => void;

    constructor(private readonly cards: PrivateCards) {}
    loading(context: LivePageRequestContext) { this.items = this.cards.store.list(tokenOf(context)); }
    connected(context: LivePageConnectionContext) {
        this.disconnected();
        this.unsubscribe = this.cards.subscribe(context, items => { this.items = items; });
    }
    disconnected() { this.unsubscribe?.(); this.unsubscribe = undefined; }
    disposed() { this.disconnected(); }

    @action({ input: addInput })
    add({ title }: ActionInput<typeof addInput>, context: LivePageConnectionContext) {
        this.cards.publish(this.cards.store.add(tokenOf(context), title));
    }

    @action({ input: removeInput })
    remove({ id }: ActionInput<typeof removeInput>, context: LivePageConnectionContext) {
        this.cards.publish(this.cards.store.remove(tokenOf(context), id));
    }

    render() {
        return <section class="cards" aria-label="Your saved cards">
            <form rw-submit="add">
                <label for="card-title">New card</label>
                <input id="card-title" name="title" maxlength="80" required autocomplete="off" />
                <button type="submit" disabled={this.items.length >= MAX_CARDS}>Add card</button>
            </form>
            <p>{this.items.length} / {MAX_CARDS} cards · saved automatically</p>
            <ul class="card-grid">{this.items.map(card => <li key={card.id} data-card-id={card.id}>
                <h2>{card.title}</h2>
                <form rw-submit="remove">
                    <input type="hidden" name="id" value={card.id} />
                    <button type="submit" aria-label={`Delete ${card.title}`}>Delete</button>
                </form>
            </li>)}</ul>
            {!this.items.length && <p>No cards yet. Add your first one above.</p>}
        </section>;
    }
}
```

### src/admin.ts

```ts
import { randomBytes } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { credentials } from './auth';
import { databasePath } from './app';
import { DashboardStore, USERNAME } from './store';

async function main() {
    const account = process.argv[2];
    if (!account || !USERNAME.test(account) || process.argv.length !== 3) throw new Error('Usage: npm run add-user -- alice (3–32 lowercase letters, digits, _ or -; starts with a letter).');
    const filename = databasePath();
    mkdirSync(dirname(filename), { recursive: true });
    const store = new DashboardStore(filename);
    try {
        const password = randomBytes(24).toString('base64url');
        store.provision(account, await credentials(password));
        console.log(`Created ${account}. Store this password safely; it is displayed only once:\n${password}`);
    } finally { store.close(); }
}

void main().catch(error => { console.error(error.message); process.exitCode = 1; });
```
