v6.2.0
Minor Changes
-
e60409d: Bring the admin shell to structural parity with awcms-micro's admin pages.
Admin shell (
src/layouts/AdminLayout.astro) — adopted from awcms-micro'sAdminLayout.astro:.admin-shellcolumn wrapper + sticky topbar. The layout row's hardcodedmin-height: calc(100vh - 57px)(a measured topbar height) is replaced byflex: 1, so added topbar chrome can no longer desync it.TenantBadge(src/components/TenantBadge.astro) names the active tenant in the topbar. Rendered as a plain non-interactive badge, never a<select disabled>— awcms scopes an identity to exactly one tenant, so there is nothing to switch to, and a disabled control would advertise a capability with no server-side enforcement behind it.availableTenantsis kept as the seam for a real, server-computed switcher later.ThemeToggle(src/components/ThemeToggle.astro) cycles system → light → dark, persists tolocalStorage["awcms_theme"], and follows the OS while in system mode. awcms already shipped:root[data-theme="dark"]tokens with nothing to set the attribute — dark mode existed but was unreachable. This closes the dark-mode follow-up noted in PR #215.SyncIndicator(src/components/SyncIndicator.astro) — dot + label driven by the realfetchSyncIndicatorActive, a boundedEXISTSoverawcms_sync_nodesrather than the full sync-health aggregation. It shares ONE transaction with the tenant-name lookup, so the whole topbar costs a single round trip per/admin/*render.LocaleBadge(src/components/LocaleBadge.astro) fills micro'sLanguageSwitcherslot. awcms has no gettext catalog, so a<select>with one option would be a control that cannot do anything; the badge states the served language without pretending to offer a choice.- Avatar + roles + log-out cluster in the topbar. The avatar is a plain tile, not a link — micro's points at
/admin/profile, which awcms does not have. - Two-level sidebar (section heading → owning module → links: General; Identity → Profile Identity / Identity & Access; System → Tenant Admin / Tenant Domain / Module Management / Email; Operations → Visitor Analytics) replacing one flat list, with the app version pinned to the footer. Grouping is presentation only — every route still runs its own ABAC guard, and a visible link grants nothing.
- Breadcrumb above the page slot.
Dashboard (
src/pages/admin/index.astro) — rebuilt, and not only cosmetically. It previously renderedAstro.locals.ssrContextalone (tenant id, role count, permission count) plus quick links, with no database read at all — a page about your SESSION rather than your TENANT. It now renders the same four reports awcms-micro's dashboard does, every one of which already existed in this repo'sreportingmodule and had simply never been surfaced in the UI:- Accent-barred KPI tiles: active users, active offices, allow-decisions in the window, and active/total sync nodes with a "Needs attention" badge when sync is unhealthy.
- Detail cards for Tenant Activity, Access & Audit, and Sync Health, with alert styling on non-zero denies, open conflicts, and failed objects.
- A Module Usage table (18 rows against a fresh tenant).
Reads are gated on
reporting.dashboard.read, so "you may not see this" stays distinguishable from "there is nothing here", and a report failure degrades to a notice rather than 500-ing the first page every admin lands on. The session cards remain below as the fallback view, preserving the#admin-dashboard-heading/#dashboard-tenant-idhooks asserted bytests/e2e/admin-offices.e2e.ts.CSP change —
script-srcis now unconditional. The theme-init script must run synchronously in<head>or the shell flashes the wrong theme, which a deferred Astro-bundled module cannot do. It is therefore the oneis:inlinescript in this repo, admitted by SHA-256 (src/lib/security/theme-init-script.ts), not by'unsafe-inline'— a hash authorises one exact byte sequence.script-src 'self' '<hash>'is now always emitted instead of appearing only for Turnstile; the LAN/offline guarantee that no third-party origin appears is unchanged. Verified in a real browser-shaped render, not just bycurl: the bytes Astro emits hash to exactly the registered value (tests/theme-init-script.test.tsfails on drift, since a mismatch is otherwise silent — no error, no log, just a blocked script).Deliberately NOT ported from awcms-micro, each because the backing capability does not exist here:
LanguageSwitcher(no gettext catalog),SyncIndicator(would add a per-request reporting query), the profile icon (no/admin/profileroute), the per-tenant sidebar-arrangement subsystem, and micro's JS drawer — awcms's CSS-only checkbox drawer is kept, since it needs no script at all and swapping it for JS would be a regression dressed as parity.Verified against a real PostgreSQL: all 10 admin screens render 200 through the new shell, and the tenant badge resolves its name from the database with a shape-checked fallback (this repo's
withTenantreturns a 503Responseon circuit-open rather than throwing, so a barerows[0]would have silently producedundefined). -
952d616: Port the
commentsmodule from awcms-micro (ADR-0041) — moderation-first
commenting over published, public resources.Registers the 21st base module. Content modules declare which of their resources
accept comments through the newModuleDescriptor.commentableResources
descriptor list (MODULE_CONTRACT_VERSION2.2.0 → 2.3.0, additive optional
field);commentsdiscovers them vialistModules()and depends only on Core,
so nothing depends on it and the DAG stays acyclic.blog_contentcontributes
the first descriptor.Ships seven tables (
sql/066, all ENABLE + FORCE RLS), eight permissions
(sql/067, reusing existingAccessActionliterals — no union widening), ten
API routes, an SSR moderation queue at/admin/comments, three domain events, a
legal-hold-aware retention sweep (bun run comments:retention), and a registry
gate (bun run comments:resources:check).Because this is an unauthenticated public write surface: bodies are stored as
plain text and escaped on render (no stored HTML, so no stored XSS); public
submit responses are uniform, so the endpoint cannot be used as an oracle for
blocked terms or unpublished content; author email, IP, and user-agent are only
ever stored hashed or masked; and notification recipients are encrypted under
their own key, with an unresolvable sentinel rather than plaintext when no key
is configured.Three defects in the source were fixed rather than carried over: a
millisecond-rounded keyset cursor that skipped rows,published_atbeing
cleared on archive, and a worker INSERT grant justified by a retention event
that was never written. -
6308a84: Emit edge-cache invalidation from blog content changes (ADR-0042).
enqueueEdgeCachePurgepreviously had no callers, so a published edit stayed
visible at the edge until its TTL expired. The four blog write paths — create,
update, soft-delete, and scheduled publish — now enqueue a purge inside the same
transaction as the content change, so a rolled-back write leaves no stray purge
and a committed one cannot lose its invalidation.Purges are module-scoped, not resource-scoped: cached responses carry
tenant/surface/module surrogate keys only, so a resource-scoped ban would match
no object and leave the page stale while reporting success.No-op when
EDGE_CACHE_MODEis off, so deployments that have not adopted the
edge cache do not accumulate queue rows. -
8a8e25c: Port the
form_draftsmodule from awcms-micro (Issue #484) — row 1 of Gelombang 1 indocs/awcms/absorb-awcms-micro-roadmap.md. Net-new and additive: nothing existing changes behaviour, the module DAG stays acyclic (dependencies: ["identity_access"]), and nothing consumes it yet.A generic, domain-agnostic server-side draft store for multi-step forms. One table holds an opaque JSONB payload plus the coordinates needed to resume it (
module_key,wizard_key,resource_type,resource_id,current_step); what the payload MEANS stays owned by whichever module created it.type: "system"— shared platform mechanism, likelogginganddata_lifecycle.- Migrations
062(schema) +063(permissions).awcms_form_drafts,ENABLE+FORCE ROW LEVEL SECURITY+tenant_isolation, four indexes covering the resume/expire/purge/dry-run query paths.awcms_workergets exactly SELECT/UPDATE/DELETE — no INSERT, since the purge job never creates a draft. Four permissions (draft.{read,create,update,delete}). - Endpoints under
/api/v1/form-drafts— list/create, get/patch/delete, and submit. Submit requires anIdempotency-Key; create deliberately does not, because a retried create costs one deletable scratch row while a retried submit hands the payload to a domain action twice. Requiring a key everywhere would just train callers to generate throwaway ones. - Payload safety. 32 KB ceiling, and any key at any nesting depth resembling a secret (
password/token/secret/credential/apiKey/privateKey) is rejected outright, never silently redacted — a caller who gets a 200 back must not have to wonder whether a field was stripped. - No
submitpermission. Submit guards ondraft.update; a separate action would widen theAccessActionunion and plant a latent-authz trap, since an action nobody seeds into a role denies even the tenant owner while looking correct in review. - Two-phase retention via
bun run form-drafts:purge: expire overdue drafts tostatus='expired'(a transition, not a delete), then physically purgeexpired/abandonedrows past the cutoff (default 30d). Both bounded and self-auditing. - Legal-hold enforcement lives in this module, not in the engine. The
data_lifecycledescriptor isdelegated: that engine only READS this table for backlog visibility and never mutates it, so a hold enforced only there would stop nothing. The real gate is inpurgeExpiredFormDrafts, which asks the injectedLegalHoldGuardPortbefore its DELETE and skips the batch when held. Phase 1 is deliberately ungated — it deletes nothing.
Verified:
tests/form-draft-validation.test.ts(18) plus a newtests/form-drafts-module.test.ts(12) whose drift guards were mutation-proven red — renaming the lifecycle key, droppingFORCE ROW LEVEL SECURITY, and over-granting the worker each fail the suite. One assertion was rewritten after the first mutation run showed it was tautological (both sides read the same constant, so a rename kept it green); the descriptor key is now pinned as a literal, because a rename silently orphans every legal hold already recorded against the old key.Not included, and not claimed: awcms-micro's wizard COMPONENT library (
src/components/ui/) is a separate, still-open Gelombang-0 row, and there is no integration test against a real PostgreSQL for this module yet. - Migrations
-
c2a981c: feat(site-search): port the
site_searchmodule from awcms-micro (ADR-0040)Adds a tenant-scoped, cross-content PostgreSQL full-text search index over
PUBLISHED public website content, its public host-resolved query/suggest
surface, and its ABAC-guarded admin index/settings/diagnostics API.- New module
site_search(type: domain, depends only on
tenant_admin/identity_access) owningawcms_site_search_documentsplus
tenant config, the index run ledger, failed-item diagnostics, and an opt-in
minimized query log (sql/064,sql/065). - New contribution seam
ModuleDescriptor.searchSources— content modules
declare reviewed, pure-data source descriptors in their ownmodule.tsand
the aggregator discovers them throughlistModules(), so nothing depends on
site_search.MODULE_CONTRACT_VERSION2.1.0 → 2.2.0 (additive: a
module.tsthat omitssearchSourcesstays valid).blog_content
contributesblog_content.post. - New public endpoints
GET /api/v1/site-search/queryand/suggest
(anonymous, host-resolved, rate-limited) plus the public/searchpage, and
new admin endpointsGET|PUT /api/v1/site-search/settingsand
/api/v1/site-search/index/{status,reconcile,rebuild,failures}. - New scheduled job
bun run site-search:reconcileand a new registry gate
bun run site-search:sources:check(added to thecheckchain). - New
AccessActionmemberreconcile(deliberately not high-risk; the
route is still idempotency-keyed and audited).
Public URLs are built with a server-resolved
:tenantCodebecause this base's
public content routes are path-tenant-scoped (/blog/{tenantCode}/{slug}).
awcms-micro's inline typeahead script on/searchis not ported: this base's
CSP forbids inline scripts and its public pages have no bundling step, so the
page ships the no-JS core search and/suggeststays available to a theme's own
client.Existing tenants do not retroactively gain the six new permissions — like every
prior permission-seed migration, only tenants created after it runs get them via
setup initialization. Backfillawcms_role_permissionswhen deploying. - New module
-
476e6d1: Wire the Cloudflare DNS adapter so a database row becomes a working subdomain.
Adds
ensureServingRecordto theTenantDomainDnsProviderport and a
reconciliation job (bun run tenant-domain:dns:sync) that brings the managed
Cloudflare zone into line with the activedomain_type = 'subdomain'rows in
awcms_tenant_domains.Reconciliation, not a create-time API call: it is idempotent, retries a failed
record on the next pass, and heals drift introduced by hand in the dashboard —
none of which a side effect inside the create request can do. Serving records
are desired-state, so a drifted record is moved withPUTrather than joined by
a second record that would round-robin the tenant between two targets.Scope: platform subdomains only. Custom domains live in the tenant's own zone
and keep the manual/TXT verification flow. Nothing is ever deleted.sql/069grants the workerSELECT(only) onawcms_tenant_domains. Unset
config is a no-op: there is deliberately no default serving target. -
f4ee902: Add an optional Varnish edge-cache tier with origin-pressure auto-activation (ADR-0042).
Public, tenant-scoped, content-derived GET surfaces can now be answered by a
cache in front of the application instead of re-running the same database work
for every anonymous visitor. Off by default and a genuine no-op when off.src/lib/edge-cache/— fail-closed cacheability decision, surrogate-key
vocabulary, rolling-window pressure tracker, surface allow-list, header
application, durable purge queue, Varnish BAN client.sql/068—awcms_edge_cache_purgesinvalidation queue (ENABLE + FORCE RLS),
with matchingWORKER_ROLE_GRANTSentries.infra/varnish/— default-deny VCL and a compose overlay.bun run edge-cache:surfaces:check— new registry gate inbun run check.bun run edge-cache:purge— scheduled invalidation worker.
Cacheability is an allow-list: an undeclared route is never cached. The
auto-activation ramp can only change how long something is cached, never whether
a private response becomes cacheable.
Patch Changes
-
bc7a883: Document the awcms-mini backbone absorption programme
(docs/awcms/absorb-awcms-mini-backbone-roadmap.md).Records an audit finding: five modules are admitted by Accepted ADRs in this
repository but have no code —organization_structure(ADR-0016),
document_infrastructure(ADR-0017),data_exchange(ADR-0018),
integration_hub(ADR-0019) andreference_data(ADR-0021). ADR-0020 (ERP
readiness contracts) is likewise Accepted with no_sharedimplementation. The
SaaS control-plane cluster is not admitted here at all and is gated behind a new
admission ADR.Documentation only — no runtime change.
-
4343f9e: Fix edge-cache invalidation, which had never worked.
The ban expression built by
infra/varnish/default.vclused(^| )key( |$)to
anchor a surrogate key to a whole token. Varnish parses a ban expression by
splitting it on whitespace into<field> <operator> <argument>, so the
literal spaces inside that regex produced the wrong token count and every ban
was rejected withWrong number of arguments.Nothing surfaced it. The VCL's BAN handler returns
200regardless, so
sendEdgeCachePurgerecorded success, the queue row was marked done, and the
object stayed cached until its TTL expired. The subsystem reported healthy
invalidation while performing none — the precise failure mode ADR-0042 exists to
prevent. It was found by putting Varnish in front of staging and watching
X-CachestayHITafter a purge.Both sides now emit
(^|[[:space:]])key([[:space:]]|$): same boundary semantics,
no literal space. Quoting the regex is not an alternative — the split happens
before quote handling (verified against Varnish 7.5).Also corrects
infra/varnish/docker-compose.varnish.yml, which named
varnishcache/varnish:7.5. No such Docker Hub repository exists, so adopting the
overlay failed withpull access denied. The image isvarnish:7.5.Guarded by two file-level assertions in
tests/edge-cache.test.ts, because the
runtime expression is built in VCL rather than TypeScript and no unit test of the
origin can observe it. -
7e338da: Fix the edge-cache purge queue's tenant-isolation policy, which read a GUC the
application never sets — breaking every blog write when the cache was enabled.sql/068createdawcms_edge_cache_purges_tenant_isolationagainst
current_setting('awcms.tenant_id', true).withTenant()sets
app.current_tenant_id, and so do the other 108 tenant policies insql/.
sql/068was the only outlier.The consequence was not a stale cache.
current_settingreturned NULL, so the
WITH CHECKpredicate was NULL and every INSERT was rejected with
new row violates row-level security policy.enqueueModuleContentPurgeis
awaited inside the content transaction (ADR-0042 §9 / ADR-0006) and is not
guarded, so that rejection aborted the publish: withEDGE_CACHE_MODEset to
autooron, blog create, update, delete, and scheduled publish all returned 500. TheUSINGside failed in the opposite, quieter direction — the purge
worker matched zero rows and reportedsent=0, which reads exactly like an empty
queue.It could not surface earlier. The subsystem defaults to
off, where the enqueue
returns before touching the database, so no CI job, integration test, or
deployment had ever written to this table. It appeared on the first request after
the feature was switched on.sql/070replaces the policy.sql/068is left untouched — it is applied in a
running deployment and rewriting it would change its checksum and block
db:migrate.Adds
tests/migration-tenant-guc-consistency.test.ts: a database-free gate that
scans every migration's executable SQL (comments stripped, so a repair migration
may name the wrong GUC while explaining itself) and fails on any
current_settingthat is notapp.current_tenant_id. It runs in thequality
job on every PR, which is where this class of typo needs to be caught — at
authoring time, not on the day a flag is enabled in production. -
7e338da: Fix the purge transport: Bun cannot send the
BANmethod, so no purge ever
reached Varnish.sendEdgeCachePurgeissuedfetch(endpoint, { method: "BAN" }), the
conventional Varnish idiom. Bun does not transmit non-standard HTTP methods.
Bothfetchandnode:httpdeliver that request asGET— confirmed against
Bun 1.3.14 withvarnishlog -i ReqMethod, where the same request written
byte-for-byte over a raw socket logsBANand answers200 Banned.Every purge therefore fell past the VCL's ban branch to the origin, which 404s an
unrouted path. On a Bun-only runtime (ADR-0002) no configuration makes theBAN
method work.The wire protocol is now
POST /__edge-cache-purge. The security model is
unchanged — the method was never a control; the purge ACL, the shared token, and
the key-charset re-validation at the edge all still apply, to both entry points.
The VCL continues to accept a realBAN, socurl -X BANremains available for
operator debugging.Adds
tests/edge-cache-purge-client.test.ts, the first tests this client has had.
They run against a realBun.serveand assertrequest.methodas received,
because that is the only formulation that can fail for the reason this failed: an
injectedfetchImplobserves the argument, not the wire, and would have asserted
method === "BAN"and passed forever. -
f2b96da: Pin the two deployed environments to their domains:
awcms.ahlikoding.com
(production) andawcms-staging.ahlikoding.com(staging).Adds
docs/awcms/environments.md(domains, per-environmentAPP_ENV/APP_URL,
staging isolation rules, DNS, edge-cache settings) and references it from
.env.exampleanddeploy-coolify.md, which previously used only generic
placeholders.APP_URLis called out specifically because it builds the OIDC/SSO callback URL
— a wrong host breaks login rather than just looking wrong.Documentation and example configuration only; no runtime change.
-
78a530b: docs(site-search): correct the CSP rationale on the
/searchpage rendererPR #229 landed between the site_search port and this change:
script-srcis now
always emitted, carrying'self'plus the SHA-256 of the admin theme-init
script. The renderer's comment still described the policy asdefault-src 'self'
and implied inline scripts are categorically impossible.The no-
'unsafe-inline'guarantee is unchanged, and the page's behaviour is
unchanged — but a reader would now find a sanctioned hashed-inline script in the
tree and conclude the comment was simply out of date. It names that pattern
explicitly and states the reason it does not apply here: this route is a plain
APIRoute with no build step to compute or keep such a hash in sync. -
f6d0353: Record the real state of the deployed environments: staging is live at
awcms-staging.ahlikoding.com(own Coolify app and database, R2/email/sync off),
production DNS and app already existed, andawcms-micro-staginghas been
removed.Also documents why
db:migratecannot run viadocker execon the production
image — it is runtime-only and does not shipscripts/— and gives the one-shot
container command instead. Staging has no schema until that is run.Documentation only.