Files
fitme3070.pt/shared/platform-tools/PHILOSOPHY.md
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

275 lines
14 KiB
Markdown

# How the andmore platforms are built
Seven sites — **dance**, **design**, **fotografia**, **hub2**, **mstudio**,
**quack**, **silver** — share one VPS, one MariaDB, one reverse proxy, and this
submodule. They are separate products with separate databases, but they are
built the same way on purpose, so that knowing one means knowing all seven.
Two of them are worth naming as special cases, because they are the shape the
conventions have to survive: **mstudio** is a second instance of fotografia's
platform under different branding, and **quack** is a classroom game that grew
up outside this submodule and was brought in later. Where this document says
"every site", those two are included — that is the point of them.
This document records the reasoning behind that shape. It is descriptive, not
aspirational: everything here is a decision already visible in the code. When a
choice below stops being true, change the code or change this file — a
convention nobody follows is worse than no convention.
---
## 1. Plain PHP, no framework, no build step
PHP 8.4 with PDO, server-side rendering, Bootstrap 5. Composer is used for
autoloading and real libraries; there is no bundler, no transpiler, no
`node_modules` in production.
The reason is maintenance horizon. These are small products that must keep
working for years with long idle stretches between changes. A build step is a
second thing that can rot — toolchain versions, lockfiles, a CI runner — and it
rots silently while the site still appears fine. Editing a `.php` file and
reloading is a workflow that cannot break.
The cost is real and accepted: no type checking beyond PHP's own, no component
model, more repetition in templates. That trade is worth it at this size and
would not be at ten times the size.
## 2. Comments explain why, never what
The code is unusually densely commented, and almost none of it restates the
line below. A comment earns its place by recording something the reader cannot
recover from the code: a constraint, a rejected alternative, a bug that a
previous version had.
```php
// MariaDB does not read cgroup limits -- without this it sizes its buffer
// pool against all 7.8GB of host RAM and then gets OOM-killed by mem_limit.
- --innodb-buffer-pool-size=1G
```
That comment is worth more than the line it sits above, because the line looks
arbitrary without it and someone will eventually "clean it up".
Where a decision is a knowing compromise, it is labelled as one. The site
containers mount their own source read-write so the backoffice updater can run
`git pull`, and the compose file says so under an **ACCEPTED RISK** heading
naming the exact exposure. A documented risk is a decision; an undocumented one
is an accident waiting to be discovered by someone else.
## 3. Shared code lives here, once
Anything two platforms both need moves into `platform-tools` and is consumed as
a git submodule. Edit one file, every platform gets it.
The failure mode this exists to prevent is genuine and has already happened:
three sites independently grew their own AES-256-GCM secret helpers with three
different key derivations, all writing the same `enc:v1:` prefix, all mutually
unreadable, and nothing surfaced the divergence until something tried to read
across sites. Copies do not stay identical. They stay *similar*, which is worse,
because similar code invites the assumption that it is the same.
The corollary: **a site must not reimplement something this submodule provides.**
If the shared version is wrong for one site, fix it here with a parameter, or
say plainly in the site's own docs why it deviates.
**The site declares; the shared code does not guess.** Each site's
`config/db.php` makes one `platform_connect()` call that states its PDO, base
URL, environment, column map, roles, session and sysadmin guard. Before it,
every tool probed for variants — a global `$pdo` or `App\Database`, a
`base_url()` or a `getBaseUrl()` — and each new tool had to remember them all.
The same call runs `platform_conformance()`: the database is compared with what
the shared code expects, and a missing column that would switch a defence off
without an error fails loudly in development and turns the hub card yellow in
production (§7).
**New tools reach every site without a file on the site.** The shared pages are
served by one front controller (`public/platform.php?tool=…`) and the shared
endpoints by another (`public/api/platform.php?endpoint=…`), both from closed
route tables here. A per-page stub on each of seven sites was seven chances to
forget one.
Submodules have one sharp edge worth stating: `git pull` in a site advances the
submodule *pointer* but leaves its working tree on the old commit, silently and
with no error. Every deploy script therefore runs `git submodule update
--init --recursive` as a separate, mandatory step.
## 4. Configuration has two homes, and a rule for choosing
`.env` for anything needed to reach or decrypt the database, or to repair the
site when it is broken. The `system_settings` table for anything an operator
should be able to change without shell access.
The test: *can the system read this after a cold start with an empty database,
and would I still need it if the app were broken?*
This is why `DB_*` and `APP_SECRET_KEY` can never move to the database — the
first is needed to reach it, the second to decrypt it. And it is why
`GIT_HTTP_TOKEN` and `HUB_OPS_SECRET` stay in `.env` by choice rather than
necessity: they are the credentials used to *repair* a platform, and a repair
channel must not depend on the thing it repairs.
Full precedence rules and the secret-encryption contract are in
[README.md](README.md#settings-and-secrets).
## 5. Secrets are encrypted at rest, because backups exist
The nightly job keeps many copies of each database. A plaintext SMTP password
would therefore sit in every one of them, and the backups live on disk next to
everything else. So secret-valued settings are encrypted, and the key lives only
in `.env` — never in the database, never in git. A leaked dump alone is not
enough to recover credentials.
Each platform keeps its **own** `APP_SECRET_KEY`. A secret encrypted on hub2
deliberately does not decrypt on dance: one compromised dump must not unlock the
estate.
Which keys count as secret is decided centrally, by name, in
`platform_settings_is_secret()` — never by the calling page. The previous
arrangement had each site keeping its own list, and a credential added later on
a different screen was simply missed.
## 6. Authentication is mechanism, not policy
Sessions, password hashing, login throttling, guards and password recovery live
in `functions_auth.php` and `functions_password_reset.php`. None of it contains
business logic, so there was never a reason for one version per site — and the
separate versions had exactly the drift you would expect. dance ran a bare
`session_start()` with no HttpOnly, no SameSite and no strict mode, never
rotated the session id on login, kept the password hash in `$_SESSION`, and
accepted unlimited login attempts. The other three had each solved some of that,
separately.
Two things stay per-site, deliberately:
- **The users table.** The columns genuinely differ — fotografia's users carry
CalDAV credentials, silver's carry a guardian. The shared code adapts through
the `schema` given to `platform_connect()` (`auth_schema()` underneath),
declaring only the columns that differ, rather than demanding one shape, because renaming a column
with 147 references is a migration and adapting is a function call.
The same map covers *what you sign in with*. Six sites match on `email`;
quack matches on `username` and keeps `email` as the address recovery mails
to, so `auth_schema()` carries a `login` key separate from `email`. It
defaults to null, meaning "same as email" — a site that never thought about
it gets the behaviour it already had.
- **The roles.** `sysadmin`, `admin` and `user` are common and mean the same
everywhere. Past that the vocabulary is the product's: teacher, student,
parent, staff, client, formador, aluno. A site registers its own with a rank,
so
`auth_at_least('admin')` works without anyone hardcoding a list, and an
unrecognised role ranks zero — a typo can never grant access.
Password recovery goes out over the same SMTP transport as everything else, and
stores only the SHA-256 of its token: a leaked database dump must not be usable
to reset anyone's password.
## 7. Fail loudly; silence must mean healthy
The most expensive bug in this estate's history was not a crash. dance's nightly
backup ran every night, printed `DB backup FAILED`, exited `0`, and touched its
success heartbeat. Both monitoring signals — an empty error log and a fresh
heartbeat — reported health while zero backups existed. It took four
independent faults to produce that, and every one of them was individually
survivable; what made it invisible was the exit code.
A related habit from the same incident: dance ran `error_reporting(0)` in
production, which silences the log as well as the screen. Suppress *display*,
never *logging* — the log is the only thing that can explain a production
failure after the fact.
Two rules come from that:
- **An operation that failed must exit non-zero.** Printing the failure is not
reporting it; nothing downstream reads stdout.
- **Health signals must be positive, not the absence of a negative.** An empty
log is ambiguous between "everything succeeded" and "it never ran". Cron jobs
therefore touch a heartbeat file *only* on real success, and the check is the
heartbeat's freshness rather than the log's emptiness.
The same instinct applies in code: a missing configuration value should be a
loud error, not a quiet default that happens to be empty.
## 8. Expose the minimum
Only the reverse proxy publishes ports. MariaDB publishes none and is reachable
only on an internal Docker network. Each site has its own database user with
access to its own schema and nothing else.
Where a control surface must exist, it is bound to loopback and reached over an
SSH tunnel rather than firewalled — because Docker writes iptables rules
directly and a `ufw` rule would not actually apply. That distinction is written
into the compose file, since the safe-looking alternative is the one people
reach for.
Every container runs with `no-new-privileges`, a memory limit and a pid cap,
sized to its workload rather than copied between sites.
Application code sits outside the document root: the web root is `public/`, and
`shared/`, `config/` and `src/` are not reachable over HTTP. Anything that
genuinely must be callable goes through the two front controllers or a thin,
explicit stub in `public/api/` — and the front controllers only dispatch names
from a closed list; they never include a path taken from the request.
## 9. Migrations are append-only
A migration that has been applied is never edited. If it seeded a value that
later turns out wrong, a new migration corrects it. The applied set is tracked
in `applied_migrations`, and the schema is exported deterministically to
`database/schema.sql` so that a git diff on that file is a real changelog.
Editing an applied migration produces a database that matches no known state —
recoverable on the machine where it happened, and nowhere else.
**The filename is the order.** The runner globs `database/migration_*.sql` and
sorts by name, so the numeric prefix is not decoration — it is the only thing
saying what runs when. quack's were originally named after what they did, and
alphabetically `migration_multi_deck` (which creates `decks`) sorted *after*
`migration_anti_hoarding` (which updates it). Thirteen of thirty-five failed
on a fresh install, and nobody noticed for months because the only database
that mattered had been built incrementally, in the order the files were
written. A migration set that cannot rebuild the database from scratch is not
a migration set; it is a changelog that happens to be executable.
## 10. The page works without JavaScript
Pages render complete from the server. JavaScript filters, copies, reorders and
warns — it does not fetch the content. Without it a page degrades to *more*
information rather than less: a filter simply stays off and everything shows.
**Vendoring is the goal, not yet the practice.** hub2 serves every front-end
asset from itself and says so in its own code. quack does the same by a
different route — composer `post-install-cmd` downloads Bootstrap, jQuery,
DataTables and A-Frame into `public/assets/vendor/` at deploy time, and each
`<script>` falls back to the CDN only when the local file is absent, so the
fallback is visible in the page rather than assumed. dance, fotografia and
silver still load Bootstrap and Bootstrap Icons from `cdn.jsdelivr.net` — 12,
11 and 8 files respectively. So this is a direction, not yet a description:
new pages should vendor, and the remaining CDN links are debt worth paying
down, because a third party host is a runtime dependency the container
isolation cannot protect.
## 11. Portuguese for people, English for machines
User-facing text is European Portuguese (pt-PT). Code identifiers, commit
subjects and the infrastructure documentation are English. In-code comments are
written in whichever language the surrounding file already uses — consistency
within a file matters more than uniformity across the estate.
## 12. Data lives outside the checkout
Uploads, logs, caches and backups are bind-mounted from `/srv/data/<host>/` and
never live inside the git working tree. The checkout should be disposable: a
`git pull`, a rebuild, or a fresh clone must never risk user data, and `git
status` on a production site should be quiet enough that real drift stands out.
---
## When these conflict
They will. The ordering that has held so far:
1. **Do not lose data.** Backups and migrations win over convenience.
2. **Do not hide failure.** A loud error beats a graceful degradation nobody sees.
3. **Do not duplicate.** One implementation, parameterised, beats two that agree today.
4. **Do not surprise.** A boring, obvious solution beats a clever one, because the reader six months from now is the constraint.