## Summary
API key foundation for the Truck Wash API: data model, key generation,
and scope system.
Three atomic commits, one PR:
- **TRU-143** — `api_keys` table + repository wrapper
- **TRU-144** — Key generation + argon2id hashing
- **TRU-145** — Scope registry with role bindings
## Linear
- TRU-143 — [Backend] API key data model + storage schema
- TRU-144 — [Backend] Key generation + argon2id hashing
- TRU-145 — [Backend] Scope system + role bindings
## Files
**Production code:**
- `services/nginx/app/classes/api_key_schema_bootstrap.php` — idempotent
`CREATE TABLE IF NOT EXISTS` for `api_keys`
- `services/nginx/app/classes/api_key_repository.php` — `create`,
`findActiveByKeyId`, `findById`, `revoke`, `delete`, `listForCustomer`,
`touchLastUsed`
- `services/nginx/app/classes/api_key_generator.php` — `generateKeyId`,
`generateSecret`, `formatKey`, `hash` (argon2id), `verify`, `parseKey`
- `services/nginx/app/classes/auth/scope_registry.php` — canonical scope
constants + role defaults + `hasScope` / `expand` / `matches` /
`isValid`
-
`services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php`
— human-readable migration record
**Tests (all new, all passing):**
- `tests/Unit/Auth/ApiKeyGeneratorTest.php` — 15 tests
- `tests/Unit/Auth/ApiKeyRepositoryTest.php` — 11 tests
- `tests/Unit/Auth/ScopeRegistryTest.php` — 24 tests
**Totals:** 50 new tests, 140 assertions. Full unit suite: 1329 passed
(up from 1279 baseline), 0 new failures (10 pre-existing unrelated
failures remain).
## Schema
```sql
CREATE TABLE api_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
key_id VARCHAR(64) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL,
scopes JSON NULL,
customer_id BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NULL,
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_api_keys_key_id (key_id),
INDEX idx_api_keys_customer (customer_id),
INDEX idx_api_keys_key_hash (key_hash),
INDEX idx_api_keys_revoked (revoked_at),
INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
```
## Key format
```
<prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
```
- `key_id` is stored in plain text (the lookup key).
- The secret is **never** stored in plain text — only the argon2id hash.
- The full key is shown to the user exactly once at creation.
- Prefix is configurable via `config('api_key.prefix', 'truck')`.
## Role → scope defaults
| Role | Scopes |
|---|---|
| superuser | `*` |
| admin | `customer:*`, `booking:*`, `subuser:*`, `invoice:*` |
| customer | `customer:read`, `booking:read`, `invoice:read` |
| subuser | `booking:read`, `booking:write` |
Wildcard support: `*` matches everything; `resource:*` matches every
action on a resource.
## Design notes
- **No migration framework** — this codebase uses
`*_schema_bootstrap.php` files for idempotent table creation. The
`database/migrations/` file is kept as a human-readable change record.
- **No Eloquent** — the repository is a thin wrapper over the existing
mysqli `$db` global, matching the pattern in `classes/orders_o.php`,
`classes/invoice_store.php`, etc.
- **Coexistence with TRU-149** — `classes/auth/scope_registry.php`
(TRU-145) is the canonical implementation; the local `app\auth\Scope`
stub from `feat/TRU-149-route-scopes` is documented in the class header
as the thing this will replace once that branch merges. The two can
coexist in the meantime.
- **"Self" / "assigned" qualifiers** (e.g. "customer can only read their
own bookings") are intentionally **out of scope** here — they live in
the resolver layer that maps an authenticated principal to a
customer/subuser record. Scopes only encode "can the caller read
bookings at all".
- **No secrets in code** — the generator uses `random_bytes()` with
rejection sampling (no modulo bias). No test fixtures contain real keys.
## Checklist
- [x] All new tests pass (50/50)
- [x] No new test failures in the full unit suite
- [x] No PHP syntax errors
- [x] No committed secrets
- [x] No modifications to existing test files
- [x] No modifications to `openclaw.json` or any config files
---------
Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
Copenhagen Truck Wash API
Backend API for Copenhagen Truck Wash services.
Changes are published from a scoped feature branch through a pull request to
master; direct default-branch pushes are not part of the release workflow.
See default branch protection for the CI gate
and emergency procedure.
Architecture & Stack
- Edge Proxy: Traefik 2.11 (Handles TLS termination, routing, and rate limiting).
- Web Server: Caddy 2.7 (Serves the PHP application via FastCGI).
- PHP Runtime: PHP 8.2-FPM (Scale out with workers
php1tophp5). - Cache/Queue: Redis 7.
- Observability: Jaeger (Tracing), Prometheus (Metrics).
Getting Started
Prerequisites
- Docker Desktop 4.x+
- PHP 8.2 CLI (optional, for host-side testing)
Local Development
To bring up the minimal development stack (Traefik, Redis, MySQL debug DB, Caddy, and one PHP worker):
docker compose up -d traefik redis mysql-debug php1 caddy
The API is accessible at:
http://localhosthttps://localhost(using Traefik default cert)http(s)://localhost/api/(proxied with/apiprefix stripped)
Test Gateway Container
To run a real PHP edge agent as a disposable Dockerized test gateway against the local stack, first create an install token from the edge gateway admin UI, then start the helper:
.\scripts\test-gateway.ps1 start --install-token <token>
./scripts/test-gateway.sh start --install-token <token>
The helper will:
- start the local compose dependencies if needed
- claim a gateway through
http://localhost/api/edge-agent/claim - write the generated config to
.tmp/test-gateway/test-gateway.json - build
services/edge-agent/Dockerfile.test-gateway - run the PHP agent container on the local compose network
Useful follow-up commands:
.\scripts\test-gateway.ps1 logs
.\scripts\test-gateway.ps1 status
.\scripts\test-gateway.ps1 stop
To start all services including multiple PHP workers and development tools (Jaeger, Portainer):
docker compose --profile dev up -d
Configuration
Configuration is primarily managed via environment variables.
- The application root is located at
services/nginx/app. services/nginx/app/config.phploads configuration from the environment (requiresUSE_ENV=true).php1performs an automaticcomposer installon startup ifAUTO_COMPOSER_INSTALL=true.
Environment Change Runbook
When updating .env values used by PHP containers (for example e-conomic tokens), recreate affected services so Docker applies the new env:
docker compose up -d --force-recreate php1 php2 php3 php4 php5 php-cron
Edge Broker Public URL
Set EDGE_PUBLIC_BROKER_URL to the public route that serves the edge broker, including the path prefix handled by the proxy. Local Traefik uses http://localhost/api/edge-broker; production routes use the public broker prefix, for example https://api.truckwash.dk/edge-broker.
The browser terminal connects to the exact advertised EDGE_PUBLIC_BROKER_URL plus /ws/browser-shell. That URL must be routable through the proxy to the edge-broker service. Do not rely on derived /api/edge-broker fallback paths outside the local Traefik setup.
Testing
The project now uses Pest as the primary test runner in services/nginx/app.
Running Tests
All commands are run from services/nginx/app:
composer test
composer test:unit
composer test:integration
composer test:api
composer test:coverage
For local Docker development, run the PHP suites inside php1:
docker exec php1 sh -lc "cd /var/www/html && composer test:unit"
docker exec php1 sh -lc "cd /var/www/html && composer test:integration"
docker exec php1 sh -lc "cd /var/www/html && composer test:api"
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:api:edge"
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:integration:edge"
Integration tests are opt-in and should be run with required services available:
$env:RUN_INTEGRATION_TESTS='1'
composer test:integration
Edge Gateway Regression Coverage
The dedicated backend regression lane for the PHP edge gateway stack is split into:
- API contract tests for operator, agent, and broker-facing routes
- DB-backed integration tests for install sessions, heartbeats, tasks, logs, statistics, and shell persistence
- a local dockerized smoke that runs the real PHP edge agent against the local backend and broker
Run the targeted PHP suites inside php1:
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:api:edge"
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:integration:edge"
Run the full local smoke from backend-php on the host:
node .\scripts\edge-gateway-e2e.mjs
The E2E smoke expects the local compose stack and Docker daemon to be available. It boots a disposable gateway container, waits for a real heartbeat, validates live operations and telemetry, and verifies browser shell transcript persistence.
Public Staging Edge-Gateway Smoke
api.truckwash.io:4433 is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from services/nginx/app/resources/edge-gateway-agent, not from the legacy dist/agent.mjs output or a separate runtime mount.
To verify the public staging stack after a deploy, use a real installer token and run:
node .\scripts\staging-edge-gateway-smoke.mjs --install-token <token>
node ./scripts/staging-edge-gateway-smoke.mjs --install-token <token>
The smoke check fails unless all of these return 200 from the public domain:
/ping/edge-agent/artifacts/agent.php/edge-agent/artifacts/truckwash-edge-agent.service/edge-agent/install.sh?token=<real token>
API Test Suite
The Api suite exercises real HTTP endpoints instead of calling route handlers in-process.
composer test:apienablesRUN_API_TESTS=1automatically.- By default the suite starts a temporary PHP server with
php -S 127.0.0.1:18080 index.phpand hits the app over HTTP. - The suite covers the real router, request parsing, auth headers, status codes, and JSON response envelopes.
- Tests run serially and use explicit database/Redis fixtures plus reverse-order cleanup instead of transaction rollbacks.
- The default phase-1 coverage includes
/ping, auth session routes, departments, department categories, and orders CRUD including the legacyPUT /orderalias. - API server logs are written to
services/nginx/app/build/logs/api-server.out.logandservices/nginx/app/build/logs/api-server.err.log.
To point the suite at an already-running base URL instead of the self-started PHP server:
$env:API_TEST_BASE_URL='http://127.0.0.1:18080'
composer test:api
The suite requires an initialized application schema. Redis-backed flows are used when Redis is configured, but the suite can still boot without caddy or the full reverse-proxy stack.
Run Tests Against A Cloned Live DB (Docker-Isolated)
Use the helper scripts in scripts/ to:
- Clone the configured live DB into a local MySQL Docker container.
- Start an isolated Redis container.
- Run tests in a separate temporary PHP Docker container pointed at that clone.
PowerShell:
powershell -ExecutionPolicy Bypass -File .\scripts\clone-live-db-and-test.ps1 -Force
Bash:
FORCE=1 ./scripts/clone-live-db-and-test.sh
Optional overrides:
- Test command:
-TestCommand "composer test:integration"orTEST_COMMAND="composer test:integration" - Keep containers after run:
-KeepContainersorKEEP_CONTAINERS=1 - Skip image build:
-SkipBuildorSKIP_BUILD=1 - Force image rebuild:
-ForceBuildorFORCE_BUILD=1
Clone Live DB Into mysql-debug Service
To refresh the docker-compose debug DB from live credentials in .env:
PowerShell:
powershell -ExecutionPolicy Bypass -File .\scripts\clone-live-to-debug-db.ps1 -Force
Bash:
FORCE=1 ./scripts/clone-live-to-debug-db.sh
Test Layout
tests/Unit/*: isolated unit and route-level behavior tests.tests/Integration/*: Redis/DB-backed tests intended for Docker/CI environments.tests/Api/*: real HTTP endpoint tests that boot a temporary PHP server and assert full request/response behavior.tests/Api/api_coverage_manifest.php: selected phase-1 endpoint manifest used by the API meta-test to enforce happy-path and failure coverage.tests/<legacy-domain>/*: legacy procedural scripts retained during migration; keep them runnable until matching Pest coverage exists.
Logs & Monitoring
- Logs:
docker compose logs -f [service_name](e.g.,php1,caddy,traefik). - Traefik Dashboard:
http://traefik.localhost(available in dev). - Jaeger (Tracing):
http://localhost:16686(when running with--profile dev). - Portainer:
http://localhost:9000(when running with--profile dev).
API Documentation
- OpenAPI: The authoritative OpenAPI 3.0 contract is at
openapi.yaml. - Self-Serve Module Guide: Implementation and API guide at
services/nginx/app/modules/selfserve/selfserve.md. - Writerside: Documentation projects are located in
/Writersideand/Writerside2. - Generated Writerside API Reference: The active Writerside project lives in
/documentationand is generated fromopenapi.yaml.
Writerside OpenAPI Generation Workflow
Prerequisite:
- Python 3 with PyYAML (
pip install pyyaml)
Run from repository root:
python scripts/generate_writerside_openapi_docs.py generate
python scripts/generate_writerside_openapi_docs.py check
Contribution rule:
- Update
openapi.yaml. - Regenerate docs (
python scripts/generate_writerside_openapi_docs.py generate). - Verify (
python scripts/generate_writerside_openapi_docs.py check).