10 August 2026¶
16 changes:
pramnos initchecks both Docker ports before proposing oneinitscaffolds a working SPA — Svelte + daisyUI, Vite, Vitest and Docker- Files the container writes now belong to you, not to root
- SPA follow-ups: the dev server, the site root, and the docs generator
- The FK migration stops assuming the schema of tables it does not own
- The scaffolded SPA now actually talks to the API
create:crudbuilds the SPA half too- A debug bar a SPA can actually use — and a guide for testing the front end
- Generated CRUD gets real authorisation — and
deletegets any at all - A SPA project gets an administration screen
cache:clearno longer wipes every installation sharing the backend- Signing in works, screens have URLs, and the SPA wears the project's colours
Permissionsanswers from whichever store the installation has- One permission system, available to every project with users
timescale:ensure— repairing hypertables on a database that got TimescaleDB late- The GDPR endpoints queried a table that does not exist
pramnos init checks both Docker ports before proposing one¶
The port suggested for a Docker environment now accounts for the database tool's
port too, and it is verified by actually binding it — so init no longer proposes a
port pair that docker-compose up cannot bring up.
The failure¶
Deep into an init run, after the images had been pulled:
Error response from daemon: failed to set up container networking: driver failed
programming external connectivity on endpoint testapp_adminer:
Bind for 0.0.0.0:8081 failed: port is already allocated
Starting Docker environment: FAILED (Exit Code: 1)
A generated docker-compose.yml publishes two host ports: $port for the
application and $port + 1 for Adminer/PHPMyAdmin. The wizard only ever looked at
the first one, so with 8080 free and 8081 taken it cheerfully suggested 8080 — and
the environment died on the tool container minutes later.
Two separate holes¶
Only half the ports were checked. The scan advanced past a busy application port
but never considered $port + 1, even though the compose file it was about to write
publishes it.
The check asked the wrong question. It attempted a connection:
That only finds a port something is listening on and accepting connections from us.
It misses a port held on another interface, and on a host where localhost resolves
to ::1 first it misses an IPv4-only bind — which is exactly how Docker publishes
ports by default. isPortAvailable() now binds 0.0.0.0:$port instead, the same
operation Docker will perform, and reports failure as unavailable.
What init does now¶
busyPorts($port)returns which of$port/$port + 1are taken, so messages can name the offender and say what each port is for.- The suggested default comes from
findAvailablePortPair(): the first base port whose whole pair is free. - An interactive answer is validated, not trusted — a taken port is rejected and asked again (bounded, so a stubborn answer still gets through with a warning).
- An explicit
--docker-portis still honoured (the conflict may be about to clear) but is reported up front:
Warning: port 9801 already in use; "docker-compose up" will fail unless it is
freed first (9800 = application, 9801 = database tool).
Tests¶
tests/Unit/Console/InitPortSelectionTest.php binds real sockets to reproduce each
case: the reported bug (base free, tool port taken), both ports busy, a free pair,
the suggestion stepping over a conflicted pair, and an explicit busy port being
warned about while still ending up in the generated docker-compose.yml.
init scaffolds a working SPA — Svelte + daisyUI, Vite, Vitest and Docker¶
pramnos init gained an application style question. Pick spa (or hybrid)
and it generates the whole front end — sources, build, tests, and the Node
toolchain inside the app image — instead of leaving three stubs to copy by hand.
Before¶
SPA support existed in three unconnected pieces: cookie-as-API-credential auth
(Phase 16), the Services + API app style with create:service, and three
scaffolding stubs. The stubs were referenced by no code at all — not init,
not scaffold:views — so the documented path was "copy them to your document
root". Nothing wired the API prefix, the routing, the build, the tests or the
container.
After¶
--app-style |
Result |
|---|---|
mvc |
Unchanged default — server-rendered controllers, views, themes |
spa |
SPA at the site root; API and scaffolded server-rendered areas keep reaching the front controller |
hybrid |
MVC stays in charge, SPA mounted under /app |
--spa-stack |
Sources | Build | Tests |
|---|---|---|---|
svelte |
frontend/ — Svelte 5 runes, Tailwind v4 + daisyUI v5 |
Vite → www/assets/spa/ |
Vitest + jsdom + @testing-library/svelte |
vanilla-vite |
frontend/ — plain ES modules |
Vite → www/assets/spa/ |
Vitest + jsdom |
vanilla |
www/assets/js/ — served as written |
none | node --test, zero dependencies |
Svelte is what the interactive prompt offers first, and what an invalid value
falls back to — but the flag has no default when it is absent: a run with
--app-style=spa and no --spa-stack asks the question. A non-interactive run
must pass it explicitly.
(Corrected 2026-08-14: this post described svelte as the default, which is only
true of the invalid-value fallback.)
What lands in the project¶
- An API client (
lib/api.js) covering both authentication modes the framework supports: the session cookie for a same-origin SPA (credentials: 'same-origin', whatUnifiedAuthMiddlewareaccepts) and a Bearer token for anything else. Failures throw anApiErrorcarrying the status, so screens can branch on401/422instead of parsing strings. - A shell (
www/spa.php) that handles both cache-busting modes and chooses at runtime: Vite's manifest hashes when a build exists, file-mtime stamps when it does not. It sendsno-cachefor itself. - Routing that keeps the scaffolded server-rendered areas reachable. A SPA
project still has a login page and admin CRUD; the generated
.htaccesslists exactly the prefixesinitcreated and sends everything else to the shell, so client-side routes survive a refresh without swallowing/login. - Tests, and a runner. The API client contract (cookies, Bearer, JSON
encoding,
204, error statuses, a non-JSON error body) plus Svelte component tests for the root screen../testjsruns them in the container, falling back to the host. - Docker that matches the stack. Node/npm are installed in the app image only
when something needs them, Vite's dev-server port is published, and
./dockernpmruns npm inside the container.initfinishes withnpm install && npm run buildso the app is visible immediately.
Verified end to end, not just asserted¶
Each stack was generated into a throw-away project and actually run —
npm install, npm run build, npm test — which caught three defects that unit
tests over generated strings would have missed:
- Vitest resolved Svelte's server build under jsdom, so every component test
failed with "mount(...) is not available on the server". Fixed with
resolve.conditions: ['browser']. node --test tests/js/(a directory argument) makes Node 24 try to load the directory as a module; the run dies before a single test executes. Now an explicit glob.- The
vanilla-vitestack never imported its stylesheet from the entry point, so Vite emitted no CSS and the built page rendered unstyled.
Developing: keep the app URL open¶
./dockernpm run dev starts Vite, but the Vite port serves no HTML — there is
no index.html, the page comes from the application. While the dev server runs it
writes www/assets/spa/.vite/hot, and the shell loads the Vite client and entry
module from it; stop it and the shell falls back to the built bundle. So HMR
happens on the normal application URL, against the real backend and real session
cookies — no proxy, no second origin to log into.
Tests¶
tests/Unit/Console/InitSpaScaffoldingTest.php — the MVC default stays SPA-free;
each stack's sources, build config, dependencies and test runner; the shell's two
cache-busting modes; SPA and hybrid routing; Node in the image only when needed;
.gitignore coverage; the API layer always scaffolded; and the summary telling
the developer how to build, develop and test.
Files the container writes now belong to you, not to root¶
A scaffolded project's Docker image maps its www-data user to the host user's
ids, and every command init runs inside the container runs as that user — so
vendor/, node_modules/ and var/logs stop landing as root-owned and deleting
a test project no longer needs sudo.
The symptom¶
Re-scaffolding a project meant typing a sudo password, because the working tree was littered with files the developer could not delete.
The cause¶
The project is bind-mounted into the container (.:/var/www/html), so file
ownership is shared with the host — by numeric id, not by name. Everything init
ran inside the container ran as root (uid 0):
docker-compose exec -T app composer update # vendor/ → root
docker-compose exec -T app php app.php migrate # var/logs → root
docker-compose exec -T app sh -lc "npm install" # node_modules/ → root
Apache's workers made it worse in the other direction: they run as www-data,
whose uid inside a stock php:apache image (33) matches nobody on the host.
The fix¶
Three parts, all needed — any one alone leaves a gap:
- The image adopts the host user's ids. The generated
DockerfiletakesUID/GIDbuild args and remapswww-data:
-o allows a duplicate id, which some hosts already have in use.
-
The ids reach the build.
docker-compose.ymlpasses them as build args, andinitwrites the host's actual ids into.env, which compose reads automatically. This matters more than it looks: a plain shell does not exportUID, so${UID}taken from the environment would silently fall back to the default on every machine. An existing.envis preserved — only the missing keys are appended — and/.envis added to.gitignore, since it describes one machine. -
Commands run as that user. Every
docker-compose execthat can write into the mount now passes-u www-data, ininititself and in the generated helper scripts (dockerbash,dockertest,dockernpm,testjs, and the./<app>CLI wrapper). Composer and npm also get a writableHOME(COMPOSER_HOME=/tmp/composer,HOME=/tmp), becausewww-data's home is not writable and the cache would otherwise fail.
npm was the last root left¶
The API-docs generator (scripts/doc.sh) also installs node modules, and it still
ran as root — which created a root-owned node_modules that every later npm command
then crashed on:
doc.sh now runs as the mapped user as well. Two repair paths handle whatever is
already root-owned: init chowns the tree once after the containers come up, and
./dockernpm hands back node_modules / www/assets/spa before running if they
are not owned by the web user — so the command that used to fail now fixes itself.
Existing projects¶
Add the two lines to your Dockerfile, the args: block to docker-compose.yml
and a .env with your ids (id -u, id -g), then rebuild:
docker-compose build app && docker-compose up -d
docker-compose exec -u root app chown -R www-data:www-data /var/www/html
Tests¶
InitSpaScaffoldingTest — the image declares and applies the args, compose passes
them, .env carries this host's real ids (via Init::hostUserIds()), an existing
.env survives, /.env is ignored, and every generated helper script runs as the
mapped user.
SPA follow-ups: the dev server, the site root, and the docs generator¶
Three defects found by running a scaffolded project instead of reading it: the Vite
dev server had nothing to serve, the site root rendered the MVC page instead of the
SPA, and npm run docs:build died on require is not defined.
npm run dev answered 404¶
Correct, and unavoidable: a Vite dev server serves an index.html, and this project
has none — the page is a PHP shell served by Apache. The dev server was never the
thing to open.
The fix is the pattern Laravel uses. While it runs, the dev server writes
www/assets/spa/.vite/hot containing its origin (a tiny inline Vite plugin), and
the shell prefers that file over any build output:
if (is_file($hotFile)) {
$origin = rtrim(trim(file_get_contents($hotFile)), '/');
$scripts = [$origin . '/assets/spa/@vite/client', $origin . '/assets/spa/frontend/main.js'];
}
Note the base prefix on both URLs — with base: '/assets/spa/' the dev server
serves its own client underneath it too, and a bare /@vite/client is a 404
(confirmed against a running server). server.cors is enabled because the page and
the modules now come from different origins, and the old /api proxy is gone: the
page is served by the backend, so API calls were always same-origin.
Result: ./dockernpm run dev, keep browsing the application URL, get HMR against
the real backend with real session cookies. Stop the dev server and the shell
returns to the built bundle by itself.
The site root served the MVC page¶
curl http://localhost:8190/ on a --app-style=spa project returned the
server-rendered home page. The catch-all rewrite is guarded by !-d, and the
document root is a directory, so / never reached it — Apache's DirectoryIndex
picked index.php. The generated .htaccess now sets DirectoryIndex spa.php
first.
npm run docs:build broke on a SPA project¶
ReferenceError: require is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension
and '/var/www/html/package.json' contains "type": "module".
The SPA scaffolding adds "type": "module" to package.json, which retroactively
turned the CommonJS API-docs generator into an ES module. It ships as
scripts/apidoc-to-openapi.cjs now, with the npm script and project:resync
updated to match. An existing project gets the new file from
pramnos project:resync --scripts; the stale .js copy is inert afterwards and can
be deleted.
Tests¶
InitSpaScaffoldingTest gained the hot-file wiring, the base-prefixed Vite client,
the DirectoryIndex, the .cjs generator under "type": "module" — plus a check
that runs node --check over every generated JavaScript file, because the
missing comma that broke vite.config.js (tailwindcss()pramnosHotFile()) was
invisible to substring assertions and only surfaced in a real build.
The FK migration stops assuming the schema of tables it does not own¶
AddMissingForeignKeysToExistingTables guarded its optional foreign keys by
asking whether a table existed. It never asked whether the columns did — so
an application with its own locations table saw the migration fail on every
migrate, having done nothing wrong.
The failure¶
✗ add_missing_foreign_keys_to_existing_tables
ERROR: column "locationid" referenced in foreign key constraint does not exist
ALTER TABLE "public"."users" ADD CONSTRAINT fk_users_locationid
FOREIGN KEY ("locationid") REFERENCES "locations" ("locationid")
The block's own comment said it: "The framework does not define locations — it
is an app-level concept." The guard, however, was only
hasTable('locations'), which assumes that any table by that name is keyed
on locationid, and that users has a locationid column. An application that
keys its locations on id — entirely ordinary — hit an impossible
ALTER TABLE, and the migration then showed as failed forever after.
The fix¶
constraintDoesNotExist() already refused to touch a missing child table; the
logic simply stopped one level short. A new canAddForeignKey() beside it now
verifies every side before the ALTER:
if ($this->canAddForeignKey('users', 'locationid', 'locations', 'locationid', 'fk_users_locationid')) {
- the child table exists and the constraint is not already there (unchanged, via the existing per-driver helper),
- the child column exists,
- the referenced table exists,
- the referenced column exists.
All eleven blocks in the file now go through it, not just locations — this
is a category of bug, and tokenactions.urlid → urls.urlid carried exactly the
same latent risk with an equally generic table name. Schema-qualified references
(public.applications) are reduced to the bare name, which is what
information_schema matches on.
Skips are reported¶
A silent skip is indistinguishable from success, so each one writes a single line naming the constraint and what was missing:
Skipping foreign key fk_users_locationid: users has no column 'locationid'.
The referenced schema belongs to the application, not the framework.
Compatibility¶
Nothing is required of applications: an installation with its own locations
simply steps over that one FK. Where both sides exist with the expected schema,
the constraint is created exactly as before. The migration stays idempotent and
safe to re-run — it is already recorded as Ran on existing installations, and
running it again changes nothing.
Tests¶
tests/Integration/Database/ForeignKeyGuardMigrationTest.php, against the real
database — the bug lives in the SQL, not in any PHP branch:
locationskeyed onidwith nousers.locationid→ the migration completes and the FK is not created. Verified to fail with the exact reported error when the old guard is put back.locationskeyed onlocationidwithusers.locationid→ the FK is created, as before.- no
locationsat all → unchanged. - running it twice → safe.
The scaffolded SPA now actually talks to the API¶
A fresh SPA greeted its author with "API answered 403". It called an endpoint that was never generated, without the header the API layer requires. Both halves are now scaffolded — a service, a controller, the route, and a client that speaks the framework's real contract — plus a working sign-in flow when the auth feature is on.
Two things were missing¶
The endpoint did not exist. The demo screen probed /health, which nothing
ever generated. Every unknown route in the API layer answers 403, so the very
first thing a new project showed was a failure.
The request was unauthenticated. The framework's API layer rejects any request
without an apiKey header ("API key is missing", HTTP 403) — so even a correct
path would have failed. The client also sent Authorization: Bearer, which is not
the header the framework reads (accessToken).
The endpoint, in the shape the style prescribes¶
init now generates the whole vertical slice the front end talks to:
src/Services/StatusService.php the behaviour (and its data access)
src/Api/Controllers/Status.php thin: asks the service, shapes the response
src/Api/routes.php GET /status, public
So a Services + API + SPA project starts with one worked example of its own layering, and the first screen shows real data:
The client speaks the framework's contract¶
The shell derives this application's own API key — the md5 of the site URL that
Api::checkApiKey() accepts — and publishes it, with the API prefix and the
enabled features, as window.__PRAMNOS__:
Nothing is hard-coded per environment, and the client attaches it to every call,
along with the framework's accessToken header when a token is held, and cookies
so that a user who signed in through the server-rendered pages is already
authenticated in the SPA.
Sign-in, when the auth feature is on¶
login() / logout() / currentUser() wrap the endpoints that already existed
(/account/login, /account/logout, /me), storing the issued token — in
localStorage, with an in-memory fallback for private mode and tests. The Svelte
screen ships a real form; the vanilla stack the same flow without a framework.
Logging out clears the token even when the server refuses a stale one, and
currentUser() answers null for an anonymous visitor rather than throwing.
Without the auth feature none of this is emitted — a login form posting to endpoints that were never scaffolded is worse than no form.
Authorization: Bearer is accepted too¶
Independently of the SPA: the API used to read only the accessToken header,
so curl, Postman, RapiDoc's Authorize button and any OpenAPI-generated SDK — all
of which send Authorization: Bearer … — came through as anonymous, which reads
like a broken token rather than a header-name mismatch.
Request::accessToken() now resolves, in order: accessToken,
Authorization: Bearer, REDIRECT_HTTP_AUTHORIZATION (Apache with CGI/FastCGI
rewrites it). The framework header still wins when both are present, so nothing
changes for existing clients. ApiAuthMiddleware and ApiAccount::logout() use it.
The generated docs know about it too¶
CLAUDE.md used to describe an MVC project no matter what was scaffolded — an
assistant reading it would add a server-rendered view to a SPA, edit generated
build output, or call the API without the header it requires. It now states the
application style up front and, for a SPA, carries a front-end chapter: where the
sources are, that www/assets/spa/ is generated and off-limits, the
./dockernpm / ./testjs loop, why the Vite port must not be opened, the API
contract, and how to add an endpoint through service → controller → route.
A README.md is written too — the scaffold used to explain itself only to an AI assistant and not to the person cloning the repository. It carries the style, how to start the project, the URL it answers on, the everyday commands, the front-end workflow when there is one, and the API contract.
Tests¶
InitSpaScaffoldingTest — the status slice across all three layers, the runtime
config the shell injects, the client's headers, the sign-in flow with auth on and
its absence with auth off. RequestAccessTokenTest — the header precedence, a
case-insensitive scheme, the REDIRECT_ variant, empty values, and that a Basic
credential is never mistaken for a token. The generated front-end suites cover the
apiKey header, accessToken, login/logout/currentUser. Plus: CLAUDE.md gains
the front-end chapter for a SPA and stays MVC-only without one, and the README
matches the stack it was generated for (npm instructions only where there is a
toolchain).
create:crud builds the SPA half too¶
Running a migration and getting a working feature was an MVC-only privilege: the
generator produced a model, a controller and views, while a SPA project got
nothing it could use. create:crud now reads how the application is built and
generates the matching halves — including the API controller, its routes and a
front-end screen that shows up in the navigation by itself.
What it generates¶
init records the application style in app.php (app_style, spa_stack), and
the generator follows it. --target=mvc|spa|both overrides one run.
| Style | create:crud thing produces |
|---|---|
mvc |
model + controller + server-rendered views (unchanged) |
spa |
model + API controller + routes + front-end screen |
hybrid |
both, over one model: a single domain object, two controllers |
src/Models/Thing.php the model, with getApiList() (created)
src/Api/Controllers/Thing.php list / read / create / update / delete (created)
src/Api/routes.php the routes, inside the version group (edited)
frontend/screens/Thing.svelte table + paging + search + form + delete (created)
frontend/screens/registry.js the entry that puts it in the navigation (edited)
The last two are edited, not written: an existing file gains a line. And
src/Api/routes.php is edited when it can be — a project without that file, or
one whose routes carry no version-group marker to insert into, is left alone and the
routes have to be added by hand.
(Clarified 2026-08-14: this list did not distinguish the files created from the files edited, and did not mention that the routes edit is skipped silently when there is nowhere to make it.)
The screen uses the model's getApiList() pipeline, so paging and search happen
on the server — it never loads a table into the browser to filter it there. The
columns come from the table itself, so they match the migration that created it.
Three defects this uncovered¶
Generating the API half and then actually calling it exposed problems that had been there all along:
Every generated endpoint was unreachable. create:api appended its routes
after the version group, registering them at /thing while requests arrive as
/1.0/thing. Nothing matched, the API fell through to legacy controller
resolution, and the caller got Cannot find controller: 1.0. Routes now go
inside the group.
The routes called the wrong resolver. They used
$this->getController('Thing'), which resolves against src/Controllers — the
MVC side — and cannot see src/Api/Controllers. They now instantiate the API
controller directly, the way the feature-scaffolded routes always did.
Re-running the generator crashed. With the model already present,
createModel() called updateModel() — a method that exists nowhere in the
framework — so create:model or create:crud on an existing entity died with a
fatal error. That is exactly what one does after adding a column. An existing
model is now left untouched (regenerating it would discard hand-written methods)
and the command says so.
Screens are wired up, not just written¶
A generated component nobody imports is not even bundled. create:crud appends
its entry to frontend/screens/registry.js, which the application reads to build
its navigation — so a new CRUD appears by itself, the way a generated MVC
controller does. Both steps are idempotent: re-running leaves the screen and the
registry byte-identical.
The target rides on a property¶
createCrud() is public and applications (and the framework's own tests) override
it. Giving it a $target parameter therefore broke them at load time — PHP
rejects an override that lacks the new parameter, and a broken public signature is
not an additive change. The target is set on $crudTarget before the call
instead, so every existing override keeps working. A test now pins that
signature.
Tests¶
MakeCrudSpaTest — the target follows app_style (including an app.php that
predates the key), the screen is generated with server-side paging and
registered, both steps are idempotent, nothing is generated without a front-end
stack, routes land inside the version group and instantiate the API controller
directly, and registering twice changes nothing.
Verified end to end against a running project: create:crud thing --table=things
→ model + API controller + routes + screen, the endpoint answering through the
version prefix, and vite build including the new screen.
A debug bar a SPA can actually use — and a guide for testing the front end¶
The HTML toolbar is injected before </body>. A JSON response has none, and a
SPA's page is a static shell that never reaches that middleware — so
single-page applications had no debug information at all, for exactly the
requests that do the work. Now the data travels with each response, and a panel
shows it.
Debug data rides along with the response¶
In development the API attaches a _debug key to every JSON response:
{
"application": "myapp",
"status": "ok",
"_debug": { "time": 154.61, "memory": {…}, "queries": { "count": 3, "queries": […] } }
}
No storage, no extra endpoint, nothing to correlate — the data describes the
response it is attached to, including the ones that failed. A Server-Timing
header goes out too, which browsers render in the network panel with no
front-end code at all and which also works for responses with no body.
Never in production. ApiDebugPayload::isEnabled() asks the toolbar whether
it has collectors, and collectors are registered only by
DebugBarServiceProvider, which only boots in debug mode. That keeps one
definition of "development" rather than two that can drift apart. Verified both
ways against a running project: with development => true the key is there, with
false it is gone entirely.
Two things the payload will not do: fail the request — a collector that throws is reported as an error entry inside the payload, because instrumentation is never a good reason for an API call to fail — and outweigh the response: the query list is capped at 100 with the real count kept, so an N+1 is still obvious without shipping a megabyte of SQL.
The panel¶
Every SPA project gets lib/debug.js, and the API client feeds it each
response. It is inert unless debug data arrives, so it ships in every project
rather than being a development-only file the client would have to import
conditionally: no data, no DOM, no panel. When there is data it shows the last
20 calls — method, path, status, duration, query count — each expandable.
Front-end testing guide¶
docs/FRONTEND_TESTING.md is now generated into every SPA project, describing
that project: its runner (Vitest or node --test), its directories, its
commands, with examples using its own API prefix.
It covers what is worth asserting in the API client (the URL, the apiKey
header, credentials: 'same-origin', an ApiError whose status survives a
non-JSON error body), what a screen test should assert (visible behaviour, and
the two unhappy paths that matter: a failed request must become visible text, and
paging must go to the server), what not to test, and the traps — a leaked
fetch stub, awaiting what the component awaits, a token left in
localStorage, importing an entry point for its side effects.
CLAUDE.md and the README both point at it, so it is found rather than
discovered.
Tests¶
ApiDebugPayloadTest — disabled with no collectors (the production path),
enabled once one is registered, the payload carrying timings and collector data,
a broken collector reported rather than thrown, the query cap keeping the true
count and stating what it dropped, and the Server-Timing value.
InitSpaScaffoldingTest — the panel is scaffolded and wired into the client, and
the guide is generated per stack (no Vitest API in a project without Vitest) and
not at all for an MVC project.
Generated CRUD gets real authorisation — and delete gets any at all¶
Every generated API action opened with the same two lines: is there a session
user, and is their id at least 2. That is authentication. It meant any signed-in
user could list, edit and delete every record of every entity — and delete
carried no check whatsoever.
What was there¶
public function display()
{
if (!isset($_SESSION['user']) || !is_object($_SESSION['user'])) {
return array('status' => 401);
}
$user = $_SESSION['user'];
if ($user->userid < 2) {
return array('status' => 401);
}
Repeated in four actions. The fifth — delete<Entity>() — had none of it:
An API key alone was enough to destroy records. The key is not a secret in a same-origin SPA: the shell hands it to the browser, because the API layer demands it on every request.
What replaces it¶
Generated controllers now extend Pramnos\Application\ApiCrudController, and
each action asks one question:
guard() separates the two answers that were previously conflated:
- 401
not_authenticated— no signed-in user. Sign in. - 403
forbidden— signed in, but not permitted. Signing in again will not help, which is exactly what a 401 tells a client to do, forever.
authorize() consults the framework's permission store per action, with three
outcomes and a deliberate default:
| Permission store | Result |
|---|---|
| explicit allow | allowed |
| explicit deny | refused (403) |
| no rule at all | allowed |
The last row is the compatibility guarantee. A project that has granted nothing
behaves exactly as before; anything stricter would lock every existing project
out of its own API on upgrade. Authorisation becomes data: adding a permission
row takes effect without touching code, and it can grant read without granting
delete — which the old single check could not express.
$nonExistEqualsFalse = false is what makes that possible: it lets a missing row
answer null ("no opinion") instead of collapsing to false.
Overriding it¶
The generated controller is the file the application owns, so the seam is there:
protected function authorize(string $action): bool
{
return parent::authorize($action) && $_SESSION['user']->isAdmin();
}
protected string $resource names the resource in the permission store; it
defaults to the controller's own name, so a Thing controller guards thing.
Compatibility¶
Only newly generated controllers change — the base class is additive and nothing
rewrites files already in a project. An existing controller keeps its inline
checks until it is regenerated. Projects with no permission rows see no
behavioural change beyond delete finally requiring a signed-in user, which is
a fix, not a regression.
Tests¶
ApiCrudControllerTest — anonymous requests are 401 without consulting
permissions at all, user 1 does not count as signed in, no rule means
authentication is enough, an explicit deny is 403 rather than 401, an explicit
allow passes, each action is asked about separately (so read can be granted
without delete), and the resource name defaults to the controller's name or is
declared.
A SPA project gets an administration screen¶
The MVC scaffold has always generated whole admin areas — users, settings, logs. A SPA project got none of them, so "the SPA should have the application's functions" meant hand-writing every one. It now starts with the same three things an administrator opens first.
What is scaffolded¶
With the auth feature on a Svelte SPA, init generates
frontend/screens/Admin.svelte and registers it, so it appears in the navigation
like any create:crud screen. It has three tabs:
- Overview — user and session counts, PHP version;
- Users — the user list with server-side paging and search;
- Logs — one page of a log file.
The endpoints are framework-side (Pramnos\Auth\Controllers\ApiAdmin), so
the only generated file is the screen: frontend/screens/Admin.svelte. The routes
instantiate the framework controller directly — no wrapper is generated, so
overriding one means adding your own route ahead of it.
(Corrected 2026-08-14: this post originally described a generated
src/Api/Controllers/Admin.php wrapper. No such file has ever been written.)
Read-only, deliberately¶
Creating and deactivating users has consequences — sessions, tokens, GDPR records — that the existing server-rendered flows already handle correctly. Duplicating them behind a thinner API is how two implementations drift apart until one of them is wrong. Listing, searching and inspecting is what an admin screen needs most, and it is safe to serve twice.
The user list is served by the User model's own _getApiList() pipeline, so
paging, sorting and searching behave exactly as they do everywhere else rather
than being re-implemented for one screen.
The log endpoint takes a name, validated against the viewer's whitelist, not a path: a log endpoint that accepts a path is a file-disclosure endpoint. An unknown name answers 404 instead of reading whatever was asked for.
Authorisation¶
Every action goes through ApiCrudController::guard(), so each is authenticated
and permission-checked separately — a project can grant admin.users without
granting admin.logs. The screen distinguishes the answers too: a 403 reads
"This account does not have permission for this section.", not "could not load",
because on an admin screen those are different problems with different fixes.
The vanilla stacks¶
They get the endpoints — those are framework-side — but no generated screen.
Hand-written DOM for three tabs is not a starting point anybody wants, and
create:crud already covers the screens people actually build.
Tests¶
InitSpaScaffoldingTest — the screen is generated, registered and calls the
three endpoints, the routes and the wrapper controller exist, the 403 case has
its own message, and nothing at all is generated without the auth feature.
Verified live: every admin endpoint answers 401 not_authenticated to an
unauthenticated caller, and the built bundle includes the screen.
cache:clear no longer wipes every installation sharing the backend¶
The cache adapters prefix every read and every write. clear() — the one
operation where the damage is largest — ignored that prefix and flushed the whole
database. Since cache:clear runs on most deploys, every release quietly emptied
the sessions, rate limiters and settings caches of every co-tenant sharing the
server.
The path¶
The category path did it correctly — prefix + category + '_*'. Only "clear
everything" threw the prefix away. A subsystem that defines an isolation rule and
then breaks it itself, in the single case that matters most.
Worse: at least one deployment had chosen cache:clear over
redis-cli FLUSHDB precisely because FLUSHDB ignores prefixes. The replacement
did the same thing, one layer down.
What it does now¶
clear('') sweeps prefix* with SCAN and deletes in batches of 500. KEYS
walks the entire keyspace in one blocking pass, stalling every other client on a
large database, so the category path was moved to the same sweep — that is its
only change.
With no prefix there is nothing to scope to, so flushing remains the correct meaning of "clear everything" — logged, because at that point it genuinely is global.
SCAN+DEL is slower than FLUSHDB on a large keyspace. That is the trade:
clearing is not a hot path, and destroying another installation's data is not an
acceptable optimisation.
Asking for a global flush¶
It is still available, by name:
Cache::flushEverything() and Adapter::flushEverything() back it. --all and
--category are refused together, since they ask for opposite things.
The other adapters¶
The report asked whether this is a Redis problem or a contract problem. It is the contract:
- Memcached —
flush()empties the whole server too. It cannot enumerate keys, so a prefixed installation now clears the category indexes the adapter already maintains; what that misses expires on its own rather than being taken from someone else. - Memcache (legacy) — cannot enumerate or delete by prefix at all. A prefixed
clear('')now refuses and logs why, pointing atflushEverything(). Refusing is worse than working, and much better than silently destroying a neighbour's cache. - File — already correct: it scopes to the prefix directory.
- Array — per-process, so its whole store is its own.
Tests¶
RedisAdapterTest, against a real Redis:
- two prefixes in one database —
clear('')on one leaves the other's keys intact (verified to fail, taking the neighbour's data with it, when the oldflushDb()is restored); - unprefixed keys written by something else survive;
- an empty prefix still flushes globally;
- a category clear removes that category only, in that installation only;
flushEverything()is still global;- 1200 keys clear correctly, so the SCAN cursor advances and DEL batches.
CacheClearTest covers --all and its refusal alongside --category; the
Memcache and Memcached suites now assert the scoped behaviour they previously
pinned in its unsafe form.
Signing in works, screens have URLs, and the SPA wears the project's colours¶
Four problems found by using a scaffolded SPA rather than reading it: signing in was impossible after a redeploy, an anonymous visitor was told they lacked permission instead of being asked to sign in, no screen had a URL, and the SPA looked like a different product from the pages beside it.
Signing in was impossible with a stale token¶
The API validates the access token before routing. A token signed with a key the application no longer has — which every re-scaffold and every key rotation produces — therefore failed every request with 403, including the login that would have replaced it. The application was wedged until someone cleared their browser storage by hand.
Two changes, either of which would have been enough, and both of which are right:
login()is explicitly anonymous — it sends no access token. The one call whose purpose is to obtain a token must not require a valid one.- Any response carrying
InvalidAccessTokenclears the stored token, so the next request is clean instead of failing identically forever.
Reproduced and verified against a running project: with a stale token the login
POST returns exactly the reported 403; without it, {"status":"success",
"access_token":…}.
Anonymous visitors are asked to sign in¶
Clicking a protected screen while signed out said "You do not have permission to see this" — a dead end, and untrue: permission was never the problem. Access is now decided in one place:
- not signed in → the sign-in screen;
- signed in but not allowed → the front page.
Both use replaceState, so the back button does not bounce the user into the
page that just refused them. The decision waits until the current user is known,
or a reload would throw a signed-in user back to the sign-in screen.
Every screen has a URL¶
lib/router.js is a small History API router fed by the screen registry, so a
screen generated by create:crud gets its URL without anyone editing it.
Navigation is real <a href> — modified clicks (new tab, middle button) stay
with the browser — the back button works, and deep links survive a refresh
because the server already renders the shell for unmatched page requests.
The sign-in screen lives at /signin, not /login: the server-rendered login
page owns /login, along with password reset, 2FA and the OAuth flows.
The SPA wears the project's colours¶
The shell now mirrors the theme's structure — header with the application name and navigation, content, footer — and the palette is generated from the theme:
:root:root {
--color-primary: #2563eb; /* the theme's --primary-color */
--color-base-content: #1e293b; /* its --text-main */
--color-neutral: #64748b; /* its --text-muted */
}
daisyUI 5 reads its palette from CSS custom properties, and so does the
scaffolded theme, so scripts/build-theme.mjs parses the theme's :root block
and maps it across rather than having the colours chosen twice by hand. A theme
that declares none (bootstrap, tailwind — they bring their own systems) falls
back to that framework's own brand colour, not daisyUI's default purple, and the
generated file says why.
At build time, not per request. npm runs it from prebuild and predev, so
a colour changed in the theme reaches the SPA on the next build — which is when
the CSS is rebuilt anyway. Deriving it in the PHP shell instead was measured at
0.0043 ms per page request (0.009% of a 50 ms request), so CPU was never the
argument: it would have meant an inline <style> on every page load for a
Content-Security-Policy to be taught about, to buy nothing. The script is the
single implementation of the mapping, so there is nothing for it to drift from.
The selector is doubled on purpose: CSS requires @import before other rules, so
the palette wins on specificity rather than on where it happens to land in the
output.
Elsewhere¶
The init summary now prints the SPA's full URL (open it at
http://localhost:8390/app) instead of describing its mount point, and a hybrid
project's server-rendered front page links to the SPA — otherwise it is easy to
forget behind the pages that answer at the root.
Tests¶
InitSpaScaffoldingTest — login is anonymous and a rejected token is dropped;
the router has history entries, honours modified clicks and uses /signin;
anonymous visitors are redirected to sign in; the shell has header, nav and
footer with the theme's footer line; and the MVC front page links to the SPA.
The palette is covered by running the generator, not by re-stating its mapping: the theme's own colours come out, a colour changed in the theme comes out changed on the next run, and a theme with no custom properties falls back to its framework's colour with the reason written into the file. A test that re-implemented the mapping could only ever agree with itself.
Permissions answers from whichever store the installation has¶
Superseded the same day
This post describes the legacy table as the preferred store. That ordering
was inverted a few hours later, once the permission tables moved into the
auth feature and every installation with users started having the new
store. See One permission system, available to every project with
users.
A brand-new project's own administrator was told "You do not have permission to see this". Nothing had been denied — the permission lookup had failed, and a failed lookup was indistinguishable from a refusal.
Fixed¶
Pramnos\Auth\Permissions read one table, <prefix>permissions. No migration
creates it and nothing calls setupDb(), so on a stock installation it does not
exist — every query failed, every failure was reported as false, and any caller
that trusted the answer refused everything.
It now selects its store: the legacy table when an installation has one, and
otherwise authserver.permissions, the schema the framework actually maintains
and that PermissionResolver reads. With neither, isAllowed(..., false)
returns null — "no opinion", which is what a missing store always meant.
The API is unchanged, and the legacy table stays authoritative wherever it exists, so an installation that has one sees no difference. Its 26 characterization tests pass unmodified on MySQL and PostgreSQL.
Reading the richer model through the older interface narrows it, deliberately:
admin maps to the * action, object scoping is respected in both directions,
and grants carrying ABAC conditions are ignored — the resolver hands
conditions to the application to evaluate against its own request context, and
this API cannot receive one. Treating a conditional grant as unconditional would
hand out access the rule did not give. Use PermissionResolver where conditions
matter.
Generated API CRUD controllers also consult the application's own permission
scheme first: when the User class implements hasPermission() and declares the
name in getAllPermissions(), that answer wins, so a generated endpoint is never
looser than the hand-written ones beside it. A permission the application does
not declare is no opinion rather than a denial — otherwise a new entity would
return 403 until somebody added a column.
The SPA admin screen now separates 401 (sign in again), 403 (this account lacks a permission) and an unreachable endpoint, which are three problems with three different fixes.
Documentation¶
Legacy Permissions Migration —
how the two models line up, and re-runnable SQL for moving rows out of a legacy
table into authserver.permissions, for any installation that still has one.
One permission system, available to every project with users¶
Permissions used to arrive with the OAuth server. An application that only had users — no OAuth, no clients, no tokens — got no permission tables at all, so there was nowhere to record who may do what.
Changed¶
The RBAC tables moved from the authserver feature to auth:
authserver.schema, authserver.roles, authserver.permissions,
authserver.user_roles, and the audience/conditions columns on permissions.
Anything that is genuinely about running an authorisation server — permission
and role templates, inheritance, the audit log, the effective-permissions view,
the RBAC functions, client capabilities — stayed where it was.
Nothing was renamed. The schema is still called authserver, the filenames and
migration slugs are unchanged, and every up() guards on the table already
existing, so an installation that has run these migrations sees no change and
re-runs nothing. A project that enables authserver without auth still
resolves the dependency, because the migration runner pulls a declared
dependency from the full framework pool regardless of which features are on.
Pramnos\Auth\Permissions now treats authserver.permissions as the store,
not as a fallback: it is what every installation with users has, and what the
rest of the framework reads and writes. The legacy <prefix>permissions table
is used only where the new store is absent. Where both exist — an installation
that hand-built the old table years ago — the new one is used and the fact is
logged, because rows left behind stop counting from that moment and finding out
through a permission that "stopped working" is worse than being told.
Fixed¶
allow(), deny() and removePermission() wrote to the store that is
actually there. The read path had moved; the write path had not, so a grant
could be neither made nor revoked on any installation that had not hand-built
the legacy table — and the class would then report "no such permission" about a
grant it had just refused to store. Both sides now use the same store, with the
mapping the migration guide documents: admin becomes the * action, an empty
element becomes a NULL object_id, a group becomes a role, and a deny is stored
above allow so it wins a tie. A subject type the model cannot express is refused
and logged rather than written under a wrong subject_type.
removePermission() did not clear the instance cache. setPermission()
did. Within a single request, a revoked permission kept answering "allowed" from
memory: the query cache was flushed, but nothing ever asked the database again.
Auth::useraccess(), groupaccess() and setaccess() reached the permission
system at all. All three called pramnos_factory::getPermissions() — a class
that exists nowhere in the framework or its dependencies. Every call raised
Class "pramnos_factory" not found before consulting a single permission, so
the framework's own documented way of asking about access was unreachable
regardless of database or table. (Factory::getPermissions() does exist; only
the legacy pramnos_factory alias does not.)
Store detection asked the schema builder instead of running a SELECT. A
failed SELECT does not reliably raise — it can simply return false — so a
missing table could be mistaken for a present one. The builder also resolves
authserver.permissions to whatever the driver actually calls it: a schema on
PostgreSQL, a prefixed table on MySQL.
Documentation¶
The Authentication Guide permissions
section was rewritten. It documented Permissions::allow() and a
Permissions::check() as static methods — check() does not exist, and the
real methods are instance methods reached through getInstance(). It also
printed a CREATE TABLE permissions statement for a table no migration has ever
created, which is one way installations ended up with a hand-built legacy table
in the first place. It now describes one store, the two APIs that read it, and
when to use which.
Legacy Permissions Migration was updated for the new precedence.
timescale:ensure — repairing hypertables on a database that got TimescaleDB late¶
Seven framework tables are meant to be hypertables. On a database that ran the migrations before the extension was installed, they are plain tables — and stay plain tables for ever, growing without bound because their retention policies never apply.
The gap¶
Those migrations create their table and convert it inside
ifCapable(TIMESCALEDB, …). That is the right tool for creating a hypertable on
a fresh install and the wrong one for its lifecycle: the migration is recorded
as applied, so it never runs again. Install TimescaleDB a year later and nothing
goes back to finish the job.
The tables are correct in every other respect — the composite primary keys
(id, <time column>) are created unconditionally, outside the capability block,
which is exactly what makes a later repair possible. But they are never
partitioned, never compressed, and their retention policies never apply. That is
the normal path for any long-lived installation that adopts TimescaleDB later,
not an edge case.
Added¶
Pramnos\Database\HypertableRegistry — one declaration of which tables are
hypertables and with what parameters. The seven migrations now read it instead
of writing the values out inline, so the table of chunk intervals, compression
windows and retention periods exists exactly once. Applications register their
own the same way and get the same repair.
php pramnos timescale:ensure — walks the registry and brings each declared
table in line: convert with migrate_data => true, enable compression, add the
compression policy, add the retention policy. Every step is guarded by its own
existence check, so a second run is a no-op and a run against a correct database
changes nothing. That is not a nicety — add_compression_policy() and
add_retention_policy() raise on a duplicate rather than no-opping, so an
unguarded repair would work exactly once and fail ever after, which is worse
than not having one.
--dry-run reports each table's state, the row count of anything pending
conversion, and the total, before anything is locked. Conversion rewrites the
table under an exclusive lock; on a years-old audit table that is not instant,
and the command says so rather than letting an operator find out.
--table= limits the run to one declared table.
A database without the extension comes out unchanged and is told why: retention there is handled by the software policy engine, a different mechanism rather than a broken one.
Before converting, the command verifies that the primary key contains the partitioning column — TimescaleDB requires it in every unique constraint. It should always hold, which is why it is checked rather than assumed: a table whose key omits the time column is reported as blocked, with its actual key, instead of surfacing a driver error.
Schema introspection: hasHypertable(), isCompressionEnabled(),
hasCompressionPolicy(), hasRetentionPolicy() and primaryKeyColumns() on
SchemaBuilder. All return false (or []) on backends without TimescaleDB
rather than raising.
Fixed¶
isHypertable() always answered false for an unqualified table. It defaulted
its schema argument to resolveSchema(), which is an empty string unless a
withSchema() override is in force — and '' matches no row in
timescaledb_information.hypertables. Fine for building SQL, where an
unqualified name resolves through the search path; useless for querying a
catalogue view that reports the real schema. Unqualified tables now resolve to
public, which is where the framework creates them.
Documentation¶
Hypertables (TimescaleDB) — the declared parameters, how to repair an installation, how to declare your own, and why the conversion locks.
The GDPR endpoints queried a table that does not exist¶
Every one of them — create a request, check its status, list requests — read
oauth2_gdpr_requests. No migration has ever created that table. The endpoints
failed at runtime, on a feature with legal weight.
Fixed¶
The GDPR controller now reads the table the framework actually creates.
authserver.gdpr_requests, with the columns it actually has: id not
request_id, userid not user_id, requested_at not created_at. The
controller had come from an OAuth server whose schema — with
apps_notified, apps_confirmed, data_export_url, expires_at — was never
adopted here, and was never adapted.
The response keys are unchanged, read through SQL aliases, so what the endpoints documented is what they still return. Nothing could have depended on them regardless: they returned an error every time they were called.
Two smaller mismatches came with it. The table records the data subject and has
no column for a second person, so when an admin files a request on somebody's
behalf that now goes into the audit trail (request_details) instead of a
column that does not exist. And the request-type vocabulary is reconciled: the
column documents access, erasure, portability, rectification,
restriction, while the endpoint accepted export, delete, portability.
Both spellings are accepted; the GDPR vocabulary is what gets stored.
Every query in the controller now goes through the query builder. That is what
made the underlying problem invisible for so long: Database::query() translates
neither authserver.gdpr_requests (a schema on PostgreSQL, a prefixed table on
MySQL) nor anything else, so a hand-written table name was never checked by
anything.
Five foreign keys were never created on any installation.
2020_01_01_000050 adds them with schema('public')->table('gdpr_requests'),
but these tables live in authserver: user_privacy_settings, user_consents,
data_processing_records, gdpr_requests and user_activity_log. The lookup
found no such table in public, the guard skipped the block, and the skip was
indistinguishable from success. The migration now addresses them by their real
names, and its constraint check splits the schema before asking
information_schema — table_name = 'authserver.gdpr_requests' matched nothing,
so an existing constraint read as missing.
PermissionResolver no longer fails outright when the role-assignment table
is absent. It queried authserver.user_roles unconditionally; on an
installation without it the driver error escaped and took the whole resolution
down. Callers that turn "cannot answer" into "denied" then refused every direct
user grant as well — the opposite of what the rows said. A missing role table now
means what it should: this installation grants nothing through roles.
Added¶
2026_08_10_000001 repairs installations that already have the defects.
Correcting the migrations above only helps fresh installs — the originals are
recorded as applied everywhere else and will never run again, which is exactly
the gap being closed. The repair renames gdpr_requests.notes to
processing_notes (the name the production schema this table was modelled on
uses) and adds the five missing foreign keys.
Every step is guarded: a correct database comes out unchanged, an installation missing only part gets only that part, and running it twice does nothing.
It refuses to add a foreign key while orphaned rows exist, and says how many.
These tables went years without the constraint, so nothing stopped a request
from outliving its user; adding the key on top of one fails, and a failing
ALTER aborts the whole batch, taking unrelated migrations with it. A skip is
recoverable — clean up the rows, run migrations again, the key appears.
Verified by integration tests that build a broken installation and assert both
halves of the question: that the runner selects the repair when the baseline
is already recorded as applied, that it survives migration_cutoff =
2020_01_02_000000, and that the database is correct afterwards — including that
the rename keeps every row.