Un-hard-wrap all Markdown documentation
Every prose paragraph and list item was manually wrapped at ~80-100
columns; joined each back into a single line. Headings, table rows,
and fenced code blocks are untouched -- tables already had one row per
line, and wrapping inside a code fence is the code's own formatting,
not something this applies to.
Also fixed two pre-existing typos this surfaced (both from wrapping
without leaving the space that was actually intended): a missing space
in "{ challenge_id, options }" and a stray "+ TypeScript" that had
accidentally been written as if it were a new line, in web/README.md
and README.md respectively.
Code comments are explicitly out of scope for this -- left exactly as
they were.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+5
-12
@@ -1,15 +1,8 @@
|
||||
# Documentation
|
||||
|
||||
Technical documentation for the PHP Project Manager. Start with the root
|
||||
[README](../README.md) if you just want to run the app.
|
||||
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.
|
||||
- **[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.
|
||||
|
||||
+25
-113
@@ -1,7 +1,6 @@
|
||||
# API reference
|
||||
|
||||
Base path: `/api`. All request and response bodies are JSON; send
|
||||
`Content-Type: application/json`.
|
||||
Base path: `/api`. All request and response bodies are JSON; send `Content-Type: application/json`.
|
||||
|
||||
### `GET /api/health`
|
||||
|
||||
@@ -11,10 +10,7 @@ Base path: `/api`. All request and response bodies are JSON; send
|
||||
|
||||
## 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.
|
||||
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 |
|
||||
|--------|------|------|---------|
|
||||
@@ -29,14 +25,7 @@ address and a returning one alike. A user can also register one or more
|
||||
|
||||
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
|
||||
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.
|
||||
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 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." }
|
||||
@@ -44,8 +33,7 @@ address is malformed.
|
||||
|
||||
### `POST /api/auth/verify-email`
|
||||
|
||||
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
|
||||
`400` (distinct messages). Success signs the caller in:
|
||||
Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is `400` (distinct messages). Success signs the caller in:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -63,24 +51,15 @@ Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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 <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.
|
||||
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.
|
||||
|
||||
### `POST /api/email/change`
|
||||
|
||||
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`:
|
||||
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`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -90,20 +69,11 @@ soon. On success, `202` with `retry_after` and `pending_email`:
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
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.
|
||||
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 |
|
||||
|--------|------|------|---------|
|
||||
@@ -114,35 +84,16 @@ users, not a fleet of company-issued security keys.
|
||||
| `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:
|
||||
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.
|
||||
- `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.
|
||||
`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 <jwt>`. A project belongs to one
|
||||
owner (the creator); another user's project — or a missing one — always responds
|
||||
`404`.
|
||||
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 `404`.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
@@ -152,9 +103,7 @@ owner (the creator); another user's project — or a missing one — always resp
|
||||
| `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`.
|
||||
`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).
|
||||
|
||||
@@ -176,17 +125,11 @@ Project representation:
|
||||
|
||||
`GET /api/projects` returns `{ "projects": [ … ] }`.
|
||||
|
||||
Creating a project also seeds it with three **statuses** — "To do", "Doing",
|
||||
"Done" (see [Statuses](#statuses)).
|
||||
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:
|
||||
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 |
|
||||
|--------|------|---------|
|
||||
@@ -197,31 +140,15 @@ own id, rather than nested under a project:
|
||||
| `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.
|
||||
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:
|
||||
**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.
|
||||
`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:
|
||||
|
||||
@@ -241,17 +168,11 @@ Card representation:
|
||||
}
|
||||
```
|
||||
|
||||
`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": [ … ] }`.
|
||||
`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`).
|
||||
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 |
|
||||
|--------|------|---------|
|
||||
@@ -270,15 +191,9 @@ have one; deleting the last one is rejected (`409`).
|
||||
}
|
||||
```
|
||||
|
||||
Requires `Authorization: Bearer <jwt>`; a project that is missing or not owned by
|
||||
the caller responds `404`.
|
||||
Requires `Authorization: Bearer <jwt>`; 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": <another status id> }` in
|
||||
the same project; those cards are moved there and the status deleted, in one
|
||||
transaction.
|
||||
**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": <another status id> }` in the same project; those cards are moved there and the status deleted, in one transaction.
|
||||
|
||||
## Error shape
|
||||
|
||||
@@ -292,10 +207,7 @@ Every error response looks like:
|
||||
|
||||
## 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):
|
||||
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
|
||||
|
||||
+4
-17
@@ -1,8 +1,6 @@
|
||||
# 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.
|
||||
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
|
||||
|
||||
@@ -29,23 +27,12 @@ docker/ Apache vhost (mod_rewrite, document root) + entrypo
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
+1
-5
@@ -1,10 +1,6 @@
|
||||
# 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.
|
||||
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 |
|
||||
|-------|-------|-------|
|
||||
|
||||
+12
-41
@@ -1,8 +1,6 @@
|
||||
# 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.
|
||||
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
|
||||
|
||||
@@ -12,35 +10,17 @@ 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 (Alpine-based; apk's own prebuilt packages rather than
|
||||
compiling PHP from source, which is most of why the image is ~90MB rather
|
||||
than several times that) 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.
|
||||
This runs a multi-stage build — a Node stage compiles the Vue frontend, then a PHP 8.3 + Apache stage (Alpine-based; apk's own prebuilt packages rather than compiling PHP from source, which is most of why the image is ~90MB rather than several times that) 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.
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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)).
|
||||
- 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:
|
||||
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
|
||||
@@ -60,18 +40,13 @@ composer migrate # creates storage/database.sqlite and its tables
|
||||
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.
|
||||
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).
|
||||
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).
|
||||
All settings are optional environment variables (read from `.env` or the real environment). See [.env.example](../.env.example).
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
@@ -89,10 +64,7 @@ environment). See [.env.example](../.env.example).
|
||||
| `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 <http://localhost:8025> to
|
||||
read what was "sent".
|
||||
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".
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -101,5 +73,4 @@ 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.
|
||||
Each test run applies every file in `migrations/*.sql` to a fresh SQLite database, so it always exercises the current schema from scratch.
|
||||
|
||||
Reference in New Issue
Block a user