Commit Graph
19 Commits
Author SHA1 Message Date
Sluijsens 6882eda03f Adds systemd --user bus-connection troubleshooting
Enabling lingering doesn't start the user manager retroactively, and
sudo -u often drops XDG_RUNTIME_DIR - both produce the same "Failed
to connect to bus" error when setting this up by hand.
2026-07-29 12:56:01 +02:00
Sluijsens 33d18ebbf8 Switches the database from SQL Server to MariaDB
The target Pi only has MariaDB, and SQL Server has no ARM64 build at
all - not a config problem, a real gap discovered during deployment
setup. Swapped the EF Core provider, regenerated every migration,
updated connection strings and the backup script everywhere they
appear.

Took two tries to land on a provider that actually works: Pomelo
builds fine against this project's EF Core 10 packages but fails at
runtime (it's compiled against 9's internal API surface, which moved
in 10 wherever Identity/DataProtection force the newer packages).
Oracle's official provider builds and migrates fine but has a real
MariaDB bug in its own migration-lock code, reproduced against a live
database. Kept Oracle's provider and worked around just that one
broken method - everything else it does is correct - rather than
give up more of the stack to chase a workaround.

Verified against a real local MariaDB end to end: all three
migrations applied, both hosts start clean, full suite still green.
2026-07-29 11:59:09 +02:00
Sluijsens 579e0ceaac Fixes a wrong placeholder and explains the optional env fields
AllowedConnectOrigins had a sentry-ingest-origin placeholder that
was simply incorrect - the Sentry tunnel exists precisely so that
exception is never needed. Both SecurityHeaders fields are Umami-only
now, with inline comments on when to set them versus leave them out,
and a note that MasterModule__MasterUrl degrades to a warning rather
than a startup failure when unset.
2026-07-28 23:17:52 +02:00
Sluijsens 97f18d00e5 Names the deployed instance slpsoftware, not the software itself
SlpModularCms is the product; slpsoftware.nl is the customer running
it - the first one, and a fine stand-in for what a real deployment
looks like. Renamed the directories, systemd units, and backup paths
accordingly. Left the backup script's own filename alone since the
already-committed deploy-scp.yaml calls it by that exact name.
2026-07-28 22:16:04 +02:00
Sluijsens 843253888e Nests environments under one slpmodularcms folder instead of siblings
Applies to both the app's own release tree and the website upload
path - slpmodularcms/test and slpmodularcms/production side by side
under one parent, not two separately-named directories. Service unit
names stay hyphenated; those aren't folders.
2026-07-28 21:43:37 +02:00
Sluijsens 25a3de0f1f Stops mixing gitea-workflow with the generic deploy-user placeholder
Same account, one name throughout instead of switching partway in.
2026-07-28 20:08:44 +02:00
Sluijsens 366e912e73 Renames the example deploy account to gitea-workflow
pi-deploy restated the obvious (it's on the Pi) without saying what
the account is actually for.
2026-07-28 19:51:10 +02:00
Sluijsens 960760c24e Makes the deploy account generic instead of per-project
No reason to mint a new system account for every project on the
same Pi - one shared, host-wide account works, namespaced by
directory per project underneath it.
2026-07-28 19:46:58 +02:00
Sluijsens 2f243f23c3 Fixes the account model deploy setup got wrong
webadmin turned out to be FTP-only and belongs to whoever uploads
customer websites, not to this pipeline. Switched to a separate
deploy account with its own home-directory paths, well away from
where the other sites live, and made the persistent website symlink
cross into webadmin's territory instead of the other way around.
2026-07-28 19:41:34 +02:00
Sluijsens cf80f827ae Deployment setup - the parts the workflow deliberately left out
Host setup checklist, real domains and ports, the database backup
script the deploy workflow only ever invokes, and a rollback plan.
Also drafted the FTPS switch procedure for whenever production moves
off the Pi, with a note that shared hosting is likely IIS-based -
that's a bigger change than swapping the transport.
2026-07-28 19:07:26 +02:00
Sluijsens 9ab30fe2a6 Closes construction - build and test across all seven units
Unit suites already passed individually; this is the full solution
together plus what none of them could check alone: two real hosts
talking to each other, a slave API key encrypted by one process and
decrypted by the next one after a restart, and headers landing on
actual static files instead of a simulated response.
2026-07-28 16:47:45 +02:00
Sluijsens 73126f5a9c Closes round 4 - all seven units generated
U7 was the last one. State doc corrected: it had U7 down for an
infrastructure design pass that never actually happened, because by
the time units were carved up U7 had shrunk to documentation only.
2026-07-28 16:30:58 +02:00
Sluijsens 88770c5bd0 U7 — tells a website builder what they need to know
WEBSITE_WORKSPACE.md, plus the parts of the README that were still
describing the old layout or a manual step the code doesn't need
anymore (the key-ring paragraph, mainly - that one was actively
wrong now, not just stale).
2026-07-28 16:30:19 +02:00
Sluijsens 9f4ae475e7 U5 — the gate everything else has to pass
continuous_integration.yaml: six blocking checks, then a separate
publish per environment so a Vite build never gets tagged for the
wrong one, then a call into last commit's deploy workflow. Along the
way: the lint list had drifted (two problems not in the requirement,
one already fixed), and the Umami-origin gate needed a variable pair
of its own since the backend's side of that comparison lives on the
host, not in CI. Pinned the two vulnerable packages while at it.
2026-07-28 16:07:03 +02:00
Sluijsens bd2a963498 U6 — the deploy workflow itself
deploy-scp.yaml: download, back up the production database before
touching anything, upload into a timestamped release, symlink the
persistent website in, switch current and restart, verify /health
with retries, prune old releases only once that check passes. No
sudo, no container actions, one shared interface U5 will call next.
2026-07-28 15:33:03 +02:00
SluijsensandClaude Opus 5 247d8be241 Closes round 2 of the deployment feature
Records the stage as complete and logs the two defects that local testing
turned up after U3 and U4 were generated: ciphertext predating the key-ring
move, and an integrity check that could not tell an unreachable slave from
one that does not recognise the master.

Both were fixed in this branch rather than filed. The second is master/slave
domain behaviour rather than deployment work, so the state file says so
plainly — it sits here by decision, not because it belongs to the feature.

Verified at close: build 0 errors, 372 backend tests, 237 frontend tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
2026-07-28 12:35:44 +02:00
SluijsensandClaude Opus 5 8e79a72340 Makes the application say what it is doing and when it fails
U4. Console logging plus Sentry, a same-origin tunnel so ad blockers cannot
silence browser errors, Umami on the admin SPA, and six security events that
alert rules can actually be built on.

The correlation id is the W3C trace id from the ambient Activity, enabled by
one line of ActivityTrackingOptions so every entry from every category carries
it without touching a call site. It propagates across the master/slave
boundary via traceparent, which TraceIdentifier cannot do at all, and it is
the same value ProblemDetails already returns to the browser.

The security events use source-generated LoggerMessage with constant
templates. Sentry groups log events by message, so interpolating an email
address would give every address its own issue and "more than 20 failed
logins in five minutes" could never fire — the events would arrive, be
visible, be tagged, and the alerting would silently be impossible. A test
asserts the rendered message is identical across argument values.

Scrubbing happens in-process, before transmission, and covers Set-Cookie as
well as Cookie: the login response issues the refreshToken there, so
scrubbing only the request side would protect nothing. Transactions are
scrubbed too, because they carry request data and are the channel nobody
thinks of.

The tunnel derives its destination from the DSN once at startup and reads
nothing from the request, which is what separates a tunnel from a
server-side request forgery primitive. Size is capped by a bounded read
rather than by trusting Content-Length, and the endpoint is rate limited.

Two things found along the way. Zod 4's url() hands the value to the URL
constructor, which accepts any scheme — so the existing frontend validation
would have accepted the exact "htp://" typo BR-U4-24 names, and the SPA
would have called a nonexistent origin. Now constrained to http(s). And the
new appsettings comments are verified against the real configuration
provider, because the failure mode if it rejected them is both hosts
refusing to start after a release switch.

One deviation. IAdminTokenValidator was meant to gain a reason-reporting
overload; implemented that way, a substitute returning false by default
silently inverted the access decision while both methods compiled. Two
methods whose difference is invisible at the call site is the defect, so it
is now a single Validate returning AdminTokenResult.

Touches two files from already-committed units: DatabaseMigrationExtensions
(U2) gains a flush before the rethrow, or the one Critical event in the
system dies with the process; AdminTokenValidator (U1) classifies why a
bypass was refused.

Build 0 errors; 366 backend tests pass, up from 315, and 237 frontend tests,
up from 213. tsc clean, eslint clean on every changed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
2026-07-28 11:25:54 +02:00
SluijsensandClaude Opus 5 5102f8668b Settles how the headers and observability units get built
NFR Design for U3 and U4. Two decisions the earlier stages had deliberately
left open, plus four risks the functional design did not name.

OPEN-01 closed: the correlation ID is the W3C trace ID from the ambient
Activity, with TraceIdentifier as the fallback. It propagates across the
master/slave boundary via traceparent, which TraceIdentifier cannot do at
all, and it is the same value ProblemDetails already returns to the client.

REF-U3-01 raised: BR-U3-22's Umami-origin startup warning cannot work. The
backend never sees VITE_UMAMI_WEBSITE_ID, so the check would either always
warn or never warn. Withdrawn from U3 and replaced by a blocking U5 CI gate
that compares the frontend build variable against that environment's CSP
origins, where both values are visible.

Four additions beyond the functional design:
- Set-Cookie added to the scrub list; the login response issues the
  refreshToken there, so scrubbing only the request cookie protects nothing
- SetBeforeSendTransaction alongside SetBeforeSend; transactions carry
  request data too
- OnRejected on the rate limiter; today a 429 leaves no trace anywhere
- FlushAsync before the migration-failure rethrow, or the one Critical
  event in the system dies with the process

Two traps recorded with tests attached: Sentry groups log events by message
template, so interpolated messages make FR-19's rate-based alert rules
unimplementable while appearing to work; and DefaultHttpContext.Response
.OnStarting is a no-op, so the obvious middleware test asserts nothing.

Three values chosen rather than escalated, each one line to change and all
three listed for review at the end of U4's pattern document: JSON console
outside Development, TracesSampleRate 0.1, tunnel cap 200 KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
2026-07-28 10:25:11 +02:00
SluijsensandClaude Opus 5 357d395629 Designs the security headers and observability units
Records the functional design for the two remaining application units,
before any of their code exists.

Security headers have to come from the application, because relying on
nginx or IIS configuration is exactly what this deployment model rules
out. Strict applies to /admin, /api/v1 and /health; a relaxed policy
applies to the public website, which this repository does not author.

The strict policy needs style-src 'unsafe-inline'. That is not a
shortcut: Radix positions dropdowns and dialogs with inline style
attributes recalculated per click and scroll position, and CSP nonces
apply only to style elements, never to style attributes. No nonce- or
hash-based variant leaves the admin UI working. The exception is bounded
to styles — script-src stays closed, which is where XSS actually lives.

The website's policy is enforcing rather than absent, so every
HTML-serving path carries a CSP and no exception has to be recorded. It
still blocks external script origins, so it remains a real boundary.

HSTS is skipped in development: browsers remember it per host and
localhost is shared with unrelated projects. Every other header applies
locally, so a CSP violation surfaces while developing.

For observability, browser error reports tunnel through the API rather
than going to Sentry directly. Ad blockers block Sentry domains, which
loses errors precisely for the users most likely to have browser
oddities. The tunnel forwards only to the host derived from the
configured DSN — a caller-supplied destination would turn an anonymous
endpoint into a request-forgery primitive.

Two consequences of the chosen options are recorded rather than left
implicit:

Enabling SendDefaultPii attaches request headers, and this application
carries two standing credentials in them. Besides the refreshToken
cookie, X-Master-Api-Key would have been sent to a third party on every
error raised during a master/slave call. The scrub list removes the
whole Cookie header, Authorization, X-Master-Api-Key and the request
body.

Console logging at Information plus structured logging to Sentry would,
taken literally, mean one Sentry event per request — exhausting the free
plan within hours and burying real errors in request noise. The
thresholds are split: console keeps Information, Sentry takes warnings
and above as events with Information as breadcrumbs, so every event
arrives carrying the trail that led to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
2026-07-28 00:01:04 +02:00