patx/wred
Simplify example app and flatten repository layout
Commit 74bfac1 · patx · 2026-10-20T00:40:21-04:00
Comments
No comments yet.
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 @@
-[](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