2026-09-04 11:28:59 +01:00
# PHP Project Manager
2026-09-03 17:35:10 +01:00
2026-09-04 11:28:59 +01:00
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** .
2026-09-03 17:35:10 +01:00
## Status
| Stage | Scope | State |
|-------|-------|-------|
| 1 | Auth API — register, login, `GET /me` | ✅ done |
2026-09-03 18:12:31 +01:00
| 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done |
2026-09-04 11:28:59 +01:00
| 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 |
2026-09-03 20:04:49 +01:00
| 7 | Email verification (magic links) + profile page (resend, change email) | ✅ done |
2026-09-04 10:29:13 +01:00
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
2026-09-04 13:37:17 +01:00
| 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 |
2026-09-04 15:31:30 +01:00
| 11 | Persistent left sidebar (Dashboard link + project dropdown); dashboard = grid of project tiles + new-project form | ✅ done |
2026-09-04 14:47:46 +01:00
| 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done |
2026-09-04 15:22:33 +01:00
| 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done |
2026-09-04 15:31:30 +01:00
| 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done |
2026-09-04 19:34:53 +01:00
| 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 |
2026-09-03 18:12:31 +01:00
2026-09-04 14:47:46 +01:00
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 ).
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
## Run with Docker
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
The only requirement is Docker with the Compose plugin.
2026-09-03 17:35:10 +01:00
2026-09-03 17:45:35 +01:00
```bash
docker compose up -d
```
2026-09-04 13:37:17 +01:00
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 <http://localhost:8080>: 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.
2026-09-03 17:45:35 +01:00
2026-09-04 08:40:36 +01:00
- 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 <http://localhost:8025>. Set
`MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*` ) to send for real.
2026-09-04 13:37:17 +01:00
- 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 )).
2026-09-03 17:45:35 +01:00
- The SQLite database and the generated JWT signing key live in the `storage`
2026-09-04 13:37:17 +01:00
named volume, mounted at `/var/www/storage` , so they survive
`docker compose restart` / `down` + `up` .
2026-09-03 17:45:35 +01:00
- `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:
2026-09-03 17:35:10 +01:00
```bash
sudo dnf install php-cli php-pdo php-mbstring composer
```
2026-09-03 17:45:35 +01:00
### Setup
2026-09-03 17:35:10 +01:00
```bash
composer install
cp .env.example .env # optional; sane defaults are used without it
composer migrate # creates storage/database.sqlite and its tables
```
2026-09-03 17:45:35 +01:00
### Running
2026-09-03 17:35:10 +01:00
```bash
composer serve # http://localhost:8080 (php -S localhost:8080 -t public)
```
2026-09-04 13:37:17 +01:00
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.
2026-09-03 17:35:10 +01:00
2026-09-03 18:12:31 +01:00
## Frontend
2026-09-04 13:37:17 +01:00
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` ):
2026-09-03 18:12:31 +01:00
```bash
cd web
npm install
npm run dev # http://localhost:5173, proxies /api to localhost:8080
```
2026-09-04 14:47:46 +01:00
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 ).
2026-09-03 18:12:31 +01:00
2026-09-03 17:35:10 +01:00
## 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 |
2026-09-04 18:19:48 +01:00
| `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 |
2026-09-04 13:37:17 +01:00
| `APP_URL` | `http://localhost:8080` | Base URL used to build magic links (`http://localhost:5173` for a host `npm run dev` ) |
2026-09-04 19:34:53 +01:00
| `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 |
2026-09-03 20:04:49 +01:00
| `MAIL_TRANSPORT` | `mail` | `mail` (PHP `mail()` ), `smtp` , or `log` (append to a file) |
2026-09-04 11:28:59 +01:00
| `MAIL_FROM` / `MAIL_FROM_NAME` | `no-reply@todo.test` / `Projects` | Envelope sender |
2026-09-03 20:04:49 +01:00
| `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` |
2026-09-04 08:40:36 +01:00
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 <http://localhost:8025> to
read what was "sent".
2026-09-03 17:35:10 +01:00
## API
Base path: `/api` . All request and response bodies are JSON; send
`Content-Type: application/json` .
### `GET /api/health`
```json
{ "status" : "ok" }
```
2026-09-04 14:47:46 +01:00
### Auth
2026-09-03 17:35:10 +01:00
2026-09-04 14:47:46 +01:00
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
2026-09-04 19:34:53 +01:00
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.
2026-09-04 14:47:46 +01:00
| 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 |
2026-09-04 19:34:53 +01:00
| `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 |
2026-09-04 14:47:46 +01:00
| `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 (`<APP_URL>/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
2026-09-04 18:19:48 +01:00
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.
2026-09-03 17:35:10 +01:00
```json
2026-09-04 14:47:46 +01:00
{ "message" : "Check your email for a link to sign in." }
2026-09-03 17:35:10 +01:00
```
2026-09-04 14:47:46 +01:00
#### `POST /api/auth/verify-email`
Body: `{ "token": "..." }` . A missing/invalid, already-used, or expired token is
`400` (distinct messages). Success signs the caller in:
2026-09-03 17:35:10 +01:00
```json
{
2026-09-03 18:12:31 +01:00
"user" : {
"id" : 1 ,
"email" : "ada@example.com" ,
2026-09-04 14:47:46 +01:00
"email_verified" : true ,
"email_verified_at" : "2026-09-03T12:00:00Z" ,
2026-09-03 20:04:49 +01:00
"pending_email" : null ,
2026-09-04 19:34:53 +01:00
"has_passkey" : false ,
2026-09-03 18:12:31 +01:00
"created_at" : "2026-09-03T12:00:00Z"
},
2026-09-03 17:35:10 +01:00
"token" : "<jwt>" ,
"expires_at" : "2026-09-04T12:00:00+00:00"
}
```
2026-09-04 14:47:46 +01: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.
2026-09-03 18:12:31 +01:00
2026-09-04 14:47:46 +01:00
#### `GET /api/me`
2026-09-03 17:35:10 +01:00
2026-09-04 14:47:46 +01:00
Requires `Authorization: Bearer <jwt>` . `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.
2026-09-03 17:35:10 +01:00
2026-09-04 14:47:46 +01:00
#### `POST /api/email/change`
2026-09-03 17:35:10 +01:00
2026-09-04 14:47:46 +01:00
Requires `Authorization: Bearer <jwt>` . 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` :
2026-09-03 17:35:10 +01:00
```json
2026-09-03 18:12:31 +01:00
{
2026-09-04 14:47:46 +01:00
"message" : "Confirmation email sent to the new address." ,
"pending_email" : "new@example.com" ,
"retry_after" : 60
2026-09-03 18:12:31 +01:00
}
2026-09-03 17:35:10 +01:00
```
2026-09-04 14:47:46 +01:00
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` .
2026-09-03 20:04:49 +01:00
2026-09-04 19:34:53 +01:00
### 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.
2026-09-04 11:28:59 +01:00
### Projects
2026-09-03 18:30:52 +01:00
2026-09-04 11:28:59 +01:00
All routes below require `Authorization: Bearer <jwt>` . A project belongs to one
owner (the creator); another user's project — or a missing one — always responds
2026-09-03 18:30:52 +01:00
`404` .
| Method | Path | Purpose |
|--------|------|---------|
2026-09-04 11:28:59 +01:00
| `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}` | update `title` and/or `description` |
| `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204` ) |
2026-09-03 18:30:52 +01:00
2026-09-04 11:28:59 +01:00
`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` .
2026-09-03 18:42:15 +01:00
2026-09-03 18:30:52 +01:00
Create/update body: `title` (required on create, 1– 255 chars), `description`
(optional, ≤ 2000 chars, defaults to `""` ). `PATCH` needs at least one field.
2026-09-04 11:28:59 +01:00
Project representation:
2026-09-03 18:30:52 +01:00
```json
{
2026-09-04 11:28:59 +01:00
"project" : {
2026-09-03 18:30:52 +01:00
"id" : 1 ,
2026-09-04 11:28:59 +01:00
"title" : "Website relaunch" ,
"description" : "Q3" ,
2026-09-03 18:30:52 +01:00
"owner_id" : 1 ,
2026-09-04 11:28:59 +01:00
"card_count" : 3 ,
2026-09-03 18:30:52 +01:00
"completed_count" : 1 ,
"created_at" : "2026-09-03T12:00:00Z" ,
"updated_at" : "2026-09-03T12:00:00Z"
}
}
```
2026-09-04 11:28:59 +01:00
`GET /api/projects` returns `{ "projects": [ … ] }` .
2026-09-03 18:30:52 +01:00
2026-09-04 13:37:17 +01:00
Creating a project also seeds it with three **statuses** — "To do", "Doing",
"Done" (see [Statuses ](#statuses )).
2026-09-04 11:28:59 +01:00
### Cards
2026-09-03 18:30:52 +01:00
2026-09-04 15:22:33 +01:00
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:
2026-09-03 18:30:52 +01:00
| Method | Path | Purpose |
|--------|------|---------|
2026-09-04 15:22:33 +01:00
| `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 (its first status) |
| `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 |
2026-09-03 18:30:52 +01:00
2026-09-04 15:22:33 +01:00
Create body (either creation route): `text` (required, 1– 1000 chars),
`complete` (optional bool, default `false` ). `PATCH` accepts `text` and/or
`complete` only — moving a card is done via the order route below, not PATCH.
2026-09-03 18:30:52 +01:00
2026-09-04 13:37:17 +01:00
**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards
2026-09-04 15:22:33 +01:00
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:
2026-09-04 13:37:17 +01:00
2026-09-04 15:22:33 +01:00
```json
{ "project_id" : 5 , "status_id" : 12 , "card_ids" : [ 3 , 1 , 2 ] }
```
2026-09-04 13:37:17 +01:00
2026-09-04 15:22:33 +01:00
`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.
2026-09-03 18:59:51 +01:00
2026-09-04 11:28:59 +01:00
Card representation:
2026-09-03 18:30:52 +01:00
```json
{
2026-09-04 11:28:59 +01:00
"card" : {
2026-09-03 18:30:52 +01:00
"id" : 10 ,
2026-09-04 11:28:59 +01:00
"project_id" : 1 ,
"text" : "Design homepage" ,
2026-09-03 18:30:52 +01:00
"complete" : false ,
"position" : 0 ,
2026-09-04 15:22:33 +01:00
"status_id" : 2 ,
"status" : { "id" : 2 , "name" : "Doing" },
2026-09-03 18:30:52 +01:00
"created_at" : "2026-09-03T12:00:00Z" ,
"updated_at" : "2026-09-03T12:00:00Z"
}
}
```
2026-09-04 15:22:33 +01:00
`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": [ … ] }` .
2026-09-04 13:37:17 +01:00
### 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
2026-09-04 15:22:33 +01:00
rows. There is no create/update/delete for the statuses themselves yet, and (as
a project card must always have one) a referenced status can't be deleted at
the database level either.
2026-09-04 13:37:17 +01:00
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/projects/{id}/statuses` | the project's statuses, ordered by `position` |
```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 <jwt>` ; a project that is missing or not owned by
the caller responds `404` .
2026-09-03 18:30:52 +01:00
2026-09-03 17:35:10 +01:00
### 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
2026-09-04 14:47:46 +01:00
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):
2026-09-03 17:35:10 +01:00
```bash
BASE = http://localhost:8080
2026-09-04 14:47:46 +01:00
curl -s -X POST $BASE /api/auth/magic-link \
2026-09-03 17:35:10 +01:00
-H 'Content-Type: application/json' \
2026-09-04 14:47:46 +01:00
-d '{"email":"ada@example.com"}'
2026-09-03 17:35:10 +01:00
2026-09-04 14:47:46 +01:00
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 \
2026-09-03 17:35:10 +01:00
-H 'Content-Type: application/json' \
2026-09-04 14:47:46 +01:00
-d "{\"token\":\" $LINK_TOKEN \"}" | tr -d ' \n' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
2026-09-03 17:35:10 +01:00
curl -s $BASE /api/me -H "Authorization: Bearer $TOKEN "
2026-09-03 18:30:52 +01:00
2026-09-04 11:28:59 +01:00
PROJECT = $( curl -s -X POST $BASE /api/projects \
2026-09-03 18:30:52 +01:00
-H "Authorization: Bearer $TOKEN " -H 'Content-Type: application/json' \
2026-09-04 11:28:59 +01:00
-d '{"title":"Website relaunch","description":"Q3"}' \
2026-09-04 14:47:46 +01:00
| tr -d ' \n' | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
2026-09-03 18:30:52 +01:00
2026-09-04 13:37:17 +01:00
curl -s $BASE /api/projects/$PROJECT /statuses -H "Authorization: Bearer $TOKEN "
2026-09-04 11:28:59 +01:00
curl -s -X POST $BASE /api/projects/$PROJECT /cards \
2026-09-03 18:30:52 +01:00
-H "Authorization: Bearer $TOKEN " -H 'Content-Type: application/json' \
2026-09-04 11:28:59 +01:00
-d '{"text":"Design homepage"}'
2026-09-03 18:30:52 +01:00
2026-09-04 11:28:59 +01:00
curl -s $BASE /api/projects/$PROJECT /cards -H "Authorization: Bearer $TOKEN "
2026-09-03 17:35:10 +01:00
```
## 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
2026-09-03 20:04:49 +01:00
src/Auth/SessionPayload.php Shared user + session JSON shape
src/Mail/ Mailer interface, SMTP/mail()/log transports, EmailVerifier
2026-09-03 17:35:10 +01:00
src/Http/JsonErrorHandler.php Uniform JSON error envelope
2026-09-04 19:34:53 +01:00
src/Http/Controllers/ Request handlers (Auth, EmailVerification, Passkey, Project, Card, CardStatus)
src/Repository/ Database access (User, EmailVerification, Passkey, WebAuthnChallenge, Project, Card, CardStatus)
2026-09-03 18:30:52 +01:00
src/Support/Validator.php Request-body validation helper
2026-09-03 17:35:10 +01:00
migrations/*.sql Schema, applied by bin/migrate.php
2026-09-04 13:37:17 +01:00
Dockerfile Multi-stage: Node frontend build + PHP 8.3/Apache runtime
docker-compose.yml Local stack: app (SPA + API) + Mailpit
2026-09-03 17:45:35 +01:00
docker/ Apache vhost + container entrypoint
2026-09-04 13:37:17 +01:00
web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host)
2026-09-03 17:35:10 +01:00
```
2026-09-03 17:37:20 +01:00
## Provenance
This project was generated with [Claude Code ](https://claude.com/claude-code ).