Skip to content

10 August 2026

16 changes:

  • pramnos init checks both Docker ports before proposing one
  • init scaffolds 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:crud builds 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 delete gets any at all
  • A SPA project gets an administration screen
  • cache:clear no longer wipes every installation sharing the backend
  • Signing in works, screens have URLs, and the SPA wears the project's colours
  • Permissions answers 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:

@fsockopen('localhost', $port, $errno, $errstr, 0.1)

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 + 1 are 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-port is 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

php vendor/bin/pramnos init --app-style=spa --spa-stack=svelte
--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', what UnifiedAuthMiddleware accepts) and a Bearer token for anything else. Failures throw an ApiError carrying the status, so screens can branch on 401 / 422 instead 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 sends no-cache for itself.
  • Routing that keeps the scaffolded server-rendered areas reachable. A SPA project still has a login page and admin CRUD; the generated .htaccess lists exactly the prefixes init created 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. ./testjs runs 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 ./dockernpm runs npm inside the container. init finishes with npm install && npm run build so 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-vite stack 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

docker-compose down -v && cd .. && sudo rm -rf test-app
[sudo] password for mrpc:

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:

  1. The image adopts the host user's ids. The generated Dockerfile takes UID/GID build args and remaps www-data:
ARG UID=1000
ARG GID=1000
RUN groupmod -o -g $GID www-data && usermod -o -u $UID -g $GID www-data

-o allows a duplicate id, which some hosts already have in use.

  1. The ids reach the build. docker-compose.yml passes them as build args, and init writes the host's actual ids into .env, which compose reads automatically. This matters more than it looks: a plain shell does not export UID, so ${UID} taken from the environment would silently fall back to the default on every machine. An existing .env is preserved — only the missing keys are appended — and /.env is added to .gitignore, since it describes one machine.

  2. Commands run as that user. Every docker-compose exec that can write into the mount now passes -u www-data, in init itself and in the generated helper scripts (dockerbash, dockertest, dockernpm, testjs, and the ./<app> CLI wrapper). Composer and npm also get a writable HOME (COMPOSER_HOME=/tmp/composer, HOME=/tmp), because www-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:

npm ERR! Error: EACCES: permission denied, mkdir '/var/www/html/node_modules/@esbuild/aix-ppc64'

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

  ➜  Local:   http://localhost:8084/assets/spa/
This localhost page can't be found — HTTP ERROR 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:

  • locations keyed on id with no users.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.
  • locations keyed on locationid with users.locationid → the FK is created, as before.
  • no locations at 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:

{"application":"myapp","status":"ok","database":"up","time":"2026-08-10T00:58:49+00:00"}

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__:

'apiKey' => md5(str_replace('/api/', '/', getUrl())),

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:

public function delete{{ modelClass }}(${{ primaryKey }})
{
    $model = new ...;
    $model->delete(...);

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:

if (($denied = $this->guard('delete')) !== null) {
    return $denied;
}

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

cache:clear → CacheClear::clearCache('') → Cache::clear('') → redis->flushDb()

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:

./myapp cache:clear --all      # flush the ENTIRE backend, co-tenants included

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:

  • Memcachedflush() 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 at flushEverything(). 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 old flushDb() 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

{"status":403,"error":"InvalidAccessToken","message":"Invalid Access 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 InvalidAccessToken clears 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_schematable_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.