diff --git a/README.md b/README.md index c5c103e..c143182 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,19 @@ # PHP Project Manager -A small project-management application: a REST API written in PHP (Slim 4) -backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/). -Each user owns **projects**, and each project holds ordered **cards**. +A small project-management app: each user owns **projects**, and each +project holds **cards** you can organise as a flat list or a drag-and-drop +kanban board. It's a REST API in PHP (Slim 4) backed by SQLite, with a Vue 3 ++ TypeScript PWA frontend. There's no password — signing in is a link +emailed to you, which creates your account the first time, and you can add +a passkey afterwards for a quicker sign-in next time. -## Status +## Documentation -| Stage | Scope | State | -|-------|-------|-------| -| 1 | Auth API — register, login, `GET /me` | ✅ done | -| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done | -| 3 | Project + card CRUD API | ✅ done | -| 4 | Frontend projects view — project index + create form | ✅ done | -| 5 | Frontend project detail — cards UI with drag-and-drop reorder | ✅ done | -| 6 | Project view — inline title/description editing, delete via a Manage menu | ✅ done | -| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done | -| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done | -| 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done | -| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done | -| 11 | Persistent left sidebar (Dashboard link + project dropdown); dashboard = grid of project tiles + a "Create a project" tile | ✅ done | -| 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done | -| 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done | -| 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done | -| 15 | Passkeys (WebAuthn) — register from the profile page, sign in with one instead of a magic link; a dismissible notice nudges users with none | ✅ done | -| 16 | Project configuration view — manage a project's statuses: add, drag to reorder, delete (reassigning any cards on it first) | ✅ done | -| 17 | Card detail view (`/cards/:id`) + its own configuration view — a card's text is no longer inline-editable; every list links to its own page instead | ✅ done | -| 18 | Project view split into real routes — Explore (`/projects/:id`) and Kanban (`/projects/:id/kanban`) are separate pages under a shared layout, not client-side tab state | ✅ done | +This page covers running the app. For anything more technical — the REST +API, configuration, running without Docker, project layout, and the +development history — see [docs/](docs/). -There is no password. Signing in is entering an email address and opening the -magic link sent to it — the same step creates the account the first time. See -[Auth](#auth). - -## Run with Docker +## Getting started The only requirement is Docker with the Compose plugin. @@ -39,457 +21,29 @@ The only requirement is Docker with the Compose plugin. docker compose up -d ``` -This runs a multi-stage build — a Node stage compiles the Vue frontend, then a -PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies -migrations, and serves the whole app at : the SPA at `/` -(assets and all) and the REST API under `/api` (e.g. -`curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA -shell for client-side routing. - -- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog - successor — one ~15 MB Go binary, messages kept in memory) also starts. The API - sends all email to it; read it at . Set - `MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real. -- The image is the artifact: PHP source and the compiled frontend are copied in - at build time, not bind-mounted. Rebuild to pick up any code change: - `docker compose up -d --build`. For iterating on the frontend, run the Vite - dev server on the host instead (see [Frontend](#frontend)). -- The SQLite database and the generated JWT signing key live in the `storage` - named volume, mounted at `/var/www/storage`, so they survive - `docker compose restart` / `down` + `up`. -- `docker compose down -v` removes the volume and gives you a clean database. -- Override settings via the environment or a `.env` file in this directory - (Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see - [docker-compose.yml](docker-compose.yml)). - -## Run without Docker - -Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus -[Composer](https://getcomposer.org/). On Fedora: - -```bash -sudo dnf install php-cli php-pdo php-mbstring composer -``` - -### Setup - -```bash -composer install -cp .env.example .env # optional; sane defaults are used without it -composer migrate # creates storage/database.sqlite and its tables -``` - -### Running - -```bash -composer serve # http://localhost:8080 (php -S localhost:8080 -t public) -``` - -Any web server can serve the API as long as the document root is `public/` and -unknown paths fall through to `public/index.php`. `public/.htaccess` also serves -a built frontend from `public/` (copy `web/dist/` there) and only falls back to -`index.php` for `/api` and when no `index.html` is present. - -## Frontend - -The Vue/TypeScript PWA lives in [web/](web/). The production build is compiled -into the Docker image and served from the `app` container at `/`. For frontend -development, run the Vite dev server on the host — with the API container running -(`docker compose up -d`): - -```bash -cd web -npm install -npm run dev # http://localhost:5173, proxies /api to localhost:8080 -``` - -Unauthenticated visitors are redirected to `/login` — enter an email address -and open the link that arrives; there is no separate sign-up. See -[web/README.md](web/README.md). - -## Configuration - -All settings are optional environment variables (read from `.env` or the real -environment). See [.env.example](.env.example). - -| Variable | Default | Purpose | -|----------|---------|---------| -| `APP_DEBUG` | `false` | Include exception details in error responses | -| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location | -| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key | -| `JWT_TTL` | `86400` | Token lifetime in seconds | -| `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created | -| `MAGIC_LINK_RESEND_SECONDS` | `60` | Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to `0`, so links resend immediately in development | -| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) | -| `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work | -| `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt | -| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) | -| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender | -| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | -| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` | - -Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`. -Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled -Mailpit container (`mailpit:1025`, no auth/TLS); open to -read what was "sent". - -## API - -Base path: `/api`. All request and response bodies are JSON; send -`Content-Type: application/json`. - -### `GET /api/health` - -```json -{ "status": "ok" } -``` - -### Auth - -There is no password and no separate registration endpoint. Entering an email -address and opening the link sent to it is the entire flow, for a brand-new -address and a returning one alike. A user can also register one or more -[passkeys](#passkeys) and use one instead, once signed in at least once. - -| Method | Path | Auth | Purpose | -|--------|------|------|---------| -| `POST` | `/api/auth/magic-link` | — | email a one-time sign-in link, creating the account first if the address is new | -| `POST` | `/api/auth/verify-email` | — | consume the token: sign in, and (the first time) mark the address verified, or apply a pending email change | -| `POST` | `/api/auth/passkey/options` | — | a challenge for signing in with a passkey (see [Passkeys](#passkeys)) | -| `POST` | `/api/auth/passkey/verify` | — | verify a passkey response and sign in | -| `GET` | `/api/me` | ✔ | the current user | -| `POST` | `/api/email/change` | ✔ | request a **deferred** email change | - -#### `POST /api/auth/magic-link` - -Request: `{ "email": "ada@example.com" }`. - -Emails a one-time sign-in link (`/verify-email?token=…`, 15-minute -expiry) and always returns `202` with the same message. If the address has no -account yet, one is created (unverified) right here — that's the only "sign -up" there is — unless `APP_ALLOW_REGISTRATION=false`, in which case an unknown -address is silently ignored (still `202`, nothing sent) and only an address -that already has an account can sign in. A link is only actually (re-)sent if -this address hasn't been emailed one in the last 60 seconds. `422` if the -address is malformed. - -```json -{ "message": "Check your email for a link to sign in." } -``` - -#### `POST /api/auth/verify-email` - -Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is -`400` (distinct messages). Success signs the caller in: - -```json -{ - "user": { - "id": 1, - "email": "ada@example.com", - "email_verified": true, - "email_verified_at": "2026-09-03T12:00:00Z", - "pending_email": null, - "has_passkey": false, - "created_at": "2026-09-03T12:00:00Z" - }, - "token": "", - "expires_at": "2026-09-04T12:00:00+00:00" -} -``` - -Opening a link is the only way to obtain a session, so an authenticated request -is always for a verified address — `email_verified` is `true` from the first -token a user's browser ever holds. - -#### `GET /api/me` - -Requires `Authorization: Bearer `. `200 OK`: the same `user` object shown -above. `pending_email` is the address a still-valid email-change link is -waiting on, or `null`. `401` if the header is missing, malformed, or the token -is invalid/expired. - -#### `POST /api/email/change` - -Requires `Authorization: Bearer `. Body: `{ "email": "new@example.com" }`. -The address must be free (`409`) and different from the current one (`422`). -Throttled to **once per 60 seconds** (shared with `/api/auth/magic-link`'s -resend window, per user) — `429` with `error.details.retry_after` when too -soon. On success, `202` with `retry_after` and `pending_email`: - -```json -{ - "message": "Confirmation email sent to the new address.", - "pending_email": "new@example.com", - "retry_after": 60 -} -``` - -The change is **not applied until** the magic link sent to the new address is -opened — until then `GET /api/me` still shows the old address, with -`pending_email` set. Opening that link both changes the address and re-verifies -it, via the same `/api/auth/verify-email`. - -### Passkeys - -WebAuthn, via [lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). A passkey -is always registered as a **discoverable, user-verified** credential, which is -what makes login usernameless: the browser prompts the signed-in device for -whichever passkey it has for this site, with no email typed first. There's no -attestation/provenance check (`'none'` format) — this only confirms "the same -device that registered", the standard trust model for a public site's own -users, not a fleet of company-issued security keys. - -| Method | Path | Auth | Purpose | -|--------|------|------|---------| -| `GET` | `/api/passkeys` | ✔ | list the caller's passkeys | -| `POST` | `/api/passkeys/options` | ✔ | a registration challenge | -| `POST` | `/api/passkeys` | ✔ | verify the browser's response and store the credential | -| `DELETE` | `/api/passkeys/{id}` | ✔ | remove a passkey (`204`) | -| `POST` | `/api/auth/passkey/options` | — | a login challenge (no email — discoverable) | -| `POST` | `/api/auth/passkey/verify` | — | verify and sign in | - -Both `.../options` endpoints return `{ "challenge_id": 1, "options": { "publicKey": {…} } }` -— `options.publicKey` is passed more or less directly to -[`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create) -/ [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get) -(binary fields travel as base64url strings; the frontend converts them — -see [web/README.md](web/README.md)). `challenge_id` identifies a **single-use** -challenge, good for 5 minutes, and must be sent back with the browser's -response: - -- `POST /api/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`. - `credential` is `{ id, response: { clientDataJSON, attestationObject } }` - (all base64url). `201` with the stored passkey - (`{ id, label, created_at, last_used_at }` — never the credential id or - public key) on success; `400` if the response doesn't check out, `409` if - that credential is already registered. -- `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`, - where `credential` also carries `authenticatorData`, `signature`, and - `userHandle`. Success returns the same `{ user, token, expires_at }` envelope - as `/api/auth/verify-email`. `401` if the credential isn't recognised or the - signature doesn't check out. - -`user.has_passkey` (on every user object) is `true` once at least one is -registered — that's what the frontend's "add a passkey" notice keys off. - -### Projects - -All routes below require `Authorization: Bearer `. A project belongs to one -owner (the creator); another user's project — or a missing one — always responds -`404`. - -| Method | Path | Purpose | -|--------|------|---------| -| `GET` | `/api/projects` | the caller's projects, sorted A→Z by title | -| `POST` | `/api/projects` | create a project | -| `GET` | `/api/projects/{id}` | one project | -| `PATCH` | `/api/projects/{id}` | rename the project (`title`) | -| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) | - -`GET /api/projects` is always ordered alphabetically (case-insensitive) by -title; there is no other sort option. A user may own at most **100 projects** — -creating one beyond that responds `409`. - -Create/update body: `title` (required, 1–255 chars). - -Project representation: - -```json -{ - "project": { - "id": 1, - "title": "Website relaunch", - "owner_id": 1, - "card_count": 3, - "completed_count": 1, - "created_at": "2026-09-03T12:00:00Z", - "updated_at": "2026-09-03T12:00:00Z" - } -} -``` - -`GET /api/projects` returns `{ "projects": [ … ] }`. - -Creating a project also seeds it with three **statuses** — "To do", "Doing", -"Done" (see [Statuses](#statuses)). - -### Cards - -A card either sits in its owner's **inbox** (`project_id` and `status_id` both -`null`) or belongs to exactly one of their projects with a status in it (both -set) — enforced by a database CHECK constraint, never one without the other. -The inbox is global to the user, not per-project. Because a card may have no -project, single-card and ordering routes are addressed globally, by the card's -own id, rather than nested under a project: - -| Method | Path | Purpose | -|--------|------|---------| -| `GET` | `/api/projects/{id}/cards` | a project's cards, grouped by status then `position` | -| `POST` | `/api/projects/{id}/cards` | add a card directly to the project | -| `GET` | `/api/inbox/cards` | the caller's inbox | -| `POST` | `/api/inbox/cards` | add a card to the inbox | -| `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) | -| `PUT` | `/api/cards/order` | set the order/contents of one column | - -Create body (either creation route): `text` (required, 1–1000 chars), -`complete` (optional bool, default `false`). The project route also takes an -optional `status_id`, appending the card to the end of that status (must -belong to the project, else `422`) — omitted, it goes in the project's first -status instead. `PATCH` accepts `text` and/or `complete` only — moving a card -is done via the order route below, not PATCH. - -**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards -that share an `(owner, project, status)`. The inbox is its own column, per -owner. `PUT /api/cards/order` sets one column's contents and order: - -```json -{ "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] } -``` - -`project_id`/`status_id` are both `null` for the inbox, or both set to a -project owned by the caller and one of its statuses (`404`/`422` otherwise). -`card_ids` must be distinct cards owned by the caller and must include every -card already in the target column (`422` otherwise); it rewrites positions to -`0..n-1`. Any card in the list that wasn't already in that column is -re-parented into it — moving it from another project's status, or the inbox, -or vice versa — and the column it left is re-packed, all in one transaction. -Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card -in the kanban board (or the sidebar's inbox) calls on every drop; moving a -card from one project to another is just two calls, via the inbox in between. - -Card representation: - -```json -{ - "card": { - "id": 10, - "project_id": 1, - "text": "Design homepage", - "complete": false, - "position": 0, - "status_id": 2, - "status": { "id": 2, "name": "Doing" }, - "created_at": "2026-09-03T12:00:00Z", - "updated_at": "2026-09-03T12:00:00Z" - } -} -``` - -`project_id` and `status_id` are `null` together for an inbox card. `status` is -the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards` -returns `{ "cards": [ … ] }`. - -### Statuses - -Every project has an ordered set of card statuses, created with the project: -"To do", "Doing", "Done". They are project-specific — each project owns its own -rows, managed from the project's **configuration** view (create, reorder, -delete). A project always keeps at least one status, since a project card must -have one; deleting the last one is rejected (`409`). - -| Method | Path | Purpose | -|--------|------|---------| -| `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` | -| `POST` | `/api/projects/{id}/statuses` | add one at the end — `{ "name": "Blocked" }` | -| `PUT` | `/api/projects/{id}/statuses/order` | reorder — `{ "status_ids": [3, 1, 2] }`, every status once | -| `DELETE` | `/api/projects/{id}/statuses/{statusId}` | delete (see below) | - -```json -{ - "statuses": [ - { "id": 1, "project_id": 1, "name": "To do", "position": 0 }, - { "id": 2, "project_id": 1, "name": "Doing", "position": 1 }, - { "id": 3, "project_id": 1, "name": "Done", "position": 2 } - ] -} -``` - -Requires `Authorization: Bearer `; a project that is missing or not owned by -the caller responds `404`. - -**Deleting a status that still has cards** fails with `409` and -`error.details.card_count` set, rather than silently orphaning them (a -referenced status can't be deleted at the database level either — the FK is -`ON DELETE RESTRICT`). Retry with `{ "reassign_to": }` in -the same project; those cards are moved there and the status deleted, in one -transaction. - -### Error shape - -Every error response looks like: - -```json -{ "error": { "message": "The submitted data was invalid.", "details": { "email": ["Email must be a valid address."] } } } -``` - -`details` is present only when relevant (e.g. validation). - -## Try it - -Signing in needs the link the API emails, so this pulls it back out of the -bundled Mailpit catcher (adjust if you've pointed `MAIL_TRANSPORT` elsewhere). -The API pretty-prints its JSON, so responses are piped through `tr -d ' \n'` -before grep (Mailpit's own JSON doesn't need that): - -```bash -BASE=http://localhost:8080 - -curl -s -X POST $BASE/api/auth/magic-link \ - -H 'Content-Type: application/json' \ - -d '{"email":"ada@example.com"}' - -MSG_ID=$(curl -s "http://localhost:8025/api/v1/messages?limit=1" | grep -o '"ID":"[^"]*"' | head -1 | cut -d'"' -f4) -LINK_TOKEN=$(curl -s "http://localhost:8025/api/v1/message/$MSG_ID" | grep -o 'token=[a-f0-9]*' | head -1 | cut -d= -f2) - -TOKEN=$(curl -s -X POST $BASE/api/auth/verify-email \ - -H 'Content-Type: application/json' \ - -d "{\"token\":\"$LINK_TOKEN\"}" | tr -d ' \n' | grep -o '"token":"[^"]*"' | cut -d'"' -f4) - -curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN" - -PROJECT=$(curl -s -X POST $BASE/api/projects \ - -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"title":"Website relaunch"}' \ - | tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) - -curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN" - -curl -s -X POST $BASE/api/projects/$PROJECT/cards \ - -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d '{"text":"Design homepage"}' - -curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN" -``` - -## Tests - -```bash -composer install # installs phpunit (require-dev) -vendor/bin/phpunit -``` - -## Layout - -``` -public/index.php Front controller -src/bootstrap.php App wiring and route definitions -src/Support/Config.php Environment-driven configuration -src/Support/Database.php PDO/SQLite connection -src/Auth/JwtService.php Issue/verify JWTs -src/Auth/AuthMiddleware.php Bearer-token authentication -src/Auth/SessionPayload.php Shared user + session JSON shape -src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier -src/Http/JsonErrorHandler.php Uniform JSON error envelope -src/Http/Controllers/ Request handlers (Auth, EmailVerification, Passkey, Project, Card, CardStatus) -src/Repository/ Database access (User, EmailVerification, Passkey, WebAuthnChallenge, Project, Card, CardStatus) -src/Support/Validator.php Request-body validation helper -migrations/*.sql Schema, applied by bin/migrate.php -Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime -docker-compose.yml Local stack: app (SPA + API) + Mailpit -docker/ Apache vhost + container entrypoint -web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host) -``` +Then open . A **[Mailpit](https://mailpit.axllent.org/)** +mail-catcher also starts alongside the app, at — +since a default local setup has nowhere else to send the sign-in emails. + +### First-time login + +1. Enter any email address and submit. There's no separate sign-up step and + no password to choose — this both creates your account and sends it a + sign-in link. +2. Open (Mailpit) instead of a real inbox, and open + the message that just arrived there. +3. Click the link inside it. You're now signed in, on a new, + already-verified account. + +From then on, your profile page (top right, your email address) lets you add +a **passkey** — your device's fingerprint, face, or PIN — so you don't need +to wait on an email to sign in next time. + +Everything the app stores (your account, projects, cards) lives in a Docker +volume, so it survives `docker compose restart` / `down` + `up`; +`docker compose down -v` wipes it for a clean slate. See +[docs/setup.md](docs/setup.md) for configuration, running without Docker, +and running the test suite. ## Provenance diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..63439e0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# Documentation + +Technical documentation for the PHP Project Manager. Start with the root +[README](../README.md) if you just want to run the app. + +- **[API reference](api.md)** — every REST endpoint: auth (magic links, + passkeys), projects, cards, statuses, error shapes, and a curl walkthrough. +- **[Architecture](architecture.md)** — backend layout and how the pieces fit + together. Frontend architecture lives in + [web/README.md](../web/README.md) instead, since it's a large enough topic + on its own. +- **[Setup & configuration](setup.md)** — running without Docker, every + environment variable, and the test suite. +- **[Development history](history.md)** — a stage-by-stage log of how the app + got here. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..fed9aa3 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,328 @@ +# API reference + +Base path: `/api`. All request and response bodies are JSON; send +`Content-Type: application/json`. + +### `GET /api/health` + +```json +{ "status": "ok" } +``` + +## Auth + +There is no password and no separate registration endpoint. Entering an email +address and opening the link sent to it is the entire flow, for a brand-new +address and a returning one alike. A user can also register one or more +[passkeys](#passkeys) and use one instead, once signed in at least once. + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| `POST` | `/api/auth/magic-link` | — | email a one-time sign-in link, creating the account first if the address is new | +| `POST` | `/api/auth/verify-email` | — | consume the token: sign in, and (the first time) mark the address verified, or apply a pending email change | +| `POST` | `/api/auth/passkey/options` | — | a challenge for signing in with a passkey (see [Passkeys](#passkeys)) | +| `POST` | `/api/auth/passkey/verify` | — | verify a passkey response and sign in | +| `GET` | `/api/me` | ✔ | the current user | +| `POST` | `/api/email/change` | ✔ | request a **deferred** email change | + +### `POST /api/auth/magic-link` + +Request: `{ "email": "ada@example.com" }`. + +Emails a one-time sign-in link (`/verify-email?token=…`, 15-minute +expiry) and always returns `202` with the same message. If the address has no +account yet, one is created (unverified) right here — that's the only "sign +up" there is — unless `APP_ALLOW_REGISTRATION=false`, in which case an unknown +address is silently ignored (still `202`, nothing sent) and only an address +that already has an account can sign in. A link is only actually (re-)sent if +this address hasn't been emailed one in the last 60 seconds. `422` if the +address is malformed. + +```json +{ "message": "Check your email for a link to sign in." } +``` + +### `POST /api/auth/verify-email` + +Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is +`400` (distinct messages). Success signs the caller in: + +```json +{ + "user": { + "id": 1, + "email": "ada@example.com", + "email_verified": true, + "email_verified_at": "2026-09-03T12:00:00Z", + "pending_email": null, + "has_passkey": false, + "created_at": "2026-09-03T12:00:00Z" + }, + "token": "", + "expires_at": "2026-09-04T12:00:00+00:00" +} +``` + +Opening a link is the only way to obtain a session, so an authenticated request +is always for a verified address — `email_verified` is `true` from the first +token a user's browser ever holds. + +### `GET /api/me` + +Requires `Authorization: Bearer `. `200 OK`: the same `user` object shown +above. `pending_email` is the address a still-valid email-change link is +waiting on, or `null`. `401` if the header is missing, malformed, or the token +is invalid/expired. + +### `POST /api/email/change` + +Requires `Authorization: Bearer `. Body: `{ "email": "new@example.com" }`. +The address must be free (`409`) and different from the current one (`422`). +Throttled to **once per 60 seconds** (shared with `/api/auth/magic-link`'s +resend window, per user) — `429` with `error.details.retry_after` when too +soon. On success, `202` with `retry_after` and `pending_email`: + +```json +{ + "message": "Confirmation email sent to the new address.", + "pending_email": "new@example.com", + "retry_after": 60 +} +``` + +The change is **not applied until** the magic link sent to the new address is +opened — until then `GET /api/me` still shows the old address, with +`pending_email` set. Opening that link both changes the address and re-verifies +it, via the same `/api/auth/verify-email`. + +## Passkeys + +WebAuthn, via [lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). A passkey +is always registered as a **discoverable, user-verified** credential, which is +what makes login usernameless: the browser prompts the signed-in device for +whichever passkey it has for this site, with no email typed first. There's no +attestation/provenance check (`'none'` format) — this only confirms "the same +device that registered", the standard trust model for a public site's own +users, not a fleet of company-issued security keys. + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| `GET` | `/api/passkeys` | ✔ | list the caller's passkeys | +| `POST` | `/api/passkeys/options` | ✔ | a registration challenge | +| `POST` | `/api/passkeys` | ✔ | verify the browser's response and store the credential | +| `DELETE` | `/api/passkeys/{id}` | ✔ | remove a passkey (`204`) | +| `POST` | `/api/auth/passkey/options` | — | a login challenge (no email — discoverable) | +| `POST` | `/api/auth/passkey/verify` | — | verify and sign in | + +Both `.../options` endpoints return `{ "challenge_id": 1, "options": { "publicKey": {…} } }` +— `options.publicKey` is passed more or less directly to +[`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create) +/ [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get) +(binary fields travel as base64url strings; the frontend converts them — +see [web/README.md](../web/README.md)). `challenge_id` identifies a **single-use** +challenge, good for 5 minutes, and must be sent back with the browser's +response: + +- `POST /api/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`. + `credential` is `{ id, response: { clientDataJSON, attestationObject } }` + (all base64url). `201` with the stored passkey + (`{ id, label, created_at, last_used_at }` — never the credential id or + public key) on success; `400` if the response doesn't check out, `409` if + that credential is already registered. +- `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`, + where `credential` also carries `authenticatorData`, `signature`, and + `userHandle`. Success returns the same `{ user, token, expires_at }` envelope + as `/api/auth/verify-email`. `401` if the credential isn't recognised or the + signature doesn't check out. + +`user.has_passkey` (on every user object) is `true` once at least one is +registered — that's what the frontend's "add a passkey" notice keys off. + +## Projects + +All routes below require `Authorization: Bearer `. A project belongs to one +owner (the creator); another user's project — or a missing one — always responds +`404`. + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/projects` | the caller's projects, sorted A→Z by title | +| `POST` | `/api/projects` | create a project | +| `GET` | `/api/projects/{id}` | one project | +| `PATCH` | `/api/projects/{id}` | rename the project (`title`) | +| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) | + +`GET /api/projects` is always ordered alphabetically (case-insensitive) by +title; there is no other sort option. A user may own at most **100 projects** — +creating one beyond that responds `409`. + +Create/update body: `title` (required, 1–255 chars). + +Project representation: + +```json +{ + "project": { + "id": 1, + "title": "Website relaunch", + "owner_id": 1, + "card_count": 3, + "completed_count": 1, + "created_at": "2026-09-03T12:00:00Z", + "updated_at": "2026-09-03T12:00:00Z" + } +} +``` + +`GET /api/projects` returns `{ "projects": [ … ] }`. + +Creating a project also seeds it with three **statuses** — "To do", "Doing", +"Done" (see [Statuses](#statuses)). + +## Cards + +A card either sits in its owner's **inbox** (`project_id` and `status_id` both +`null`) or belongs to exactly one of their projects with a status in it (both +set) — enforced by a database CHECK constraint, never one without the other. +The inbox is global to the user, not per-project. Because a card may have no +project, single-card and ordering routes are addressed globally, by the card's +own id, rather than nested under a project: + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/projects/{id}/cards` | a project's cards, grouped by status then `position` | +| `POST` | `/api/projects/{id}/cards` | add a card directly to the project | +| `GET` | `/api/inbox/cards` | the caller's inbox | +| `POST` | `/api/inbox/cards` | add a card to the inbox | +| `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) | +| `PUT` | `/api/cards/order` | set the order/contents of one column | + +Create body (either creation route): `text` (required, 1–1000 chars), +`complete` (optional bool, default `false`). The project route also takes an +optional `status_id`, appending the card to the end of that status (must +belong to the project, else `422`) — omitted, it goes in the project's first +status instead. `PATCH` accepts `text` and/or `complete` only — moving a card +is done via the order route below, not PATCH. + +**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards +that share an `(owner, project, status)`. The inbox is its own column, per +owner. `PUT /api/cards/order` sets one column's contents and order: + +```json +{ "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] } +``` + +`project_id`/`status_id` are both `null` for the inbox, or both set to a +project owned by the caller and one of its statuses (`404`/`422` otherwise). +`card_ids` must be distinct cards owned by the caller and must include every +card already in the target column (`422` otherwise); it rewrites positions to +`0..n-1`. Any card in the list that wasn't already in that column is +re-parented into it — moving it from another project's status, or the inbox, +or vice versa — and the column it left is re-packed, all in one transaction. +Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card +in the kanban board (or the sidebar's inbox) calls on every drop; moving a +card from one project to another is just two calls, via the inbox in between. + +Card representation: + +```json +{ + "card": { + "id": 10, + "project_id": 1, + "text": "Design homepage", + "complete": false, + "position": 0, + "status_id": 2, + "status": { "id": 2, "name": "Doing" }, + "created_at": "2026-09-03T12:00:00Z", + "updated_at": "2026-09-03T12:00:00Z" + } +} +``` + +`project_id` and `status_id` are `null` together for an inbox card. `status` is +the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards` +returns `{ "cards": [ … ] }`. + +## Statuses + +Every project has an ordered set of card statuses, created with the project: +"To do", "Doing", "Done". They are project-specific — each project owns its own +rows, managed from the project's **configuration** view (create, reorder, +delete). A project always keeps at least one status, since a project card must +have one; deleting the last one is rejected (`409`). + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` | +| `POST` | `/api/projects/{id}/statuses` | add one at the end — `{ "name": "Blocked" }` | +| `PUT` | `/api/projects/{id}/statuses/order` | reorder — `{ "status_ids": [3, 1, 2] }`, every status once | +| `DELETE` | `/api/projects/{id}/statuses/{statusId}` | delete (see below) | + +```json +{ + "statuses": [ + { "id": 1, "project_id": 1, "name": "To do", "position": 0 }, + { "id": 2, "project_id": 1, "name": "Doing", "position": 1 }, + { "id": 3, "project_id": 1, "name": "Done", "position": 2 } + ] +} +``` + +Requires `Authorization: Bearer `; a project that is missing or not owned by +the caller responds `404`. + +**Deleting a status that still has cards** fails with `409` and +`error.details.card_count` set, rather than silently orphaning them (a +referenced status can't be deleted at the database level either — the FK is +`ON DELETE RESTRICT`). Retry with `{ "reassign_to": }` in +the same project; those cards are moved there and the status deleted, in one +transaction. + +## Error shape + +Every error response looks like: + +```json +{ "error": { "message": "The submitted data was invalid.", "details": { "email": ["Email must be a valid address."] } } } +``` + +`details` is present only when relevant (e.g. validation). + +## Try it + +Signing in needs the link the API emails, so this pulls it back out of the +bundled Mailpit catcher (adjust if you've pointed `MAIL_TRANSPORT` elsewhere). +The API pretty-prints its JSON, so responses are piped through `tr -d ' \n'` +before grep (Mailpit's own JSON doesn't need that): + +```bash +BASE=http://localhost:8080 + +curl -s -X POST $BASE/api/auth/magic-link \ + -H 'Content-Type: application/json' \ + -d '{"email":"ada@example.com"}' + +MSG_ID=$(curl -s "http://localhost:8025/api/v1/messages?limit=1" | grep -o '"ID":"[^"]*"' | head -1 | cut -d'"' -f4) +LINK_TOKEN=$(curl -s "http://localhost:8025/api/v1/message/$MSG_ID" | grep -o 'token=[a-f0-9]*' | head -1 | cut -d= -f2) + +TOKEN=$(curl -s -X POST $BASE/api/auth/verify-email \ + -H 'Content-Type: application/json' \ + -d "{\"token\":\"$LINK_TOKEN\"}" | tr -d ' \n' | grep -o '"token":"[^"]*"' | cut -d'"' -f4) + +curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN" + +PROJECT=$(curl -s -X POST $BASE/api/projects \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"title":"Website relaunch"}' \ + | tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + +curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN" + +curl -s -X POST $BASE/api/projects/$PROJECT/cards \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"text":"Design homepage"}' + +curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN" +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c415c72 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,48 @@ +# Architecture + +A REST API in PHP 8 (Slim 4) over a single SQLite file, plus a Vue 3 + +TypeScript PWA frontend. Auth is a bearer JWT, obtained via a magic link or a +passkey (see [docs/api.md](api.md)) — there's no session store or cookie. + +## Backend layout + +``` +public/index.php Front controller +src/bootstrap.php App wiring and route definitions +src/Support/Config.php Environment-driven configuration +src/Support/Database.php PDO/SQLite connection +src/Auth/JwtService.php Issue/verify JWTs +src/Auth/AuthMiddleware.php Bearer-token authentication +src/Auth/SessionPayload.php Shared user + session JSON shape +src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier +src/Http/JsonErrorHandler.php Uniform JSON error envelope +src/Http/Controllers/ Request handlers (Auth, EmailVerification, Passkey, Project, Card, CardStatus) +src/Repository/ Database access (User, EmailVerification, Passkey, WebAuthnChallenge, Project, Card, CardStatus) +src/Support/Validator.php Request-body validation helper +migrations/*.sql Schema, applied by bin/migrate.php +Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime +docker-compose.yml Local stack: app (SPA + API) + Mailpit +docker/ Apache vhost + container entrypoint +web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host) +``` + +A `ProjectScopedController` base class centralizes "look up a project owned +by the caller, or 404" for the controllers that need it (`Project`, `Card`, +`CardStatus`). Every table a request can reach is scoped to the +authenticated user one way or another — directly (`owner_id`/`user_id`) or +via a project that is. + +## Frontend + +The Vue/TypeScript PWA lives in [web/](../web/) and is a separate concern +with its own conventions (routing, state, styling, drag-and-drop). See +[web/README.md](../web/README.md) for all of that — this document only +covers the backend. + +## Database + +One SQLite file, migrated forward-only by `bin/migrate.php` from +`migrations/*.sql` (each applied file is recorded in a `schema_migrations` +table, so re-running is safe). See [docs/setup.md](setup.md) for how to run +migrations, and [docs/history.md](history.md) for how the schema and the +rest of the app got here. diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..a43d005 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,29 @@ +# Development history + +A stage-by-stage log of major features, in the order they landed. Each row +describes the app as it was *at that point* — later stages sometimes +superseded earlier ones (e.g. password login, added in stage 1, was removed +again in stage 12); see [docs/api.md](api.md) and +[web/README.md](../web/README.md) for how things work now. + +| Stage | Scope | State | +|-------|-------|-------| +| 1 | Auth API — register, login, `GET /me` | ✅ done | +| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done | +| 3 | Project + card CRUD API | ✅ done | +| 4 | Frontend projects view — project index + create form | ✅ done | +| 5 | Frontend project detail — cards UI with drag-and-drop reorder | ✅ done | +| 6 | Project view — inline title/description editing, delete via a Manage menu | ✅ done | +| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done | +| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done | +| 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done | +| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done | +| 11 | Persistent left sidebar (Dashboard link + project dropdown); dashboard = grid of project tiles + a "Create a project" tile | ✅ done | +| 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done | +| 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done | +| 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done | +| 15 | Passkeys (WebAuthn) — register from the profile page, sign in with one instead of a magic link; a dismissible notice nudges users with none | ✅ done | +| 16 | Project configuration view — manage a project's statuses: add, drag to reorder, delete (reassigning any cards on it first) | ✅ done | +| 17 | Card detail view (`/cards/:id`) + its own configuration view — a card's text is no longer inline-editable; every list links to its own page instead | ✅ done | +| 18 | Project view split into real routes — Explore (`/projects/:id`) and Kanban (`/projects/:id/kanban`) are separate pages under a shared layout, not client-side tab state | ✅ done | +| 19 | Backend/frontend refactoring pass — deduplicated controller/repository boilerplate and CSS, dropped the never-surfaced project `description` field, squashed the SQL migration history into one clean initial schema | ✅ done | diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..dd1fcfc --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,103 @@ +# Setup & configuration + +For the quick version — just running the app — see the root +[README](../README.md#getting-started). This covers the rest: running +without Docker, every configuration option, and the test suite. + +## Run with Docker + +The only requirement is Docker with the Compose plugin. + +```bash +docker compose up -d +``` + +This runs a multi-stage build — a Node stage compiles the Vue frontend, then a +PHP 8.3 + Apache stage bakes in the PHP source and the built SPA — applies +migrations, and serves the whole app at : the SPA at `/` +(assets and all) and the REST API under `/api` (e.g. +`curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA +shell for client-side routing. + +- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog + successor — one ~15 MB Go binary, messages kept in memory) also starts. The API + sends all email to it; read it at . Set + `MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real. +- The image is the artifact: PHP source and the compiled frontend are copied in + at build time, not bind-mounted. Rebuild to pick up any code change: + `docker compose up -d --build`. For iterating on the frontend, run the Vite + dev server on the host instead (see [web/README.md](../web/README.md)). +- The SQLite database and the generated JWT signing key live in the `storage` + named volume, mounted at `/var/www/storage`, so they survive + `docker compose restart` / `down` + `up`. +- `docker compose down -v` removes the volume and gives you a clean database. +- Override settings via the environment or a `.env` file in the repo root + (Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see + [docker-compose.yml](../docker-compose.yml)). + +## Run without Docker + +Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus +[Composer](https://getcomposer.org/). On Fedora: + +```bash +sudo dnf install php-cli php-pdo php-mbstring composer +``` + +### Setup + +```bash +composer install +cp .env.example .env # optional; sane defaults are used without it +composer migrate # creates storage/database.sqlite and its tables +``` + +### Running + +```bash +composer serve # http://localhost:8080 (php -S localhost:8080 -t public) +``` + +Any web server can serve the API as long as the document root is `public/` and +unknown paths fall through to `public/index.php`. `public/.htaccess` also serves +a built frontend from `public/` (copy `web/dist/` there) and only falls back to +`index.php` for `/api` and when no `index.html` is present. + +The frontend itself still needs its own toolchain — see +[web/README.md](../web/README.md). + +## Configuration + +All settings are optional environment variables (read from `.env` or the real +environment). See [.env.example](../.env.example). + +| Variable | Default | Purpose | +|----------|---------|---------| +| `APP_DEBUG` | `false` | Include exception details in error responses | +| `DATABASE_PATH` | `storage/database.sqlite` | SQLite file location | +| `JWT_SECRET` | auto-generated into `storage/secret.key` | Token signing key | +| `JWT_TTL` | `86400` | Token lifetime in seconds | +| `APP_ALLOW_REGISTRATION` | `true` | When `false`, a magic link is only ever sent to an existing address — an unknown one is silently ignored, so no new accounts get created | +| `MAGIC_LINK_RESEND_SECONDS` | `60` | Minimum gap before a magic link can be resent to the same address (sign-in or email-change). Docker Compose overrides this to `0`, so links resend immediately in development | +| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev`) | +| `WEBAUTHN_RP_ID` | `APP_URL`'s host | Passkey relying party ID (domain). Must be `localhost` or a real domain over HTTPS — a LAN IP won't work | +| `WEBAUTHN_RP_NAME` | `Projects` | Passkey relying party display name, shown in the browser/OS prompt | +| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()`), `smtp`, or `log` (append to a file) | +| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender | +| `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | +| `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` | + +Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`. +Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled +Mailpit container (`mailpit:1025`, no auth/TLS); open to +read what was "sent". + +## Tests + +```bash +composer install # installs phpunit (require-dev) +vendor/bin/phpunit +``` + +Each test run applies every file in `migrations/*.sql` to a fresh SQLite +database, so it always exercises the current schema from scratch. diff --git a/docs/stage-1-auth-api.md b/docs/stage-1-auth-api.md deleted file mode 100644 index f522426..0000000 --- a/docs/stage-1-auth-api.md +++ /dev/null @@ -1,60 +0,0 @@ -# Stage 1 — Authentication API - -Status: **done** and verified end-to-end (PHPUnit feature tests + a live `curl` -run against the built-in server). - -## What's there - -A Slim 4 REST API on SQLite with JWT bearer authentication. - -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/api/health` | liveness check | -| POST | `/api/auth/register` | create account, returns user + token | -| POST | `/api/auth/login` | exchange email/password for a token | -| GET | `/api/me` | current user (requires `Authorization: Bearer `) | - -- **Passwords** are hashed with `password_hash()` (bcrypt). Validation is - email-format plus an 8–72 character password. Login returns a single generic - "Invalid email or password" message so it does not leak which emails exist. -- **Emails** are stored lower-cased in a `UNIQUE COLLATE NOCASE` column; - duplicate registration returns `409`. -- **Errors** always come back as - `{ "error": { "message": ..., "details"?: ... } }` via - [../src/Http/JsonErrorHandler.php](../src/Http/JsonErrorHandler.php). -- **Tokens** are stateless HS256 JWTs. The signing secret comes from - `JWT_SECRET`, or is auto-generated into `storage/secret.key` on first run. -- **Migrations** are plain SQL files in [../migrations/](../migrations/), applied - idempotently by [../bin/migrate.php](../bin/migrate.php) and tracked in a - `schema_migrations` table. - -## Dependency note - -`firebase/php-jwt` is pinned to `^7.0` — Composer blocks `6.10`–`6.11` for a -published security advisory (`PKSA-y2cr-5h3j-g3ys`). - -## Key files - -- [../src/bootstrap.php](../src/bootstrap.php) — app wiring + routes -- [../src/Http/Controllers/AuthController.php](../src/Http/Controllers/AuthController.php) — register / login / me -- [../src/Auth/JwtService.php](../src/Auth/JwtService.php), [../src/Auth/AuthMiddleware.php](../src/Auth/AuthMiddleware.php) -- [../src/Repository/UserRepository.php](../src/Repository/UserRepository.php) -- [../src/Support/Config.php](../src/Support/Config.php), [../src/Support/Database.php](../src/Support/Database.php) -- [../tests/AuthTest.php](../tests/AuthTest.php) — 6 passing feature tests - -## Run it - -```bash -composer install && composer migrate && composer serve # http://localhost:8080 -``` - -## Local toolchain - -This machine has no `php`/`composer` binary. Tooling was run through the official -Composer container image, which bundles PHP + Composer + `pdo_sqlite` + -`mbstring`: - -```bash -podman run --rm -v "$PWD":/app:Z -w /app docker.io/library/composer:2 install -podman run --rm -v "$PWD":/app:Z -w /app docker.io/library/composer:2 vendor/bin/phpunit -```