From e251c3cbc2f1a685011caed1e37a5c87e4a6379b Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 26 Aug 2026 14:09:25 +0200 Subject: [PATCH] docs: sync wiki with the shipped system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wiki was last updated 2025-12-07. Since then the API grew from a single item-store service to eleven service modules, gained a background worker, a transactional outbox, MinIO object storage and Stripe, and a storefront landed in OpenTaberna/frontend today. Several pages did not just omit that — they described a system that was never built. Database/Architecture.md documented seven tables that do not exist, home.md named MySQL and diagrammed an ELK stack, and Getting-Started.md cloned a repository layout that was never real and documented endpoints under /api/v1 rather than /v1. Rewritten against the running stack: - home.md — PostgreSQL, the real components, the four repositories - Getting-Started.md — the seven-container stack, both frontends, real ports, real health payloads, working token flow - API/Architecture.md — all 26 endpoints, response envelope, error categories - Database/Architecture.md — the 12 tables that exist, and why the normalised proposal it used to describe was not built - Configuration.md — every setting, including storage, Stripe, DHL, worker and outbox - Deployment.md — that docker-compose.yml ships only the API, and what else a production deployment therefore needs Added Authorization.md and Orders-and-Fulfillment.md, covering two subsystems the wiki had never documented at all. tools/check_wiki.py fails when the wiki drifts from the API again: an endpoint no page mentions, a documented path the API does not serve, or a broken internal link. It reads a committed OpenAPI snapshot so CI needs nothing running. tools/serve.py renders the wiki locally for review. Closes #1 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 --- .github/workflows/check-wiki.yml | 21 ++ API/Architecture.md | 298 ++++++++++++++---- Authorization.md | 161 ++++++++++ Configuration.md | 380 ++++++++++++++--------- Database/Architecture.md | 499 ++++++++++++++---------------- Deployment.md | 508 ++++++++++++------------------- Getting-Started.md | 288 ++++++++++-------- Orders-and-Fulfillment.md | 222 ++++++++++++++ README.md | 70 +++++ home.md | 176 +++++++---- openapi.snapshot.json | 174 +++++++++++ tools/check_wiki.py | 169 ++++++++++ tools/serve.py | 222 ++++++++++++++ 13 files changed, 2222 insertions(+), 966 deletions(-) create mode 100644 .github/workflows/check-wiki.yml create mode 100644 Authorization.md create mode 100644 Orders-and-Fulfillment.md create mode 100644 README.md create mode 100644 openapi.snapshot.json create mode 100755 tools/check_wiki.py create mode 100755 tools/serve.py diff --git a/.github/workflows/check-wiki.yml b/.github/workflows/check-wiki.yml new file mode 100644 index 0000000..3335fdd --- /dev/null +++ b/.github/workflows/check-wiki.yml @@ -0,0 +1,21 @@ +name: Check wiki matches the API + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + check: + name: Wiki drift check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Check every endpoint is documented and every documented path exists + run: python3 tools/check_wiki.py diff --git a/API/Architecture.md b/API/Architecture.md index 89b37c1..e7bcf0a 100644 --- a/API/Architecture.md +++ b/API/Architecture.md @@ -1,76 +1,267 @@ --- title: API Architecture -description: Architecture Design for MVP Endpoints +description: Endpoint reference, response envelope and error model published: true -date: 2025-11-19T20:36:00.097Z -tags: api, architecture +date: 2026-08-26T12:00:00.000Z +tags: api, architecture, endpoints, reference editor: markdown dateCreated: 2025-11-19T20:13:35.465Z --- # API Architecture +> **The authoritative reference is the running instance.** Every deployment serves +> interactive documentation at `/docs` and the raw schema at `/openapi.json`, both +> generated from the code. This page describes the shape of the API and lists what it +> serves so you can read it without booting anything — but where the two disagree, `/docs` +> is right and this page is stale. Please fix it. -Item description: +All application endpoints are served under the **`/v1`** prefix. Health endpoints are not +versioned. There is no `/api` prefix — if you see one in browser dev tools it is the +storefront's own Nginx proxying `/api/v1` to `/v1`. + +## Service layout + +The API is a set of self-contained "mini-API" modules, each owning its models, routes and +persistence: + +| Service | Prefix | Purpose | +|---|---|---| +| Item store | `/v1/items` | Product catalogue and product images | +| Customers | `/v1/customers` | Profiles and addresses, always self-scoped | +| Orders | `/v1/orders` | Draft orders, checkout, cancellation, return requests | +| Inventory | `/v1/admin/inventory` | Stock levels and reservations | +| Admin | `/v1/admin/orders` | Back-office fulfillment | +| Returns | `/v1/admin/returns` | Admin decisions on return requests | +| Webhooks | `/v1/webhooks` | Inbound payment provider callbacks | +| Health | `/health` | Liveness and readiness | + +Payments and shipments have no public router of their own. They are driven by the order, +webhook and admin endpoints, and are documented under +[Orders and Fulfillment](/Orders-and-Fulfillment). + +--- + +## Endpoints + +**Auth** below means a bearer token is required. Which token is not always the same +question — admin endpoints additionally check which client issued it. See +[Authorization](/Authorization). + +### Catalogue — `/v1/items` + +Reads are public so that shoppers can browse before signing in. Writes are admin-only: +what is listed is what customers see, so anyone able to create, alter or delete a product +controls the shop. + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `GET` | `/v1/items/` | List items | — | +| `GET` | `/v1/items/{item_uuid}` | Get item by UUID | — | +| `GET` | `/v1/items/by-sku/{sku}` | Get item by SKU | — | +| `GET` | `/v1/items/{item_uuid}/image` | Get the product image | — | +| `POST` | `/v1/items/` | Create a new item | admin | +| `PATCH` | `/v1/items/{item_uuid}` | Update item | admin | +| `DELETE` | `/v1/items/{item_uuid}` | Delete item | admin | +| `PUT` | `/v1/items/{item_uuid}/image` | Upload the product image | admin | + +Product images are held in MinIO, not in the database. Uploads are capped by +`STORAGE_MAX_IMAGE_BYTES` (5 MB by default) so a single oversized file cannot fill the +object store. + +### Customers — `/v1/customers` + +Every route here is scoped to the caller. The customer is identified from the verified +`sub` claim on the token, so passing somebody else's id alongside your own valid token +does not get you their data. + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `GET` | `/v1/customers/me` | Get my profile | yes | +| `PATCH` | `/v1/customers/me` | Update my profile | yes | +| `GET` | `/v1/customers/me/addresses` | List my addresses | yes | +| `POST` | `/v1/customers/me/addresses` | Create an address | yes | +| `PATCH` | `/v1/customers/me/addresses/{address_id}` | Update an address | yes | +| `DELETE` | `/v1/customers/me/addresses/{address_id}` | Delete an address | yes | + +### Orders — `/v1/orders` + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `POST` | `/v1/orders/` | Create a draft order | yes | +| `GET` | `/v1/orders/{order_id}` | Get order by ID | yes | +| `DELETE` | `/v1/orders/{order_id}` | Cancel a draft order | yes | +| `POST` | `/v1/orders/{order_id}/checkout` | Start checkout | yes | +| `POST` | `/v1/orders/{order_id}/returns` | Request a return | yes | + +`POST /checkout` is the interesting one: it reserves stock and creates the payment intent. +See [Orders and Fulfillment](/Orders-and-Fulfillment). + +### Back office — `/v1/admin` + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `GET` | `/v1/admin/orders/` | List all orders | admin | +| `GET` | `/v1/admin/orders/pick-list` | Batch pick list | admin | +| `GET` | `/v1/admin/orders/{order_id}` | Get order detail | admin | +| `PATCH` | `/v1/admin/orders/{order_id}/status` | Override order status | admin | +| `POST` | `/v1/admin/orders/{order_id}/shipments` | Create shipment | admin | +| `POST` | `/v1/admin/orders/{order_id}/label` | Trigger DHL label job | admin | +| `GET` | `/v1/admin/orders/{order_id}/label` | Download carrier label | admin | +| `GET` | `/v1/admin/orders/{order_id}/packing-slip` | Packing slip | admin | +| `POST` | `/v1/admin/orders/{order_id}/ship` | Mark order as shipped | admin | +| `PATCH` | `/v1/admin/returns/{return_id}` | Approve, reject or complete a return | admin | + +### Inventory — `/v1/admin/inventory` + +Stock is deliberately separate from the catalogue. The `inventory` block on an item is +catalogue metadata for display; authoritative stock lives here and is what checkout +reserves against. + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `POST` | `/v1/admin/inventory/` | Create inventory record | admin | +| `GET` | `/v1/admin/inventory/` | List inventory items | admin | +| `GET` | `/v1/admin/inventory/by-sku/{sku}` | Get inventory item by SKU | admin | +| `GET` | `/v1/admin/inventory/{inventory_id}` | Get inventory item by UUID | admin | +| `PATCH` | `/v1/admin/inventory/{inventory_id}` | Update stock | admin | +| `DELETE` | `/v1/admin/inventory/{inventory_id}` | Delete inventory record | admin | + +### Webhooks and health + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| `POST` | `/v1/webhooks/stripe` | Stripe payment webhook | signature | +| `GET` | `/health` | Liveness check | — | +| `GET` | `/health/ready` | Readiness check | — | + +The webhook is unauthenticated in the bearer-token sense but is not open: it verifies the +Stripe signature and rejects anything that fails, and it is idempotent — a replayed event +is a no-op returning `200`. + +`/health` answers as soon as the process is up. `/health/ready` additionally probes +PostgreSQL and Redis and reports the latency and error for each, which is the one you want +behind a load balancer. + +--- + +## The response envelope + +Every JSON response shares a common envelope rather than returning a bare object: ```json { - "uuid": "0b9e2c50-5e3b-4cc1-9a6a-2b3e9a0b1234", - "sku": "CHAIR-RED-001", // Stock Keeping Unit (like uuid just human readable) - "status": "active", // draft | active | archived + "success": true, + "message": "Items retrieved successfully", + "timestamp": "2026-08-26T11:54:20.530614Z", + "request_id": null, + "metadata": null, + "items": [ ... ] +} +``` - "name": "Red Wooden Chair", - "slug": "red-wooden-chair", // shown in the url instead of uuid +The payload key is named for what it carries (`items`, `order`, `customer`, …), so a +client can tell a list response from a single-object one without inspecting types. + +`request_id` carries the correlation ID. Every request passes through a middleware that +assigns one and threads it through the logs, so a customer's report of "it broke at +14:32" can be traced to the exact request across the API and the worker. + +## Errors + +Errors use the same envelope with `success: false` plus error fields: + +```json +{ + "success": false, + "message": "Item not found", + "timestamp": "2026-08-26T11:54:20.530614Z", + "request_id": "3f9c…", + "status_code": 404, + "error_code": "NOT_FOUND", + "error_category": "not_found", + "details": null +} +``` + +The HTTP status is derived from the error category, so a category is never reported under +an inconsistent status: + +| `error_category` | HTTP status | +|---|---| +| `not_found` | 404 | +| `validation` | 422 | +| `authentication` | 401 | +| `authorization` | 403 | +| `business_rule` | 400 | +| `database` | 500 | +| `external_service` | 502 | +| `internal` | 500 | + +Request validation failures (wrong types, missing fields) return `422` with an extra +`validation_errors` array, each entry carrying `loc`, `msg` and `type`. This is +FastAPI's own validation output remapped into the envelope, so the response actually +matches the `422` schema documented in `/docs` — which the raw Pydantic format does not. + +Unhandled exceptions are caught by a catch-all handler, logged with the correlation ID and +returned as a generic `500`. Internal detail is never leaked to the client. + +## Rate limiting +Rate limiting is applied per client IP via SlowAPI and is **opt-in per route** rather than +global. Two settings control it: `RATE_LIMIT_ENABLED` turns it off entirely (useful in +tests and local development) and `RATE_LIMIT_PER_MINUTE` sets the budget, 60 by default. +Exceeding it returns `429`. + +Behind a load balancer the limiter needs to read `X-Forwarded-For` instead of the socket +address, or every request will look like it came from the proxy. See +[Deployment](/Deployment). + +--- + +## The item shape + +An item as the API returns it. Prices are integer minor units — cents, never floats: + +```json +{ + "uuid": "0b9e2c50-5e3b-4cc1-9a6a-2b3e9a0b1234", + "sku": "CHAIR-RED-001", + "status": "active", + "name": "Red Wooden Chair", + "slug": "red-wooden-chair", "short_description": "Comfortable red wooden chair for dining rooms.", "description": "Long HTML/Markdown description here...", - - "categories": [ - "2f61e8db-bb70-4b22-9aa0-4d7fa3b7aa11" // category UUIDs - ], + "categories": ["furniture", "chairs"], "brand": "Acme Furniture", "price": { - "amount": 9999, // store as integer cents + "amount": 9999, "currency": "EUR", "includes_tax": true, - "original_amount": 12999, // optional (for discounts) - "tax_class": "standard" // e.g. standard | reduced | none + "original_amount": 12999, + "tax_class": "standard" }, "media": { "main_image": "https://cdn.example.com/items/chair-main.jpg", - "gallery": [ - "https://cdn.example.com/items/chair-side.jpg", - "https://cdn.example.com/items/chair-back.jpg" - ] + "gallery": [] }, "inventory": { "stock_quantity": 25, - "stock_status": "in_stock", // in_stock | out_of_stock | preorder | backorder + "stock_status": "in_stock", "allow_backorder": false }, "shipping": { "is_physical": true, - "weight": { - "value": 7.5, - "unit": "kg" - }, - "dimensions": { - "width": 45.0, - "height": 90.0, - "length": 50.0, - "unit": "cm" - }, - "shipping_class": "standard" // e.g. standard, bulky, letter + "weight": { "value": 7.5, "unit": "kg" }, + "dimensions": { "width": 45.0, "height": 90.0, "length": 50.0, "unit": "cm" }, + "shipping_class": "standard" }, - "attributes": { - "color": "red", - "material": "wood" - }, + "attributes": { "color": "red", "material": "wood" }, "identifiers": { "barcode": "4006381333931", @@ -78,31 +269,22 @@ Item description: "country_of_origin": "DE" }, - "custom": { - "any_plugin_can_put": "whatever_here" - }, - - "system": { - "log_table": "uuid_to_conversation_in_different_table", - } + "custom": { "any_plugin_can_put": "whatever_here" }, + "system": {} } ``` +`status` is one of `draft`, `active` or `archived`. `stock_status` is one of `in_stock`, +`out_of_stock`, `preorder` or `backorder`. +Two fields are traps worth naming: +- **`inventory` is display metadata, not truth.** Authoritative stock lives in the + inventory service and is what checkout reserves against. An item claiming + `stock_quantity: 25` will still fail checkout if the inventory record says otherwise. +- **`custom` is free-form** and belongs to whatever plugin writes it. Nothing in the API + validates its contents. - - - - - - - - - - - - - - - \ No newline at end of file +The nested blocks are stored as `JSONB` columns rather than normalised into separate +tables — see [Database Architecture](/Database/Architecture) for why, and for what that +costs. diff --git a/Authorization.md b/Authorization.md new file mode 100644 index 0000000..4ecd4b4 --- /dev/null +++ b/Authorization.md @@ -0,0 +1,161 @@ +--- +title: Authorization +description: Roles, clients, and what the API actually enforces +published: true +date: 2026-08-26T12:00:00.000Z +tags: authorization, keycloak, security, roles, authentication +editor: markdown +dateCreated: 2026-08-26T12:00:00.000Z +--- + +# Authorization + +User management runs on **Keycloak 26**. The realm is managed *in the `fastapi` +repository* (`keycloak/opentaberna-realm.json`) and imported on container start, so the +whole setup is reproducible from a clean checkout rather than being a thing somebody once +clicked together in an admin console. + +## Two kinds of user + +| Role | How it is granted | What it allows | +|---|---|---| +| `customer` | Automatically, to every account | Nothing special. It is the default role. | +| `admin` | Only by another admin | The back-office endpoints under `/v1/admin/**` | + +`customer` is attached to `default-roles-opentaberna`, so anyone who registers receives it +without an administrator doing anything. + +`admin` is a composite role that additionally carries the `realm-management` client roles +`manage-users`, `view-users`, `query-users` and `view-realm`. That is what makes "admins +are created by admins" true rather than aspirational: an existing admin holds exactly the +permissions needed to read the `admin` role and grant it to someone else, and a customer +holds none of them. A customer attempting either is refused by **Keycloak itself** — the +API is never consulted. + +> `view-realm` is easy to miss when editing the realm by hand. Without it an admin can call +> the user endpoints but cannot read the role definition they are trying to grant, so +> promotion fails with a confusing `403` that looks like it comes from the API. + +## Clients + +| Client | Type | Purpose | +|---|---|---| +| `opentaberna-api` | confidential | The resource server. Validates tokens, mints none. Its client id is the audience the API expects. | +| `opentaberna-admin-ui` | public, PKCE | Back-office frontend. **The only client whose tokens are accepted on admin endpoints.** | +| `opentaberna-store-ui` | public, PKCE | Storefront. Never accepted on admin endpoints. | + +Both frontends carry an audience mapper adding `opentaberna-api` to the token. Without it +a public client receives a token scoped only to `account`, leaving the API nothing +meaningful to validate. + +## What the API enforces + +### Admin endpoints — `/v1/admin/**` + +All three must hold: + +1. A token whose **signature, issuer, audience and expiry** check out. +2. The **`admin` realm role**. +3. An **`azp` in `KEYCLOAK_ADMIN_CLIENT_IDS`** — that is, the token was issued to the admin + UI. + +The third is the one worth understanding. Roles alone are not enough: an administrator +browsing the shop still carries the `admin` role in their *storefront* token. Accepting it +would mean any script running on the shop page — an injected ad, a compromised dependency, +an XSS — could drive the back office using the token sitting in that tab. + +So the API asks not just *who are you* but *which application are you speaking through*. + +### Catalogue writes + +`POST`, `PATCH` and `DELETE` on `/v1/items` require an administrator too, and for the same +reason the admin endpoints do: what is listed is what customers see, so anyone able to +create, alter or delete a product controls the shop. + +Reads stay public — shoppers browse before signing in. + +### Customer-scoped endpoints + +Endpoints under `/v1/customers/me` and a customer's own orders identify the caller from +the **verified `sub` claim**. A verified `sub` always beats any user id supplied in a +header or body, so a caller cannot read another customer's profile by passing an id +alongside their own valid token. + +### Open by design + +The catalogue reads, both health endpoints, and the Stripe webhook take no bearer token. +The webhook is not unprotected — it verifies the Stripe signature — but it is not +protected *by Keycloak*, because Stripe has no account here. + +## Issuer URLs: the one that bites + +Two settings look redundant and are not: + +| Setting | Used for | +|---|---| +| `KEYCLOAK_URL` | Where the API fetches signing keys, server to server | +| `KEYCLOAK_PUBLIC_URL` | The base URL that appears in the token's `iss` claim | + +Inside Compose the API reaches Keycloak at `http://opentaberna-keycloak:8080`, while the +browser that obtained the token used `http://localhost:8080`. The `iss` claim carries the +URL the *browser* used. If issuer validation compares against the internal URL, every +otherwise-valid token is rejected. + +`KEYCLOAK_PUBLIC_URL` empty falls back to `KEYCLOAK_URL`, which is correct only when both +are the same host — typically outside Docker, or in production behind one public name. + +## Signing keys are cached, not fetched once + +`KEYCLOAK_JWKS_CACHE_SECONDS` (default 300) bounds how long the API caches Keycloak's +signing keys. Keycloak rotates keys, so fetching them once at startup means the API keeps +validating against a key that is eventually retired and then rejects everything. Caching +with an expiry is the whole fix. + +## Development users + +Three users ship in the realm import. These passwords are development-only and are +committed to a public repository — they are for your laptop, nowhere else. + +| Username | Password | Role | +|---|---|---| +| `adminuser` | `adminpassword` | `admin` | +| `testuser` | `testpassword` | `customer` | +| `testuser2` | `testpassword2` | `customer` | + +`testuser2` exists so cross-customer authorization can actually be tested: one customer +attempting to read another's data is a case you want a test for, and that needs two +customers. + +## Getting a token by hand + +Both frontend clients have direct grant enabled in development, so you can skip the browser: + +```bash +TOKEN=$(curl -s -X POST \ + http://localhost:8080/realms/opentaberna/protocol/openid-connect/token \ + -d "client_id=opentaberna-admin-ui" \ + -d "grant_type=password" \ + -d "username=adminuser" \ + -d "password=adminpassword" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/v1/admin/orders/ +``` + +Without the header the same call returns `403`. Requesting the token through +`client_id=opentaberna-store-ui` instead also returns `403`, even for `adminuser` — which +is the `azp` check doing its job, and the quickest way to confirm it is working. + +Direct grant is a **development** convenience. Production frontends use the authorization +code flow with PKCE, and `directAccessGrantsEnabled` should be off. + +## In production + +- Turn off direct access grants on both public clients. +- Replace the three seeded users, and change the Keycloak admin password from `admin`. +- Set `KEYCLOAK_CLIENT_SECRET` from a secret store, never from `.env`. +- Set `KEYCLOAK_PUBLIC_URL` to the public identity URL. +- Keep `KEYCLOAK_ADMIN_CLIENT_IDS` to the admin UI alone. Adding the storefront to it + removes the protection described above entirely. + +See [Configuration](/Configuration) and [Deployment](/Deployment). diff --git a/Configuration.md b/Configuration.md index b63b100..4dc2ebc 100644 --- a/Configuration.md +++ b/Configuration.md @@ -2,7 +2,7 @@ title: Configuration description: Environment-based configuration management published: true -date: 2025-12-07T09:15:00.000Z +date: 2026-08-26T12:00:00.000Z tags: configuration, settings, environment, docker, kubernetes editor: markdown dateCreated: 2025-12-07T09:15:00.000Z @@ -10,104 +10,210 @@ dateCreated: 2025-12-07T09:15:00.000Z # Configuration -OpenTaberna uses **environment-based configuration** that supports multiple secret sources for maximum flexibility in different deployment scenarios. +OpenTaberna uses **environment-based configuration** that supports multiple secret sources, +so the same image runs on a laptop, in Compose and in Kubernetes without code changes. -## Configuration Sources +## Configuration sources Settings are loaded in **priority order**: -1. **Docker Secrets** (highest priority) - - `/run/secrets/{secret_name}` - -2. **Kubernetes Secrets** - - `/var/run/secrets/{secret_name}` - -3. **Environment Variables** - - `UPPERCASE_WITH_UNDERSCORES` - -4. **.env File** - - `.env` in project root - -5. **Default Values** (lowest priority) - -## Quick Start - -### Development Setup - -1. **Copy the example file:** - ```bash - cp .env.example .env - ``` - -2. **Edit settings:** - ```bash - nano .env - ``` - -3. **Minimal configuration:** - ```bash - ENVIRONMENT=development - SECRET_KEY=dev-secret-key - DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/opentaberna - ``` - -### Production Setup - -For production, use **Docker or Kubernetes secrets** for sensitive values: +1. **Docker secrets** — `/run/secrets/{name}` (highest) +2. **Kubernetes secrets** — `/var/run/secrets/{name}` +3. **Environment variables** — `UPPERCASE_WITH_UNDERSCORES` +4. **`.env` file** — in the project root +5. **Default values** (lowest) + +The point of the ordering is that a production deployment can leave `.env` describing +non-secret shape while passwords arrive from a mounted secret, and neither has to know +about the other. + +## Quick start ```bash -# Don't put passwords in .env! -# Use secrets instead: -echo "postgresql://prod-db/db" > /run/secrets/database_url -echo "redis-password" > /run/secrets/redis_password +cp .env.example .env +``` + +The shipped `.env.example` is already correct for the development stack. The minimum you +would change for real use: + +```bash +ENVIRONMENT=development +SECRET_KEY=dev-secret-key +DATABASE_URL=postgresql+asyncpg://opentaberna:opentaberna_password@opentaberna-db:5432/opentaberna ``` -## Key Settings +> `DATABASE_URL` must use the **`postgresql+asyncpg://`** scheme. A plain `postgresql://` +> URL selects a synchronous driver and the API will fail to start. -### Application +--- + +## Application | Setting | Default | Description | -|---------|---------|-------------| +|---|---|---| | `APP_NAME` | `OpenTaberna API` | Application name | -| `ENVIRONMENT` | `development` | Environment: development/testing/staging/production | -| `SECRET_KEY` | ⚠️ Required in production | Secret key for JWT/sessions | -| `DEBUG` | Auto (true in dev) | Debug mode | +| `APP_VERSION` | `0.1.0` | Reported in `/openapi.json` | +| `ENVIRONMENT` | `development` | `development` / `testing` / `staging` / `production` | +| `DEBUG` | `false` | Debug mode | +| `SECRET_KEY` | ⚠️ required in production | Validated on startup — see below | -### Database +`SECRET_KEY` is checked at startup: leaving it as `CHANGE_ME_IN_PRODUCTION` while +`ENVIRONMENT=production` raises and the API refuses to boot. This is deliberate. A default +signing key that silently works is worse than a service that will not start. + +## Server | Setting | Default | Description | -|---------|---------|-------------| -| `DATABASE_URL` | PostgreSQL localhost | Database connection string | +|---|---|---| +| `HOST` | `0.0.0.0` | Bind address | +| `PORT` | `8000` | Bind port | +| `WORKERS` | `1` | Uvicorn worker processes | +| `RELOAD` | `false` | Auto-reload on code changes | + +## Database + +| Setting | Default | Description | +|---|---|---| +| `DATABASE_URL` | PostgreSQL localhost | Connection string, `postgresql+asyncpg://` | | `DATABASE_POOL_SIZE` | `20` | Connection pool size | | `DATABASE_MAX_OVERFLOW` | `40` | Maximum pool overflow | +| `DATABASE_POOL_TIMEOUT` | `30` | Seconds to wait for a connection | +| `DATABASE_POOL_RECYCLE` | — | Recycle connections after N seconds | +| `DATABASE_POOL_PRE_PING` | — | Test connections before use | +| `DATABASE_ECHO` | `false` | Log every statement | +| `DATABASE_STATEMENT_TIMEOUT` | — | Server-side statement timeout | +| `DATABASE_COMMAND_TIMEOUT` | — | Client-side command timeout | + +`DATABASE_POOL_PRE_PING` is worth turning on anywhere a connection can be closed +underneath you — a proxy with an idle timeout, a failover. It costs a round trip and saves +a class of intermittent errors that are miserable to diagnose. -### Redis +## Redis | Setting | Default | Description | -|---------|---------|-------------| -| `REDIS_URL` | `redis://localhost:6379/0` | Redis connection string | -| `REDIS_PASSWORD` | From secrets | Redis password (optional) | +|---|---|---| +| `REDIS_URL` | `redis://localhost:6379/0` | Connection string | +| `REDIS_PASSWORD` | from secrets | Optional | -### Keycloak +Redis is both the cache and the job queue. Losing it does not lose queued work — that is +what the [outbox](/Orders-and-Fulfillment#the-outbox) is for — but nothing runs until it is +back. -| Setting | Default | Description | -|---------|---------|-------------| -| `KEYCLOAK_URL` | `http://localhost:8080` | Keycloak server URL | -| `KEYCLOAK_REALM` | `opentaberna` | Keycloak realm name | -| `KEYCLOAK_CLIENT_ID` | `opentaberna-api` | OAuth2 client ID | -| `KEYCLOAK_CLIENT_SECRET` | From secrets | OAuth2 client secret | +## Keycloak -### API Behavior +| Setting | Default | Description | +|---|---|---| +| `KEYCLOAK_URL` | `http://localhost:8080` | Where the API fetches signing keys | +| `KEYCLOAK_PUBLIC_URL` | *(empty)* | Base URL in the token's `iss` claim; falls back to `KEYCLOAK_URL` | +| `KEYCLOAK_REALM` | `opentaberna` | Realm name | +| `KEYCLOAK_CLIENT_ID` | `opentaberna-api` | Expected token audience | +| `KEYCLOAK_CLIENT_SECRET` | from secrets | Confidential client secret | +| `KEYCLOAK_ADMIN_ROLE` | `admin` | Realm role required for admin endpoints | +| `KEYCLOAK_ADMIN_CLIENT_IDS` | `["opentaberna-admin-ui"]` | Clients whose tokens may reach admin endpoints | +| `KEYCLOAK_DOCS_CLIENT_ID` | `opentaberna-admin-ui` | Client the Swagger UI logs in with | +| `KEYCLOAK_JWKS_CACHE_SECONDS` | `300` | How long signing keys are cached | + +Three of these carry more weight than their one-line descriptions suggest, and each has a +failure mode that looks like something else. `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_ADMIN_CLIENT_IDS` +and `KEYCLOAK_JWKS_CACHE_SECONDS` are explained in [Authorization](/Authorization) — read +that page before changing any of them. + +## API behaviour | Setting | Default | Description | -|---------|---------|-------------| -| `CORS_ORIGINS` | `["*"]` | Allowed CORS origins (restrict in production!) | -| `LOG_LEVEL` | `INFO` | Logging level (DEBUG/INFO/WARNING/ERROR) | -| `LOG_FORMAT` | `console` | Log format: console or json | +|---|---|---| +| `CORS_ORIGINS` | `["*"]` | Allowed origins — restrict in production | +| `CORS_CREDENTIALS` | `true` | Allow credentialed requests | +| `LOG_LEVEL` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` | +| `LOG_FORMAT` | `console` | `console` or `json` | +| `LOG_FILE` | — | Optional log file path | | `CACHE_ENABLED` | `true` | Enable Redis caching | +| `CACHE_TTL` | `300` | Default cache TTL, seconds | | `RATE_LIMIT_ENABLED` | `true` | Enable rate limiting | +| `RATE_LIMIT_PER_MINUTE` | `60` | Budget per client IP | +| `FEATURE_WEBHOOKS_ENABLED` | `false` | Feature flag | + +## Payments — Stripe + +| Setting | Default | Description | +|---|---|---| +| `STRIPE_SECRET_KEY` | `sk_test_CHANGE_ME` | API key | +| `STRIPE_PUBLISHABLE_KEY` | `pk_test_CHANGE_ME` | Given to the storefront | +| `STRIPE_WEBHOOK_SECRET` | from secrets | Signature verification | +| `STRIPE_PAYMENT_METHODS` | `["card"]` | Accepted method types | +| `STRIPE_BANK_TRANSFER_COUNTRY` | — | ISO 3166-1 alpha-2, required for bank transfer | + +In the development stack the Stripe CLI listener container generates +`STRIPE_WEBHOOK_SECRET` and mounts it at `/run/secrets/stripe_webhook_secret`, which the +API picks up through the Docker-secret source above. Set it yourself only when running +outside Compose or against a Dashboard-managed endpoint. + +## Inventory + +| Setting | Default | Description | +|---|---|---| +| `RESERVATION_TTL_MINUTES` | `15` | How long a checkout holds stock | + +Too short and a slow payment loses its reservation mid-flow; too long and abandoned carts +hold stock nobody can buy. + +## Object storage — MinIO / S3 + +| Setting | Default | Description | +|---|---|---| +| `STORAGE_ENDPOINT_URL` | `http://localhost:9000` | S3 endpoint | +| `STORAGE_ACCESS_KEY` | `opentaberna` | Access key | +| `STORAGE_SECRET_KEY` | `opentaberna_secret` | Secret key | +| `STORAGE_BUCKET_ITEMS` | `item-images` | Product images | +| `STORAGE_BUCKET_LABELS` | `shipping-labels` | Carrier label files | +| `STORAGE_MAX_IMAGE_BYTES` | `5242880` | Largest accepted product image (5 MB) | +| `STORAGE_REGION` | `us-east-1` | Ignored by MinIO, required by the client | + +`MINIO_ROOT_USER` and `MINIO_ROOT_PASSWORD` configure the MinIO container itself in the +development compose file; the API authenticates with `STORAGE_ACCESS_KEY` and +`STORAGE_SECRET_KEY`, which default to the same values. -## Environment-Specific Configuration +## Carrier — DHL + +| Setting | Default | Description | +|---|---|---| +| `DHL_API_BASE_URL` | sandbox URL | Parcel DE REST API base | +| `DHL_CLIENT_ID` | `CHANGE_ME` | OAuth2 client id | +| `DHL_CLIENT_SECRET` | `CHANGE_ME` | OAuth2 client secret | +| `DHL_BILLING_NUMBER` | `CHANGE_ME` | EKP billing number | +| `DHL_DEFAULT_LABEL_FORMAT` | `pdf` | `pdf` or `zpl` | + +Leaving these unset is fine. Orders then ship through the manual adapter, with the admin +entering tracking numbers by hand. + +## Worker and outbox + +| Setting | Default | Description | +|---|---|---| +| `ARQ_MAX_JOBS` | `10` | Concurrent jobs per worker process | +| `ARQ_JOB_TIMEOUT` | `300` | Seconds before a job is killed | +| `ARQ_MAX_TRIES` | `5` | Attempts before a job is dead-lettered (`DEAD`) | +| `OUTBOX_POLL_INTERVAL` | `30` | Seconds between outbox sweeps | +| `OUTBOX_MAX_ATTEMPTS` | `5` | Enqueue attempts before an event is marked `FAILED` | + +`FAILED` and `DEAD` are different failures — see +[the outbox](/Orders-and-Fulfillment#two-ways-to-fail-and-they-mean-different-things). + +## Email + +| Setting | Default | Description | +|---|---|---| +| `SMTP_HOST` | *(empty)* | Leave empty to skip sending entirely | +| `SMTP_PORT` | `587` | | +| `SMTP_USER` / `SMTP_PASSWORD` | *(empty)* | Authentication | +| `EMAIL_FROM` | `noreply@opentaberna.local` | Sender address | + +An empty `SMTP_HOST` logs a warning instead of failing, so development does not need a +mail server. + +--- + +## Environment-specific examples ### Development @@ -116,7 +222,7 @@ ENVIRONMENT=development DEBUG=true LOG_LEVEL=DEBUG LOG_FORMAT=console -DATABASE_URL=postgresql+asyncpg://dev:dev@localhost/opentaberna_dev +RATE_LIMIT_ENABLED=false ``` ### Production @@ -126,17 +232,17 @@ ENVIRONMENT=production DEBUG=false LOG_LEVEL=INFO LOG_FORMAT=json -CORS_ORIGINS=["https://yourdomain.com"] - -# Sensitive values from secrets: -# - DATABASE_URL from /run/secrets/database_url -# - REDIS_PASSWORD from /run/secrets/redis_password -# - KEYCLOAK_CLIENT_SECRET from /run/secrets/keycloak_client_secret +CORS_ORIGINS=["https://yourdomain.com","https://admin.yourdomain.com"] +KEYCLOAK_PUBLIC_URL=https://auth.yourdomain.com + +# Sensitive values come from mounted secrets, not from here: +# /run/secrets/database_url +# /run/secrets/redis_password +# /run/secrets/keycloak_client_secret +# /run/secrets/stripe_webhook_secret ``` -## Docker Secrets - -### docker-compose.yml +## Docker secrets ```yaml services: @@ -146,6 +252,7 @@ services: - database_url - redis_password - keycloak_client_secret + - stripe_webhook_secret environment: - ENVIRONMENT=production @@ -156,36 +263,29 @@ secrets: file: ./secrets/redis_password.txt keycloak_client_secret: file: ./secrets/keycloak_client_secret.txt + stripe_webhook_secret: + file: ./secrets/stripe_webhook_secret.txt ``` -### Create Secret Files - ```bash mkdir -p secrets -echo "postgresql://user:pass@postgres:5432/opentaberna" > secrets/database_url.txt -echo "redis-secure-password" > secrets/redis_password.txt -echo "keycloak-client-secret" > secrets/keycloak_client_secret.txt +echo "postgresql+asyncpg://user:pass@postgres:5432/opentaberna" > secrets/database_url.txt chmod 600 secrets/* ``` -## Kubernetes Secrets +The secret **file name** is the setting name in lower case — the loader reads +`/run/secrets/database_url` for `DATABASE_URL`. -### Create Secret +## Kubernetes secrets ```bash kubectl create secret generic opentaberna-secrets \ - --from-literal=database_url='postgresql://...' \ - --from-literal=redis_password='secret123' \ - --from-literal=keycloak_client_secret='oauth-secret' + --from-literal=database_url='postgresql+asyncpg://...' \ + --from-literal=redis_password='...' \ + --from-literal=keycloak_client_secret='...' ``` -### Mount in Deployment - ```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: opentaberna-api spec: template: spec: @@ -195,11 +295,6 @@ spec: env: - name: ENVIRONMENT value: "production" - - name: SECRET_KEY - valueFrom: - secretKeyRef: - name: opentaberna-secrets - key: secret_key volumeMounts: - name: secrets mountPath: /var/run/secrets @@ -210,69 +305,54 @@ spec: secretName: opentaberna-secrets ``` -## Security Best Practices +## Security checklist -### ✅ Do +### Do -- Use Docker/K8s secrets for sensitive data in production -- Change `SECRET_KEY` to a strong random value -- Restrict `CORS_ORIGINS` to specific domains -- Use `LOG_FORMAT=json` in production for structured logging -- Keep `.env` files out of version control (`.gitignore`) +- Mount secrets rather than setting them as environment variables +- Generate `SECRET_KEY` with `python3 -c "import secrets; print(secrets.token_urlsafe(32))"` +- Restrict `CORS_ORIGINS` to your actual domains +- Use `LOG_FORMAT=json` in production +- Keep `.env` out of version control +- Keep `KEYCLOAK_ADMIN_CLIENT_IDS` to the admin UI only -### ❌ Don't +### Don't -- Commit `.env` files to git -- Use default `SECRET_KEY` in production -- Use `DEBUG=true` in production -- Allow `CORS_ORIGINS=["*"]` in production -- Store passwords in environment variables (use secrets!) - -## Validation - -The configuration system validates settings on startup: - -```python -# SECRET_KEY must be changed in production -if environment == "production" and secret_key == "CHANGE_ME_IN_PRODUCTION": - raise ValueError("SECRET_KEY must be changed in production!") -``` +- Commit `.env` +- Ship the default `SECRET_KEY` (the API will refuse to start, which is the point) +- Run `DEBUG=true` in production +- Leave `CORS_ORIGINS=["*"]` in production +- Add the storefront client to `KEYCLOAK_ADMIN_CLIENT_IDS` ## Troubleshooting -### Configuration Not Loading - -```bash -# Check environment variables -env | grep OPENTABERNA +### The API will not start in production -# Check if secrets exist -ls -la /run/secrets/ -ls -la /var/run/secrets/ -``` +Most often `SECRET_KEY` is still the placeholder. The startup validator raises on purpose. -### Database Connection Issues +### Configuration is not being picked up ```bash -# Verify DATABASE_URL format -# postgresql+asyncpg://user:password@host:port/database +# Is the secret actually mounted? +ls -la /run/secrets/ /var/run/secrets/ -# Test connection -docker-compose exec api python -c "from app.shared.config import get_settings; print(get_settings().database_url)" +# What did the app resolve? +docker compose exec opentaberna-api \ + python -c "from app.shared.config import get_settings; print(get_settings().database_url)" ``` -### Secret Key Validation Error +Remember the precedence: a mounted secret beats an environment variable, which beats +`.env`. A stale secret file silently wins over the variable you just changed. -```bash -# Generate a secure secret key -python -c "import secrets; print(secrets.token_urlsafe(32))" +### Database connection failures -# Set it in production -export SECRET_KEY="" -``` +Check the scheme is `postgresql+asyncpg://` and the host is right for where the API runs — +`opentaberna-db` inside Compose, `localhost` outside it. `/health/ready` names the failing +dependency and its error. -## See Also +## See also -- [Getting Started](/Getting-Started) - Initial setup guide -- [Deployment](/Deployment) - Production deployment -- [API Documentation](http://localhost:8000/docs) - Live API reference +- [Getting Started](/Getting-Started) — initial setup +- [Authorization](/Authorization) — the Keycloak settings in context +- [Deployment](/Deployment) — production +- `/docs` on any running instance — the live API reference diff --git a/Database/Architecture.md b/Database/Architecture.md index e59af5e..9c6ce3e 100644 --- a/Database/Architecture.md +++ b/Database/Architecture.md @@ -1,360 +1,317 @@ --- title: Database Architecture -description: Holds Ideas for a possible Database Architecture +description: The schema as it is actually built published: true -date: 2025-11-19T20:59:12.870Z -tags: architecture, database +date: 2026-08-26T12:00:00.000Z +tags: architecture, database, schema, postgresql editor: markdown dateCreated: 2025-11-19T20:45:46.121Z --- # Database Architecture -To improve indexation and performance I suggest the following layout. (See the Item description in [API Architecture](/API/Architecture) to have a better understanding of the data set we are thinking about). +The store runs on **PostgreSQL 17**. This page documents the schema that is actually in +the database. -**Diagram** -Sadly the wiki can not display it. Therefore please copy paste the mermaid section into any markdown convertor and have a look (I like Obsidian for that). Here is a picture of the diagram. +> **This page previously described a different schema.** It proposed `shops`, +> `categories`, `item_categories`, `item_media`, `attributes`, `item_attributes` and +> `item_events` — a fully normalised catalogue. None of those tables were ever built. What +> shipped keeps the item's nested detail in `JSONB` columns instead. The old design is +> discussed under [The road not taken](#the-road-not-taken) because the trade-off is worth +> understanding, but it is a proposal, not a description. +## The tables -![database_diagram_layout.jpg](/database/database_diagram_layout.jpg) +Twelve tables, in four groups: ```mermaid erDiagram + CUSTOMERS ||--o{ ADDRESSES : "has" + CUSTOMERS ||--o{ ORDERS : "places" + CUSTOMERS ||--o{ RETURNS : "requests" - SHOPS { - uuid id PK - text name - timestamptz created_at + ORDERS ||--o{ ORDER_ITEMS : "contains" + ORDERS ||--|| PAYMENTS : "paid by" + ORDERS ||--|| SHIPMENTS : "shipped as" + ORDERS ||--|| RETURNS : "returned as" + ORDERS ||--o{ STOCK_RESERVATIONS : "reserves" + + INVENTORY_ITEMS ||--o{ STOCK_RESERVATIONS : "reserved from" + + ITEMS { + uuid uuid PK + varchar sku UK + varchar status + varchar name + varchar slug UK + jsonb price + jsonb inventory + jsonb media + jsonb shipping + jsonb attributes + jsonb identifiers + jsonb categories + jsonb custom + jsonb system } - CATEGORIES { + CUSTOMERS { uuid id PK - uuid shop_id FK - text name - text slug - uuid parent_id FK - int position - timestamptz created_at - timestamptz updated_at + varchar keycloak_user_id UK + varchar email UK + varchar first_name + varchar last_name + varchar phone } - ITEMS { + ADDRESSES { uuid id PK - uuid shop_id FK - uuid uuid UNIQUE - text sku UNIQUE - text status - text name - text slug - text short_description - text description - text brand - - bigint price_amount - char(3) price_currency - boolean price_includes_tax - bigint price_original_amount - text tax_class - - int stock_quantity - text stock_status - boolean allow_backorder - - text main_image_url - - boolean is_physical - numeric weight_value - text weight_unit - numeric dim_width - numeric dim_height - numeric dim_length - text dim_unit - text shipping_class - - text barcode - text manufacturer_part_number - char(2) country_of_origin - - jsonb custom_data - - timestamptz created_at - timestamptz updated_at - text created_by - text updated_by + uuid customer_id FK + varchar street + varchar city + varchar zip_code + varchar country + boolean is_default } - ITEM_CATEGORIES { - uuid item_id FK - uuid category_id FK + ORDERS { + uuid id PK + uuid customer_id FK + varchar status + bigint total_amount + varchar currency + timestamptz deleted_at } - ITEM_MEDIA { + ORDER_ITEMS { uuid id PK - uuid item_id FK - text url - int position - text alt_text + uuid order_id FK + varchar sku + int quantity + bigint unit_price } - ATTRIBUTES { + PAYMENTS { uuid id PK - uuid shop_id FK - text code - text name - text data_type - timestamptz created_at + uuid order_id FK,UK + varchar provider + varchar provider_reference UK + bigint amount + varchar status } - ITEM_ATTRIBUTES { - uuid item_id FK - uuid attribute_id FK - text value_text - numeric value_number - boolean value_bool + INVENTORY_ITEMS { + uuid id PK + varchar sku UK + int on_hand + int reserved } - ITEM_EVENTS { + STOCK_RESERVATIONS { uuid id PK - uuid item_id FK - text event_type - jsonb payload - timestamptz created_at - text user_id + uuid inventory_item_id FK + uuid order_id + int quantity + timestamptz expires_at + varchar status } - %% Relationships - - SHOPS ||--o{ CATEGORIES : "has many" - SHOPS ||--o{ ITEMS : "has many" - SHOPS ||--o{ ATTRIBUTES : "has many" - - CATEGORIES ||--o{ CATEGORIES : "parent_of (self-rel)" - ITEMS ||--o{ ITEM_CATEGORIES : "categorized_by" - CATEGORIES ||--o{ ITEM_CATEGORIES : "contains_items" - - ITEMS ||--o{ ITEM_MEDIA : "has media" - ITEMS ||--o{ ITEM_ATTRIBUTES : "has attributes" - ATTRIBUTES ||--o{ ITEM_ATTRIBUTES : "used by" - - ITEMS ||--o{ ITEM_EVENTS : "has events" - -``` - - - - - - -Table for Categories: -```sql -CREATE TABLE categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - shop_id UUID NOT NULL REFERENCES shops(id) ON DELETE CASCADE, + SHIPMENTS { + uuid id PK + uuid order_id FK,UK + varchar carrier + varchar tracking_number + text label_url + varchar status + } - name TEXT NOT NULL, - slug TEXT NOT NULL, - parent_id UUID REFERENCES categories(id), + RETURNS { + uuid id PK + uuid order_id FK,UK + uuid customer_id FK + varchar status + text reason + text admin_note + } - position INT NOT NULL DEFAULT 0, -- for sorting - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); + WEBHOOK_EVENTS { + uuid id PK + varchar provider + varchar event_id + jsonb payload + timestamptz processed_at + } -CREATE UNIQUE INDEX ux_categories_shop_slug - ON categories (shop_id, slug); + OUTBOX_EVENTS { + uuid id PK + varchar event_type + text payload + varchar status + varchar arq_job_id + int attempts + } ``` -Table to store essential item data +Every table carries `created_at` and `updated_at` (`timestamptz`, defaulting to `now()`). +`orders` additionally carries `deleted_at` — it is the one table that soft-deletes, +because a cancelled order is a business record you may not destroy. -```sql -CREATE TABLE items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - shop_id UUID NOT NULL REFERENCES shops(id) ON DELETE CASCADE, - - uuid UUID NOT NULL UNIQUE, -- external UUID if you want it separate - sku TEXT UNIQUE, -- can be NULL, but unique if present - status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')), - - name TEXT NOT NULL, - slug TEXT NOT NULL, - short_description TEXT, - description TEXT, - brand TEXT, - - -- Price - price_amount BIGINT NOT NULL, -- cents - price_currency CHAR(3) NOT NULL, -- 'EUR', 'USD', ... - price_includes_tax BOOLEAN NOT NULL DEFAULT TRUE, - price_original_amount BIGINT, -- NULL if no discount - tax_class TEXT NOT NULL DEFAULT 'standard', - - -- Inventory - stock_quantity INT NOT NULL DEFAULT 0, - stock_status TEXT NOT NULL CHECK (stock_status IN ('in_stock', 'out_of_stock', 'preorder', 'backorder')), - allow_backorder BOOLEAN NOT NULL DEFAULT FALSE, - - -- Media - main_image_url TEXT, - - -- Shipping - is_physical BOOLEAN NOT NULL DEFAULT TRUE, - weight_value NUMERIC(12,4), - weight_unit TEXT, - dim_width NUMERIC(12,4), - dim_height NUMERIC(12,4), - dim_length NUMERIC(12,4), - dim_unit TEXT, - shipping_class TEXT, - - -- Identifiers - barcode TEXT, - manufacturer_part_number TEXT, - country_of_origin CHAR(2), -- ISO country code, e.g. 'DE' - - -- Custom - custom_data JSONB, -- for plugins/free-form data - - -- System - log_table_ref UUID, -- references some other log table if you want - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - created_by TEXT, -- keycloak user ID - updated_by TEXT -); - --- URL friendly & shop-scoped: one slug per shop -CREATE UNIQUE INDEX ux_items_shop_slug - ON items (shop_id, slug); - --- Useful query indexes -CREATE INDEX ix_items_shop_status - ON items (shop_id, status); - -CREATE INDEX ix_items_shop_brand - ON items (shop_id, brand); - -CREATE INDEX ix_items_price - ON items (shop_id, price_amount); - -CREATE INDEX ix_items_stock_status - ON items (shop_id, stock_status); +## Catalogue -``` +### `items` +The catalogue. Scalar, searchable fields are real columns; the nested blocks from the +[item shape](/API/Architecture#the-item-shape) are `JSONB`. -Model join relationship between the item and category table: +| Column | Type | Notes | +|---|---|---| +| `uuid` | `uuid` | Primary key | +| `sku` | `varchar(100)` | Unique | +| `slug` | `varchar(255)` | Unique — the URL-facing identifier | +| `status` | `varchar(20)` | `draft` / `active` / `archived`, indexed | +| `name` | `varchar(255)` | Indexed | +| `brand` | `varchar(100)` | Indexed | +| `short_description` | `varchar(500)` | | +| `description` | `text` | | +| `categories`, `price`, `media`, `inventory`, `shipping`, `attributes`, `identifiers`, `custom`, `system` | `jsonb` | Not null | +Indexed on `sku` (unique), `slug` (unique), `status`, `name` and `brand` — the fields you +filter and sort a shop listing by. Anything inside a `JSONB` column is not covered by +those indexes; a query filtering on `price->>'amount'` does a sequential scan unless you +add an expression index for it. -```sql -CREATE TABLE item_categories ( - item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, - category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE, - PRIMARY KEY (item_id, category_id) -); +Note there is no `shop_id` anywhere. The schema is single-tenant: one deployment, one +shop. Multi-tenancy would be a schema change, not a configuration change. -CREATE INDEX ix_item_categories_category - ON item_categories (category_id); +## Customers -``` - -Table for the Media Gallery: - -```sql -CREATE TABLE item_media ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, - url TEXT NOT NULL, - position INT NOT NULL DEFAULT 0, -- 0,10,20,... for reordering - alt_text TEXT -); - -CREATE INDEX ix_item_media_item - ON item_media (item_id); -``` +### `customers` -Attribute definition table -```sql -CREATE TABLE attributes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - shop_id UUID NOT NULL REFERENCES shops(id) ON DELETE CASCADE, +| Column | Type | Notes | +|---|---|---| +| `id` | `uuid` | Primary key | +| `keycloak_user_id` | `varchar(255)` | Unique — the `sub` claim from the token | +| `email` | `varchar(255)` | Unique | +| `first_name`, `last_name` | `varchar(100)` | Not null | +| `phone` | `varchar(32)` | Nullable | - code TEXT NOT NULL, -- e.g. 'color', 'material' - name TEXT NOT NULL, -- display name - data_type TEXT NOT NULL CHECK (data_type IN ('text', 'number', 'boolean')), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); +`keycloak_user_id` is the join back to the identity provider. Keycloak owns credentials +and roles; this table owns everything commercial about the person. That split is why +wiping the Keycloak volume orphans customer rows — the profile survives, the login does +not. -CREATE UNIQUE INDEX ux_attributes_shop_code - ON attributes (shop_id, code); +### `addresses` -``` +Belongs to a customer, cascade-deleted with them. `country` is a 2-character ISO code and +`is_default` marks the one used at checkout. +## Orders, payment and stock -Item_Attribute table: +### `orders` -```sql +| Column | Type | Notes | +|---|---|---| +| `status` | `varchar(25)` | Defaults to `draft`, indexed | +| `total_amount` | `bigint` | Minor units. `CHECK (total_amount >= 0)` | +| `currency` | `varchar(3)` | ISO 4217 | +| `deleted_at` | `timestamptz` | Soft delete | -CREATE TABLE item_attributes ( - item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, - attribute_id UUID NOT NULL REFERENCES attributes(id) ON DELETE CASCADE, +Money is `bigint` in minor units everywhere in this schema — cents, never a float. See +[Orders and Fulfillment](/Orders-and-Fulfillment) for the status machine. - value_text TEXT, - value_number NUMERIC(20,4), - value_bool BOOLEAN, +### `order_items` - PRIMARY KEY (item_id, attribute_id) -); +A line on an order, cascade-deleted with it. Carries `unit_price` as a **snapshot**, not a +lookup — repricing an item must not silently rewrite what somebody already paid. +Constrained by `CHECK (quantity > 0)` and `CHECK (unit_price >= 0)`. -CREATE INDEX ix_item_attributes_attr - ON item_attributes (attribute_id); -``` +### `payments` +One payment per order, enforced by a unique constraint on `order_id`. `provider_reference` +(the Stripe payment intent id) is also unique, which is what makes webhook processing safe +to retry. +### `inventory_items` and `stock_reservations` +Authoritative stock, deliberately separate from the `inventory` block on an item. -For later the System log and event table: +`inventory_items` holds `on_hand` and `reserved` under three check constraints: ```sql -CREATE TABLE item_events ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, -- 'created', 'updated', 'price_changed', ... - payload JSONB, -- old/new values, diff, etc. - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - user_id TEXT -- Keycloak ID if available -); - -CREATE INDEX ix_item_events_item - ON item_events (item_id); - -CREATE INDEX ix_item_events_type - ON item_events (event_type); - +CHECK (on_hand >= 0) +CHECK (reserved >= 0) +CHECK (on_hand >= reserved) ``` +That third one is the interesting one. It makes overselling a database error rather than a +race the application has to win — you cannot reserve stock that is not there, however the +code is written or however many requests arrive at once. +`stock_reservations` holds a claim on stock with an `expires_at`, indexed so the sweeper +can find lapsed ones cheaply. A reservation is taken at checkout and either committed on +payment or released on failure or expiry. `reservation_ttl_minutes` sets the window. +## Fulfillment and integration +### `shipments` +One shipment per order, enforced by a unique constraint on `order_id` — the same rule the +[roadmap](/Orders-and-Fulfillment) calls "one shipment label per order". `label_url` points +at the object store, not at bytes in the database. +### `returns` +One return per order, unique on `order_id`. `reason` comes from the customer, `admin_note` +from whoever decided. Both the order and the customer are `ON DELETE RESTRICT`: a return +is a financial record and must not vanish with its parent. +### `webhook_events` +Every inbound provider event, with a unique constraint on `(provider, event_id)`. +**That constraint is the idempotency mechanism.** Stripe will redeliver, and a duplicate +`payment_succeeded` must not mark an order paid twice or commit stock twice. The insert +fails on the second attempt, the handler returns `200`, nothing happens. `processed_at` +being null distinguishes a received event from a handled one. +### `outbox_events` +The transactional outbox. `event_type` and a serialised `payload`, plus `status`, +`arq_job_id` and an `attempts` counter, indexed on `(status, created_at)` for the poller's +sweep. +The point is that a state change and its follow-up job commit in **one transaction**. The +API never enqueues to Redis directly, so there is no window in which the database says +"paid" but the label job was lost because the process died. See +[the outbox](/Orders-and-Fulfillment#the-outbox). +`attempts` and `OUTBOX_MAX_ATTEMPTS` bound retries. Exhausting them sets `FAILED`, which +means *the event never reached the queue* — distinct from a job that ran and exhausted its +own retries, which is `DEAD`. +--- +## The road not taken +The original proposal on this page normalised the catalogue: `categories` and +`item_categories` for a category tree, `item_media` for the gallery, `attributes` and +`item_attributes` for an EAV attribute model, `item_events` for an audit log, and a +`shops` table making the whole thing multi-tenant. +What shipped collapses those into `JSONB` columns on `items`. The trade: +**What the JSONB version buys.** One insert writes a whole product. Reading one is one row +with no joins. A plugin can attach arbitrary data under `custom` without a migration — +which is close to the point of the project, since the catalogue is meant to be extended by +people who are not touching the core. +**What it costs.** No referential integrity on categories: they are strings in an array, +so nothing stops a typo creating a category of one. No efficient faceted search — filtering +across attribute values means either expression indexes per attribute or a sequential scan. +No audit trail; `item_events` does not exist, and `updated_at` is all you get. - - - - - - +For a single-tenant shop with a catalogue that fits comfortably in memory, that is a +reasonable trade. It stops being reasonable when you want faceted search across a large +catalogue, or category management that cannot be broken by a typo. If OpenTaberna grows +into either, `categories` and `item_attributes` are the first two tables to build, and the +proposal above is a decent starting point for them. diff --git a/Deployment.md b/Deployment.md index a9edbd3..4daa9fb 100644 --- a/Deployment.md +++ b/Deployment.md @@ -2,7 +2,7 @@ title: Production Deployment description: Guide for deploying OpenTaberna to production published: true -date: 2025-12-07T08:35:23.092Z +date: 2026-08-26T12:00:00.000Z tags: deployment, docker, production, setup editor: markdown dateCreated: 2025-12-06T15:46:53.723Z @@ -10,169 +10,144 @@ dateCreated: 2025-12-06T15:46:53.723Z # Production Deployment -This guide covers deploying OpenTaberna to a production environment. - ## Prerequisites - Linux server (Ubuntu 22.04+ recommended) -- Docker & Docker Compose installed -- Domain name with DNS configured +- Docker and Docker Compose +- Domain names with DNS configured - At least 4GB RAM, 2 CPU cores -- 20GB+ disk space +- 20GB+ disk -## Architecture Overview +## What ships, and what does not -``` -Internet → Reverse Proxy (Nginx) → FastAPI - → Keycloak - → Admin UI - → Frontend - ↓ - PostgreSQL, Redis -``` +Read this before planning a deployment. -## Environment Configuration +`docker-compose.dev.yml` starts the **whole world** — API, worker, PostgreSQL, Redis, +Keycloak, MinIO and a Stripe listener. It is a development convenience and is not suitable +for production: it binds the database and Redis to host ports, relaxes Keycloak's TLS +requirement, and stores data in bind-mounted directories next to the checkout. -> **📘 See [Configuration Guide](/Configuration)** for comprehensive configuration documentation including Docker secrets, Kubernetes secrets, and all available settings. +`docker-compose.yml`, the production file, ships **only the API container**, attached to an +external `frontproxy_fnet` network. It assumes PostgreSQL, Redis, Keycloak, MinIO and the +worker already exist and are reachable — it does not create them. -### 1. Create Production Environment File +So a production deployment is: -Create `.env.production`: +1. Provision the backing services yourself — managed PostgreSQL and Redis, a Keycloak + instance, S3 or MinIO. +2. Run the worker. It is the same image as the API with a different command + (`python -m app.worker_main app.worker.WorkerSettings`) and **it is not optional** — + without it, no carrier label is ever created and no reservation ever expires. +3. Point the API at all of it through configuration. -```bash -# Application -APP_ENV=production -APP_NAME=OpenTaberna -APP_VERSION=0.1.0 -SECRET_KEY= - -# Database -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -POSTGRES_DB=opentaberna -POSTGRES_USER=opentaberna -POSTGRES_PASSWORD= - -# Redis -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_PASSWORD= - -# Keycloak -KEYCLOAK_URL=https://auth.yourdomain.com -KEYCLOAK_REALM=opentaberna -KEYCLOAK_CLIENT_ID=opentaberna-api -KEYCLOAK_CLIENT_SECRET= - -# Logging -LOG_LEVEL=INFO -LOG_FORMAT=json +## Architecture -# CORS (adjust to your frontend domains) -CORS_ORIGINS=https://yourdomain.com,https://admin.yourdomain.com +``` +Internet + │ + ├── api.yourdomain.com → Reverse proxy → FastAPI :8000 + ├── auth.yourdomain.com → Reverse proxy → Keycloak :8080 + ├── yourdomain.com → Reverse proxy → Storefront + └── admin.yourdomain.com → Reverse proxy → Admin UI + │ + PostgreSQL · Redis · MinIO + │ + Worker (same image, own command) ``` -### 2. Generate Secrets +## Configuration -```bash -# Generate SECRET_KEY -python3 -c "import secrets; print(secrets.token_urlsafe(32))" +> See the [Configuration Guide](/Configuration) for every setting, and +> [Authorization](/Authorization) for the Keycloak settings that are easy to get wrong. -# Generate database password -openssl rand -base64 32 +Production values worth calling out: -# Generate Redis password -openssl rand -base64 32 -``` +```bash +ENVIRONMENT=production +DEBUG=false +LOG_FORMAT=json +CORS_ORIGINS=["https://yourdomain.com","https://admin.yourdomain.com"] -## Deployment Steps +DATABASE_URL=postgresql+asyncpg://user:pass@postgres:5432/opentaberna +REDIS_URL=redis://redis:6379/0 -### 1. Clone Repository +KEYCLOAK_URL=http://keycloak:8080 +KEYCLOAK_PUBLIC_URL=https://auth.yourdomain.com +KEYCLOAK_ADMIN_CLIENT_IDS=["opentaberna-admin-ui"] -```bash -cd /opt -git clone https://github.com/opentaberna/opentaberna.git -cd opentaberna +STORAGE_ENDPOINT_URL=https://s3.yourdomain.com ``` -### 2. Configure Environment +Three of these are the ones that break deployments: -```bash -cp .env.example .env.production -nano .env.production # Edit with your values -``` +- **`KEYCLOAK_PUBLIC_URL`** must be the URL browsers use, not the internal one. It is what + `iss` in the token will say. Get it wrong and every token is rejected as invalid issuer. +- **`SECRET_KEY`** must not be the placeholder. The API validates this at startup and + refuses to boot — intentionally. +- **`CORS_ORIGINS`** must not stay `["*"]`. -### 3. Start Services +Secrets belong in Docker or Kubernetes secrets, not in `.env`. The loader reads +`/run/secrets/{setting_name_lowercased}` before it reads the environment. ```bash -docker-compose -f docker-compose.yml --env-file .env.production up -d +# Generate a signing key +python3 -c "import secrets; print(secrets.token_urlsafe(32))" ``` -### 4. Verify Services +## Deploy ```bash -# Check all containers are running -docker-compose ps +cd /opt +git clone https://github.com/OpenTaberna/fastapi.git opentaberna +cd opentaberna +cp .env.example .env.production # then edit +docker compose -f docker-compose.yml --env-file .env.production up -d +``` -# Check API health -curl http://localhost:8000/health +Verify: -# View logs -docker-compose logs -f fastapi +```bash +docker compose ps +curl http://localhost:8000/health/ready ``` -## Reverse Proxy Setup (Nginx) +`/health/ready` is the one that matters — it probes PostgreSQL and Redis and reports each. +A `200` from `/health` only means the process is alive. -### Install Nginx +## Reverse proxy ```bash sudo apt update sudo apt install nginx certbot python3-certbot-nginx ``` -### Configure Nginx - -Create `/etc/nginx/sites-available/opentaberna`: +`/etc/nginx/sites-available/opentaberna`: ```nginx -# API Backend +# Rate limiting zone — see the note below +limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + server { listen 80; server_name api.yourdomain.com; location / { + limit_req zone=api burst=20 nodelay; + proxy_pass http://localhost:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # WebSocket support (if needed) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - # Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } - # API Documentation - location /docs { - proxy_pass http://localhost:8000/docs; - proxy_set_header Host $host; - } - - location /openapi.json { - proxy_pass http://localhost:8000/openapi.json; - proxy_set_header Host $host; - } - - client_max_body_size 100M; + client_max_body_size 10M; # product image uploads } -# Keycloak server { listen 80; server_name auth.yourdomain.com; @@ -187,302 +162,219 @@ server { } ``` -Enable the site: - ```bash sudo ln -s /etc/nginx/sites-available/opentaberna /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx -``` -### Setup SSL with Let's Encrypt - -```bash -# API sudo certbot --nginx -d api.yourdomain.com - -# Keycloak sudo certbot --nginx -d auth.yourdomain.com - -# Auto-renewal sudo certbot renew --dry-run ``` -## Database Setup +> **The API's own rate limiter keys on the socket address.** Behind a proxy that is the +> proxy's IP, so every request looks like one client and the limit is effectively global. +> Either rate-limit at Nginx as shown above, or change the limiter's `key_func` to read +> `X-Forwarded-For`. Running both is fine; running neither is not. -### Initial Migration +> `client_max_body_size` must be at least `STORAGE_MAX_IMAGE_BYTES` (5 MB by default) or +> image uploads fail at the proxy with a `413` the API never sees. -```bash -# Run migrations -docker-compose exec fastapi alembic upgrade head +`proxy_set_header X-Forwarded-Proto $scheme` is not optional for Keycloak — without it, +Keycloak builds redirect URLs with `http://` and the login loop breaks. -# Create initial admin user (if applicable) -docker-compose exec fastapi python -m app.scripts.create_admin +## The worker + +```bash +docker run -d --name opentaberna-worker \ + --env-file .env.production \ + --network fastapi_backend \ + opentaberna-api:latest \ + python -m app.worker_main app.worker.WorkerSettings ``` -### Backup Strategy +Health is a Redis ping. If the worker is down, orders reach `paid` and stop: +`outbox_events` fills with `PENDING` rows and nothing dequeues them. That is the first +place to look when nothing ships. + +## Database -#### Automated Daily Backups +The API creates its schema on startup; there is no Alembic migration step today. Plan a +migration tool before the first schema change against real data. -Create `/opt/opentaberna/backup.sh`: +### Backups + +`/opt/opentaberna/backup.sh`: ```bash #!/bin/bash +set -euo pipefail BACKUP_DIR="/opt/backups/opentaberna" DATE=$(date +%Y%m%d_%H%M%S) -POSTGRES_CONTAINER="opentaberna-postgres-1" - -mkdir -p $BACKUP_DIR +POSTGRES_CONTAINER="opentaberna-db" -# Backup database -docker exec $POSTGRES_CONTAINER pg_dump -U opentaberna opentaberna | gzip > $BACKUP_DIR/db_$DATE.sql.gz - -# Keep only last 30 days -find $BACKUP_DIR -name "db_*.sql.gz" -mtime +30 -delete +mkdir -p "$BACKUP_DIR" +docker exec "$POSTGRES_CONTAINER" pg_dump -U opentaberna opentaberna \ + | gzip > "$BACKUP_DIR/db_$DATE.sql.gz" +find "$BACKUP_DIR" -name "db_*.sql.gz" -mtime +30 -delete echo "Backup completed: db_$DATE.sql.gz" ``` -Make executable and add to cron: - ```bash chmod +x /opt/opentaberna/backup.sh - -# Add to crontab (runs daily at 2 AM) -sudo crontab -e +# crontab: daily at 02:00 0 2 * * * /opt/opentaberna/backup.sh ``` -#### Restore from Backup +**Back up the object store too.** Carrier labels and product images live in MinIO, not in +PostgreSQL, so a database-only backup restores orders whose labels have vanished. Use +`mc mirror` or your provider's replication. -```bash -# Stop services -docker-compose down - -# Restore database -gunzip < /opt/backups/opentaberna/db_20251206_020000.sql.gz | \ - docker exec -i opentaberna-postgres-1 psql -U opentaberna opentaberna +Restore: -# Restart services -docker-compose up -d +```bash +docker compose down +gunzip < /opt/backups/opentaberna/db_20260826_020000.sql.gz \ + | docker exec -i opentaberna-db psql -U opentaberna opentaberna +docker compose up -d ``` -## Monitoring & Logging +Test a restore before you need one. An untested backup is a hypothesis. -### View Logs +## Monitoring ```bash -# Real-time logs -docker-compose logs -f - -# Specific service -docker-compose logs -f fastapi - -# Last 100 lines -docker-compose logs --tail=100 fastapi +docker compose logs -f +docker compose logs -f fastapi +docker compose logs --tail=100 fastapi ``` -### Log Rotation +Set `LOG_FORMAT=json` so logs are parseable, and keep the correlation ID — it threads a +request through the API and the worker. -Docker handles log rotation, but configure limits in `/etc/docker/daemon.json`: +Log rotation in `/etc/docker/daemon.json`: ```json { "log-driver": "json-file", - "log-opts": { - "max-size": "10m", - "max-file": "3" - } + "log-opts": { "max-size": "10m", "max-file": "3" } } ``` -Restart Docker: +### What to alert on -```bash -sudo systemctl restart docker -``` +| Signal | Why | +|---|---| +| `/health/ready` non-200 | A dependency is down | +| `outbox_events` with `status='DEAD'` | A job ran and gave up — usually the carrier API | +| `outbox_events` with `status='FAILED'` | Never reached the queue — Redis or the poller | +| `outbox_events` `PENDING` and ageing | The worker is not running | +| `webhook_events` with `processed_at IS NULL` | Payments arriving but not being handled | -### Health Checks +Those four queries are worth a dashboard. They are the difference between noticing a +stalled fulfillment pipeline in minutes and hearing about it from a customer in days. -Create `/opt/opentaberna/healthcheck.sh`: +```sql +SELECT status, count(*) FROM outbox_events GROUP BY status; +SELECT count(*) FROM webhook_events WHERE processed_at IS NULL; +``` -```bash -#!/bin/bash +## Updates -API_URL="https://api.yourdomain.com/health" -RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $API_URL) - -if [ $RESPONSE -eq 200 ]; then - echo "API is healthy" - exit 0 -else - echo "API is down! Status: $RESPONSE" - # Send alert (email, Slack, etc.) - exit 1 -fi -``` +The `fastapi` repository has two GitHub workflows: -Add to cron (every 5 minutes): +| Workflow | Trigger | Does | +|---|---|---| +| `test.yml` | Push / PR to main, or manual | Integration tests, pytest, ruff, Trivy, Bandit. Results uploaded as artifacts. | +| `test-build-deploy.yml` | A `vX.Y.Z` tag | Runs tests, builds the image, pushes it to the registry, deploys `docker-compose.yml` to Portainer. | ```bash -*/5 * * * * /opt/opentaberna/healthcheck.sh +git tag v1.2.3 && git push origin v1.2.3 ``` -## Updates & Maintenance +> **The deploy job reports whether Portainer accepted the stack, not whether the +> application works.** A container that starts and then fails is still "running" as far as +> that check is concerned. Always confirm `/health/ready` yourself after a deploy. -### Update to Latest Version +Manual update: ```bash cd /opt/opentaberna - -# Pull latest code git pull - -# Rebuild containers -docker-compose build - -# Run migrations -docker-compose run fastapi alembic upgrade head - -# Restart services (zero-downtime) -docker-compose up -d --no-deps --build fastapi +docker compose build +docker compose up -d --no-deps --build fastapi +curl https://api.yourdomain.com/health/ready ``` -### Zero-Downtime Deployment +Remember to rebuild the worker too — same image, so it needs the same restart to pick up +new code. -For production with multiple instances: +## Security checklist -```bash -# Scale to 2 instances -docker-compose up -d --scale fastapi=2 +- [ ] `SECRET_KEY` generated, not the placeholder +- [ ] All default passwords changed, including Keycloak's `admin`/`admin` +- [ ] Seeded users (`adminuser`, `testuser`, `testuser2`) removed from the realm +- [ ] Direct access grants disabled on both public clients +- [ ] `KEYCLOAK_ADMIN_CLIENT_IDS` limited to the admin UI +- [ ] `CORS_ORIGINS` set to real domains +- [ ] Secrets mounted, not passed as environment variables +- [ ] TLS on every public hostname +- [ ] Database and Redis not exposed to the internet +- [ ] Rate limiting effective behind the proxy +- [ ] Firewall configured: + `sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw enable` +- [ ] Backups running **and a restore tested**, database and object store +- [ ] Alerting on the queries above -# Update one instance -docker-compose up -d --no-deps --scale fastapi=2 fastapi +## Performance -# Scale back to 1 if needed -docker-compose up -d --scale fastapi=1 -``` - -## Security Checklist - -- [ ] Change all default passwords -- [ ] Use strong SECRET_KEY -- [ ] Configure firewall (UFW): - ```bash - sudo ufw allow 22/tcp # SSH - sudo ufw allow 80/tcp # HTTP - sudo ufw allow 443/tcp # HTTPS - sudo ufw enable - ``` -- [ ] Enable SSL/TLS (Let's Encrypt) -- [ ] Restrict database to localhost only -- [ ] Set CORS_ORIGINS to specific domains -- [ ] Enable rate limiting in Nginx: - ```nginx - limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; - limit_req zone=api burst=20 nodelay; - ``` -- [ ] Regular backups enabled -- [ ] Monitoring/alerting configured -- [ ] Keep Docker images updated - -## Performance Tuning - -### Database Connection Pool - -In your application config: - -```python -# src/app/shared/database.py -SQLALCHEMY_POOL_SIZE = 20 -SQLALCHEMY_MAX_OVERFLOW = 40 -SQLALCHEMY_POOL_TIMEOUT = 30 -``` +**Uvicorn workers** — `WORKERS`, rule of thumb `(2 × cores) + 1`. -### Uvicorn Workers +**Database pool** — `DATABASE_POOL_SIZE` (20) and `DATABASE_MAX_OVERFLOW` (40) are per +process. Total connections are roughly `WORKERS × (pool_size + max_overflow)`, and that +has to stay under the server's `max_connections`. Four workers at the defaults wants up to +240 connections, which is more than a default PostgreSQL allows. -In `docker-compose.yml`: - -```yaml -command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 -``` - -Rule of thumb: `workers = (2 * CPU_cores) + 1` - -### Redis Caching - -Enable caching for frequently accessed data: - -```python -# Cache item queries for 5 minutes -@cache(expire=300) -async def get_item(item_id: str): - return await db.get_item(item_id) -``` +**Worker concurrency** — `ARQ_MAX_JOBS` (10 per process). Carrier APIs rate-limit; raising +this mostly buys retries. ## Troubleshooting -### Container Won't Start +### Container will not start ```bash -# Check logs -docker-compose logs fastapi - -# Check resource usage +docker compose logs fastapi docker stats - -# Restart specific service -docker-compose restart fastapi ``` -### Database Connection Issues +A start failure with `SECRET_KEY` in the traceback is the production validator, working. -```bash -# Check database is running -docker-compose exec postgres psql -U opentaberna -d opentaberna -c "SELECT 1;" +### Every token is rejected -# Check connection from FastAPI container -docker-compose exec fastapi nc -zv postgres 5432 -``` +`KEYCLOAK_PUBLIC_URL`. See [Authorization](/Authorization#issuer-urls-the-one-that-bites). -### High Memory Usage +### Admin gets 403 with a valid admin token -```bash -# Check memory usage -docker stats +The `azp` check. The token came from the storefront client, not the admin UI. This is the +protection working, not a bug. -# Restart services -docker-compose restart +### Orders are paid but nothing ships -# Clear Redis cache -docker-compose exec redis redis-cli FLUSHALL -``` +In order: is the worker running; `SELECT status, count(*) FROM outbox_events GROUP BY +status`; then the worker logs for the correlation ID. See +[Orders and Fulfillment](/Orders-and-Fulfillment#reading-the-trail). -### SSL Certificate Issues +### Database connection issues ```bash -# Test certificate renewal -sudo certbot renew --dry-run - -# Force renewal -sudo certbot renew --force-renewal - -# Check certificate expiry -sudo certbot certificates +docker compose exec postgres psql -U opentaberna -d opentaberna -c "SELECT 1;" +docker compose exec fastapi nc -zv postgres 5432 ``` -## API Documentation - -Remember: The complete API documentation is always available at: - -**https://api.yourdomain.com/docs** - -This is automatically generated by FastAPI and is always up-to-date with your deployment. - ## Support -- **Configuration:** [Configuration Guide](/Configuration) -- **GitHub Issues:** https://github.com/opentaberna/opentaberna/issues -- **API Docs:** https://api.yourdomain.com/docs -- **Wiki:** https://wiki.opentaberna.dev +- [Configuration](/Configuration) · [Authorization](/Authorization) · [Orders and Fulfillment](/Orders-and-Fulfillment) +- **API docs:** `https://api.yourdomain.com/docs` +- **Issues:** https://github.com/OpenTaberna/fastapi/issues diff --git a/Getting-Started.md b/Getting-Started.md index 7573e95..5ee98b6 100644 --- a/Getting-Started.md +++ b/Getting-Started.md @@ -2,7 +2,7 @@ title: Getting Started description: Quick start guide for OpenTaberna published: true -date: 2025-12-06T15:30:00.000Z +date: 2026-08-26T12:00:00.000Z tags: getting-started, quickstart, setup editor: markdown dateCreated: 2025-12-06T15:30:00.000Z @@ -10,208 +10,248 @@ dateCreated: 2025-12-06T15:30:00.000Z # Getting Started -This guide will help you get OpenTaberna up and running in minutes. +This guide gets the whole OpenTaberna stack running on your machine: the API and its +backing services, the back-office UI, and the storefront. ## Prerequisites -Before you begin, ensure you have the following installed: - - **Docker** (20.10+) and **Docker Compose** (2.0+) - **Git** +- **Node.js** 22.22+, 24.15+ or 26+ — only needed to run the admin UI from source - At least 4GB of available RAM -## Quick Start +## 1. Clone the repositories -### 1. Clone the Repository +The project is four repositories, not one. Put them side by side: ```bash -git clone https://github.com/PhilippTheServer/opentaberna.git -cd opentaberna +mkdir opentaberna && cd opentaberna +git clone https://github.com/OpenTaberna/fastapi.git +git clone https://github.com/OpenTaberna/frontend.git +git clone https://github.com/OpenTaberna/admin_frontend.git +git clone https://github.com/OpenTaberna/wiki.git ``` -### 2. Start the Development Environment +Everything below assumes you are in one of those directories. + +## 2. Start the backend stack ```bash -docker-compose -f docker-compose.dev.yml up -d +cd fastapi +cp .env.example .env +docker compose -f docker-compose.dev.yml up -d ``` -This will start: -- PostgreSQL Database -- FastAPI Backend (with auto-reload) -- Keycloak (User Management) -- Redis (Cache & Queue) +That brings up seven containers: + +| Container | Purpose | Port | +|---|---|---| +| `opentaberna-api` | The FastAPI service | 8000 | +| `opentaberna-db` | PostgreSQL 17 | 5432 | +| `opentaberna-redis` | Redis 8 — cache and job queue | 6379 | +| `opentaberna-keycloak` | Keycloak 26 — identity provider | 8080 | +| `opentaberna-minio` | MinIO — product images, carrier labels | 9000 (S3), 9001 (console) | +| `opentaberna-worker` | ARQ background worker | — | +| `opentaberna-stripe-listener` | Stripe CLI, forwards test webhooks to the API | — | -### 3. Access the Services +Keycloak takes 30–60 seconds on a first start because it imports the realm. Watch it +settle with: -Once all containers are running: +```bash +docker compose -f docker-compose.dev.yml ps +``` -| Service | URL | Credentials | -|---------|-----|-------------| -| **API Documentation** | http://localhost:8000/docs | - | -| **API (OpenAPI JSON)** | http://localhost:8000/openapi.json | - | -| **Keycloak Admin** | http://localhost:8080 | admin / admin | -| **Database** | localhost:5432 | See docker-compose | +Every container should read `healthy` before you continue. -### 4. Verify Installation +> **Stripe:** the listener generates its own webhook signing secret and mounts it into the +> API, so you never copy a `whsec_...` by hand. You only need a test-mode +> `STRIPE_SECRET_KEY` in `.env` if you intend to exercise payment. -Check if the API is running: +## 3. Verify the API ```bash curl http://localhost:8000/health ``` -Expected response: ```json { - "status": "healthy", - "version": "0.1.0" + "status": "ok", + "timestamp": "2026-08-26T11:54:20.342282Z" } ``` -### 5. Explore the API +`/health` is a liveness probe and answers as soon as the process is up. To check that the +API can actually reach its dependencies, use the readiness probe: -Open your browser and navigate to: +```bash +curl http://localhost:8000/health/ready +``` -**http://localhost:8000/docs** +```json +{ + "status": "ok", + "timestamp": "2026-08-26T11:54:20.446532Z", + "database": { "healthy": true, "latency_ms": 3.13, "error": null }, + "redis": { "healthy": true, "latency_ms": 2.34, "error": null } +} +``` -This is FastAPI's interactive API documentation (Swagger UI). Here you can: -- Browse all available endpoints -- Test API calls directly in the browser -- See request/response schemas -- Generate code examples +> Note that `GET /` is **not** an endpoint — it returns 404. There is no root version +> route; use `/health` or read `info.version` from `/openapi.json`. -## First API Call +## 4. Start the frontends -### Without Authentication +**Storefront** — runs as a container that serves the built app and proxies `/api` to the +API on your host: ```bash -# Get API version -curl http://localhost:8000/ +cd ../frontend +docker compose up --build -d # http://localhost:4300 ``` -### With Authentication - -1. **Get Access Token from Keycloak:** +**Admin UI** — run from source: ```bash -curl -X POST http://localhost:8080/realms/opentaberna/protocol/openid-connect/token \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "client_id=opentaberna-client" \ - -d "grant_type=password" \ - -d "username=admin" \ - -d "password=admin" +cd ../admin_frontend +npm install +npm start # http://localhost:4200 ``` -2. **Use the token:** +## 5. Sign in -```bash -TOKEN="" +The realm ships three development users. Passwords are in the realm import and are +development-only. -curl http://localhost:8000/api/v1/items \ - -H "Authorization: Bearer $TOKEN" -``` +| Username | Password | Role | Use it for | +|---|---|---|---| +| `adminuser` | `adminpassword` | `admin` | The admin UI, and every `/v1/admin/**` endpoint | +| `testuser` | `testpassword` | `customer` | The storefront | +| `testuser2` | `testpassword2` | `customer` | Testing that one customer cannot read another's data | -## Development Workflow +The Keycloak admin console is at **http://localhost:8080** (`admin` / `admin`). -### View Logs +## Where everything lives -```bash -# All services -docker-compose -f docker-compose.dev.yml logs -f +| Service | URL | +|---|---| +| **API documentation (Swagger)** | http://localhost:8000/docs | +| **OpenAPI schema** | http://localhost:8000/openapi.json | +| **Storefront** | http://localhost:4300 | +| **Admin UI** | http://localhost:4200 | +| **Keycloak** | http://localhost:8080 | +| **MinIO console** | http://localhost:9001 | -# Specific service -docker-compose -f docker-compose.dev.yml logs -f fastapi -``` +## Your first API call -### Stop Services +The catalogue is public — browsing needs no token: ```bash -docker-compose -f docker-compose.dev.yml down +curl http://localhost:8000/v1/items/ ``` -### Reset Database +Note the prefix is `/v1`, **not** `/api/v1`. (The storefront container proxies `/api/v1` +to `/v1`, which is why you may see that path in browser dev tools.) -```bash -docker-compose -f docker-compose.dev.yml down -v -docker-compose -f docker-compose.dev.yml up -d -``` +Everything else needs a bearer token. In development you can take a shortcut through +Keycloak's direct grant: -## Project Structure +```bash +TOKEN=$(curl -s -X POST \ + http://localhost:8080/realms/opentaberna/protocol/openid-connect/token \ + -d "client_id=opentaberna-admin-ui" \ + -d "grant_type=password" \ + -d "username=adminuser" \ + -d "password=adminpassword" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") -``` -opentaberna/ -├── fastapi_opentaberna/ # Backend API -│ ├── src/app/ -│ │ ├── main.py # FastAPI application -│ │ ├── shared/ # Shared utilities (logger, etc.) -│ │ ├── authorize/ # Keycloak integration -│ │ └── services/ # Feature modules -│ ├── tests/ # Test suite -│ └── docs/ # Developer documentation -├── wiki/ # End-user documentation -└── docker-compose.yml # Production setup +curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/v1/admin/orders/ ``` -## Next Steps +The same call without the header returns `403`. Which client the token came from matters +as much as the role on it — see [Authorization](/Authorization). -### For End Users: -- Read [Configuration Guide](/Configuration) for detailed settings -- Read [Deployment Guide](/Deployment) for production setup -- Check `/docs` on your API instance for endpoint documentation +You can also log in inside the Swagger UI: `/docs` is wired to the Keycloak realm, so the +padlocked operations can be tried out rather than only read. -### For Developers: -- See `fastapi_opentaberna/docs/architecture.md` for architecture overview -- See `fastapi_opentaberna/docs/development.md` for development workflows -- See `fastapi_opentaberna/docs/testing.md` for testing guidelines -- See `fastapi_opentaberna/docs/config.md` for configuration module details +## Running the API outside Docker -## Common Issues +For work on the API itself, run the service on your host and leave the backing containers +running: -### Port Already in Use - -If port 8000 or 5432 is already in use: +```bash +cd fastapi +uv sync +source .venv/bin/activate +python3 src/app/main.py +``` -1. Edit `docker-compose.dev.yml` -2. Change the port mapping: - ```yaml - ports: - - "8001:8000" # Change 8000 to 8001 - ``` +The project uses **uv**, not pip, and targets Python 3.14. -### Database Connection Issues +## Development workflow ```bash -# Check if PostgreSQL is running -docker-compose -f docker-compose.dev.yml ps +# All logs +docker compose -f docker-compose.dev.yml logs -f + +# One service +docker compose -f docker-compose.dev.yml logs -f opentaberna-api -# View database logs -docker-compose -f docker-compose.dev.yml logs postgres +# Stop +docker compose -f docker-compose.dev.yml down + +# Stop and wipe the database, object store and Keycloak users +docker compose -f docker-compose.dev.yml down -v ``` -### Keycloak Not Starting +> `down -v` also removes the `keycloak_data` volume, so registered users and their role +> grants are gone. The three seeded users come back on the next start; anyone you created +> by hand does not. + +## Common issues + +### A port is already in use + +The stack claims 8000, 5432, 6379, 8080, 9000 and 9001, and the frontends claim 4200 and +4300. Find the offender with `lsof -nP -iTCP:8000 -sTCP:LISTEN`, then either stop it or +remap the port in `docker-compose.dev.yml`. -Keycloak needs time to initialize (30-60 seconds). Check logs: +The admin UI takes `--port`: ```bash -docker-compose -f docker-compose.dev.yml logs keycloak +npm start -- --port 4201 ``` -## Getting Help +Keycloak only permits redirects back to the ports in the realm import +(4200, 4300, 8081, 8082), so a frontend moved to an unlisted port will fail login until +you add it to the client's redirect URIs. -- **API Documentation:** http://localhost:8000/docs (always up-to-date!) -- **GitHub Issues:** https://github.com/PhilippTheServer/opentaberna/issues -- **Wiki:** https://wiki.opentaberna.dev +### Keycloak is unhealthy or the admin console refuses HTTP + +The dev compose file relaxes `sslRequired` on the master realm on every start, because +Docker's port forwarding makes a request from your host look non-local and Keycloak would +otherwise refuse plain HTTP. If the console is unreachable, check that the container +finished booting: + +```bash +docker compose -f docker-compose.dev.yml logs opentaberna-keycloak | tail -20 +``` -## Summary +### The API starts but readiness fails -You now have OpenTaberna running locally! The most important resource is: +`/health/ready` names the dependency that is down and the error it returned. A `database` +failure with the API otherwise healthy usually means `DATABASE_URL` in `.env` still points +at `localhost` while the API is running inside Compose, where the host is +`opentaberna-db`. -**👉 http://localhost:8000/docs** +## Next steps -This page contains the complete, always up-to-date API documentation with: -- All endpoints -- Request/response schemas -- Interactive testing -- Authentication examples +- [API Architecture](/API/Architecture) — every endpoint, the response envelope, errors +- [Authorization](/Authorization) — roles, clients, and what the API enforces +- [Orders and Fulfillment](/Orders-and-Fulfillment) — the order lifecycle end to end +- [Database Architecture](/Database/Architecture) — the real schema +- [Configuration](/Configuration) — every setting and where it can come from +- [Deployment](/Deployment) — running this in production -Everything you need to integrate with OpenTaberna is documented there automatically by FastAPI. +For developer-facing detail the wiki does not carry, the `fastapi` repository has +`docs/architecture.md`, `docs/development.md`, `docs/testing.md` and +`docs/authorization.md`. diff --git a/Orders-and-Fulfillment.md b/Orders-and-Fulfillment.md new file mode 100644 index 0000000..6cd1c76 --- /dev/null +++ b/Orders-and-Fulfillment.md @@ -0,0 +1,222 @@ +--- +title: Orders and Fulfillment +description: The order lifecycle from cart to delivery, and how it survives failure +published: true +date: 2026-08-26T12:00:00.000Z +tags: orders, payments, fulfillment, shipping, returns, inventory +editor: markdown +dateCreated: 2026-08-26T12:00:00.000Z +--- + +# Orders and Fulfillment + +This is the path a purchase takes through the system, and — more usefully — what happens +when a step of it fails. + +## The flow + +```mermaid +flowchart TD + A[Customer views product] --> B[Add to cart] + B --> C[Start checkout] + C --> D[Create Order: DRAFT] + D --> E["Reserve inventory (StockReservation)"] + E -->|insufficient stock| E1[Reject / show out of stock] + E -->|ok| F["Create Payment Intent (Stripe)"] + F --> G[Customer completes payment] + G --> H[Stripe sends webhook] + + H --> I{"Signature verified + idempotent?"} + I -->|duplicate| I1["Return 200 OK (no-op)"] + I -->|new| J[DB txn: Payment=SUCCEEDED] + J --> K[Order status -> PAID] + K --> L[Commit inventory: decrement on_hand, release reservation] + L --> M[Write outbox event: CREATE_LABEL] + + M --> N[Poller enqueues ARQ job] + N --> O["Worker calls carrier API (DHL)"] + O --> P{"Label created?"} + P -->|no| P1[Retry with backoff, then DEAD + alert] + P -->|yes| Q[Shipment: tracking + label in object store] + Q --> R[Order status -> READY_TO_SHIP] + R --> S["Admin pick & pack with packing slip"] + S --> T[Hand over to carrier] + T --> U[Order status -> SHIPPED] + U --> V[Tracking email to customer] + + H -->|payment_failed| W[Payment=FAILED] + W --> X[Order status -> CANCELLED] + X --> Y[Release reservation] +``` + +## Order status + +| Status | Meaning | +|---|---| +| `draft` | A cart. Nothing is reserved, nothing is owed. | +| `pending_payment` | Checkout started. Stock is reserved, payment intent exists. | +| `paid` | Payment confirmed by webhook. Stock committed. | +| `ready_to_ship` | A shipment exists, label included where the carrier is automated. | +| `shipped` | Handed to the carrier. | +| `cancelled` | Payment failed, timed out, or the customer cancelled a draft. | +| `refunded` | Money returned, via `charge.refunded`. | + +Transitions are enforced by the application layer, not merely suggested: + +``` +draft → pending_payment (checkout starts) +draft → cancelled (customer cancels) +pending_payment → paid (webhook: payment_succeeded) +pending_payment → cancelled (webhook: payment_failed, or timeout) +paid → ready_to_ship (shipment created) +ready_to_ship → shipped (handed to carrier) +paid | shipped → refunded (webhook: charge.refunded) +``` + +Related states, each on its own record: + +| Record | States | +|---|---| +| Payment | `pending` → `succeeded` \| `failed` \| `refunded` | +| Shipment | `pending` → `label_created` → `handed_over` | +| Return | `requested` → `approved` \| `rejected`; `approved` → `completed` | + +## Checkout reserves, it does not deduct + +`POST /v1/orders/{order_id}/checkout` takes a **reservation** against `inventory_items`, +not a deduction. The row's `reserved` count goes up; `on_hand` does not move until payment +actually succeeds. + +This matters because the alternative — deducting at checkout — loses stock every time +somebody abandons a payment page. Instead the reservation carries an `expires_at` +(`RESERVATION_TTL_MINUTES`, default 15) and a sweeper job releases lapsed ones. + +Overselling is prevented by the database rather than by application care: + +```sql +CHECK (on_hand >= reserved) +``` + +Two simultaneous checkouts for the last unit cannot both succeed, regardless of how the +requests interleave. One gets a constraint violation and is rejected. + +## Payment is confirmed by webhook, never by the browser + +The API creates a Stripe payment intent, and the customer completes payment against +Stripe. **The API does not treat the browser coming back as proof of payment.** A client +can lie, close the tab, or lose its connection at exactly the wrong moment. + +The authority is `POST /v1/webhooks/stripe`, which: + +1. **Verifies the Stripe signature.** Unsigned or mis-signed events are rejected. +2. **Deduplicates** on `(provider, event_id)`, which is a unique constraint in + `webhook_events`. Stripe redelivers on any non-2xx, and at-least-once delivery means + duplicates are normal traffic, not an error case. A repeat is a no-op returning `200`. +3. **Commits atomically**: payment status, order status and the inventory commit land in + one transaction. There is no state where the order is paid but the stock was never + taken. + +### Local webhooks + +The development stack runs a Stripe CLI listener container that forwards test events to +the API and writes its generated signing secret to a volume the API reads. You never copy +a `whsec_...` by hand, and `STRIPE_WEBHOOK_SECRET` only needs setting when running the API +outside Compose or against a Dashboard-managed endpoint. + +## The outbox + +Once an order is paid, a carrier label needs creating — slow, failure-prone, and +absolutely not something to do inside the webhook request. + +The naive approach is to enqueue a job to Redis after committing the transaction. That has +a hole: if the process dies between the commit and the enqueue, the order is paid forever +and no label is ever made. Nothing retries, because nothing knows. + +So the API never enqueues directly. It writes a row to `outbox_events` **in the same +transaction as the state change**. Either both land or neither does. A poller in the +worker sweeps `PENDING` rows every `OUTBOX_POLL_INTERVAL` seconds (default 30) and hands +them to ARQ. + +The cost is latency — a job may wait up to one poll interval. The benefit is that a job +cannot be lost, only delayed. + +### Two ways to fail, and they mean different things + +| Status | Meaning | +|---|---| +| `FAILED` | The poller could not hand the event to Redis within `OUTBOX_MAX_ATTEMPTS` sweeps. **The job never ran.** | +| `DEAD` | The job was enqueued and ran, but exhausted `ARQ_MAX_TRIES` attempts. **The job ran and gave up.** | + +Worth keeping straight when something is stuck: `FAILED` points at Redis or the poller, +`DEAD` points at the job itself — usually the carrier API. + +ARQ retries with exponential backoff (2^attempt seconds) up to `ARQ_MAX_TRIES` (default +5). Exhausting them triggers the dead-letter hook, which logs at `ERROR` and marks the row +`DEAD` so it is visible in the database for investigation rather than only in a log. + +### Background jobs + +| Job | Trigger | Does | +|---|---|---| +| `create_label` | Outbox, on payment | Calls the carrier, stores the label, records tracking | +| `expire_reservations_sweep` | Scheduled | Releases reservations past `expires_at` | +| `poll_outbox` | Every `OUTBOX_POLL_INTERVAL`s | Enqueues pending outbox events | + +## Carriers + +Label creation goes through a `CarrierAdapter` interface, with two implementations: + +- **DHL** — the Parcel DE REST API. Needs `DHL_CLIENT_ID`, `DHL_CLIENT_SECRET` and + `DHL_BILLING_NUMBER`. Defaults to the sandbox base URL; returns PDF or ZPL. +- **Manual** — no carrier call. The admin enters a tracking number by hand via + `POST /v1/admin/orders/{order_id}/ship`. + +Manual is not a stub. Shipping by hand is a legitimate way to run a small shop, and it is +the path that works before any carrier account exists. + +Label files live in MinIO (`STORAGE_BUCKET_LABELS`); the database stores a URL. + +## What the back office does + +| Endpoint | Use | +|---|---| +| `GET /v1/admin/orders/` | Work queue, filtered by status | +| `GET /v1/admin/orders/pick-list` | Batch pick list across orders | +| `GET /v1/admin/orders/{id}/packing-slip` | Packing slip for one order | +| `POST /v1/admin/orders/{id}/shipments` | Create the shipment record | +| `POST /v1/admin/orders/{id}/label` | Queue a carrier label job | +| `GET /v1/admin/orders/{id}/label` | Download the label | +| `POST /v1/admin/orders/{id}/ship` | Mark shipped, with manual tracking | +| `PATCH /v1/admin/orders/{id}/status` | Override status | + +The status override is an escape hatch for when reality and the state machine disagree — +a parcel handed over without a scan, a payment settled out of band. It bypasses the +transition rules, so it is admin-only and worth logging when used. + +## Returns + +A customer opens a return with `POST /v1/orders/{order_id}/returns`, giving a reason. One +return per order, enforced by a unique constraint. + +An admin then resolves it with `PATCH /v1/admin/returns/{return_id}`: `approved`, +`rejected`, or `completed` once the goods are physically back. Refunds arrive separately +as a `charge.refunded` webhook, which moves the order to `refunded`. + +Returns are `ON DELETE RESTRICT` against both the order and the customer — a financial +record must not disappear because a parent row was removed. + +## Reading the trail + +Every request gets a correlation ID from the middleware, threaded through the logs and +returned as `request_id` in the response envelope. It follows work across the API and the +worker, so "my order never shipped" can be traced from the webhook through the outbox row +to the job and the carrier call. + +When an order is stuck, the useful order of checks is: + +1. `orders.status` — where did it stop? +2. `payments.status` — did money actually arrive? +3. `webhook_events` — did Stripe's event arrive, and is `processed_at` set? +4. `outbox_events.status` — `PENDING` (waiting), `FAILED` (never queued) or `DEAD` (ran + and gave up)? +5. Worker logs for the correlation ID. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7416a05 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# OpenTaberna Wiki + +The published wiki for the [OpenTaberna](https://github.com/OpenTaberna) project. Pages are +Markdown with Wiki.js front matter and are rendered at +[wiki.opentaberna.de](https://wiki.opentaberna.de). + +## Pages + +| Page | Covers | +|---|---| +| `home.md` | What the project is, the architecture, the four repositories | +| `Getting-Started.md` | Running the whole stack locally | +| `Authorization.md` | Keycloak roles, clients, and what the API enforces | +| `API/Architecture.md` | Endpoint reference, response envelope, error model | +| `Database/Architecture.md` | The schema as it is actually built | +| `Orders-and-Fulfillment.md` | Order lifecycle, payments, the outbox, returns | +| `Configuration.md` | Every setting and where it can come from | +| `Deployment.md` | Production deployment | + +## Reading it locally + +```bash +python3 tools/serve.py # http://localhost:8090 +``` + +Renders the Markdown with Mermaid diagrams and working internal links, so a change can be +checked before it is published. Standard library only — no install step. Marked and +Mermaid load from a CDN, so the first page view needs a network connection. + +## Keeping it honest + +This wiki drifted nine months out of date once ([#1](https://github.com/OpenTaberna/wiki/issues/1)), +documenting database tables that were never created and endpoints that never existed. + +`tools/check_wiki.py` is what stops that happening quietly again: + +```bash +python3 tools/check_wiki.py +``` + +It fails when + +1. the API serves an endpoint no page mentions, +2. a page documents a path the API does not serve, or +3. an internal link points at a page that does not exist. + +CI runs it on every push and pull request. + +### When the API changes + +The check reads `openapi.snapshot.json`, a committed copy of the API's OpenAPI paths, so +CI needs nothing running. After the API changes, refresh it against a live instance: + +```bash +# with the API running — see the fastapi repository +python3 tools/check_wiki.py --refresh http://localhost:8000 +``` + +That is the point at which the check bites: pulling in a snapshot with a new endpoint makes +the check fail until somebody documents it. Refreshing the snapshot and updating the pages +belong in the same change. + +## Conventions + +- Front matter stays. Wiki.js uses `title`, `description`, `published`, `date`, `tags`, + `editor` and `dateCreated`; bump `date` when you edit a page. +- Internal links are wiki-absolute — `/Getting-Started`, `/API/Architecture` — not relative + file paths. The checker verifies they resolve. +- Prefer describing what is built. Where something is proposed rather than shipped, say so + on the page; a proposal read as a description is how the last drift happened. diff --git a/home.md b/home.md index 6a1b180..dc13a0e 100644 --- a/home.md +++ b/home.md @@ -2,7 +2,7 @@ title: OpenTaberna description: Landing page to the OpenTaberna Project published: true -date: 2025-11-19T14:16:40.237Z +date: 2026-08-26T12:00:00.000Z tags: landing page, opentaberna, architecture, start editor: markdown dateCreated: 2025-11-19T14:16:40.237Z @@ -10,32 +10,52 @@ dateCreated: 2025-11-19T14:16:40.237Z # Open Taberna +Welcome to the OpenTaberna Wiki. Here you can find hopefully helpful information on the +OpenTaberna Project. We are an OpenSource Project, so feel free to use this software as +ever you like. -Welcome to the OpenTaberna Wiki. Here you can find hopfully helpful information on the OpenTaberna Project. -We are a OpenSource Project, so feel free to use this software as ever you like. +The Project exists because I think most Shop Systems today are expensive, slow, require +too much manual labor or are just not usable. While I can not do anything about the UI, I +can provide the Tools and Backbone for a solid Self Hosted Webshop. -The Project exists because I think most Shop Systems today are expensive, slow, require too much manual labor or are just not usable. -While I can not do anything about the UI, I can provide the Tools and Backbone for a solid Self Hosted Webshop. +The Goal is to provide the necessary Skeleton so that you can build your own UI in +whatever way you prefer. Maybe in PHP or Angular or if you are really crazy in C. No +opinion here. -The Goal is to provide the nesseccary Skeleton so that you can build your own UI in whatever way you prefer. Maybe in PHP or Angular or if you are really crazy in C. No opinion here. +## The repositories +The project is split across four repositories rather than one: -## Architecture +| Repository | What it holds | +|---|---| +| [`OpenTaberna/fastapi`](https://github.com/OpenTaberna/fastapi) | The API, the background worker, the Keycloak realm and the development stack | +| [`OpenTaberna/frontend`](https://github.com/OpenTaberna/frontend) | The customer-facing storefront (Angular 22) | +| [`OpenTaberna/admin_frontend`](https://github.com/OpenTaberna/admin_frontend) | The back-office administration UI (Angular 22) | +| [`OpenTaberna/wiki`](https://github.com/OpenTaberna/wiki) | This wiki | + +The development stack lives in the `fastapi` repository — it starts the database, cache, +identity provider, object storage and the API itself. Both frontends are started +separately and talk to it. See [Getting Started](/Getting-Started). +## Architecture ```mermaid graph LR Shopper[Customer] - Admin[Adminstrator] + Admin[Administrator] - Frontend[Customizable Storefront Frontend] - AdminUI[Administration Backend UI] + Frontend[Storefront Angular] + AdminUI[Admin UI Angular] FastAPI[FastAPI Service] - DB[Database] - Plugins[Plugin System] + Worker[ARQ Background Worker] + DB[(PostgreSQL)] + Redis[(Redis)] + Storage[(MinIO Object Storage)] Keycloak[Keycloak User Management] + Stripe[Stripe] + DHL[DHL Parcel API] Shopper --> Frontend Admin --> AdminUI @@ -44,82 +64,128 @@ graph LR AdminUI --> FastAPI FastAPI --> DB - FastAPI --> Plugins - + FastAPI --> Redis + FastAPI --> Storage FastAPI --> Keycloak AdminUI --> Keycloak + Frontend --> Keycloak + FastAPI --> Stripe + Stripe -.webhook.-> FastAPI + + FastAPI -.outbox.-> Redis + Redis --> Worker + Worker --> DB + Worker --> Storage + Worker --> DHL ``` -OpenTaberna is split in 6 different Software Parts. The first two are easy: -- MySQL for Data storage and Management -- Keycloak for User administration +OpenTaberna is built from these parts: + +- **PostgreSQL 17** for data storage. (Earlier drafts of this page said MySQL — the + project runs PostgreSQL, and the schema uses `JSONB` columns heavily.) +- **Keycloak 26** for user administration. The realm is checked into the `fastapi` + repository and imported on container start, so the whole auth setup is reproducible + from a clean checkout. See [Authorization](/Authorization). +- **Redis 8** as a cache and as the queue the background worker pulls from. +- **MinIO** (S3-compatible) for product images and carrier label files. Now to the core of the Project: -The FastAPI. I choose FastAPI for two very good reasons: -1. It is very easy for you to build on a FastAPI interface that has it own living documentation in /docs that will always be up to date. -2. Personally I do not know enough to try programming this in other languages. -The API is the logic handler. Either providing endpoints for your Shop Frontend or the Administration Backend UI and handling Data coming or going into the Database. It is the abstract interface setting how to store or provide data. Having set interfaces is the most valueable thing when it comes to inter process communication and is the reason why this is the perfect project architecture. +**The FastAPI.** I chose FastAPI for two very good reasons: -The Administration UI is written in Angular. No real reason why I choose Angular over other languages. I honestly don't care about it too much. It appeard like something that gets the Job done. +1. It is very easy for you to build on a FastAPI interface that has its own living + documentation in `/docs` that will always be up to date. +2. Personally I do not know enough to try programming this in other languages. +The API is the logic handler. It provides endpoints for the Shop Frontend and the +Administration Backend UI, and handles data coming into or going out of the database. It +is the abstract interface setting how to store or provide data. Having set interfaces is +the most valuable thing when it comes to inter-process communication and is the reason +why this is the perfect project architecture. + +**The background worker** runs the jobs that must not block a web request — creating +carrier labels, sending tracking mail. Work reaches it through a +[transactional outbox](/Orders-and-Fulfillment#the-outbox), so a job is never lost +because the process died between committing a database change and enqueuing the job. + +**The Administration UI** is written in Angular. No real reason why I chose Angular over +other languages. I honestly don't care about it too much. It appeared like something that +gets the job done. + +**The Storefront** is also Angular, and is deliberately the most replaceable part of the +system. It is one possible shop UI, not *the* shop UI — that is the point of the project. + +## What the API can do today + +| Service | What it covers | +|---|---| +| Item store | The product catalogue, including product images | +| Customers | Customer profiles and their addresses | +| Orders | Cart-to-order, checkout, cancellation | +| Payments | Stripe payment intents, confirmed by webhook | +| Inventory | Stock levels and reservations | +| Fulfillment | Carrier labels, packing slips, the job queue and outbox | +| Shipments | Tracking numbers and label files | +| Returns | Customer return requests and admin decisions | +| Health | Liveness and readiness probes | + +The full endpoint list is on the [API Architecture](/API/Architecture) page, and a live, +always-current version is served by any running instance at `/docs`. --- # Setup Infrastructure +This is what `docker-compose.dev.yml` in the `fastapi` repository actually starts: + ```mermaid graph LR User[Shop User Browser] AdminUser[Admin Browser] -subgraph DockerHost[Docker Compose Stack] - Nginx[Reverse Proxy] - - AdminUI[Admin UI Angular] - DemoUI[Demo Frontend Angular] +subgraph Host[Developer machine] + DemoUI[Storefront :4300] + AdminUI[Admin UI :4200] +end - API[Shop API FastAPI] +subgraph DockerHost[Docker Compose Stack] + API[Shop API FastAPI :8000] Worker[Background Worker] + StripeCLI[Stripe CLI Listener] - DB[PostgreSQL Database] - Redis[Redis Cache and Queue] - - Keycloak[Keycloak Identity Provider] - - Elastic[Elasticsearch] - Logstash[Logstash] - Kibana[Kibana UI] + DB[(PostgreSQL :5432)] + Redis[(Redis :6379)] + Minio[(MinIO :9000 / :9001)] + Keycloak[Keycloak :8080] end -User --> Nginx -AdminUser --> Nginx +User --> DemoUI +AdminUser --> AdminUI -Nginx --> DemoUI -Nginx --> AdminUI +DemoUI --> API +AdminUI --> API -DemoUI --> Nginx -AdminUI --> Nginx - -Nginx --> API +DemoUI --> Keycloak +AdminUI --> Keycloak API --> DB API --> Redis -Worker --> DB -Worker --> Redis - +API --> Minio API --> Keycloak -AdminUI --> Keycloak -DemoUI --> Keycloak -API --> Logstash -Nginx --> Logstash -Worker --> Logstash +Worker --> DB +Worker --> Redis +Worker --> Minio -Logstash --> Elastic -Kibana --> Elastic +StripeCLI --> API +``` +There is no reverse proxy and no Elasticsearch/Logstash/Kibana in the development stack — +an earlier version of this page drew both, and neither was ever built. The storefront +container ships an Nginx that serves the built app and proxies `/api` to the API, but that +is internal to that one container, not a stack-wide gateway. -``` \ No newline at end of file +For production topology, including where a reverse proxy *does* belong, see +[Deployment](/Deployment). diff --git a/openapi.snapshot.json b/openapi.snapshot.json new file mode 100644 index 0000000..722595a --- /dev/null +++ b/openapi.snapshot.json @@ -0,0 +1,174 @@ +{ + "info": { + "title": "OpenTaberna API", + "version": "0.1.0" + }, + "paths": { + "/health": { + "get": { + "summary": "Liveness check" + } + }, + "/health/ready": { + "get": { + "summary": "Readiness check" + } + }, + "/v1/admin/inventory/": { + "get": { + "summary": "List inventory items (admin)" + }, + "post": { + "summary": "Create inventory record (admin)" + } + }, + "/v1/admin/inventory/by-sku/{sku}": { + "get": { + "summary": "Get inventory item by SKU (admin)" + } + }, + "/v1/admin/inventory/{inventory_id}": { + "delete": { + "summary": "Delete inventory record (admin)" + }, + "get": { + "summary": "Get inventory item by UUID (admin)" + }, + "patch": { + "summary": "Update inventory stock (admin)" + } + }, + "/v1/admin/orders/": { + "get": { + "summary": "List all orders (admin)" + } + }, + "/v1/admin/orders/pick-list": { + "get": { + "summary": "Batch pick list (admin)" + } + }, + "/v1/admin/orders/{order_id}": { + "get": { + "summary": "Get order detail (admin)" + } + }, + "/v1/admin/orders/{order_id}/label": { + "get": { + "summary": "Download carrier label (admin)" + }, + "post": { + "summary": "Trigger DHL label job (admin)" + } + }, + "/v1/admin/orders/{order_id}/packing-slip": { + "get": { + "summary": "Packing slip (admin)" + } + }, + "/v1/admin/orders/{order_id}/ship": { + "post": { + "summary": "Mark order as shipped (admin)" + } + }, + "/v1/admin/orders/{order_id}/shipments": { + "post": { + "summary": "Create shipment (admin)" + } + }, + "/v1/admin/orders/{order_id}/status": { + "patch": { + "summary": "Override order status (admin)" + } + }, + "/v1/admin/returns/{return_id}": { + "patch": { + "summary": "Update return request (admin)" + } + }, + "/v1/customers/me": { + "get": { + "summary": "Get my profile" + }, + "patch": { + "summary": "Update my profile" + } + }, + "/v1/customers/me/addresses": { + "get": { + "summary": "List my addresses" + }, + "post": { + "summary": "Create an address" + } + }, + "/v1/customers/me/addresses/{address_id}": { + "delete": { + "summary": "Delete an address" + }, + "patch": { + "summary": "Update an address" + } + }, + "/v1/items/": { + "get": { + "summary": "List items" + }, + "post": { + "summary": "Create a new item" + } + }, + "/v1/items/by-sku/{sku}": { + "get": { + "summary": "Get item by SKU" + } + }, + "/v1/items/{item_uuid}": { + "delete": { + "summary": "Delete item" + }, + "get": { + "summary": "Get item by UUID" + }, + "patch": { + "summary": "Update item" + } + }, + "/v1/items/{item_uuid}/image": { + "get": { + "summary": "Get the product image" + }, + "put": { + "summary": "Upload the product image (admin)" + } + }, + "/v1/orders/": { + "post": { + "summary": "Create a draft order" + } + }, + "/v1/orders/{order_id}": { + "delete": { + "summary": "Cancel a draft order" + }, + "get": { + "summary": "Get order by ID" + } + }, + "/v1/orders/{order_id}/checkout": { + "post": { + "summary": "Start checkout" + } + }, + "/v1/orders/{order_id}/returns": { + "post": { + "summary": "Request a return" + } + }, + "/v1/webhooks/stripe": { + "post": { + "summary": "Stripe payment webhook" + } + } + } +} diff --git a/tools/check_wiki.py b/tools/check_wiki.py new file mode 100755 index 0000000..26bdf95 --- /dev/null +++ b/tools/check_wiki.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Check that the wiki still describes the system that is actually built. + +The wiki drifted nine months out of date once (OpenTaberna/wiki#1) — documenting +tables that were never created and endpoints that never existed. This is the check +that fails when that starts happening again. + +Three things are verified: + +1. Every endpoint the API serves is documented somewhere in the wiki. +2. Every endpoint path the wiki mentions actually exists in the API. +3. Every internal wiki link resolves to a page that exists. + +The API surface is read from openapi.snapshot.json rather than a live server, so +CI needs nothing running. Refresh the snapshot with: + + python3 tools/check_wiki.py --refresh http://localhost:8000 + +Refreshing is what makes the check bite: pull in a snapshot with a new endpoint and +the check fails until somebody documents it. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SNAPSHOT = ROOT / "openapi.snapshot.json" + +# Paths the wiki is allowed to mention without them being API endpoints. +IGNORED_PATHS = {"/health/live"} + +# A path the API serves, as written in markdown. Placeholders are normalised so +# /v1/orders/{id} and /v1/orders/{order_id} compare equal. +PATH_RE = re.compile(r"(? bool: + """ + True if the path is an endpoint, or a router prefix the wiki names in prose. + + The wiki legitimately refers to `/v1/admin/**` and `/v1/customers` when + describing a service as a whole, so a proper prefix of a real endpoint is + accepted. A path that prefixes nothing real — a typo, or an endpoint that was + removed — still fails. + """ + if path in api_paths: + return True + return any(real.startswith(path + "/") for real in api_paths) + + +def normalise(path: str) -> str: + path = PLACEHOLDER_RE.sub("{}", path) + return path.rstrip("/") or "/" + + +def wiki_pages() -> list[Path]: + return sorted( + p + for p in ROOT.rglob("*.md") + if ".git" not in p.parts and p.name != "README.md" + ) + + +def load_api_paths() -> set[str]: + if not SNAPSHOT.exists(): + sys.exit( + f"{SNAPSHOT.name} is missing. Generate it with:\n" + f" python3 tools/check_wiki.py --refresh http://localhost:8000" + ) + spec = json.loads(SNAPSHOT.read_text()) + return {normalise(p) for p in spec["paths"]} + + +def refresh(base_url: str) -> None: + url = base_url.rstrip("/") + "/openapi.json" + with urllib.request.urlopen(url, timeout=10) as response: + spec = json.load(response) + trimmed = { + "info": spec["info"], + "paths": { + path: { + method: {"summary": op.get("summary", "")} + for method, op in ops.items() + if method in ("get", "post", "put", "patch", "delete") + } + for path, ops in sorted(spec["paths"].items()) + }, + } + SNAPSHOT.write_text(json.dumps(trimmed, indent=2, sort_keys=True) + "\n") + print(f"Wrote {SNAPSHOT.name}: {len(trimmed['paths'])} paths from {url}") + + +def check() -> int: + api_paths = load_api_paths() + pages = wiki_pages() + if not pages: + sys.exit("No wiki pages found.") + + page_names = { + p.relative_to(ROOT).with_suffix("").as_posix().lower() for p in pages + } + documented: dict[str, set[str]] = {} + failures: list[str] = [] + + for page in pages: + text = page.read_text(encoding="utf-8") + rel = page.relative_to(ROOT).as_posix() + + for raw in PATH_RE.findall(text): + path = normalise(raw) + if path in IGNORED_PATHS: + continue + documented.setdefault(path, set()).add(rel) + if not is_known(path, api_paths): + failures.append( + f"{rel}: documents `{raw}`, which the API does not serve" + ) + + for target, _anchor in LINK_RE.findall(text): + name = target.strip("/").lower() + if not name or name in page_names: + continue + failures.append(f"{rel}: link to /{target.strip('/')} — no such page") + + for path in sorted(api_paths): + if path not in documented and path not in IGNORED_PATHS: + failures.append(f"API serves `{path}`, but no wiki page mentions it") + + print(f"Checked {len(pages)} pages against {len(api_paths)} API paths.") + + if failures: + unique = sorted(set(failures)) + print(f"\n{len(unique)} problem(s):\n") + for failure in unique: + print(f" FAIL {failure}") + print("\nThe wiki no longer matches the system. Fix the pages, or refresh") + print("the snapshot if the API legitimately changed.") + return 1 + + print("OK — every endpoint is documented, and every documented path exists.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--refresh", + metavar="BASE_URL", + help="Fetch a fresh snapshot from a running API instead of checking", + ) + args = parser.parse_args() + + if args.refresh: + refresh(args.refresh) + return 0 + return check() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/serve.py b/tools/serve.py new file mode 100755 index 0000000..c535880 --- /dev/null +++ b/tools/serve.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Serve the wiki locally so it can be read the way a reader will read it. + + python3 tools/serve.py # http://localhost:8090 + +Renders the markdown with Mermaid diagrams, a page index and working internal +links, so a change can be checked before it reaches wiki.opentaberna.de. + +Standard library only — no install step, nothing added to the repository's +dependencies. Marked and Mermaid are pulled from a CDN at view time, so the +first load needs a network connection. +""" + +from __future__ import annotations + +import http.server +import json +import socketserver +from pathlib import Path +from urllib.parse import unquote + +ROOT = Path(__file__).resolve().parent.parent +PORT = 8090 + +SHELL = """ + + + + +OpenTaberna Wiki (local) + + + + +
+ +
Loading…
+
+ + + +""" + + +def pages() -> list[str]: + found = sorted( + p.relative_to(ROOT).as_posix() + for p in ROOT.rglob("*.md") + if ".git" not in p.parts and p.name != "README.md" + ) + # home first, it is the landing page + found.sort(key=lambda p: (p != "home.md", p)) + return found + + +class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + path = unquote(self.path.split("?")[0]) + + if path in ("/", "/index.html"): + body = SHELL.replace("__PAGES__", json.dumps(pages())).encode() + self._send(body, "text/html; charset=utf-8") + return + + if path.startswith("/raw/"): + target = (ROOT / path[len("/raw/"):]).resolve() + if ROOT in target.parents and target.is_file(): + self._send(target.read_bytes(), "text/markdown; charset=utf-8") + else: + self.send_error(404) + return + + super().do_GET() + + def _send(self, body: bytes, content_type: str) -> None: + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: + pass + + +def main() -> None: + import os + + os.chdir(ROOT) + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(("127.0.0.1", PORT), Handler) as httpd: + print(f"Wiki preview: http://localhost:{PORT} (Ctrl-C to stop)") + print(f"Serving {len(pages())} pages from {ROOT}") + httpd.serve_forever() + + +if __name__ == "__main__": + main()