Simplify example app and flatten repository layout

Commit 74bfac1 · patx · 2026-10-20T00:40:21-04:00

Changeset
74bfac1205d665ba133f851c363eb114ed45db24
Parents
9d8538e45b784e82d39b9249f07b64a326083684

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index da34f9d..0000000
--- a/.coveragerc
+++ /dev/null
@@ -1,8 +0,0 @@
-[run]
-branch = True
-source =
-    wred
-
-[report]
-fail_under = 100
-show_missing = True
diff --git a/docs/README.md b/docs/README.md
deleted file mode 100644
index 7eb1d8b..0000000
--- a/docs/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-[![Logo](https://patx.gitman.io/wred/wred-logo.png)](https://harrisonerd.com/wred)
-
-[Wred](https://harrisonerd.com/wred) is a pretty fast Python backend for JavaScript web apps. View the documentation and source [here](https://gitman.io/patx/wred).
-
-## Installation
-
-```bash
-$ pip install 'wred[server]'
-```
-
-## Quick Start
-
-```python
-from wred import App
-
-app = App()
-
[email protected]('/')
-async def index(request):
-    return {'hello': 'world'}
-
-# run with `weft app:app`
-```
diff --git a/example_app.py b/example_app.py
new file mode 100644
index 0000000..fa1b73e
--- /dev/null
+++ b/example_app.py
@@ -0,0 +1,76 @@
+"""
+    example_app
+    ~~~~~~~~~~~
+
+    Greeting validation and JWT authentication with Wred and msgspec.
+
+    Install the example dependencies and run from the repository root::
+
+        python -m pip install -e '.[server]' PyJWT msgspec
+        export JWT_SECRET="$(python -c 'import secrets; print(secrets.token_hex(32))')"
+        weft app:app
+
+    POST /greet accepts a JSON object with a name of 1–40 characters.
+    GET /me requires a Bearer token signed with JWT_SECRET using HS256,
+    with issuer "demo", audience "greetings", a nonempty subject, and an
+    expiration time.
+
+    :copyright: (c) 2026 by Harrison Erd.
+    :license: BSD-3-Clause, see LICENSE for more details.
+"""
+import os
+from typing import Annotated
+
+import jwt
+import msgspec
+from wred import App
+
+
+SECRET = os.environ['JWT_SECRET']
+app = App()
+
+
+class Greeting(msgspec.Struct, forbid_unknown_fields=True):
+    name: Annotated[str, msgspec.Meta(min_length=1, max_length=40)]
+
+
+def verified_user(authorization):
+    scheme, _, token = (authorization or '').partition(' ')
+
+    if scheme.lower() != 'bearer' or not token:
+        raise jwt.InvalidTokenError('Bearer token required')
+
+    claims = jwt.decode(
+        token, SECRET, algorithms=['HS256'],
+        issuer='demo', audience='greetings',
+        options={'require': ['sub', 'exp', 'iss', 'aud']},
+    )
+
+    if not claims['sub']:
+        raise jwt.InvalidTokenError('User required')
+
+    return claims['sub']
+
+
[email protected]('/greet', methods=['POST'])
+async def greet(request):
+    try:
+        data = msgspec.json.decode(await request.body(), type=Greeting)
+
+    except msgspec.DecodeError:
+        return {'detail': 'Invalid greeting'}, 'application/json', 422
+
+    return {'message': f'Hello, {data.name}!'}
+
+
[email protected]('/me')
+async def me(request):
+    try:
+        user = verified_user(request.headers.get('authorization'))
+
+    except jwt.InvalidTokenError:
+        return {'detail': 'Not authenticated'}, 'application/json', 401, [
+            ('WWW-Authenticate', 'Bearer'),
+        ]
+
+    return {'user': user}
diff --git a/example_app/app.py b/example_app/app.py
deleted file mode 100644
index acdf67b..0000000
--- a/example_app/app.py
+++ /dev/null
@@ -1,89 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.app
-    ~~~~~~~~~~~~~~~
-
-    Build the notebook demo explicitly with ``create_app``. Importing this
-    module does not create an application or a database client. See
-    ``example_app.asgi`` for the server entry point and startup instructions.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-import os
-from pathlib import Path
-
-from servestatic import ServeStaticASGI
-
-from wred import App
-
-from . import auth, notes, pages
-from .rate_limit import RateLimiter
-from .storage import Database
-
-STATIC_ROOT = Path(__file__).resolve().parent / 'static'
-
-
-class NotebookApp:
-    """Own the example's database lifecycle while keeping Wred HTTP-only."""
-
-    def __init__(self, database):
-        self.database = database
-        self.http = App()
-        rate_limit = RateLimiter()
-        pages.register_routes(self.http, rate_limit)
-        auth.register_routes(self.http, self.database, rate_limit)
-        notes.register_routes(self.http, self.database)
-        self.http = ServeStaticASGI(
-            self.http,
-            root=str(STATIC_ROOT),
-            index_file=True,
-            autorefresh=True,
-            max_age=0,
-        )
-        self.http.add_files(str(STATIC_ROOT), prefix='static/')
-
-    async def __call__(self, scope, receive, send):
-        if scope['type'] != 'lifespan':
-            return await self.http(scope, receive, send)
-        while True:
-            message = await receive()
-            if message['type'] == 'lifespan.startup':
-                try:
-                    await self.database.initialize()
-                except Exception as error:
-                    await self.database.close()
-                    await send({
-                        'type': 'lifespan.startup.failed',
-                        'message': str(error),
-                    })
-                    return
-                await send({'type': 'lifespan.startup.complete'})
-            elif message['type'] == 'lifespan.shutdown':
-                await self.database.close()
-                await send({'type': 'lifespan.shutdown.complete'})
-                return
-
-
-def create_app(mongodb_uri=None, database_name=None, *, database=None):
-    """Build an app with independent rate limits and an owned database.
-
-    Without ``database``, create a MongoDB client using the explicit URI and
-    name, falling back to ``MONGODB_URI`` and ``MONGODB_DATABASE``. Defaults
-    are ``mongodb://127.0.0.1:27017`` and ``wred_notebook``.
-
-    An injected ``database`` takes precedence over URI and name settings.
-    The app calls its async ``initialize`` and ``close`` methods during
-    lifespan startup and shutdown, including closing it on startup failure.
-    """
-    if database is None:
-        database = Database(
-            mongodb_uri
-            if mongodb_uri is not None
-            else os.environ.get('MONGODB_URI', 'mongodb://127.0.0.1:27017'),
-            database_name
-            if database_name is not None
-            else os.environ.get('MONGODB_DATABASE', 'wred_notebook'),
-        )
-    return NotebookApp(database)
diff --git a/example_app/asgi.py b/example_app/asgi.py
deleted file mode 100644
index afe95b0..0000000
--- a/example_app/asgi.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.asgi
-    ~~~~~~~~~~~~~~~~
-
-    Run the notebook demo with MongoDB listening on localhost:27017::
-
-        python -m pip install -e '.[server]' pymongo servestatic
-        weft example_app.asgi:app
-
-    Open http://127.0.0.1:8000. MongoDB preserves accounts, sessions,
-    and notes. ServeStatic serves the frontend in the same ASGI process,
-    with file refresh and caching settings configured for local development.
-    Set MONGODB_URI and MONGODB_DATABASE to choose another server/database.
-    Rate limits are isolated per app and process-local. Use HTTPS outside
-    localhost.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-from .app import create_app
-
-
-app = create_app()
diff --git a/example_app/auth.py b/example_app/auth.py
deleted file mode 100644
index 22be010..0000000
--- a/example_app/auth.py
+++ /dev/null
@@ -1,270 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.auth
-    ~~~~~~~~~~~~~~~~
-
-    Signup, login, refresh cookies, and bearer authentication for the demo.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-from asyncio import to_thread
-from functools import wraps
-from hashlib import pbkdf2_hmac, sha256
-from http.cookies import CookieError, SimpleCookie
-from secrets import compare_digest, token_bytes, token_urlsafe
-from time import time
-
-import orjson
-
-TOKEN_LIFETIME = 3600
-REFRESH_LIFETIME = 7 * 24 * 3600
-REFRESH_COOKIE_NAME = 'notebook_refresh'
-
-
-def same_origin_request(request):
-    # HTML forms cannot supply this custom header; cross-origin fetch requires
-    # a preflight.
-    if request.headers.get('x-notebook-request') != '1':
-        return False
-    if request.headers.get('sec-fetch-site', 'same-origin') != 'same-origin':
-        return False
-    # Browsers send Origin on POST; API clients may omit it but still need the
-    # custom header.
-    origin = request.headers.get('origin')
-    expected = (
-        f'{request.scope.get("scheme", "http")}://'
-        f'{request.headers.get("host", "")}'
-    )
-    return origin is None or origin == expected
-
-
-def refresh_cookie(request, value, max_age):
-    cookie = SimpleCookie()
-    cookie[REFRESH_COOKIE_NAME] = value
-    morsel = cookie[REFRESH_COOKIE_NAME]
-    morsel['path'] = '/api'
-    morsel['httponly'] = True
-    morsel['samesite'] = 'Strict'
-    morsel['max-age'] = max_age
-    # HTTPS deployments get Secure; plain HTTP remains usable for the localhost
-    # demo.
-    if request.scope.get('scheme') == 'https':
-        morsel['secure'] = True
-    return 'Set-Cookie', morsel.OutputString()
-
-
-def read_refresh_cookie(request):
-    cookie = SimpleCookie()
-    try:
-        cookie.load(request.headers.get('cookie', ''))
-    except CookieError:
-        return ''
-    morsel = cookie.get(REFRESH_COOKIE_NAME)
-    return morsel.value if morsel else ''
-
-
-def token_response(token, username):
-    return {
-        'access_token': token,
-        'token_type': 'bearer',
-        'expires_in': TOKEN_LIFETIME,
-        'user': {'id': username},
-    }
-
-
-async def read_credentials(request):
-    try:
-        data = await request.json()
-    except orjson.JSONDecodeError:
-        raise ValueError('Invalid JSON body') from None
-    if not isinstance(data, dict):
-        raise ValueError('Send a JSON object with username and password')
-    username, password = data.get('username'), data.get('password')
-    if not isinstance(username, str) or not isinstance(password, str):
-        raise ValueError('Username and password must be strings')
-    username = username.strip().casefold()
-    if not 1 <= len(username) <= 64 or not 15 <= len(password) <= 128:
-        raise ValueError(
-            'Use a username of 1–64 and a password of 15–128 characters'
-        )
-    return username, password
-
-
-async def hash_password(password, salt):
-    return await to_thread(
-        pbkdf2_hmac, 'sha256', password.encode('utf-8'), salt, 600_000
-    )
-
-
-def protected(database):
-    def decorator(handler):
-        @wraps(handler)
-        async def wrapper(request, *args, **kwargs):
-            header = request.headers.get('authorization', '')
-            scheme, _, token = header.partition(' ')
-            user = None
-            if scheme.lower() == 'bearer' and token:
-                digest = sha256(token.encode('utf-8')).hexdigest()
-                user = await database.get_access_token(digest, time())
-            if user is None:
-                return (
-                    {'error': 'Unauthorized'},
-                    'application/json',
-                    401,
-                    [('WWW-Authenticate', 'Bearer')],
-                )
-            request.user = {'id': user['user_id']}
-            request.login_id = user['login_id']
-            response = await handler(request, *args, **kwargs)
-            if isinstance(response, tuple):
-                body, content_type, status = response[:3]
-                headers = list(response[3]) if len(response) == 4 else []
-            else:
-                body, content_type, status, headers = (
-                    response,
-                    'application/json',
-                    200,
-                    [],
-                )
-            return (
-                body,
-                content_type,
-                status,
-                headers + [('Cache-Control', 'no-store')],
-            )
-
-        return wrapper
-
-    return decorator
-
-
-def register_routes(app, database, rate_limit):
-    """Register authentication endpoints on the supplied Wred app."""
-
-    @app.route('/api/signup', methods=['POST'])
-    @rate_limit(requests=10, seconds=60)
-    async def signup(request):
-        try:
-            username, password = await read_credentials(request)
-        except ValueError as error:
-            return {'error': str(error)}, 'application/json', 400
-        salt = token_bytes(16)
-        password_hash = await hash_password(password, salt)
-        # MongoDB's unique _id also handles signups in different workers.
-        if not await database.create_user(username, salt, password_hash):
-            return (
-                {'error': 'Username already exists'},
-                'application/json',
-                409,
-            )
-        return {'id': username}, 'application/json', 201
-
-    @app.route('/api/login', methods=['POST'])
-    @rate_limit(requests=5, seconds=60)
-    async def login(request):
-        if not same_origin_request(request):
-            return (
-                {
-                    'error': (
-                        'A same-origin request with '
-                        'X-Notebook-Request: 1 is required'
-                    )
-                },
-                'application/json',
-                403,
-            )
-        try:
-            username, password = await read_credentials(request)
-        except ValueError as error:
-            return {'error': str(error)}, 'application/json', 400
-        # Use a dummy record for unknown users so failed logins still perform
-        # password hashing.
-        record = await database.get_user(username)
-        salt = record['salt'] if record else bytes(16)
-        password_hash = await hash_password(password, salt)
-        if record is None or not compare_digest(
-            password_hash, record['password_hash']
-        ):
-            return {'error': 'Invalid credentials'}, 'application/json', 401
-        login_id = token_urlsafe(16)
-        refresh = token_urlsafe(32)
-        access = token_urlsafe(32)
-        now = time()
-        # Persist digests and Unix timestamps so sessions survive restarts.
-        await database.create_login(
-            username,
-            login_id,
-            sha256(refresh.encode()).hexdigest(),
-            now + REFRESH_LIFETIME,
-            sha256(access.encode()).hexdigest(),
-            now + TOKEN_LIFETIME,
-        )
-        return (
-            token_response(access, username),
-            'application/json',
-            200,
-            [
-                ('Cache-Control', 'no-store'),
-                refresh_cookie(request, refresh, REFRESH_LIFETIME),
-            ],
-        )
-
-    @app.route('/api/refresh', methods=['POST'])
-    @rate_limit(requests=30, seconds=60)
-    async def refresh(request):
-        if not same_origin_request(request):
-            return (
-                {
-                    'error': (
-                        'A same-origin request with '
-                        'X-Notebook-Request: 1 is required'
-                    )
-                },
-                'application/json',
-                403,
-            )
-        digest = sha256(read_refresh_cookie(request).encode()).hexdigest()
-        access = token_urlsafe(32)
-        now = time()
-        username = await database.refresh_login(
-            digest,
-            sha256(access.encode()).hexdigest(),
-            now + TOKEN_LIFETIME,
-            now,
-        )
-        if username is None:
-            return (
-                {'error': 'Unauthorized'},
-                'application/json',
-                401,
-                [
-                    ('Cache-Control', 'no-store'),
-                    refresh_cookie(request, '', 0),
-                ],
-            )
-        # Reuse the cookie while issuing a fresh access token; concurrent tabs
-        # work.
-        return (
-            token_response(access, username),
-            'application/json',
-            200,
-            [('Cache-Control', 'no-store')],
-        )
-
-    @app.route('/api/me')
-    @protected(database)
-    async def me(request):
-        return request.user
-
-    @app.route('/api/logout', methods=['POST'])
-    @protected(database)
-    async def logout(request):
-        await database.revoke_login(request.login_id)
-        return (
-            {'logged_out': True},
-            'application/json',
-            200,
-            [refresh_cookie(request, '', 0)],
-        )
diff --git a/example_app/notes.py b/example_app/notes.py
deleted file mode 100644
index 3bcedf7..0000000
--- a/example_app/notes.py
+++ /dev/null
@@ -1,176 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.notes
-    ~~~~~~~~~~~~~~~~~
-
-    Private note CRUD, validation, filtering, pagination, and JSON export.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-from datetime import UTC, datetime
-from secrets import token_urlsafe
-
-import orjson
-
-from .auth import protected
-
-
-async def read_note(request, partial=False):
-    try:
-        data = await request.json()
-    except orjson.JSONDecodeError:
-        raise ValueError('Invalid JSON body') from None
-    if (
-        not isinstance(data, dict)
-        or not data
-        or data.keys() - {'title', 'body', 'pinned'}
-    ):
-        raise ValueError('Send an object containing title, body, or pinned')
-    if not partial and 'title' not in data:
-        raise ValueError('A title is required')
-    if 'title' in data and (
-        not isinstance(data['title'], str)
-        or not 1 <= len(data['title'].strip()) <= 100
-    ):
-        raise ValueError('Title must contain 1–100 characters')
-    if 'body' in data and (
-        not isinstance(data['body'], str) or len(data['body']) > 10_000
-    ):
-        raise ValueError('Body must be a string of at most 10,000 characters')
-    if 'pinned' in data and not isinstance(data['pinned'], bool):
-        raise ValueError('Pinned must be a boolean')
-    if 'title' in data:
-        data['title'] = data['title'].strip()
-    return data if partial else {'body': '', 'pinned': False, **data}
-
-
-async def owned_note(database, note_id, user):
-    note = await database.get_note(note_id)
-    if note is None:
-        return {'error': 'Note not found'}, 'application/json', 404
-    if note['owner'] != user['id']:
-        return (
-            {'error': 'This note belongs to another user'},
-            'application/json',
-            403,
-        )
-    return note
-
-
-def register_routes(app, database):
-    """Register note endpoints; each handler requires a valid bearer token."""
-
-    @app.route('/api/notes', methods=['GET'])
-    @protected(database)
-    async def list_notes(request):
-        # Keep this demo's query budget at 20 fields, including repeated keys.
-        if request.scope.get('query_string', b'').count(b'&') >= 20:
-            return {'error': 'Invalid query string'}, 'application/json', 400
-        try:
-            query = request.query
-        except UnicodeDecodeError:
-            return {'error': 'Invalid query string'}, 'application/json', 400
-        try:
-            limit = int(query.get('limit') or '5')
-            offset = int(query.get('offset') or '0')
-        except ValueError:
-            return (
-                {'error': 'Limit and offset must be integers'},
-                'application/json',
-                400,
-            )
-        # The offset is echoed in JSON, whose serializer supports uint64.
-        if not 1 <= limit <= 50 or not 0 <= offset < 2**64:
-            return (
-                {
-                    'error': (
-                        'Limit must be 1–50; offset must be between '
-                        '0 and 18446744073709551615'
-                    )
-                },
-                'application/json',
-                400,
-            )
-        search = query.get('q', '').strip().casefold()
-        pinned = query.get('pinned', '')
-        if pinned not in ('', 'true', 'false'):
-            return (
-                {'error': 'Pinned filter must be true or false'},
-                'application/json',
-                400,
-            )
-        return await database.list_notes(
-            request.user['id'], search, pinned, limit, offset
-        )
-
-    @app.route('/api/notes', methods=['POST'])
-    @protected(database)
-    async def create_note(request):
-        try:
-            data = await read_note(request)
-        except ValueError as error:
-            return {'error': str(error)}, 'application/json', 400
-        note_id = token_urlsafe(9)
-        now = datetime.now(UTC).isoformat(timespec='microseconds')
-        note = {
-            'id': note_id,
-            'owner': request.user['id'],
-            **data,
-            'created_at': now,
-            'updated_at': now,
-        }
-        await database.create_note(note)
-        return (
-            note,
-            'application/json',
-            201,
-            [('Location', f'/api/notes/{note_id}')],
-        )
-
-    @app.route('/api/notes/{note_id}', methods=['GET'])
-    @protected(database)
-    async def get_note(request, note_id):
-        return await owned_note(database, note_id, request.user)
-
-    @app.route('/api/notes/{note_id}', methods=['PUT', 'PATCH'])
-    @protected(database)
-    async def update_note(request, note_id):
-        note = await owned_note(database, note_id, request.user)
-        if isinstance(note, tuple):
-            return note
-        try:
-            data = await read_note(request, partial=request.method == 'PATCH')
-        except ValueError as error:
-            return {'error': str(error)}, 'application/json', 400
-        data['updated_at'] = datetime.now(UTC).isoformat(
-            timespec='microseconds'
-        )
-        updated = await database.update_note(note_id, request.user['id'], data)
-        if updated is None:
-            return {'error': 'Note not found'}, 'application/json', 404
-        return updated
-
-    @app.route('/api/notes/{note_id}', methods=['DELETE'])
-    @protected(database)
-    async def delete_note(request, note_id):
-        note = await owned_note(database, note_id, request.user)
-        if isinstance(note, tuple):
-            return note
-        if not await database.delete_note(note_id, request.user['id']):
-            return {'error': 'Note not found'}, 'application/json', 404
-        return b'', 'application/json', 204
-
-    # This fixed path wins over /api/notes/{note_id}, even though it is
-    # registered afterward.
-    @app.route('/api/notes/export', methods=['GET'])
-    @protected(database)
-    async def export_notes(request):
-        items = await database.export_notes(request.user['id'])
-        return (
-            items,
-            'application/json',
-            200,
-            [('Content-Disposition', 'attachment; filename="notes.json"')],
-        )
diff --git a/example_app/pages.py b/example_app/pages.py
deleted file mode 100644
index 813fab6..0000000
--- a/example_app/pages.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.pages
-    ~~~~~~~~~~~~~~~~~
-
-    Redirect, health check, and a rate-limit demonstration.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-
-def register_routes(app, rate_limit):
-    """Register public demonstration endpoints; ServeStatic handles files."""
-
-    @app.route('/ui')
-    async def old_ui(request):
-        return b'', 'text/plain', 303, [('Location', '/')]
-
-    @app.route('/api/health')
-    async def health(request):
-        return {'status': 'ok'}
-
-    @app.route('/api/rate-limit-demo')
-    @rate_limit(requests=3, seconds=10)
-    async def rate_limit_demo(request):
-        return {'message': 'Request accepted'}
diff --git a/example_app/rate_limit.py b/example_app/rate_limit.py
deleted file mode 100644
index ea25aa3..0000000
--- a/example_app/rate_limit.py
+++ /dev/null
@@ -1,62 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.rate_limit
-    ~~~~~~~~~~~~~~~~~~~~~~
-
-    Application-owned sliding-window rate limiting, independent of Wred.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-from collections import deque
-from functools import wraps
-from math import ceil
-from time import monotonic
-
-
-class RateLimiter:
-    """Create rate-limit decorators with state owned by one application."""
-
-    def __init__(self):
-        self._buckets = {}
-
-    def __call__(self, requests, seconds):
-        def decorator(handler):
-            @wraps(handler)
-            async def wrapper(request, *args, **kwargs):
-                now = monotonic()
-                # Drop inactive clients so their buckets do not remain in
-                # memory forever.
-                for key, timestamps in list(self._buckets.items()):
-                    if timestamps[-1] <= now - key[2]:
-                        del self._buckets[key]
-                # Use ASGI's client address, not an untrusted X-Forwarded-For
-                # header.
-                client = (request.scope.get('client') or ('unknown', 0))[0]
-                key = (handler, client, seconds)
-                timestamps = self._buckets.setdefault(key, deque())
-                while timestamps and timestamps[0] <= now - seconds:
-                    timestamps.popleft()
-                if len(timestamps) >= requests:
-                    retry_after = max(1, ceil(timestamps[0] + seconds - now))
-                    return (
-                        {
-                            'error': 'Too many requests',
-                            'retry_after': retry_after,
-                        },
-                        'application/json',
-                        429,
-                        [
-                            ('Retry-After', str(retry_after)),
-                            ('Cache-Control', 'no-store'),
-                        ],
-                    )
-                # Reserve the slot before any await so concurrent requests
-                # cannot bypass it.
-                timestamps.append(now)
-                return await handler(request, *args, **kwargs)
-
-            return wrapper
-
-        return decorator
diff --git a/example_app/static/app.css b/example_app/static/app.css
deleted file mode 100644
index 787478b..0000000
--- a/example_app/static/app.css
+++ /dev/null
@@ -1,126 +0,0 @@
-:root {
-  color-scheme: light;
-  --ink: #172746;
-  --muted: #64748b;
-  --blue: #2457d6;
-  --paper: #fff;
-  --canvas: #f5f7fc;
-  --line: #d7e0ee;
-  font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
-  color: var(--ink);
-  background: var(--canvas);
-  font-synthesis: none;
-}
-* { box-sizing: border-box; }
-[hidden] { display: none !important; }
-body { margin: 0; }
-button, input, textarea { font: inherit; }
-button { cursor: pointer; }
-button:disabled { cursor: default; opacity: .45; }
-a { color: inherit; }
-button:focus-visible, a:focus-visible { outline: 3px solid #9bb9ff; outline-offset: 4px; }
-input:focus, textarea:focus { outline: 2px solid #9bb9ff; outline-offset: 2px; border-color: var(--blue); }
-button { border: 0; border-radius: 6px; padding: 11px 16px; font-size: 14px; font-weight: 600; }
-.primary { color: #fff; background: var(--blue); }
-.primary:hover:not(:disabled) { background: #1944b1; }
-.quiet { color: var(--ink); background: transparent; }
-.quiet:hover:not(:disabled) { background: #e9eef9; }
-.danger { color: #a1353d; background: transparent; }
-.danger:hover:not(:disabled) { background: #fcebee; }
-.small { padding: 9px 12px; white-space: nowrap; }
-.shell { max-width: 1260px; margin: auto; padding: 0 38px; }
-.app-header { display: flex; justify-content: space-between; align-items: center; min-height: 100px; gap: 24px; }
-.brand { display: inline-flex; gap: 12px; align-items: center; font-size: 19px; font-weight: 700; text-decoration: none; letter-spacing: -.5px; }
-.book-mark { width: 24px; height: 28px; border: 2px solid var(--blue); border-left: 7px solid var(--blue); border-radius: 2px 5px 5px 2px; transform: rotate(-5deg); }
-.account { display: flex; align-items: center; gap: 8px; font-size: 14px; }
-#username { margin-right: 12px; color: var(--muted); overflow-wrap: anywhere; }
-.notice { margin: 0 0 20px; padding: 14px 18px; color: #244f48; background: #e5f2ee; border-radius: 6px; line-height: 1.5; }
-.notice.error { color: #902c39; background: #fcebee; }
-.welcome { display: grid; grid-template-columns: 1.15fr 1fr; align-items: center; gap: 90px; min-height: 660px; padding: 40px 35px 65px; }
-.introduction h1 { font-size: clamp(44px, 5.1vw, 68px); line-height: 1.06; font-weight: 650; letter-spacing: -3px; margin: 0 0 25px; }
-.introduction > p { color: var(--muted); max-width: 360px; font-size: 18px; line-height: 1.65; margin-bottom: 40px; }
-.paper-preview { max-width: 385px; padding: 28px 32px 24px 40px; background: var(--paper); border: 1px solid var(--line); border-left: 4px solid #9bb9ff; transform: rotate(-3deg); box-shadow: 0 12px 30px #17274608; }
-.preview-title { display: block; margin-bottom: 17px; font-family: Georgia, serif; font-size: 23px; }
-.preview-line { display: block; min-height: 34px; line-height: 33px; border-bottom: 1px solid #e8edf5; font-family: Georgia, serif; font-size: 14px; color: var(--muted); }
-.auth-panel { padding: 32px; background: var(--paper); border: 1px solid var(--line); border-radius: 10px; }
-.auth-switch { display: flex; gap: 4px; background: #eef2f9; border-radius: 6px; padding: 4px; margin-bottom: 28px; }
-.auth-switch button { width: 50%; color: var(--muted); background: transparent; }
-.auth-switch button[aria-pressed="true"] { background: var(--paper); color: var(--ink); box-shadow: 0 1px 3px #17274612; }
-.auth-panel h2 { margin: 0 0 9px; font-size: 25px; letter-spacing: -.7px; }
-.auth-panel > p { margin: 0 0 26px; color: var(--muted); line-height: 1.5; }
-label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 9px; }
-input:not([type="checkbox"]), textarea { width: 100%; color: var(--ink); background: #fff; border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; }
-.auth-panel input { margin-bottom: 21px; }
-.auth-panel input[type="password"], .auth-panel input[name="password"] { margin-bottom: 8px; }
-.field-help { font-size: 12px; color: var(--muted); margin: 0 0 25px; line-height: 1.5; }
-.auth-panel .primary { width: 100%; }
-.workspace { display: grid; grid-template-columns: 350px minmax(0, 1fr); min-height: 680px; background: var(--paper); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
-.notes-pane { border-right: 1px solid var(--line); padding: 26px 23px; background: #fafbfe; }
-.pane-heading, .editor-heading { display: flex; justify-content: space-between; align-items: center; gap: 14px; }
-.pane-heading { margin-bottom: 24px; }
-.pane-heading h1 { font-size: 22px; margin: 0; letter-spacing: -.6px; }
-.notes-pane input[type="search"] { font-size: 14px; }
-.check { display: flex; align-items: center; gap: 9px; font-weight: 400; font-size: 13px; margin: 0; }
-.check input { accent-color: var(--blue); width: 15px; height: 15px; margin: 0; }
-.filter { margin: 16px 0 23px; }
-.list-summary { font-size: 12px; color: var(--muted); margin: 0 0 12px; }
-.note-list { display: grid; gap: 8px; }
-.note-item { display: block; width: 100%; padding: 16px; text-align: left; color: var(--ink); border: 1px solid transparent; background: transparent; }
-.note-item:hover { background: #eef2fa; }
-.note-item[aria-current="true"] { border-color: #b1c6f7; background: #eef3ff; }
-.note-item strong { display: block; font-size: 15px; font-weight: 650; overflow-wrap: anywhere; }
-.note-item .excerpt { display: block; font-family: Georgia, serif; color: var(--muted); font-size: 13px; font-weight: 400; line-height: 1.6; margin-top: 7px; overflow-wrap: anywhere; }
-.note-item .note-meta { display: block; color: var(--muted); font-size: 11px; font-weight: 400; margin-top: 12px; }
-.empty-list { margin-top: 45px; color: var(--muted); font-family: Georgia, serif; line-height: 1.7; font-size: 17px; }
-.pagination { display: flex; justify-content: space-between; align-items: center; gap: 3px; margin-top: 20px; }
-.pagination button { padding: 9px 6px; font-size: 12px; }
-.pagination span { color: var(--muted); font-size: 12px; }
-.editor { position: relative; padding: 30px 38px 25px 48px; }
-.editor::before { content: ""; position: absolute; top: 104px; bottom: 105px; left: 25px; border-left: 1px solid #c7d7f7; }
-.editor-heading { margin-bottom: 34px; }
-.editor-heading h2 { font-size: 16px; font-weight: 600; margin: 0; }
-.save-state { color: var(--muted); font-size: 12px; }
-.editor label { color: var(--muted); }
-#note-title { font-family: Georgia, serif; font-size: 28px; padding: 10px 0 14px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; margin-bottom: 25px; }
-#note-body { font-family: Georgia, serif; font-size: 17px; line-height: 1.75; border: 0; padding: 8px 0; resize: vertical; height: 300px; min-height: 295px; }
-#note-title::placeholder { color: #a0aabc; }
-#note-body::placeholder { color: #a0aabc; }
-.editor-details { display: flex; justify-content: space-between; align-items: center; color: var(--muted); font-size: 12px; gap: 14px; padding: 20px 0; border-top: 1px solid var(--line); }
-.editor-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
-.editor-actions .danger { margin-left: auto; }
-.note-date { margin: 24px 0 0; color: var(--muted); font-size: 11px; }
-.app-footer { padding: 25px 0; text-align: center; color: var(--muted); font-size: 12px; line-height: 1.6; }
-.demo-tools { margin-top: 20px; padding: 16px 20px; color: var(--muted); border: 1px solid var(--line); border-radius: 6px; font-size: 13px; }
-.demo-tools summary { cursor: pointer; color: var(--ink); font-weight: 600; }
-.demo-tools p { line-height: 1.6; }
-.demo-tools button { background: #e9eef9; }
-.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
-@media (max-width: 1000px) {
-  .shell { padding: 0 24px; }
-  .welcome { gap: 40px; padding: 30px 0 60px; }
-  .workspace { grid-template-columns: 300px minmax(0, 1fr); }
-  .editor { padding-right: 26px; padding-left: 35px; }
-  .editor::before { left: 15px; }
-}
-@media (max-width: 760px) {
-  .shell { padding: 0 18px; }
-  .app-header { min-height: 85px; gap: 14px; flex-wrap: wrap; padding: 18px 0; }
-  .account { width: 100%; justify-content: flex-end; gap: 3px; }
-  #username { margin-right: auto; }
-  .welcome { grid-template-columns: 1fr; gap: 28px; min-height: auto; padding: 25px 0; }
-  .introduction h1 { font-size: 44px; letter-spacing: -1.8px; }
-  .introduction > p { max-width: 100%; margin-bottom: 0; font-size: 16px; }
-  .paper-preview { display: none; }
-  .auth-panel { padding: 25px; }
-  .workspace { grid-template-columns: 1fr; }
-  .notes-pane { border-right: 0; border-bottom: 1px solid var(--line); padding: 22px; }
-  .note-list { max-height: 270px; overflow-y: auto; }
-  .editor { padding: 26px 24px 26px 35px; }
-  .editor::before { left: 17px; }
-  .editor-heading { margin-bottom: 25px; }
-  #note-title { font-size: 25px; }
-  #note-body { min-height: 260px; }
-  .editor-actions .danger { margin-left: 0; }
-  .empty-list { margin-top: 20px; font-size: 15px; }
-  .app-footer { text-align: left; }
-}
diff --git a/example_app/static/app.js b/example_app/static/app.js
deleted file mode 100644
index 7f19135..0000000
--- a/example_app/static/app.js
+++ /dev/null
@@ -1,337 +0,0 @@
-// Bearer tokens stay in memory. The server's HttpOnly refresh cookie restores login.
-const state = { token: null, user: null, selected: null, notes: [], total: 0, offset: 0, limit: 5, mode: 'login' };
-const $ = id => document.getElementById(id);
-let listSequence = 0;
-let selectionSequence = 0;
-let searchTimer;
-let sessionGeneration = 0;
-let refreshPromise = null;
-
-function notice(message, error = false) {
-  $('notice').textContent = message;
-  $('notice').classList.toggle('error', error);
-  $('notice').hidden = !message;
-}
-
-// This one client helper handles JSON, bearer auth, errors, and empty 204 responses.
-async function api(path, { method = 'GET', data, authenticated = true, retry = true } = {}) {
-  const token = state.token;
-  const generation = sessionGeneration;
-  // Cookie-using endpoints require this header; cross-origin HTML forms cannot set it.
-  const headers = { 'X-Notebook-Request': '1' };
-  if (data !== undefined) headers['Content-Type'] = 'application/json';
-  if (authenticated && token) headers.Authorization = `Bearer ${token}`;
-  let response;
-  try {
-    response = await fetch(path, { method, headers, credentials: 'same-origin', cache: 'no-store',
-      body: data === undefined ? undefined : JSON.stringify(data) });
-  } catch {
-    throw new Error('Could not reach the notebook. Check that the server is running and try again.');
-  }
-  const result = response.status === 204 ? null : await response.json();
-  // Ignore a response from a previous login rather than exposing it in another account.
-  if (authenticated && generation !== sessionGeneration) throw new DOMException('Login changed', 'AbortError');
-  // Share one refresh across concurrent requests, then retry the original request once.
-  if (response.status === 401 && authenticated && retry) {
-    try { await renewLogin(); }
-    catch (error) {
-      if (error.status === 401) {
-        leaveNotebook();
-        throw new Error('Your login has ended. Log in again to continue.');
-      }
-      throw error;
-    }
-    return api(path, { method, data, authenticated, retry: false });
-  }
-  if (response.status === 401 && authenticated) {
-    leaveNotebook();
-    throw new Error('Your login has ended. Log in again to continue.');
-  }
-  if (!response.ok) {
-    const wait = Number(response.headers.get('Retry-After')) || result.retry_after || 0;
-    const error = new Error((result.error || `Request failed (${response.status}).`) + (wait ? `. Try again in ${wait} seconds.` : ''));
-    error.status = response.status;
-    error.retryAfter = wait;
-    throw error;
-  }
-  return { data: result, headers: response.headers };
-}
-
-function renewLogin() {
-  if (!refreshPromise) {
-    const generation = sessionGeneration;
-    const previousUser = state.user?.id;
-    refreshPromise = api('/api/refresh', { method: 'POST', authenticated: false })
-      .then(({ data }) => {
-        if (generation !== sessionGeneration) throw new DOMException('Login changed', 'AbortError');
-        // Another tab may have logged into a different account using the shared cookie.
-        // Never retry an old account's pending write using that new account's identity.
-        if (previousUser && previousUser !== data.user.id) {
-          leaveNotebook();
-          throw new Error('This browser changed accounts. Reload to open the current notebook.');
-        }
-        state.token = data.access_token;
-        return data;
-      }).finally(() => { refreshPromise = null; });
-  }
-  return refreshPromise;
-}
-
-async function enterNotebook(login, message) {
-  sessionGeneration++;
-  state.token = login.access_token;
-  const { data: user } = await api('/api/me');
-  state.user = user;
-  $('auth-password').value = '';
-  $('username').textContent = user.id;
-  $('welcome').hidden = true;
-  $('workspace').hidden = false;
-  $('account').hidden = false;
-  showNote(null);
-  await loadNotes();
-  notice(message);
-}
-
-async function restoreNotebook() {
-  const buttons = [$('auth-submit'), $('login-mode'), $('signup-mode')];
-  buttons.forEach(button => { button.disabled = true; });
-  notice('Checking your login…');
-  try { await enterNotebook(await renewLogin(), 'Welcome back.'); }
-  catch (error) {
-    leaveNotebook();
-    // A new visitor simply has no refresh cookie; other failures deserve a visible message.
-    notice(error.status === 401 ? '' : error.message, error.status !== 401);
-  }
-  finally { buttons.forEach(button => { button.disabled = false; }); }
-}
-
-async function action(callback, button) {
-  if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); }
-  try { await callback(); }
-  catch (error) { if (error.name !== 'AbortError') notice(error.message, true); }
-  finally { if (button) { button.disabled = false; button.removeAttribute('aria-busy'); } }
-}
-
-function authMode(mode) {
-  state.mode = mode;
-  const signup = mode === 'signup';
-  $('signup-mode').setAttribute('aria-pressed', String(signup));
-  $('login-mode').setAttribute('aria-pressed', String(!signup));
-  $('auth-title').textContent = signup ? 'Start your notebook' : 'Open your notebook';
-  $('auth-description').textContent = signup ? 'Choose a username and a long password. Your notes belong to you.' : 'Log in to pick up where you left off.';
-  $('auth-submit').textContent = signup ? 'Create account' : 'Log in';
-  $('auth-password').autocomplete = signup ? 'new-password' : 'current-password';
-  notice('');
-}
-
-function leaveNotebook() {
-  sessionGeneration++;
-  state.token = null;
-  state.user = null;
-  state.notes = [];
-  state.total = 0;
-  state.offset = 0;
-  listSequence++;
-  selectionSequence++;
-  $('note-list').replaceChildren();
-  $('search').value = '';
-  $('pinned-filter').checked = false;
-  showNote(null);
-  $('workspace').hidden = true;
-  $('account').hidden = true;
-  $('welcome').hidden = false;
-  $('username').textContent = '';
-  $('auth-password').value = '';
-}
-
-function dirty() {
-  const original = state.selected || { title: '', body: '', pinned: false };
-  return $('note-title').value !== original.title || $('note-body').value !== original.body || $('note-pinned').checked !== original.pinned;
-}
-
-function mayDiscard() {
-  return !dirty() || window.confirm('Discard your unsaved changes?');
-}
-
-function updateDraftState() {
-  $('character-count').textContent = `${$('note-body').value.length.toLocaleString()} / 10,000`;
-  $('save-state').textContent = dirty() ? 'Unsaved changes' : state.selected ? 'Saved' : 'Ready when you are';
-}
-
-function showNote(note, draft = note) {
-  state.selected = note ? structuredClone(note) : null;
-  $('note-title').value = draft?.title || '';
-  $('note-body').value = draft?.body || '';
-  $('note-pinned').checked = draft?.pinned || false;
-  $('editor-heading').textContent = note ? 'Edit note' : 'New note';
-  $('save-note').textContent = note ? 'Save changes' : 'Save note';
-  $('discard').textContent = note ? 'Discard changes' : 'Clear draft';
-  $('delete-note').hidden = !note;
-  $('note-date').textContent = note ? `Last saved ${new Date(note.updated_at).toLocaleString()}` : '';
-  updateDraftState();
-}
-
-function renderNotes() {
-  $('note-list').replaceChildren();
-  for (const note of state.notes) {
-    const button = document.createElement('button');
-    button.type = 'button';
-    button.className = 'note-item';
-    button.setAttribute('aria-current', String(note.id === state.selected?.id));
-    const title = document.createElement('strong');
-    title.textContent = note.title;
-    const excerpt = document.createElement('span');
-    excerpt.className = 'excerpt';
-    excerpt.textContent = note.body.replace(/\s+/g, ' ').slice(0, 85) || 'Empty note';
-    const meta = document.createElement('span');
-    meta.className = 'note-meta';
-    meta.textContent = `${note.pinned ? 'Pinned · ' : ''}${new Date(note.updated_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
-    button.append(title, excerpt, meta);
-    button.addEventListener('click', () => action(() => selectNote(note.id)));
-    $('note-list').append(button);
-  }
-  $('empty-list').hidden = state.total !== 0;
-  $('empty-list').textContent = $('search').value || $('pinned-filter').checked
-    ? 'No notes match this filter. Try another search or turn off Pinned only.'
-    : 'Your first note starts in the editor. Give it a title and save it.';
-  $('list-summary').textContent = state.total ? `${state.offset + 1}–${Math.min(state.offset + state.limit, state.total)} of ${state.total} notes` : 'No notes yet';
-  $('pagination').hidden = state.total <= state.limit;
-  $('previous').disabled = state.offset === 0;
-  $('next').disabled = state.offset + state.limit >= state.total;
-  $('page-number').textContent = `Page ${Math.floor(state.offset / state.limit) + 1}`;
-}
-
-async function loadNotes() {
-  const sequence = ++listSequence;
-  const query = new URLSearchParams({ limit: state.limit, offset: state.offset });
-  const search = $('search').value.trim();
-  if (search) query.set('q', search);
-  if ($('pinned-filter').checked) query.set('pinned', 'true');
-  const { data } = await api(`/api/notes?${query}`);
-  if (sequence !== listSequence) return;
-  // If deletion emptied the last page, show the last remaining page instead.
-  if (data.total && state.offset >= data.total) {
-    state.offset = Math.floor((data.total - 1) / state.limit) * state.limit;
-    return loadNotes();
-  }
-  if (!data.total) state.offset = 0;
-  state.notes = data.items;
-  state.total = data.total;
-  renderNotes();
-}
-
-async function selectNote(id) {
-  if (id === state.selected?.id || !mayDiscard()) return;
-  const sequence = ++selectionSequence;
-  const { data } = await api(`/api/notes/${encodeURIComponent(id)}`);
-  if (sequence !== selectionSequence) return;
-  showNote(data);
-  renderNotes();
-  notice('');
-  if (window.matchMedia('(max-width: 760px)').matches) $('editor-heading').scrollIntoView({ block: 'start' });
-}
-
-$('login-mode').addEventListener('click', () => authMode('login'));
-$('signup-mode').addEventListener('click', () => authMode('signup'));
-$('auth-form').addEventListener('submit', event => {
-  event.preventDefault();
-  const credentials = { username: $('auth-username').value, password: $('auth-password').value };
-  const signup = state.mode === 'signup';
-  action(async () => {
-    if (signup) await api('/api/signup', { method: 'POST', data: credentials, authenticated: false });
-    const { data: login } = await api('/api/login', { method: 'POST', data: credentials, authenticated: false });
-    await enterNotebook(login, signup ? 'Your notebook is ready. Write your first note.' : 'Welcome back.');
-    $('note-title').focus();
-  }, $('auth-submit'));
-});
-
-$('note-form').addEventListener('input', updateDraftState);
-$('note-form').addEventListener('submit', event => {
-  event.preventDefault();
-  const id = state.selected?.id;
-  const sequence = selectionSequence;
-  const values = { title: $('note-title').value, body: $('note-body').value, pinned: $('note-pinned').checked };
-  action(async () => {
-    const { data } = await api(id ? `/api/notes/${encodeURIComponent(id)}` : '/api/notes', { method: id ? 'PATCH' : 'POST', data: values });
-    if (sequence === selectionSequence) {
-      // Keep edits made during the save, but retain the saved ID and baseline.
-      const draft = { title: $('note-title').value, body: $('note-body').value, pinned: $('note-pinned').checked };
-      for (const key of Object.keys(draft)) {
-        if (draft[key] === values[key]) draft[key] = data[key];
-      }
-      showNote(data, draft);
-      notice(dirty() ? 'Note saved. Newer edits are unsaved.' : 'Note saved.');
-    }
-    state.offset = 0;
-    await loadNotes();
-  }, $('save-note'));
-});
-
-$('new-note').addEventListener('click', () => {
-  if (!mayDiscard()) return;
-  selectionSequence++;
-  showNote(null);
-  renderNotes();
-  notice('');
-  $('note-title').focus();
-});
-$('discard').addEventListener('click', () => {
-  if (!mayDiscard()) return;
-  showNote(state.selected);
-  notice('');
-});
-$('delete-note').addEventListener('click', () => {
-  const id = state.selected?.id;
-  if (!id || !window.confirm('Delete this note? This cannot be undone.')) return;
-  action(async () => {
-    await api(`/api/notes/${encodeURIComponent(id)}`, { method: 'DELETE' });
-    selectionSequence++;
-    showNote(null);
-    await loadNotes();
-    notice('Note deleted.');
-  }, $('delete-note'));
-});
-$('search').addEventListener('input', () => {
-  clearTimeout(searchTimer);
-  searchTimer = setTimeout(() => { state.offset = 0; action(loadNotes); }, 250);
-});
-$('pinned-filter').addEventListener('change', () => { state.offset = 0; action(loadNotes); });
-$('previous').addEventListener('click', () => { state.offset = Math.max(0, state.offset - state.limit); action(loadNotes); });
-$('next').addEventListener('click', () => { state.offset += state.limit; action(loadNotes); });
-$('export').addEventListener('click', () => action(async () => {
-  const { data } = await api('/api/notes/export');
-  const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
-  const link = document.createElement('a');
-  link.href = url;
-  link.download = 'notes.json';
-  document.body.append(link);
-  link.click();
-  link.remove();
-  setTimeout(() => URL.revokeObjectURL(url), 1000);
-  notice('Your notes have been exported.');
-}, $('export')));
-$('logout').addEventListener('click', () => action(async () => {
-  if (!mayDiscard()) return;
-  await api('/api/logout', { method: 'POST' });
-  leaveNotebook();
-  notice('Logged out.');
-}, $('logout')));
-
-// A harmless endpoint makes the request limit visible without exhausting login attempts.
-$('rate-demo').addEventListener('click', () => action(async () => {
-  let accepted = 0;
-  let limited = 0;
-  let wait = 0;
-  for (let index = 0; index < 5; index++) {
-    try { await api('/api/rate-limit-demo', { authenticated: false }); accepted++; }
-    catch (error) {
-      if (error.status !== 429) throw error;
-      limited++;
-      wait = Math.max(wait, error.retryAfter);
-    }
-  }
-  $('rate-result').textContent = `${accepted} accepted; ${limited} limited (HTTP 429).${wait ? ` Try again in ${wait} seconds.` : ''}`;
-}, $('rate-demo')));
-
-// The browser sends the HttpOnly cookie automatically; JS never reads its value.
-restoreNotebook();
diff --git a/example_app/static/index.html b/example_app/static/index.html
deleted file mode 100644
index 4293b30..0000000
--- a/example_app/static/index.html
+++ /dev/null
@@ -1,106 +0,0 @@
-<!doctype html>
-<html lang="en">
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <meta name="theme-color" content="#2457d6">
-  <title>Wred notebook</title>
-  <link rel="icon" href="data:,">
-  <link rel="stylesheet" href="/static/app.css">
-  <script src="/static/app.js" defer></script>
-</head>
-<body>
-  <div class="shell">
-    <header class="app-header">
-      <a class="brand" href="/" aria-label="Wred notebook home"><span class="book-mark" aria-hidden="true"></span>Wred notebook</a>
-      <div id="account" class="account" hidden>
-        <span id="username"></span>
-        <button id="export" class="quiet" type="button">Export notes</button>
-        <button id="logout" class="quiet" type="button">Log out</button>
-      </div>
-    </header>
-
-    <p id="notice" class="notice" role="status" aria-live="polite" hidden></p>
-
-    <main id="welcome" class="welcome">
-      <section class="introduction">
-        <h1>A notebook<br>of your own.</h1>
-        <p>Keep your ideas, lists, and little reminders in one place. Find them when you need them.</p>
-        <div class="paper-preview" aria-hidden="true">
-          <span class="preview-title">Things to remember</span>
-          <span class="preview-line">Leave a little room for the next idea.</span>
-          <span class="preview-line">Start with a blank page.</span>
-          <span class="preview-line"></span>
-        </div>
-      </section>
-      <section class="auth-panel" aria-labelledby="auth-title">
-        <div class="auth-switch" aria-label="Choose how to sign in">
-          <button id="login-mode" type="button" aria-pressed="true">Log in</button>
-          <button id="signup-mode" type="button" aria-pressed="false">Create account</button>
-        </div>
-        <h2 id="auth-title">Open your notebook</h2>
-        <p id="auth-description">Log in to pick up where you left off.</p>
-        <form id="auth-form">
-          <label for="auth-username">Username</label>
-          <input id="auth-username" name="username" autocomplete="username" maxlength="64" required>
-          <label for="auth-password">Password</label>
-          <input id="auth-password" name="password" type="password" autocomplete="current-password" minlength="15" maxlength="128" aria-describedby="password-help" required>
-          <p id="password-help" class="field-help">Use 15–128 characters. Spaces are welcome.</p>
-          <button id="auth-submit" class="primary" type="submit">Log in</button>
-        </form>
-      </section>
-    </main>
-
-    <main id="workspace" class="workspace" hidden>
-      <aside class="notes-pane" aria-labelledby="notes-heading">
-        <div class="pane-heading">
-          <h1 id="notes-heading">Your notes</h1>
-          <button id="new-note" class="primary small" type="button">+ New note</button>
-        </div>
-        <label class="sr-only" for="search">Search notes</label>
-        <input id="search" type="search" placeholder="Search your notes…" maxlength="200">
-        <label class="check filter"><input id="pinned-filter" type="checkbox"> Pinned only</label>
-        <p id="list-summary" class="list-summary" aria-live="polite"></p>
-        <div id="note-list" class="note-list" aria-label="Notes"></div>
-        <p id="empty-list" class="empty-list" hidden>Your first note starts on the right. Give it a title and save it.</p>
-        <nav id="pagination" class="pagination" aria-label="Notes pages" hidden>
-          <button id="previous" class="quiet" type="button">← Previous</button>
-          <span id="page-number"></span>
-          <button id="next" class="quiet" type="button">Next →</button>
-        </nav>
-      </aside>
-
-      <section class="editor" aria-labelledby="editor-heading">
-        <div class="editor-heading">
-          <h2 id="editor-heading">New note</h2>
-          <span id="save-state" class="save-state">Ready when you are</span>
-        </div>
-        <form id="note-form">
-          <label for="note-title">Title</label>
-          <input id="note-title" name="title" placeholder="Give this thought a title" maxlength="100" required>
-          <label for="note-body">Note</label>
-          <textarea id="note-body" name="body" placeholder="Start writing…" maxlength="10000" rows="12"></textarea>
-          <div class="editor-details">
-            <label class="check"><input id="note-pinned" name="pinned" type="checkbox"> Keep at top</label>
-            <span id="character-count">0 / 10,000</span>
-          </div>
-          <div class="editor-actions">
-            <button id="save-note" class="primary" type="submit">Save note</button>
-            <button id="discard" class="quiet" type="button">Clear draft</button>
-            <button id="delete-note" class="danger" type="button" hidden>Delete note</button>
-          </div>
-        </form>
-        <p id="note-date" class="note-date"></p>
-      </section>
-    </main>
-
-    <details class="demo-tools">
-      <summary>Try request limits</summary>
-      <p>This demo endpoint accepts three requests every ten seconds. Extra requests return HTTP 429 and tell you when to retry.</p>
-      <button id="rate-demo" class="quiet" type="button">Send 5 requests</button>
-      <p id="rate-result" role="status" aria-live="polite"></p>
-    </details>
-    <footer class="app-footer">This is a demo. Your notes are saved. You stay logged in for up to seven days, including after a server restart.</footer>
-  </div>
-</body>
-</html>
diff --git a/example_app/storage.py b/example_app/storage.py
deleted file mode 100644
index e2c1adb..0000000
--- a/example_app/storage.py
+++ /dev/null
@@ -1,266 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.storage
-    ~~~~~~~~~~~~~~~~~~~
-
-    Application-owned MongoDB storage using PyMongo's native async client.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-from asyncio import Lock
-from datetime import UTC, datetime
-
-from pymongo import AsyncMongoClient, ReturnDocument
-from pymongo.errors import DuplicateKeyError
-
-
-NOTE_FIELDS = {'_id': 0, '_title_search': 0, '_body_search': 0}
-
-
-def timestamp(seconds):
-    return datetime.fromtimestamp(seconds, UTC)
-
-
-class Database:
-    def __init__(self, uri, name):
-        # One client/pool per app worker and event loop; connection is lazy.
-        self.client = AsyncMongoClient(uri, serverSelectionTimeoutMS=5000)
-        self.db = self.client[name]
-        self._initialized = False
-        self._init_lock = Lock()
-
-    async def initialize(self):
-        if self._initialized:
-            return
-        async with self._init_lock:
-            if not self._initialized:
-                await self.client.admin.command('ping')
-                await self.db.sessions.create_index(
-                    'refresh_digest', unique=True
-                )
-                await self.db.sessions.create_index(
-                    'access_tokens.digest', unique=True
-                )
-                await self.db.sessions.create_index(
-                    'expires_at', expireAfterSeconds=0
-                )
-                await self.db.notes.create_index(
-                    [
-                        ('owner', 1),
-                        ('pinned', -1),
-                        ('updated_at', -1),
-                        ('id', -1),
-                    ],
-                )
-                self._initialized = True
-
-    async def close(self):
-        await self.client.close()
-
-    async def create_user(self, username, salt, password_hash):
-        await self.initialize()
-        try:
-            await self.db.users.insert_one(
-                {
-                    '_id': username,
-                    'salt': salt,
-                    'password_hash': password_hash,
-                },
-            )
-        except DuplicateKeyError:
-            return False
-        return True
-
-    async def get_user(self, username):
-        await self.initialize()
-        return await self.db.users.find_one({'_id': username})
-
-    async def create_login(self, username, login_id, refresh_digest,
-                           refresh_expires, access_digest, access_expires):
-        await self.initialize()
-        # Keep a login and its access credentials in one document. Refresh and
-        # logout are atomic without requiring replica-set transactions.
-        await self.db.sessions.insert_one({
-            '_id': login_id,
-            'user_id': username,
-            'refresh_digest': refresh_digest,
-            'expires_at': timestamp(refresh_expires),
-            'access_tokens': [
-                {
-                    'digest': access_digest,
-                    'expires_at': timestamp(access_expires),
-                }
-            ],
-        })
-
-    async def refresh_login(self, refresh_digest, access_digest,
-                            access_expires, now):
-        await self.initialize()
-        current_time = timestamp(now)
-        session = await self.db.sessions.find_one_and_update(
-            {
-                'refresh_digest': refresh_digest,
-                'expires_at': {'$gt': current_time},
-            },
-            [
-                {
-                    '$set': {
-                        'access_tokens': {
-                            '$concatArrays': [
-                                # Prune old access tokens while retaining
-                                # tokens used by other tabs.
-                                {
-                                    '$filter': {
-                                        'input': '$access_tokens',
-                                        'as': 'token',
-                                        'cond': {
-                                            '$gt': [
-                                                '$$token.expires_at',
-                                                current_time,
-                                            ]
-                                        },
-                                    }
-                                },
-                                {
-                                    '$literal': [
-                                        {
-                                            'digest': access_digest,
-                                            'expires_at': timestamp(
-                                                access_expires
-                                            ),
-                                        }
-                                    ]
-                                },
-                            ]
-                        }
-                    }
-                }
-            ],
-            projection={'user_id': 1},
-            return_document=ReturnDocument.AFTER,
-        )
-        return session['user_id'] if session else None
-
-    async def get_access_token(self, digest, now):
-        await self.initialize()
-        current_time = timestamp(now)
-        # TTL cleanup is asynchronous, so authorization must check both
-        # deadlines.
-        session = await self.db.sessions.find_one(
-            {
-                'expires_at': {'$gt': current_time},
-                'access_tokens': {
-                    '$elemMatch': {
-                        'digest': digest,
-                        'expires_at': {'$gt': current_time},
-                    }
-                },
-            },
-            {'user_id': 1},
-        )
-        if session:
-            return {'user_id': session['user_id'], 'login_id': session['_id']}
-
-    async def revoke_login(self, login_id):
-        await self.initialize()
-        await self.db.sessions.delete_one({'_id': login_id})
-
-    async def list_notes(self, owner, search, pinned, limit, offset):
-        await self.initialize()
-        query = {'owner': owner}
-        if search:
-            # Store Python-casefolded fields to preserve Unicode substring
-            # search.
-            # $literal keeps search text such as "$title" from becoming an
-            # expression.
-            query['$expr'] = {
-                '$gte': [
-                    {
-                        '$indexOfCP': [
-                            {
-                                '$concat': [
-                                    '$_title_search',
-                                    '\n',
-                                    '$_body_search',
-                                ]
-                            },
-                            {'$literal': search},
-                        ]
-                    },
-                    0,
-                ]
-            }
-        if pinned:
-            query['pinned'] = pinned == 'true'
-        # MongoDB skip accepts signed 64-bit integers. Larger offsets are
-        # empty.
-        items = (
-            [{'$match': {'_id': {'$exists': False}}}]
-            if offset >= 2**63
-            else [
-                {'$skip': offset},
-                {'$limit': limit},
-                {'$project': NOTE_FIELDS},
-            ]
-        )
-        cursor = await self.db.notes.aggregate([
-            {'$match': query},
-            {'$sort': {'pinned': -1, 'updated_at': -1, 'id': -1}},
-            {'$facet': {'items': items, 'count': [{'$count': 'total'}]}},
-        ])
-        result = (await cursor.to_list(length=1))[0]
-        return {
-            'items': result['items'],
-            'total': result['count'][0]['total'] if result['count'] else 0,
-            'limit': limit,
-            'offset': offset,
-        }
-
-    async def create_note(self, note):
-        await self.initialize()
-        await self.db.notes.insert_one({
-            **note,
-            '_id': note['id'],
-            '_title_search': note['title'].casefold(),
-            '_body_search': note['body'].casefold(),
-        })
-
-    async def get_note(self, note_id):
-        await self.initialize()
-        return await self.db.notes.find_one({'_id': note_id}, NOTE_FIELDS)
-
-    async def update_note(self, note_id, owner, data):
-        await self.initialize()
-        fields = {
-            key: data[key]
-            for key in ('title', 'body', 'pinned', 'updated_at')
-            if key in data
-        }
-        for key in ('title', 'body'):
-            if key in fields:
-                fields[f'_{key}_search'] = fields[key].casefold()
-        return await self.db.notes.find_one_and_update(
-            {'_id': note_id, 'owner': owner},
-            {'$set': fields},
-            projection=NOTE_FIELDS,
-            return_document=ReturnDocument.AFTER,
-        )
-
-    async def delete_note(self, note_id, owner):
-        await self.initialize()
-        result = await self.db.notes.delete_one(
-            {'_id': note_id, 'owner': owner}
-        )
-        return result.deleted_count == 1
-
-    async def export_notes(self, owner):
-        await self.initialize()
-        return (
-            await self.db.notes.find({'owner': owner}, NOTE_FIELDS)
-            .sort(
-                [('created_at', 1), ('id', 1)],
-            )
-            .to_list(length=None)
-        )
diff --git a/example_app/tests/__init__.py b/example_app/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/example_app/tests/helpers.py b/example_app/tests/helpers.py
deleted file mode 100644
index f4a8591..0000000
--- a/example_app/tests/helpers.py
+++ /dev/null
@@ -1,9 +0,0 @@
-async def call_app(app, scope, receive):
-    """Drive an ASGI request and capture its actual outgoing messages."""
-    messages = []
-
-    async def send(message):
-        messages.append(message)
-
-    await app(scope, receive, send)
-    return messages
diff --git a/example_app/tests/test_example_app.py b/example_app/tests/test_example_app.py
deleted file mode 100644
index 4ee48bc..0000000
--- a/example_app/tests/test_example_app.py
+++ /dev/null
@@ -1,125 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.tests.test_example_app
-    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-    Exercise example application construction and resource ownership.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-import subprocess
-import sys
-import unittest
-from pathlib import Path
-
-import orjson
-
-from example_app.tests.helpers import call_app
-
-
-class AppFactoryTests(unittest.IsolatedAsyncioTestCase):
-    async def test_public_files_and_api_share_one_application(self):
-        from example_app.app import create_app
-
-        app = create_app(database=object())
-
-        async def request(path):
-            async def receive():
-                raise AssertionError('Public endpoints should not read a body')
-
-            messages = await call_app(
-                app,
-                {
-                    'type': 'http',
-                    'path': path,
-                    'method': 'GET',
-                    'headers': [],
-                    'query_string': b'',
-                },
-                receive,
-            )
-            self.assertEqual(messages[0]['type'], 'http.response.start')
-            self.assertEqual(messages[-1]['type'], 'http.response.body')
-            self.assertFalse(messages[-1].get('more_body', False))
-            return (
-                messages[0]['status'],
-                b''.join(message.get('body', b'') for message in messages[1:]),
-                dict(messages[0]['headers']),
-            )
-
-        for path, content_type, marker in [
-            ('/', b'text/html', b'Wred notebook'),
-            ('/static/app.css', b'text/css', b'.workspace'),
-            ('/static/app.js', b'text/javascript', b'/api/notes'),
-        ]:
-            with self.subTest(path=path):
-                status, body, headers = await request(path)
-                self.assertEqual(status, 200)
-                self.assertEqual(
-                    headers[b'content-type'].split(b';')[0], content_type
-                )
-                self.assertIn(b'max-age=0', headers[b'cache-control'])
-                self.assertIn(marker, body)
-
-        status, body, headers = await request('/ui')
-        self.assertEqual((status, body, headers[b'location']), (303, b'', b'/'))
-        status, body, headers = await request('/api/health')
-        self.assertEqual((status, orjson.loads(body)), (200, {'status': 'ok'}))
-        for path in ['/static/missing.js', '/auth.py', '/static/../auth.py']:
-            with self.subTest(path=path):
-                status, body, _ = await request(path)
-                self.assertEqual(
-                    (status, orjson.loads(body)), (404, {'error': 'Not Found'})
-                )
-
-    def test_import_factory_does_not_construct_a_database(self):
-        script = """
-from unittest.mock import patch
-
-with patch('pymongo.AsyncMongoClient', side_effect=RuntimeError(
-    'Importing the factory must not construct a database client'
-)):
-    from example_app.app import create_app
-"""
-        result = subprocess.run(
-            [sys.executable, '-c', script],
-            cwd=Path(__file__).resolve().parents[2],
-            capture_output=True,
-            timeout=10,
-        )
-        self.assertEqual(result.returncode, 0, result.stderr.decode())
-
-    async def test_injected_database_follows_application_lifespan(self):
-        from example_app.app import create_app
-
-        events = []
-
-        class Database:
-            async def initialize(self):
-                events.append('initialize')
-
-            async def close(self):
-                events.append('close')
-
-        app = create_app(database=Database())
-        self.assertEqual(events, [])
-        messages = iter([
-            {'type': 'lifespan.startup'},
-            {'type': 'lifespan.shutdown'},
-        ])
-
-        async def receive():
-            return next(messages)
-
-        async def send(message):
-            events.append(message['type'])
-
-        await app({'type': 'lifespan'}, receive, send)
-        self.assertEqual(events, [
-            'initialize',
-            'lifespan.startup.complete',
-            'close',
-            'lifespan.shutdown.complete',
-        ])
diff --git a/example_app/tests/test_notebook.py b/example_app/tests/test_notebook.py
deleted file mode 100644
index f4dfabf..0000000
--- a/example_app/tests/test_notebook.py
+++ /dev/null
@@ -1,811 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-    example_app.tests.test_notebook
-    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-    Exercise the example's API contracts through the actual Wred ASGI app.
-
-    :copyright: (c) 2026 by Harrison Erd.
-    :license: BSD-3-Clause, see LICENSE for more details.
-"""
-
-import asyncio
-import os
-import subprocess
-import sys
-import unittest
-from http.cookies import SimpleCookie
-from pathlib import Path
-from unittest.mock import patch
-from urllib.parse import unquote, urlsplit
-from uuid import uuid4
-
-import orjson
-
-from example_app import auth, rate_limit
-from example_app.app import create_app
-from example_app.tests.helpers import call_app
-
-mongodb_uri = os.environ.get('WRED_TEST_MONGODB_URI')
-
-
[email protected](
-    mongodb_uri,
-    'Set WRED_TEST_MONGODB_URI to run the MongoDB integration tests',
-)
-class NotebookTests(unittest.IsolatedAsyncioTestCase):
-    def setUp(self):
-        self.database_name = 'wred_test_' + uuid4().hex
-        self.apps = []
-        self.app = self.new_app()
-        self.addAsyncCleanup(self.clean_database)
-
-    def new_app(self):
-        app = create_app(mongodb_uri, self.database_name)
-        self.apps.append(app)
-        return app
-
-    async def clean_database(self):
-        try:
-            await self.app.database.client.drop_database(self.database_name)
-        finally:
-            for app in self.apps:
-                await app.database.close()
-
-    async def request(self, path, method='GET', data=None, token=None,
-                      authorization=None, cookie=None, client='127.0.0.1',
-                      csrf_header=True, extra_headers=(), scheme='http',
-                      app=None):
-        url = urlsplit(path)
-
-        async def receive():
-            if method == 'GET':
-                raise AssertionError(
-                    'GET handlers should not consume the request body'
-                )
-            return {'type': 'http.request', 'body': orjson.dumps(data)}
-
-        headers = [
-            (b'content-type', b'application/json'),
-            (b'host', b'notebook.test'),
-        ]
-        if csrf_header:
-            headers.append((b'x-notebook-request', b'1'))
-        if cookie is not None:
-            headers.append((b'cookie', cookie.encode()))
-        headers.extend(extra_headers)
-        if token is not None:
-            headers.append((b'authorization', f'Bearer {token}'.encode()))
-        if authorization is not None:
-            headers.append((b'authorization', authorization))
-        messages = await call_app(
-            self.app if app is None else app,
-            {
-                'type': 'http',
-                'path': unquote(url.path),
-                'method': method,
-                'query_string': url.query.encode(),
-                'headers': headers,
-                'scheme': scheme,
-                'client': (client, 12345),
-            },
-            receive,
-        )
-        self.assertEqual(len(messages), 2)
-        self.assertEqual(messages[0]['type'], 'http.response.start')
-        self.assertEqual(messages[1]['type'], 'http.response.body')
-        response_headers = dict(messages[0]['headers'])
-        body = messages[1]['body']
-        if body and response_headers[b'content-type'] == b'application/json':
-            body = orjson.loads(body)
-        return messages[0]['status'], body, response_headers
-
-    async def account(self, username='alice'):
-        credentials = {
-            'username': username,
-            'password': 'a long example passphrase',
-        }
-        status, user, _ = await self.request(
-            '/api/signup', 'POST', credentials
-        )
-        self.assertEqual(
-            (status, user), (201, {'id': username.strip().casefold()})
-        )
-        status, login, headers = await self.request(
-            '/api/login', 'POST', credentials
-        )
-        self.assertEqual(status, 200)
-        self.assertEqual(headers[b'cache-control'], b'no-store')
-        self.assertEqual(login['token_type'], 'bearer')
-        self.assertEqual(login['expires_in'], 3600)
-        return login['access_token']
-
-    async def create(self, token, **data):
-        status, note, headers = await self.request(
-            '/api/notes', 'POST', data, token
-        )
-        self.assertEqual(status, 201)
-        self.assertEqual(
-            headers[b'location'], f"/api/notes/{note['id']}".encode()
-        )
-        self.assertEqual(headers[b'cache-control'], b'no-store')
-        return note
-
-    async def test_lifespan_starts_and_stops_the_database(self):
-        worker = self.new_app()
-        messages = iter(
-            [{'type': 'lifespan.startup'}, {'type': 'lifespan.shutdown'}]
-        )
-
-        async def receive():
-            return next(messages)
-
-        responses = await call_app(worker, {'type': 'lifespan'}, receive)
-        self.assertEqual(
-            responses,
-            [
-                {'type': 'lifespan.startup.complete'},
-                {'type': 'lifespan.shutdown.complete'},
-            ],
-        )
-
-    async def test_signup_login_and_independent_login_revocation(self):
-        token = await self.account(' Alice ')
-        status, profile, headers = await self.request('/api/me', token=token)
-        self.assertEqual((status, profile), (200, {'id': 'alice'}))
-        self.assertEqual(headers[b'cache-control'], b'no-store')
-        credentials = {
-            'username': 'alice',
-            'password': 'a long example passphrase',
-        }
-        self.assertEqual(
-            (await self.request('/api/signup', 'POST', credentials))[0], 409
-        )
-        for data in [
-            dict(credentials, username='unknown'),
-            dict(credentials, password='an incorrect long passphrase'),
-        ]:
-            self.assertEqual(
-                (await self.request('/api/login', 'POST', data))[:2],
-                (401, {'error': 'Invalid credentials'}),
-            )
-        _, login, _ = await self.request('/api/login', 'POST', credentials)
-        second_token = login['access_token']
-        self.assertNotEqual(token, second_token)
-        self.assertEqual(
-            (await self.request('/api/logout', 'POST', token=token))[:2],
-            (200, {'logged_out': True}),
-        )
-        self.assertEqual((await self.request('/api/me', token=token))[0], 401)
-        self.assertEqual(
-            (await self.request('/api/me', token=second_token))[:2],
-            (200, {'id': 'alice'}),
-        )
-        expired_time = auth.time() + 3601
-        with patch.object(auth, 'time', return_value=expired_time):
-            self.assertEqual(
-                (await self.request('/api/me', token=second_token))[0], 401
-            )
-
-    async def test_accounts_notes_and_sessions_survive_a_new_process(self):
-        token = await self.account()
-        note = await self.create(token, title='Remember me', pinned=True)
-        credentials = {
-            'username': 'alice',
-            'password': 'a long example passphrase',
-        }
-        _, login, headers = await self.request(
-            '/api/login', 'POST', credentials
-        )
-        morsel = SimpleCookie(headers[b'set-cookie'].decode())[
-            auth.REFRESH_COOKIE_NAME
-        ]
-        cookie = f'{auth.REFRESH_COOKIE_NAME}={morsel.value}'
-        script = """
-import asyncio
-import sys
-import orjson
-from example_app.asgi import app
-from example_app.tests.helpers import call_app
-
-
-async def main():
-    data = orjson.loads(sys.stdin.buffer.read())
-    results = []
-    for path, method, headers in [
-        (
-            '/api/notes',
-            'GET',
-            [(b'authorization', ('Bearer ' + data['token']).encode())],
-        ),
-        (
-            '/api/refresh',
-            'POST',
-            [
-                (b'cookie', data['cookie'].encode()),
-                (b'x-notebook-request', b'1'),
-            ],
-        ),
-        ('/api/login', 'POST', [(b'x-notebook-request', b'1')]),
-    ]:
-
-        async def receive():
-            return {
-                'type': 'http.request',
-                'body': orjson.dumps(data['credentials']),
-            }
-
-        messages = await call_app(
-            app,
-            {
-                'type': 'http',
-                'path': path,
-                'method': method,
-                'headers': headers,
-            },
-            receive,
-        )
-        results.append(
-            [messages[0]['status'], orjson.loads(messages[1]['body'])]
-        )
-    await app.database.close()
-    sys.stdout.buffer.write(orjson.dumps(results))
-
-
-asyncio.run(main())
-"""
-        result = await asyncio.to_thread(
-            subprocess.run,
-            [sys.executable, '-c', script],
-            input=orjson.dumps({
-                'token': login['access_token'],
-                'cookie': cookie,
-                'credentials': credentials,
-            }),
-            env={
-                **os.environ,
-                'MONGODB_URI': mongodb_uri,
-                'MONGODB_DATABASE': self.database_name,
-            },
-            cwd=Path(__file__).resolve().parents[2],
-            capture_output=True,
-            timeout=20,
-        )
-        self.assertEqual(result.returncode, 0, result.stderr.decode())
-        listed, refreshed, logged_in = orjson.loads(result.stdout)
-        self.assertEqual((listed[0], listed[1]['items']), (200, [note]))
-        self.assertEqual(
-            (refreshed[0], refreshed[1]['user']), (200, {'id': 'alice'})
-        )
-        self.assertEqual(
-            (logged_in[0], logged_in[1]['user']), (200, {'id': 'alice'})
-        )
-        # Tokens issued in the new process must be usable and revocable in the
-        # original.
-        renewed = refreshed[1]['access_token']
-        self.assertEqual(
-            (await self.request('/api/me', token=renewed))[0], 200
-        )
-        await self.request('/api/logout', 'POST', token=renewed)
-        restarted = self.new_app()
-        self.assertEqual(
-            (
-                await self.request(
-                    '/api/me', token=login['access_token'], app=restarted
-                )
-            )[0],
-            401,
-        )
-        self.assertEqual(
-            (
-                await self.request(
-                    '/api/refresh', 'POST', cookie=cookie, app=restarted
-                )
-            )[0],
-            401,
-        )
-        self.assertEqual(
-            (await self.request('/api/me', token=token, app=restarted))[0], 200
-        )
-
-    async def test_workers_handle_duplicate_signups_and_concurrent_patches(
-                                                                           self
-          ):
-        worker = self.new_app()
-        credentials = {
-            'username': 'alice',
-            'password': 'a long example passphrase',
-        }
-        responses = await asyncio.gather(
-            self.request('/api/signup', 'POST', credentials),
-            self.request('/api/signup', 'POST', credentials, app=worker),
-        )
-        self.assertEqual(
-            sorted(response[0] for response in responses), [201, 409]
-        )
-        _, login, _ = await self.request(
-            '/api/login', 'POST', credentials, app=worker
-        )
-        token = login['access_token']
-        note = await self.create(token, title='Original', body='Original body')
-        path = f"/api/notes/{note['id']}"
-        responses = await asyncio.gather(
-            self.request(path, 'PATCH', {'title': 'New title'}, token),
-            self.request(
-                path, 'PATCH', {'body': 'New body'}, token, app=worker
-            ),
-        )
-        self.assertEqual([response[0] for response in responses], [200, 200])
-        _, edited, _ = await self.request(path, token=token, app=worker)
-        self.assertEqual(
-            (edited['title'], edited['body']), ('New title', 'New body')
-        )
-        for search in ('New+title', 'New+body'):
-            _, page, _ = await self.request(
-                '/api/notes?q=' + search, token=token, app=worker
-            )
-            self.assertEqual(page['items'], [edited])
-
-    async def test_invalid_credentials_and_bearer_headers(self):
-        for data in [
-            [],
-            {},
-            {'username': 1, 'password': 'a long passphrase'},
-            {'username': 'alice', 'password': False},
-            {'username': ' ', 'password': 'a long passphrase'},
-            {'username': 'alice', 'password': 'short'},
-            {'username': 'x' * 65, 'password': 'a long passphrase'},
-            {'username': 'alice', 'password': 'x' * 129},
-        ]:
-            with self.subTest(data=data):
-                self.assertEqual(
-                    (await self.request('/api/signup', 'POST', data))[0], 400
-                )
-        for header in [b'', b'Basic example', b'Bearer ', b'Bearer unknown']:
-            with self.subTest(header=header):
-                status, error, headers = await self.request(
-                    '/api/notes', authorization=header
-                )
-                self.assertEqual(
-                    (status, error), (401, {'error': 'Unauthorized'})
-                )
-                self.assertEqual(headers[b'www-authenticate'], b'Bearer')
-
-    async def test_create_read_patch_replace_delete_and_export(self):
-        token = await self.account()
-        note = await self.create(
-            token, title='  First idea  ', body='Some details', pinned=True
-        )
-        self.assertEqual(
-            (note['title'], note['body'], note['pinned'], note['owner']),
-            ('First idea', 'Some details', True, 'alice'),
-        )
-        path = f"/api/notes/{note['id']}"
-        self.assertEqual(
-            (await self.request(path, token=token))[:2], (200, note)
-        )
-        status, edited, _ = await self.request(
-            path, 'PATCH', {'body': 'Edited details'}, token
-        )
-        self.assertEqual(status, 200)
-        self.assertEqual(
-            (edited['title'], edited['body'], edited['pinned']),
-            ('First idea', 'Edited details', True),
-        )
-        self.assertEqual(edited['created_at'], note['created_at'])
-        status, replaced, _ = await self.request(
-            path, 'PUT', {'title': 'Replacement'}, token
-        )
-        self.assertEqual(status, 200)
-        self.assertEqual(
-            (replaced['title'], replaced['body'], replaced['pinned']),
-            ('Replacement', '', False),
-        )
-        status, exported, headers = await self.request(
-            '/api/notes/export', token=token
-        )
-        self.assertEqual((status, exported), (200, [replaced]))
-        self.assertEqual(
-            headers[b'content-disposition'],
-            b'attachment; filename="notes.json"',
-        )
-        self.assertEqual(headers[b'cache-control'], b'no-store')
-        status, body, _ = await self.request(path, 'DELETE', token=token)
-        self.assertEqual((status, body), (204, b''))
-        self.assertEqual((await self.request(path, token=token))[0], 404)
-        self.assertEqual(
-            (await self.request('/api/notes/export', token=token))[:2],
-            (200, []),
-        )
-
-    async def test_accounts_cannot_access_each_others_notes(self):
-        alice = await self.account('alice')
-        bob = await self.account('bob')
-        note = await self.create(
-            alice, title='Private', body='Only Alice can read this'
-        )
-        bob_note = await self.create(bob, title='Bob only')
-        path = f"/api/notes/{note['id']}"
-        for method, data in [
-            ('GET', None),
-            ('PATCH', {'title': 'Stolen'}),
-            ('PUT', {'title': 'Stolen'}),
-            ('DELETE', None),
-        ]:
-            with self.subTest(method=method):
-                self.assertEqual(
-                    (await self.request(path, method, data, bob))[0], 403
-                )
-        self.assertEqual(
-            (await self.request(path, token=alice))[:2], (200, note)
-        )
-        _, page, _ = await self.request('/api/notes', token=bob)
-        self.assertEqual((page['items'], page['total']), ([bob_note], 1))
-        self.assertEqual(
-            (await self.request('/api/notes/export', token=bob))[:2],
-            (200, [bob_note]),
-        )
-        for method in ['GET', 'PATCH', 'DELETE']:
-            self.assertEqual(
-                (
-                    await self.request(
-                        '/api/notes/missing', method, {'title': 'Missing'}, bob
-                    )
-                )[0],
-                404,
-            )
-
-    async def test_filtering_pagination_and_invalid_queries(self):
-        token = await self.account()
-        first = await self.create(
-            token,
-            title='Coffee idea',
-            body='Learn C++; Straße; 100%_done; $title; [.*]',
-            pinned=True,
-        )
-        second = await self.create(
-            token, title='Second idea', body='Coffee later'
-        )
-        await self.create(token, title='A separate thought')
-        _, page, _ = await self.request(
-            '/api/notes?q=COFFEE&limit=1&offset=0', token=token
-        )
-        self.assertEqual(
-            (page['items'], page['total'], page['limit'], page['offset']),
-            ([first], 2, 1, 0),
-        )
-        _, page, _ = await self.request(
-            '/api/notes?q=coffee&limit=1&offset=1', token=token
-        )
-        self.assertEqual(page['items'], [second])
-        _, page, _ = await self.request(
-            '/api/notes?q=C%2B%2B&pinned=true', token=token
-        )
-        self.assertEqual(page['items'], [first])
-        for search in ['STRASSE', '100%25_done', '$title', '%5B.*%5D']:
-            _, page, _ = await self.request(
-                '/api/notes?q=' + search, token=token
-            )
-            self.assertEqual(page['items'], [first])
-        _, page, _ = await self.request(
-            '/api/notes?offset=9223372036854775808', token=token
-        )
-        self.assertEqual((page['items'], page['total']), ([], 3))
-        _, page, _ = await self.request('/api/notes?pinned=false', token=token)
-        self.assertEqual(page['total'], 2)
-        for query in [
-            'limit=abc',
-            'offset=abc',
-            'limit=0',
-            'limit=51',
-            'offset=-1',
-            'offset=18446744073709551616',
-            'offset=' + '9' * 100,
-            'pinned=maybe',
-            '&'.join(f'x={i}' for i in range(21)),
-        ]:
-            with self.subTest(query=query):
-                self.assertEqual(
-                    (await self.request('/api/notes?' + query, token=token))[
-                        0
-                    ],
-                    400,
-                )
-
-    async def test_notes_reject_invalid_or_unowned_field_updates(self):
-        token = await self.account()
-        for data in [
-            [],
-            {},
-            {'body': 'No title'},
-            {'title': 7},
-            {'title': ' '},
-            {'title': 'x' * 101},
-            {'title': 'Valid', 'body': 3},
-            {'title': 'Valid', 'body': 'x' * 10_001},
-            {'title': 'Valid', 'pinned': 'true'},
-            {'title': 'Valid', 'owner': 'bob'},
-        ]:
-            with self.subTest(data=data):
-                self.assertEqual(
-                    (await self.request('/api/notes', 'POST', data, token))[0],
-                    400,
-                )
-        note = await self.create(token, title='Keep me')
-        path = f"/api/notes/{note['id']}"
-        for data in [{}, {'owner': 'bob'}, {'title': ''}, {'pinned': 1}]:
-            self.assertEqual(
-                (await self.request(path, 'PATCH', data, token))[0], 400
-            )
-        self.assertEqual(
-            (await self.request(path, token=token))[:2], (200, note)
-        )
-        self.assertEqual(
-            (
-                await self.request(
-                    path, 'PUT', {'body': 'Missing title'}, token
-                )
-            )[0],
-            400,
-        )
-        status, pinned, _ = await self.request(
-            path, 'PATCH', {'pinned': True}, token
-        )
-        self.assertEqual(
-            (status, pinned['pinned'], pinned['title']), (200, True, 'Keep me')
-        )
-
-    async def test_cookie_restores_login_and_logout_revokes_login(self):
-        credentials = {
-            'username': 'alice',
-            'password': 'a long example passphrase',
-        }
-        self.assertEqual(
-            (await self.request('/api/signup', 'POST', credentials))[0], 201
-        )
-        status, login, headers = await self.request(
-            '/api/login', 'POST', credentials, scheme='https'
-        )
-        self.assertEqual(status, 200)
-        cookies = SimpleCookie(headers[b'set-cookie'].decode())
-        morsel = cookies[auth.REFRESH_COOKIE_NAME]
-        self.assertTrue(morsel['httponly'])
-        self.assertTrue(morsel['secure'])
-        self.assertEqual(morsel['samesite'], 'Strict')
-        self.assertEqual(morsel['path'], '/api')
-        self.assertEqual(int(morsel['max-age']), 7 * 24 * 3600)
-        self.assertEqual(morsel['domain'], '')
-        cookie = f'{auth.REFRESH_COOKIE_NAME}={morsel.value}'
-        status, restored, headers = await self.request(
-            '/api/refresh', 'POST', cookie=cookie
-        )
-        self.assertEqual(status, 200)
-        self.assertNotEqual(restored['access_token'], login['access_token'])
-        self.assertEqual(restored['user'], {'id': 'alice'})
-        self.assertEqual(headers[b'cache-control'], b'no-store')
-        self.assertEqual(
-            (await self.request('/api/me', token=restored['access_token']))[
-                :2
-            ],
-            (200, {'id': 'alice'}),
-        )
-        # Cookies alone must never authorize notes endpoints, keeping bearer
-        # auth separate.
-        self.assertEqual(
-            (await self.request('/api/notes', cookie=cookie))[0], 401
-        )
-        refreshed, logged_out = await asyncio.gather(
-            self.request('/api/refresh', 'POST', cookie=cookie),
-            self.request(
-                '/api/logout', 'POST', token=restored['access_token']
-            ),
-        )
-        self.assertEqual(logged_out[0], 200)
-        self.assertIn(refreshed[0], (200, 401))
-        if refreshed[0] == 200:
-            self.assertEqual(
-                (
-                    await self.request(
-                        '/api/me', token=refreshed[1]['access_token']
-                    )
-                )[0],
-                401,
-            )
-        _, _, headers = logged_out
-        cleared = SimpleCookie(headers[b'set-cookie'].decode())[
-            auth.REFRESH_COOKIE_NAME
-        ]
-        self.assertEqual(cleared['max-age'], '0')
-        self.assertEqual(cleared['path'], '/api')
-        for access in [login['access_token'], restored['access_token']]:
-            self.assertEqual(
-                (await self.request('/api/me', token=access))[0], 401
-            )
-        self.assertEqual(
-            (await self.request('/api/refresh', 'POST', cookie=cookie))[0], 401
-        )
-
-    async def test_cross_origin_refresh_preserves_valid_login(self):
-        credentials = {
-            'username': 'alice',
-            'password': 'a long example passphrase',
-        }
-        await self.request('/api/signup', 'POST', credentials)
-        _, _, headers = await self.request('/api/login', 'POST', credentials)
-        cookie = SimpleCookie(headers[b'set-cookie'].decode())[
-            auth.REFRESH_COOKIE_NAME
-        ]
-        cookie = f'{auth.REFRESH_COOKIE_NAME}={cookie.value}'
-        for options in [
-            {'csrf_header': False},
-            {'extra_headers': [(b'origin', b'https://attacker.test')]},
-            {'extra_headers': [(b'origin', b'null')]},
-            {'extra_headers': [(b'sec-fetch-site', b'cross-site')]},
-            {'extra_headers': [(b'sec-fetch-site', b'same-site')]},
-        ]:
-            with self.subTest(options=options):
-                self.assertEqual(
-                    (
-                        await self.request(
-                            '/api/refresh', 'POST', cookie=cookie, **options
-                        )
-                    )[0],
-                    403,
-                )
-        self.assertEqual(
-            (
-                await self.request(
-                    '/api/refresh',
-                    'POST',
-                    cookie=cookie,
-                    extra_headers=[(b'origin', b'http://notebook.test')],
-                )
-            )[0],
-            200,
-        )
-        self.assertEqual(
-            (
-                await self.request(
-                    '/api/login', 'POST', credentials, csrf_header=False
-                )
-            )[0],
-            403,
-        )
-
-    async def test_invalid_refresh_cookies_clear_browser_state(self):
-        credentials = {
-            'username': 'alice',
-            'password': 'a long example passphrase',
-        }
-        await self.request('/api/signup', 'POST', credentials)
-        _, login, headers = await self.request(
-            '/api/login', 'POST', credentials
-        )
-        morsel = SimpleCookie(headers[b'set-cookie'].decode())[
-            auth.REFRESH_COOKIE_NAME
-        ]
-        self.assertFalse(
-            morsel['secure'], 'Local HTTP demo cookies should work without TLS'
-        )
-        cookie = f'{auth.REFRESH_COOKIE_NAME}={morsel.value}'
-        # Access expiry is recoverable while the independent refresh credential
-        # is valid.
-        now = auth.time()
-        with patch.object(auth, 'time', return_value=now + 3601):
-            self.assertEqual(
-                (await self.request('/api/me', token=login['access_token']))[
-                    0
-                ],
-                401,
-            )
-            self.assertEqual(
-                (await self.request('/api/refresh', 'POST', cookie=cookie))[0],
-                200,
-            )
-        with patch.object(auth, 'time', return_value=now + 7 * 24 * 3600 + 1):
-            status, _, headers = await self.request(
-                '/api/refresh', 'POST', cookie=cookie
-            )
-            self.assertEqual(status, 401)
-            self.assertEqual(
-                SimpleCookie(headers[b'set-cookie'].decode())[
-                    auth.REFRESH_COOKIE_NAME
-                ]['max-age'],
-                '0',
-            )
-        for invalid in [None, 'notebook_refresh=unknown', 'malformed; cookie']:
-            self.assertEqual(
-                (await self.request('/api/refresh', 'POST', cookie=invalid))[
-                    0
-                ],
-                401,
-            )
-
-    async def test_rate_limits_slide_and_isolate_clients_and_endpoints(self):
-        with patch.object(rate_limit, 'monotonic', return_value=100):
-            self.assertEqual(
-                (await self.request('/api/rate-limit-demo'))[0], 200
-            )
-        with patch.object(rate_limit, 'monotonic', return_value=105):
-            self.assertEqual(
-                (await self.request('/api/rate-limit-demo'))[0], 200
-            )
-        with patch.object(rate_limit, 'monotonic', return_value=106):
-            self.assertEqual(
-                (await self.request('/api/rate-limit-demo'))[0], 200
-            )
-        with patch.object(rate_limit, 'monotonic', return_value=109):
-            status, error, headers = await self.request('/api/rate-limit-demo')
-            self.assertEqual(status, 429)
-            self.assertEqual(error['retry_after'], 1)
-            self.assertEqual(headers[b'retry-after'], b'1')
-            self.assertEqual(headers[b'cache-control'], b'no-store')
-            worker = self.new_app()
-            self.assertEqual(
-                (await self.request('/api/rate-limit-demo', app=worker))[0],
-                200,
-            )
-            self.assertEqual(
-                (await self.request('/api/rate-limit-demo'))[0], 429
-            )
-            self.assertEqual(
-                (
-                    await self.request(
-                        '/api/rate-limit-demo', client='127.0.0.2'
-                    )
-                )[0],
-                200,
-            )
-            self.assertEqual((await self.request('/api/health'))[0], 200)
-            self.assertEqual(
-                (
-                    await self.request(
-                        '/api/rate-limit-demo',
-                        extra_headers=[(b'x-forwarded-for', b'127.0.0.3')],
-                    )
-                )[0],
-                429,
-            )
-        with patch.object(rate_limit, 'monotonic', return_value=110):
-            self.assertEqual(
-                (await self.request('/api/rate-limit-demo'))[0], 200
-            )
-        with patch.object(rate_limit, 'monotonic', return_value=200):
-            self.assertEqual(
-                (await self.request('/api/rate-limit-demo'))[0], 200
-            )
-
-    async def test_concurrent_login_attempts_cannot_bypass_rate_limit(self):
-        credentials = {
-            'username': 'unknown',
-            'password': 'a long example passphrase',
-        }
-        with patch.object(rate_limit, 'monotonic', return_value=100):
-            responses = await asyncio.gather(
-                *[
-                    self.request('/api/login', 'POST', credentials)
-                    for _ in range(6)
-                ]
-            )
-            self.assertEqual(
-                sorted(status for status, _, _ in responses), [401] * 5 + [429]
-            )
-            limited = next(
-                response for response in responses if response[0] == 429
-            )
-            self.assertEqual(limited[2][b'retry-after'], b'60')
-        with patch.object(rate_limit, 'monotonic', return_value=110):
-            self.assertEqual(
-                (await self.request('/api/login', 'POST', credentials))[2][
-                    b'retry-after'
-                ],
-                b'50',
-            )
-        with patch.object(rate_limit, 'monotonic', return_value=160):
-            self.assertEqual(
-                (await self.request('/api/login', 'POST', credentials))[0], 401
-            )
-
-
-if __name__ == '__main__':
-    unittest.main()
diff --git a/example_app/tests/test_notebook_ui.cjs b/example_app/tests/test_notebook_ui.cjs
deleted file mode 100644
index 6d6892b..0000000
--- a/example_app/tests/test_notebook_ui.cjs
+++ /dev/null
@@ -1,91 +0,0 @@
-const assert = require('node:assert/strict');
-const { readFileSync } = require('node:fs');
-const { test } = require('node:test');
-const { join } = require('node:path');
-const vm = require('node:vm');
-
-const source = readFileSync(join(__dirname, '../static/app.js'), 'utf8');
-const tick = () => new Promise(resolve => setImmediate(resolve));
-
-async function notebook() {
-  const elements = new Map();
-  const element = id => {
-    if (!elements.has(id)) elements.set(id, {
-      value: '', checked: false, hidden: false, listeners: {},
-      classList: { toggle() {} }, setAttribute() {}, removeAttribute() {},
-      replaceChildren() {}, append() {}, focus() {},
-      addEventListener(type, callback) { this.listeners[type] = callback; },
-    });
-    return elements.get(id);
-  };
-  const writes = [];
-  const response = data => ({ status: 200, ok: true, headers: new Headers(), json: async () => data });
-  const context = vm.createContext({
-    document: { getElementById: element, createElement: () => element(Symbol()) },
-    window: { confirm: () => true },
-    structuredClone, URLSearchParams, DOMException, setTimeout, clearTimeout,
-    fetch: async (path, options) => {
-      if (path === '/api/refresh') return response({ access_token: 'token', user: { id: 'alice' } });
-      if (path === '/api/me') return response({ id: 'alice' });
-      if (options.method === 'POST' || options.method === 'PATCH') {
-        return new Promise(resolve => writes.push({ path, options, resolve }));
-      }
-      return response({ items: [], total: 0 });
-    },
-  });
-  vm.runInContext(source, context);
-  await tick();
-  return {
-    element, writes,
-    submit() { element('note-form').listeners.submit({ preventDefault() {} }); },
-    async finishSave(index) {
-      const write = writes[index];
-      write.resolve(response({
-        ...JSON.parse(write.options.body), id: 'saved-note',
-        updated_at: '2026-01-01T00:00:00Z',
-      }));
-      await tick();
-    },
-  };
-}
-
-for (const editingExisting of [false, true]) {
-  test(`a delayed ${editingExisting ? 'update' : 'create'} preserves newer edits and the saved identity`, async () => {
-    const ui = await notebook();
-    if (editingExisting) {
-      ui.element('note-title').value = 'Original';
-      ui.submit();
-      await ui.finishSave(0);
-    }
-    const index = ui.writes.length;
-    ui.element('note-title').value = 'Submitted title';
-    ui.submit();
-    ui.element('note-title').value = 'Newer title';
-    ui.element('note-body').value = 'Newer body';
-    ui.element('note-pinned').checked = true;
-    await ui.finishSave(index);
-    assert.equal(ui.element('note-title').value, 'Newer title');
-    assert.equal(ui.element('note-body').value, 'Newer body');
-    assert.equal(ui.element('note-pinned').checked, true);
-    assert.equal(ui.element('save-state').textContent, 'Unsaved changes');
-    ui.submit();
-    assert.equal(ui.writes[index + 1].path, '/api/notes/saved-note');
-    assert.equal(ui.writes[index + 1].options.method, 'PATCH');
-    await ui.finishSave(index + 1);
-    assert.equal(ui.element('save-state').textContent, 'Saved');
-  });
-}
-
-test('a delayed save cannot replace a different draft opened while waiting', async () => {
-  const ui = await notebook();
-  ui.element('note-title').value = 'Submitted title';
-  ui.submit();
-  ui.element('new-note').listeners.click();
-  ui.element('note-title').value = 'Different draft';
-  await ui.finishSave(0);
-  assert.equal(ui.element('note-title').value, 'Different draft');
-  ui.submit();
-  assert.equal(ui.writes[1].path, '/api/notes');
-  assert.equal(ui.writes[1].options.method, 'POST');
-  await ui.finishSave(1);
-});
diff --git a/pyproject.toml b/pyproject.toml
index 063eb86..0329048 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "flit_core.buildapi"
 name = "wred"
 dynamic = ["version"]
 description = "An ultra-minimal ASGI framework for JSON APIs."
-readme = "docs/README.md"
+readme = "README.md"
 requires-python = ">=3.11"
 license = "BSD-3-Clause"
 license-files = ["LICENSE"]
diff --git a/tests/test_wred.py b/tests.py
similarity index 99%
rename from tests/test_wred.py
rename to tests.py
index 6eb5713..e0b4762 100644
--- a/tests/test_wred.py
+++ b/tests.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 """
-    tests.test_wred
-    ~~~~~~~~~~~~~~~
+    tests
+    ~~~~~
 
     Exercise Wred request handling and ASGI response behavior.
 
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index e69de29..0000000