APP_ALLOW_REGISTRATION (default true) gates the only "sign up" this app has --
the account-creation side effect of POST /api/auth/magic-link. When false, an
unknown address is silently ignored (find-only, no findOrCreateByEmail) while
an existing address still gets its sign-in link as normal; the response is
identical either way (202, same message), so there's still no enumeration
signal.
- Config::allowRegistration, read from APP_ALLOW_REGISTRATION.
- AuthController::requestLoginLink takes the flag; only looks up (doesn't
create) when it's off.
- docker-compose.yml / .env.example / README document the new var.
- ApiTestCase::reconfigure() rebuilds the app against changed env (same
database) for tests that need a non-default Config; two new AuthTest
cases cover both halves (blocks a new address, doesn't block an existing
one). 59 tests pass.
Verified against the rebuilt container: with the flag on (default), a new
address gets a link and an account; switched off via the same env var, a
brand-new address gets the same 202 but no email and no user row, while an
address that already had an account still receives its link.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Dockerfile is now multi-stage: a Node stage runs `npm run build`, and the
PHP/Apache stage copies the result into public/. Apache + public/.htaccess route
/api* to the Slim front controller, serve real files, and fall back to
index.html for client-side routes.
docker-compose.yml loses the `web` service, its volume, and the source
bind-mount -- the image is the artifact now (rebuild to pick up changes).
Frontend dev moves to `npm run dev` on the host; APP_URL defaults to :8080 since
the one container serves both halves.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a mailpit service (axllent/mailpit, ~15 MB, in-memory) to
docker-compose.yml — chosen over the unmaintained MailHog for the same
footprint. It has no profile, so `docker compose up -d` starts it alongside
the API; the app defaults to MAIL_TRANSPORT=smtp -> mailpit:1025 (no
auth/TLS) and mail is read at http://localhost:8025.
Also change the default MAIL_FROM to no-reply@todo.test: PHPMailer v7 rejects
the dotless no-reply@localhost as an invalid address.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Backend
- New Mail namespace: a Mailer interface with SMTP (phpmailer), PHP mail()
(the default fallback), and log-to-file transports, selected by
MAIL_TRANSPORT. EmailVerifier issues a hashed, 15-minute magic-link token
and sends the link (APP_URL/verify-email?token=...).
- Migration 005: email_verifications table + users.verification_email_sent_at.
- Registration now emails a verification link (best effort — a send failure
doesn't fail registration).
- POST /api/auth/verify-email consumes a token and returns a session, so
opening the link verifies the address (or applies a pending email change)
and logs the user in. Single-use; distinct 400s for invalid/used/expired.
- POST /api/email/verification resends; POST /api/email/change requests a
deferred change (current password required; link goes to the new address;
users.email only updates when that link is opened). Both throttled to once
per 60s, returning 429 + retry_after.
- GET /api/me and every session payload now include pending_email. Shared
SessionPayload builds the user/session JSON for all entry points.
Frontend
- /verify-email view: posts the token, adopts the returned session, redirects.
- /profile view: shows address + status, a resend button with a live cooldown
(driven by retry_after / 429), and a change-email form (new address +
current password) that surfaces the pending change.
- Header shows a "verify email" badge linking to the profile.
Tests: 9 new (EmailVerificationTest) covering the link lifecycle, throttle,
and deferred change; AuthTest folded into ApiTestCase, which now routes mail
to a per-test log. Suite: 32 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docker-compose.yml now mounts the working directory at /var/www/html so
PHP changes take effect without an image rebuild. To avoid a mount nested
inside that bind mount, the storage directory moves out to /var/www/storage
(still a named volume). Config gains a STORAGE_PATH env var driving both the
SQLite database and the JWT signing-key location; the entrypoint chowns that
directory. The Dockerfile is unchanged and still builds a self-contained
image (STORAGE_PATH defaults back to ./storage when unset).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Slim 4 + SQLite todo-list API providing email/password registration,
login, and an authenticated GET /me endpoint. Stateless HS256 JWTs,
bcrypt password hashing, uniform JSON error envelope, and a SQL
migration runner. Includes PHPUnit feature tests and stage-1 docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>