Skip to content

v7.0.0

Choose a tag to compare

@ahliweb ahliweb released this 08 Aug 00:18
· 425 commits to main since this release
ea25fff

Major Changes

  • 611286f: Security / breaking: region-dataset activation and rollback become operator jobs; their HTTP endpoints are removed and their permissions revoked.

    POST /api/v1/idn-regions/datasets/{id}/activate and POST /api/v1/idn-regions/datasets/rollback both swapped the Indonesia administrative-region dataset served to every tenant — those tables are global, with no tenant_id and no RLS. But sql/081 seeded their permissions (idn_admin_regions.dataset.configure / .restore) into the global ABAC catalogue, and POST /api/v1/setup/initialize grants the whole catalogue to each new tenant's owner role. So an ordinary tenant owner held authority over data served to other tenants, and ABAC could not see anything wrong: it evaluates the permission, not who the action ultimately affects.

    Replaced by bun run idn-regions:activate -- --dataset <code|uuid> and bun run idn-regions:rollback, both dry-run by default and writing only with --commit, running as awcms_worker. This matches bun run idn-regions:import, which ADR-0046 §5 had already made job-only for the identical reason: a global action has no request-time tenant subject for an ABAC guard to evaluate.

    sql/084 revokes both permissions and any role grants that already reference them. Two permissions remain for this module, both genuinely read-only: region.read and dataset.read.

    Breaking: two OpenAPI paths are removed. No consumer existed — no screen in this repo called them, and a repo-wide search found no caller.

    Accepted cost, stated rather than hidden: these actions no longer write an awcms_audit_events row. That table is tenant-scoped while the action is global; the old row landed in whichever tenant's log the clicking owner belonged to, misrepresenting a global change as that tenant's and staying invisible to every other affected tenant. Evidence now lives on the dataset row itself (status, activated_at, activated_by) plus the command's own output. A correct cross-tenant audit needs a global log this base does not have yet.

    See ADR-0052.

Minor Changes

  • dc54236: ADR-0044 §4 Fase 2, langkah pertama: awcms_news_portal_ad_placements kini
    punya targeting (target_type global/widget/post/page + target_id), sehingga
    ia bisa menyatakan segala yang bisa dinyatakan sistem iklan free-URL yang akan
    dipensiunkan.

    Penggabungan ADR-0044 meninggalkan blog_content memiliki DUA sistem iklan,
    masing-masing punya kemampuan yang tidak dimiliki lawannya. Yang lama menerima
    image_url bebas — URL apa pun, tanpa registry media — tetapi bisa menarget
    post dan page. Yang baru mengikat media_object_id sebagai foreign key ke objek
    media terverifikasi, tetapi setiap barisnya efektif site-wide.

    Yang berbasis media adalah yang bertahan, karena image_url text persis
    merupakan jalan pintas yang dituju ADR-0036 saat membalik kepemilikan media.
    Tetapi menghapus yang lama LEBIH DULU akan diam-diam memusnahkan targeting
    per-post dan per-page — iklan yang dibeli untuk satu artikel berhenti muncul,
    tanpa satu pun error. Karena itu pelebaran ini berdiri sendiri, sebelum satu
    baris pun dipindahkan.

    Migrasi 078 SENGAJA tidak memindahkan data dan tidak menghapus tabel. Ingest
    awcms_blog_ads.image_url ke media_library (dengan laporan residu yang bisa
    di-dry-run) dan penghapusan kedua tabel lama adalah langkah terpisah
    berikutnya, dalam urutan itu.

    • placement_key tetap SLOT (di mana pada halaman); target_type/target_id
      adalah SCOPE (halaman mana). Keduanya ortogonal.
    • Render sebuah halaman mengembalikan iklan bertarget halaman itu DIGABUNG
      dengan setiap iklan global untuk slot yang sama — perbaikan yang disengaja
      atas sistem lama yang mencocokkan satu scope persis dan menyerahkan
      penggabungan ke pemanggil.
    • Aturan berpasangan (target_id wajib untuk tipe bertarget, terlarang untuk
      global) adalah CHECK di basis data, bukan hanya di validator seperti tabel
      lama. Diuji dengan INSERT sebagai peran admin — penulis yang persis tidak bisa
      dijangkau aturan tingkat-aplikasi.
    • target_id polimorfik (post/page/widget), jadi tidak ada foreign key yang
      bisa menjangkaunya. Keberadaannya diperiksa saat tulis; target yang dihapus
      KEMUDIAN bukan error dan tidak pernah menjadi error — barisnya sekadar
      berhenti cocok.
    • Baris yang ditulis dengan bentuk pra-078 bernilai global, jadi tidak ada
      iklan lama yang berubah perilakunya. Dibuktikan terhadap PostgreSQL 16 nyata,
      bukan disimpulkan dari default kolom.
  • 52e333a: Add the /admin/approvals inbox and put workflow_approval in the admin sidebar.

    The module shipped a complete engine — graph definitions, quorum, delegation, escalation, administrative recovery — and no screen, so every approval in this base could only be decided with curl. Under ADR-0051 the screen belongs here.

    The inbox lists tasks with the same filters the JSON route accepts (status, workflow key, resource type, overdue, safe search) over keyset pagination, and offers approve/reject, reassign and force-decision per row, a per-instance history panel carrying the cancel action, and the delegation ledger with create and revoke. Reads go through this module's own application functions inside one withTenantOrThrow, awaited sequentially; instance history is fetched only when ?instance= names one, because doing it per row would be up to 100 queries for a list nobody expanded.

    Writes go to the guarded endpoints, all six with a fresh Idempotency-Key per click — unlike /admin/reporting there is no exception here, because every one of them requires the header.

    Cancel sits on the instance panel rather than the task row: cancelling ends the whole instance and every pending task under it, so offering it beside a single task would misrepresent its blast radius.

    tests/admin-approvals-page-contract.test.ts pins the page's eight permission keys against what the routes enforce and the descriptor declares. Two traps are specific to this module and both would deny every caller while reading perfectly: the permission namespace is workflow, not workflow_approval (the directory, README and descriptor name all say the latter), and approve/reject share one permission — approval.approve is the ability to decide, not its direction, and approval.reject is seeded nowhere.

    The six definition.* permissions are deliberately left to their own screen: authoring a node graph needs a real editor, and a raw-JSON textarea that accepts a malformed graph until publish rejects it is a worse affordance than none. The contract test asserts they stay off this page, so the split remains a decision rather than a gap.

    MAX_REASON_LENGTH — written out as a bare 500 in five separate files — moves to workflow-approval/domain/reason-bounds.ts, imported by all of them and by the form that renders it as maxlength.

    Also corrects workflow-approval/README.md, which described an /admin/workflows page that never existed in this repo.

  • 4b998bf: feat(blog-content): /admin/blog-pages — the page console (ADR-0057 step 3)

    Completes ADR-0057. The screen drives all eight pages.* permissions —
    read/create/update/publish/archive/delete/restore/purge — four
    of which had no surface at all until the previous change, and so no screen
    could have driven them.

    Two views, because delete and archive are different axes: the default lists
    live pages, ?view=deleted lists the bin. Control placement follows what each
    endpoint accepts — Restore on bin rows, Publish/Archive/Delete on live rows,
    Purge on both. listBlogPagesForAdmin gains the deletedOnly filter that makes
    the bin reachable.

    pages.update is driven through the structure fields this screen owns (title,
    slug, page type, menu order), not a body editor. Re-parenting is deliberately
    absent: the API performs no cycle detection, and a control that can make a page
    its own ancestor is worse than none.

    The status filter offers the three states a page can reach, not all five —
    there is no pages.schedule and no review queue.

    Sidebar gains a second blog_content entry, gated on pages.read rather than
    posts.read.

  • c0163b1: Add the /admin/blog post lifecycle console and put blog_content in the admin sidebar.

    blog_content is the largest module in this repo — 43 permissions across 15 activity codes and ~30 route files — and until now it had no screen at all. Under ADR-0051 the screens belong here; this is the first, and it covers the surface an editor uses every day.

    The console lists posts with the module's own admin search/status filters and page-number pagination, and drives eleven permissions: posts.read/create/update/publish/schedule/archive/delete/restore/purge plus revisions.read/restore. Reads go through listBlogPostsForAdmin and listBlogRevisions inside one withTenantOrThrow, awaited sequentially; revisions are fetched only when ?post= names one. Every mutation posts to the guarded endpoint.

    Pagination is page-number rather than keyset, which is the opposite of /admin/approvals — deliberately. listBlogPostsForAdmin is LIMIT/OFFSET by design for a human-browsed table with "page 2, 3" controls, and its own header comment records that choice.

    The other 32 permissions belong to sibling screens that are not in this change (pages, taxonomy, templates/menus/widgets, settings/seo/theme, internal links, homepage sections, ad placements). Two absences are different in kind, and tests/admin-blog-page-contract.test.ts asserts both rather than leaving them to look like gaps:

    • posts.export is declared and seeded by sql/036, and no endpoint anywhere enforces it. The test proves this by scanning every route under src/pages/api/v1/blog/, so a future export endpoint fails it and forces the screen question to be answered instead of missed.
    • search.read has a route and the page still does not use it. The admin list already searches by title ILIKE, which tolerates the empty query that the websearch_to_tsquery surface behind search.read rejects.

    There is also no body/content editor: authoring a post body needs a rich-text surface plus SEO fields, terms and featured media. posts.update is still driven, through "submit for review".

    The module-specific trap the contract test pins: submit-review is gated on posts.update, not a posts.submit or posts.review — neither is seeded anywhere — and that route builds its guard in two pieces, so a regex over guard triples cannot see it and the test asserts it directly. Idempotency splits too: six lifecycle mutations require an Idempotency-Key, while POST /api/v1/blog/posts requires none by documented design, because a retry duplicating a create is caught by the (tenant_id, locale, slug) partial unique index.

    MAX_TITLE_LENGTH/MAX_EXCERPT_LENGTH are now exported from content-validation.ts so the form's maxlength comes from the same constants the validator enforces.

    Also corrects blog-content/README.md, whose §Admin UI described a fifteen-screen /admin/blog/* tree that never existed in this repo. It is kept, clearly marked as the awcms-mini specification, because it is a useful target for the sibling screens.

  • 9e0da39: /admin/blog-presentation — templates, menus, widgets and theme, the fourth
    blog console.

    Four activities on one screen because they answer one question (how the blog
    looks) and each is a short bounded list. ?section= reads only the section
    being shown, and a section the operator cannot read is not offered at all.

    The eight permissions are gated as four INDEPENDENT pairs: holding
    widgets.configure must not reveal a template control.

    Three deliberate absences, each mutation-proven:

    • menu ITEMS are not editable. PATCH /api/v1/blog/menus/{id} replaces the
      whole item list, so a flat form would delete every item it did not render.
      The client never sends the key at all;
    • no "revert to tenant default" for the theme. upsertBlogThemeSettings
      only INSERTs or UPDATEs and no delete route exists, so an override is
      one-way. The screen states that instead of offering a control that cannot
      succeed;
    • no bin, no Restore. Templates, menus and widgets all soft-delete with no
      counterpart and no *.restore permission to build one against.

    key is sent on create and never on update, because the update inputs have no
    key field.

  • e20c942: /admin/blog-taxonomy — the categories-and-tags console, third sibling of
    /admin/blog and /admin/blog-pages.

    Drives both taxonomies.* permissions. configure gates create, update AND
    delete together, because sql/036 seeds no per-verb rows — the permission is
    the capability "manage taxonomy", not one flag per verb, and a screen that
    invented taxonomies.create would gate on authority nothing honours.

    Three deliberate absences, each held by the contract test:

    • no bin view and no Restore. Term soft delete is one-way BY DESIGN (no
      restore route, no taxonomies.restore to build one against), so a bin would
      imply a way back that does not exist. The confirmation states the finality
      instead — copy promising recoverability is what made #351 hard to see;
    • no re-parenting on edit. Neither term route detects cycles, so pointing a
      parent at its own descendant is accepted and every reader then walks forever.
      Create still offers a parent: a term with no children cannot close a loop;
    • no Idempotency-Key. None of the three term endpoints reads it.
  • 5368c23: Add the /admin/domain-events operator console and put domain_event_runtime in the admin sidebar.

    The module shipped consumers, deliveries, retry/dead-letter and replay with no screen, so the only way to see why an event never arrived — or to unstick it — was curl. Under ADR-0051 the screen belongs here.

    All five of the module's permissions are driven from this one page: the consumer registry with pause state and backlog counts (pause/resume), the delivery list filtered by status/consumer/event type with replay on dead-lettered rows, and the outbox itself with a payload inspector. Reads go through this module's own application functions inside one withTenantOrThrow, awaited sequentially; every mutation posts to the guarded endpoint.

    The interesting part is the idempotency split, which the screen reproduces exactly: replay sends an Idempotency-Key because each call does new work (it enqueues another attempt), while pause and resume send none because setting a flag twice has the same end state — resume takes no body at all. Sending a key to pause would imply a replay contract that endpoint does not have; omitting it on replay would render a button that always fails with IDEMPOTENCY_REQUIRED.

    tests/admin-domain-events-page-contract.test.ts pins all five permission keys against what the routes enforce and the descriptor declares, pins the three-way idempotency split per request rather than as a global count, and asserts the endpoints themselves still disagree the way the page assumes. The module-specific trap: pause and resume are opposite actions sharing ONE permission, consumers.manage — consumers.pause and consumers.resume read better and are seeded nowhere, so inventing them would hide both buttons from every operator including the owner.

    MAX_REASON_LENGTH, written out twice, moves to domain-event-runtime/domain/reason-bounds.ts.

  • 05c247c: Add /admin/media and put media_library in the admin sidebar — the last module in this base without a screen.

    It was listed for two waves beside modules that were genuinely only missing a page, and that was wrong for this one. ADR-0056 found that five of eleven permissions were enforced by nothing, five application functions had zero callers, and there was no list function at all — so this screen could not have been built on the surface that existed, whatever the permission catalog said. attach/detach were revoked (§A), delete/restore/purge got endpoints (§B), and the browse listing got its own function and route (§C). This is what those three were for.

    The console browses with §C's filters — status, mime type, and the three-way live/deleted/all — then deletes, restores, and purges. Reads go through listMediaObjects inside one withTenantOrThrow; writes post to the guarded endpoints, each with a fresh Idempotency-Key. Unlike /admin/blog there is no opt-out, and unlike /admin/sync there is no endpoint that declines the header: all three here require it.

    Three deliberate absences, each pinned by tests/admin-media-page-contract.test.ts so they stay decisions rather than becoming gaps:

    • Upload (media.create/.verify/.cancel) — a three-step browser flow (create session → PUT the bytes straight to R2 → finalize) with real file input, progress, and client-side failure modes. A button that starts a session this page cannot finish leaves a pending_upload row behind on every misclick, which is precisely the litter the reconciliation job exists to clean up.
    • enforcement.read/.enable — a tenant-wide, ONE-WAY content policy switch, not an object action. It belongs on /admin/security with the other policy controls; offering it beside a row of files would misrepresent its blast radius.
    • No <img> preview. A registry row can be pending_upload or failed — the bytes may be absent, unverified, or the very thing an operator is here to delete. Rendering them is how a policy-violating image gets shown one more time, to the person removing it.

    The delete prompt asks for a real reason rather than sending a placeholder, because it lands on an audit row that outlives the object, and its maxlength comes from the constant the validator enforces. Purge is the only irreversible action and is the only one behind a confirm. It is also the only failure this screen names specifically: MEDIA_OBJECT_REFERENCED gets "remove that reference first" rather than "please try again", because retrying will never succeed while the foreign key is live.

    This closes ADR-0021's first criterion. idn-admin-regions is now the only module without a screen, and that is a documented decision (ADR-0052 moved its lifecycle to operator jobs). The contract test asserts it repo-wide, so the next module to land without navigation turns CI red instead of quietly becoming a second exception.

    Mutation-proven four ways: gating a control on the revoked media.detach, dropping one mutation's Idempotency-Key, rendering a preview <img>, and removing the navigation entry each turn it red.

  • 821387b: Add the /admin/reporting console and put reporting in the admin sidebar.

    reporting had seven permissions and, between them, one page: /admin renders four of its five dashboard views. Everything Issue #753 built — the projection registry, live freshness, rebuild, reconciliation, scheduled exports and artifact download — had no screen at all, and neither did email-health, the fifth dashboard view. All of it was reachable only by curl. Under ADR-0051 the screen belongs here.

    The console renders each registered projection with its live freshness status, metric values and most recent reconciliation, plus rebuild history, scheduled-export management, on-demand export, and the export-run history with checksum-verified download links. It deliberately does not repeat the four aggregations /admin already shows; a projection links to its own drillDownPath instead.

    Reads reuse this module's own application functions inside one withTenantOrThrow transaction, awaited sequentially. listProjectionSummariesForTenant is handed the caller's real granted-permission set, so the per-descriptor requiredPermission filter stays honest on this path too. Writes go to the guarded /api/v1/reports/* endpoints — five with a fresh Idempotency-Key per click, reconcile with none, because that endpoint mutates no business state and requires none.

    tests/admin-reporting-page-contract.test.ts pins all seven permission keys against what the routes enforce and the descriptor declares. Three plausible-but-wrong guesses would each have rendered a control that denies every caller including the owner: projections.cancel for cancelling a rebuild (it is projections.rebuild), projections.read for reconciling (it is projections.analyze), and exports.configure for triggering an export (it is exports.export).

    MIN_EXPORT_INTERVAL_MINUTES / MAX_EXPORT_INTERVAL_MINUTES / MIN_REASON_LENGTH / MAX_REASON_LENGTH move to reporting/domain/operator-input-bounds.ts and are now imported by both the three routes that validate them and the form that renders them as min / max / maxlength, so the browser cannot accept what the server rejects.

    Also corrects reporting/README.md, which described an /admin/reporting/projections page and a submitJson helper that never existed in this repo.

  • 48d5bcb: Add /admin/security — the screen for authentication policy that the endpoints
    have been waiting for since #184/#185.

    Tenant auth policy (password/SSO/break-glass/JIT/allowed domains) and MFA
    enforcement have been fully implemented and guarded for two releases, reachable
    only by hand-writing curl. This renders them: deployment posture (read-only),
    the tenant authentication policy, MFA enforcement level, and a read-only list of
    configured OIDC providers.

    It adds no enforcement of its own. Every mutation posts to the real endpoint
    and inherits its ABAC guard, its break-glass rule and its audit row. The
    permission checks decide what to render, never what is allowed.

    The gates reuse the endpoints' exact permission keys — including
    mfa_admin.reset as the MFA read gate, which reads like a mistake and is
    precisely what GET /api/v1/auth/mfa/policy requires. Inventing a friendlier
    mfa_admin.read that no migration seeds would hide the section from everyone
    including the owner, which is the latent-authz bug this repo has already shipped
    twice. tests/admin-security-page-contract.test.ts extracts the guard triples
    from the route sources and the permissionKey(...) triples from the page and
    requires the second to be a subset of the first; mutation-proven — swapping in
    mfa_admin.read turns three tests red.

    Deployment posture is shown because the tenant policy cannot be judged without
    it.
    ssoRequired with AUTH_SSO_ENABLED=false produces a tenant nobody
    outside the break-glass list can sign into, and that contradiction was
    previously invisible from any screen. It now renders as a warning. No key or
    secret value is displayed — only whether a control is active.

    The break-glass picker deals in identity ids, not tenant_user ids: the
    policy column stores identity ids, both are uuid, and passing the wrong one is
    accepted by the endpoint, filtered out as ineligible, and saved as an empty
    list — a silent no-op exactly where an operator is trying to keep themselves
    able to log in. New listBreakGlassCandidates uses the same predicate as
    fetchEligibleBreakGlassIdentityIds, and an integration test pins the two
    together across inactive identities, inactive memberships, locked identities and
    cross-tenant rows, so the picker can never offer an option the save path
    discards.

    409 BREAK_GLASS_REQUIRED surfaces verbatim rather than collapsing into a
    generic failure: the caller is already an authenticated admin holding
    sso_policy.update, so it leaks nothing they cannot read directly, and a
    generic message would leave them retrying the one change the server will never
    accept.

    OIDC provider CRUD stays API-only — a form that posts a client secret deserves
    its own change.

  • b993159: Render the admin sidebar from the module registry instead of a hand-written array.

    ModuleDescriptor.navigation was already synced to awcms_module_navigation
    and served by GET /api/v1/modules, while AdminLayout.astro rendered a
    separate static list. Nothing compared them and both had rotted: three declared
    entries pointed at admin pages that do not exist (/admin/blog, two
    /admin/news-portal/*) and were being published as valid menu items, while
    eight pages that do exist were unknown to the registry.

    The sidebar now composes from listModules() through the new
    module-management/domain/sidebar-menu.ts (ported from awcms-micro, without
    its per-tenant override tables). Tenant-disabled modules and the caller's
    permissions both filter it, so an operator no longer sees links to screens that
    will only deny them. tests/admin-navigation-registry.test.ts binds
    declarations to the filesystem in both directions.

    AdminLayout's active prop is gone — the current entry derives from the
    request path, which cannot disagree with itself the way /admin/comments did
    (it never passed one and was never highlighted).

  • 16cf031: Add the /admin/sync operator console and put sync_storage in the admin sidebar.

    The module shipped node management, conflict resolution and the object upload queue with no screen, so an operator could see on the dashboard that sync was unhealthy and had no way to act on it except curl. application/sync-directory.ts has named "the future /admin/sync SSR page" in its own header comment since it was written. Under ADR-0051 this is that page.

    All six of the module's permissions are driven here: the node list with activate/deactivate, the conflict list with the three resolutions and an optional note, and the object queue with retry on failed entries, keyset-paginated. Reads go through this module's own application functions inside one withTenantOrThrow, awaited sequentially.

    fetchSyncConflicts is new in sync-directory.ts, and GET /api/v1/sync/conflicts now calls it too — the query used to be inline in that route, which was fine while it was the only reader; a screen that re-wrote it would be free to drift from the endpoint it is meant to mirror. The endpoint keeps its exact wire format: fetchSyncConflicts returns null for an unresolved conflict's resolution fields, and the route maps them back to undefined so they stay absent from the JSON rather than becoming null — that is a contract change, not a refactor.

    None of the three mutations sends an Idempotency-Key, because none of the endpoints requires one: all three are naturally idempotent state transitions (status = 'active', 'resolved', 'pending') rather than requests that do fresh work per call. Sending one would imply a replay contract they do not have. tests/admin-sync-page-contract.test.ts pins that in both directions, so an endpoint that later starts requiring a key turns the contract red instead of failing silently at runtime.

    The HMAC node protocol (push/pull/objects/status) gets no controls, and the test asserts the page never names those paths: they authenticate a node by signature, not an administrator by session, so a button for them would be a control no browser can legitimately use and whose failure would read as a bug rather than a category error.

    The module-specific latent-authz trap the test also pins: resolving a conflict is conflict_resolution.approve. Both conflict_resolution.resolve and .update read better than the permission that exists, and neither is seeded anywhere.

  • 2c722ee: Add the /admin/audit-trail viewer and put logging in the admin sidebar.

    logging has exactly one HTTP surface (GET /api/v1/logs/audit) and had no screen, so the tenant's audit history — the record of every high-risk action the system takes — was readable only by curl. For the module whose whole purpose is accountability, that is a poor place to have no UI.

    The screen lists events newest-first with a resource-type filter and a per-event detail disclosure (correlation id + the already-redacted attributes, rendered as escaped text, never as HTML). It is read-only and ships no client script at all: the audit trail is append-only by design, so the filter is a plain method="get" form that works with JavaScript disabled.

    listAuditEvents clamps to 100 rows and has no cursor, so the page states that bound whenever the view is full rather than letting a truncated audit log read as "this is everything that happened". Adding keyset pagination to that endpoint is a follow-up with its own OpenAPI change, deliberately not smuggled in here.

  • c6b9ceb: Freeze and gate the API slice ahliweb/awcms-astro consumes (ADR-0065).

    The existing frozen snapshot is the pre-#182-migration monolith, and every
    surface that repo actually calls landed after it — /auth/session and
    /access/machine-credentials (ADR-0049), /media/objects (#318),
    /media/public-origin (#370), the /blog/posts cursor traversal (#317).
    Searching the snapshot for them returns zero. So a response-shape change to any
    of them was green here and broke the other repo's build: a failure surfacing
    where whoever caused it is not looking.

    bun run api:consumer-contract:check freezes 6 paths plus the 16 components
    their $refs reach. The closure is the point — freezing path objects alone
    would be near-useless, since a path is a few lines of $ref and the interesting
    breakages happen in the schema.

    The rule is additive-superset: a new optional field passes, a rename or retype
    fails. Regenerating is deliberate and means the consumer must change too, which
    the fixture header and the failure message both say — whoever reads that message
    is in the wrong repo to realise it unaided. A missing consumer path throws
    rather than silently shrinking the contract.

    This is a schema contract, not a behavioural one: a change of meaning with an
    unchanged shape is not caught.

    No migrations, no permissions, no runtime change.

  • da9b51f: ADR-0044 §4 Fase 2, langkah kedua: job bun run blog:ads:ingest yang
    memindahkan sistem iklan free-URL ke sistem berbasis media — dan melaporkan
    setiap baris yang tidak bisa dipindahkan
    .

    Pratinjau adalah default, bukan flag. Job scheduled lain memakai --dry-run
    sebagai opt-in karena mereka berjalan tanpa penunggu dan mode normalnya adalah
    bekerja. Yang ini kebalikannya: ia tidak menulis apa pun sampai diberi
    --apply. Kesalahan mahal di sini bukan "lupa pratinjau", melainkan
    "sudah pratinjau, lalu tidak pernah membaca residunya" — oleh operator yang
    sebentar lagi menghapus tabel sumbernya.

    Yang otomatis hanya satu kasus, dan itu disengaja. Sebuah iklan pindah bila
    image_url-nya sudah merupakan URL publik salah satu objek media tenant itu
    yang terdaftar di registry. Selain itu — remote, malformed, object key milik
    tenant lain, atau byte di bucket yang tidak diklaim baris registry mana pun —
    menjadi residu, dilaporkan lengkap dengan URL-nya untuk diunggah ulang manusia
    lewat media library.

    Dua jalan pintas yang ditolak, dan alasannya:

    • Mengambil URL eksternal dari server. Itu primitif SSRF, dan tempat
      terburuk untuk membangunnya adalah skrip migrasi data yang dijalankan sekali,
      di bawah tekanan waktu, oleh operator yang sedang mengawasi jumlah baris
      alih-alih egress. Repo ini sudah memutuskan sikapnya soal ini di jalur
      discovery OIDC (ADR-0031).
    • Mendaftarkan objek yang ada di bucket tapi tanpa baris registry. Itu akan
      membuat skrip migrasi mencetak baris verified untuk byte yang tidak pernah
      ia ambil, sniff, atau batasi ukurannya — persis pernyataan yang menjadi alasan
      keberadaan pipeline unggah. Peran awcms_worker bahkan tidak diberi INSERT
      yang memungkinkannya (sql/079).

    Rincian lain:

    • --apply wajib disertai --placement-key=<key>. Sistem lama tidak punya
      konsep slot, yang baru menuntut satu dari dua belas, dan tidak ada di data
      lama yang menyatakan mana. Job menolak menebak.
    • Idempoten lewat source_legacy_ad_id di bawah unique index PARSIAL dengan
      NULLS NOT DISTINCT (sql/079). Keduanya load-bearing: tanpa NULLS NOT DISTINCT sebuah run kedua menggandakan seluruh iklan global; tanpa
      predikat parsial, index itu justru menolak pekerjaan editorial biasa. Kedua
      sisi dibuktikan dengan mutasi terhadap PostgreSQL 16 nyata.
    • Job tidak menulis satu pun statement sendiri — semuanya di
      application/legacy-ad-ingest-directory.ts, milik modul pemilik tabel
      (modules:table-writes:check).
    • Tidak ada tabel yang dihapus. Menghapus awcms_blog_ads adalah keputusan
      manusia yang sudah membaca laporan residu, bukan efek samping dari job yang
      menghasilkannya.

    Ditemukan sambil jalan: seluruh blok NEWS_MEDIA_R2_* tidak pernah ada di
    .env.example, jadi operator yang menyalin berkas itu tak punya cara menemukan
    lima variabel wajib media_library. Sekarang terdokumentasi.

  • 3f9a2ab: ADR-0044 §4 Fase 2, langkah ketiga: jalur TULIS iklan free-URL ditutup, dan
    gerbang kesiapan yang membuat penghapusan tabel bisa dibuktikan alih-alih
    dipercaya.

    POST /api/v1/blog/ads dan PATCH /api/v1/blog/ads/{id} sekarang menjawab
    410 ENDPOINT_RETIRED, tanpa auth dan tanpa sentuhan basis data. Keduanya
    menyimpan imageUrl teks bebas — URL apa pun yang diketik admin, dirender
    langsung ke <img src> halaman publik. Itulah bypass managed-media yang ditutup
    ADR-0036, dan ia terbuka selama masih ada rute yang bisa menulisnya.

    Urutannya yang menjadi isi perubahan ini. Job ingest memindahkan apa yang
    ada saat ia berjalan. Jalur tulis yang masih terbuka membiarkan editor membuat
    iklan free-URL di jendela antara ingest dan penghapusan — iklan yang tidak
    bermigrasi ke mana pun dan lenyap saat tabelnya hilang, tanpa satu pun laporan
    menyebut ia pernah ada.

    Menutup POST saja tidak cukup: PATCH bisa menulis ulang imageUrl pada
    iklan yang sudah ada — bypass yang sama lewat rute yang lebih senyap, dan yang
    tidak menghasilkan baris baru untuk diperhatikan siapa pun.

    GET dan DELETE sengaja bertahan. Operator yang menyelesaikan laporan residu
    harus bisa membaca baris yang disebut laporan itu, dan mempensiunkan yang tidak
    ingin ia buat ulang — blog:ads:drop-readiness menghitung iklan yang
    soft-delete sebagai sudah-diputuskan.

    bun run blog:ads:drop-readiness menjawab "bolehkah kedua tabel lama
    dihapus sekarang?" dari data, dan keluar non-nol selama jawabannya belum.
    Migrasi penghapusan tak bisa dibatalkan dan membawa serta iklan situs hidup;
    seluruh pengaman epik ini menjadi hiasan bila langkah terakhirnya diambil atas
    dasar ingatan seseorang bahwa ia sudah menjalankan ingest. Kolom
    source_legacy_ad_id (sql/079) membuatnya jadi sebuah join.

    Iklan lama terhitung sudah-diputuskan bila ada baris penerus yang menyebutnya,
    ATAU bila ia soft-delete. Selain itu memblokir. Tidak ada flag override —
    gerbang yang bisa disuruh lulus adalah gerbang yang tak perlu dipenuhi siapa
    pun.

    Catatan proses: mutasi pertama saya terhadap query kesiapan (menghapus predikat
    p.tenant_id = a.tenant_id) lolos ketujuh test — RLS diam-diam mengerjakan
    apa yang diklaim predikat itu. Dua mekanisme diklaim, dan test yang tak bisa
    membedakannya hanya membuktikan setidaknya satu ada. Test kedelapan menjalankan
    penilaian yang sama sebagai peran admin yang melewati RLS sepenuhnya, sehingga
    predikatnya menjadi satu-satunya penghalang — dan mutasi itu kini merah.

  • 267749e: feat(blog-content): blog pages can be published (ADR-0057)

    pages.publish, pages.archive, pages.restore and pages.purge have been
    seeded since sql/036 and enforced by nothing. That was not a spare catalogue
    row: createBlogPage wrote a literal 'draft', updateBlogPage never touched
    status, and the scheduled-publish job reads only posts — so no code path
    could publish a page
    , while public page search filtered on
    status = 'published' and always returned nothing.

    Four guarded, audited, Idempotency-Key-bearing routes close it:
    POST /api/v1/blog/pages/{id}/publish, /archive, /restore, /purge.
    Publish runs the same content-quality checklist posts do, which the page
    preview endpoint has been reporting with nothing to gate.

    The page lifecycle is deliberately narrower than posts' — no review, no
    scheduled, since no pages.schedule permission was ever seeded. purge
    reports the ad placements it leaves inert rather than refusing or cascading.

    Also adds bun run access:permissions:enforcement:check: every declared
    permission must have an authorizeInTransaction guard or a recorded reason.
    It found five further gaps beyond pages, all now recorded and tracked.

    No migrations — the columns, CHECK, index and catalogue rows already existed.

  • 505a5e4: GET /api/v1/blog/posts dapat traversal stabil ber-cursor — build feed tidak
    lagi berhenti di 100 post.

    Endpoint ini hanya punya ?limit= (maks 100) dan tanpa cursor, jadi tidak ada
    cara membaca lebih dari 100 post. Adapter awcms-astro melempar saat
    menyentuh batas itu alih-alih memotong diam-diam, sehingga situs dengan lebih
    dari 100 artikel tidak bisa di-build sama sekali.

    Yang TIDAK dilakukan: menambahkan ?cursor= ke urutan yang sudah ada.
    Default-nya updated_at DESC — benar untuk tabel admin dan tidak sah sebagai
    kunci keyset, karena menyunting sebuah post memindahkannya: satu baris bisa
    melintasi batas halaman di antara dua permintaan lalu terlewat atau muncul dua
    kali, dan tak ada apa pun yang bisa mendeteksinya. Sebuah cursor hanya sah di
    atas urutan yang tidak berubah oleh tulisan yang dibalapinya.

    Jadi ?order=created_at memilih traversal stabil (kolom immutable) dan
    ?cursor= hanya berlaku bersamanya; ?cursor= di atas urutan default ditolak
    400
    dengan alasannya, bukan diam-diam dilayani. Default endpoint tidak berubah
    sama sekali — tabel admin tetap urut updated_at.

    nextCursor dicetak di lapisan yang masih memegang teks presisi mikrodetik,
    tidak pernah diturunkan ulang dari Date JS di rute. Itu bukan kehati-hatian
    teoretis: timestamptz menyimpan mikrodetik, Date hanya milidetik, dan driver
    MEMBULATKAN KE BAWAH — cursor dari Date menunjuk instant yang lebih awal dari
    barisnya sendiri dan melewatkan setiap baris yang berbagi milidetik itu (Issue
    #158; terukur: 105 baris → halaman 2 berisi 4, batch-insert → halaman 2 berisi
    0).

    Diverifikasi terhadap PostgreSQL nyata dengan kasus terburuknya: 25 post
    di-insert dalam SATU statement sehingga berbagi created_at sampai mikrodetik.
    Mutation-proven — mengganti sumber cursor jadi new Date(row.created_at)
    memerahkan 3 dari 5 test.

    BlogPostSummary mendapat field createdAt (aditif).

  • 300a407: Add ?locale= to GET /api/v1/blog/posts.

    This closes item 2 of awcms-astro's ADR-0021 hold list, which recorded on 2 August 2026 that the filter was still absent and that the build therefore had to pull every locale and pair them up client-side — correct, and wasteful for a single-language site.

    Exact match, not a prefix: en does not sweep in en-GB. A LIKE 'en%' implementation would look right until someone published a regional variant they did not want served.

    Absent means every locale, which stays the correct default for the admin table — hiding a translation because the operator did not name its language would be the surprising answer. An empty ?locale= is a 400 rather than being read as absent: a caller that meant to filter and silently got the unfiltered feed builds a site containing every translation of every article, and nothing anywhere fails.

    The shape is deliberately not validated beyond non-empty and a 35-character bound. awcms_blog_posts.locale is plain text NOT NULL DEFAULT 'id' and the write path accepts any non-empty string, so a read filter stricter than the write path would make a stored locale unreachable — a row that exists, that the admin table shows, and that no query can select.

    All three list functions take it (listBlogPosts, listBlogPostsPage, listBlogPostsFullPage), because the route branches between them on view/order and a filter wired into two of the three would stay invisible until someone changed a query string. listBlogPosts collapses its two-branch status ? … : … into the single ${param}::text IS NULL statement its paged siblings already use — two optional filters written the old way is four copies of one SELECT, and a third would make it eight.

    Verified against a real database (tests/integration/blog-post-locale-filter.integration.test.ts, six tests) because the failure mode of a parsed-but-unapplied parameter is a 200 with the wrong rows — the same shape as the view=full defect this endpoint already shipped once. Mutation-proven: dropping the SQL predicate turns all six red, and dropping the parameter at one of the three route call sites turns the pure contract test red.

  • a526e69: Give business_scope_hierarchy a real provider: tenant_admin resolves office scopes against awcms_offices (ADR-0060).

    POST /api/v1/identity/business-scope/assignments is permission-gated, SoD-evaluated, audited, idempotency-keyed and RLS-protected — and until now it refused every input in every deployment. Its only composition root injected a NO-OP adapter that resolved every scope to resolved: false, and the reserved tenant scope type is rejected by the validator as unassignable (#180 review F2), so both roads led to a denial. Everything downstream was dead with it: no assignment rows to read, so businessScopeFacts was never populated, the expiry job never had anything to expire, and SoD's same_scope_only matching never had a scope to match.

    The NO-OP was correct when written — ADR-0011/0014 expected a DERIVED application to inject its own hierarchy resolver — and then ADR-0034 deleted that pathway and ADR-0055 confined development to this repo. Its providedBy named organization_structure, a module ADR-0016 accepted and nobody ever wrote here. What was missing was never the hierarchy: awcms_offices has had parent_office_id since sql/002, FORCE RLS since sql/017, and a composite cross-tenant-proof parent FK since sql/020.

    The new adapter resolves the office scope type and nothing else. Only LIVE rows resolve — not soft-deleted, not inactive, same tenant only — and dead rows are skipped anywhere in a chain, so a live office under a deactivated parent gets a shorter ancestor chain rather than borrowing coverage through a resource its tenant switched off. Every bound REFUSES rather than truncates (cycle, depth, result count): a truncated list still claims resolved: true, which would answer a coverage question from part of the graph with no signal the rest existed.

    One read-path hardening ships with it: resolveBusinessScopeFacts minted a covers-everything fact from scope_type = 'tenant' alone. It now requires that row to name this tenant. No supported path can write such a row, which is exactly why the check belongs there — a row carrying it came from outside the service and passed no validation at all.

    The NO-OP adapter is deleted (zero callers once the root is rewired); optional: true stays on the consumption, so a tenant with no offices still works and still fails closed. Zero migrations, zero new permissions, no change to any existing endpoint's behaviour — a route must still opt into scope-gated authorization explicitly, and none does today.

  • 1551473: POST /api/v1/comments/admin/{id}/delete — the moderator half of a transition
    this module has implemented since ADR-0041 (ADR-0058 §B).

    applyModerationAction has accepted "delete" all along, it is legal from all
    four non-terminal statuses, and the moderation queue can already filter on
    deleted — so moderators could see soft-deleted comments without being able to
    delete one. The only actor who could reach that state was the comment's own
    author, inside the edit window.

    This is the one irreversible moderator action, and it stays that way: deleted
    remains terminal and recovering a deleted comment remains an operator/database
    action. It is accepted because the state was already reachable, the row, body
    and append-only moderation history all survive, and every other moderator
    action is reversible and keeps the body in the queue — leaving no in-band
    answer for content that must be pulled permanently. Bulk moderation
    deliberately does not gain it.

    delete now also resolves the comment's open reports, alongside
    approve/reject/spam: a deleted comment cannot be acted on again, so
    leaving them open would inflate the queue's report count forever. No existing
    caller is affected — nothing could reach that branch with delete before.

    Permission-enforcement coverage moves from 202/205 with 3 exceptions to 203/205
    with 2, and the two that remain are exactly the revocations ADR-0058 §C/§D
    decided.

  • 0385fb1: Terbitkan kosakata blok content_json sebagai kontrak yang bisa dibaca mesin,
    dan patok ketiga tempat ia dinyatakan agar tak bisa menyimpang diam-diam.

    Sampai perubahan ini kosakata itu hidup di dua tempat: tipe TypeScript
    ContentBlock (tak terlihat siapa pun di luar tsc) dan satu kalimat prosa
    di salah satu dari lima kemunculan contentJson di OpenAPI — empat sisanya
    hanya menyebut type: object. Konsumen yang membaca kontrak punya peluang empat
    dari lima untuk tidak mempelajari apa pun tentang isi field itu.

    Akibatnya nyata dan sudah terjadi: awcms-astro menurunkan ulang kosakata itu
    dengan membaca, lalu keliru dalam tiga hal sekaligus — mengarang tipe
    ordered_list yang tak ada, dan menjatuhkan gallery serta video_news karena
    keduanya tak punya field text sementara fallback-nya merender text. Tidak
    ada yang gagal di mana pun. Daftar bernomor keluar berbutir dan bagian bermedia
    lenyap dari halaman yang tayang.

    Kosakata yang hanya hidup di prosa akan diturunkan ulang, dan penurunan ulang
    itulah tempat ia patah.

    • CONTENT_BLOCK_TYPES — kosakata sebagai nilai RUNTIME, disatukan dengan union
      ContentBlock lewat assertion saling-assignable. Menambah varian ke union
      tanpa menambahnya ke konstanta (atau sebaliknya) memerahkan typecheck,
      bukan sebuah test yang mungkin tak dijalankan orang. Terbukti dua arah.
    • Skema BlogContentBlock + BlogContentJson di OpenAPI: oneOf enam varian
      lengkap dengan field-nya, dirujuk dari kelima kemunculan contentJson.
      Dua bentuk yang paling mudah salah tebak diberi catatan eksplisit — urutan
      adalah FIELD pada list (bukan tipe ordered_list), dan gallery/
      video_news TIDAK punya field text.
    • tests/content-block-contract.test.ts memaku kontrak OpenAPI dan switch
      renderer ke konstanta yang sama, plus menegaskan setiap tipe merender sesuatu
      yang tak kosong dan tak ada varian HTML mentah. Diuji dengan mutasi: kontrak
      menyebut tipe berbeda (1 merah), satu contentJson kembali type: object
      polos (1 merah), renderer berhenti menangani gallery (2 merah).
  • c244697: Tutup dua celah keamanan yang asesmen 4 Agustus 2026 (§9.1, §9.2) temukan, keduanya
    di lapisan yang tak punya pemeriksa sendiri.

    AUTH_COOKIE_SECURE tidak lagi gagal-terbuka saat tidak diset. Aturan produksi
    scripts/validate-env.ts dulu hanya menolak string literal "false", sementara
    runtime menyetel secure: process.env.AUTH_COOKIE_SECURE === "true". Ejaan salah
    ("1"/"TRUE"/"yes") memang sudah ditolak aturan tipe bool — diverifikasi
    dengan menjalankan validator, bukan membacanya — sehingga yang benar-benar lolos
    tepat satu keadaan, dan ia justru keadaan bawaan: variabel tidak diset sama
    sekali. Produksi seperti itu mengirim cookie sesi tanpa atribut Secure sambil
    bun run config:validate melaporkan konfigurasi bersih. Aturannya kini !== "true"
    dengan pesan yang menyebutkan nilai terbaca. Non-produksi sengaja tidak dituntut:
    dev berjalan di http://, dan environments.md sudah mencatat itu sebagai selisih
    per-environment yang disengaja.

    Cross-Origin-Opener-Policy dan Cross-Origin-Resource-Policy kini dikirim
    (same-origin, keduanya tanpa gerbang produksi — tidak seperti HSTS, keduanya tidak
    menunggu TLS). Keduanya "dianjurkan" OWASP Secure Headers Project dan berlaku di sini
    justru karena repo ini punya sesi manusia dan 42 halaman ber-render: COOP memutus
    tautan browsing-context-group ke window mana pun yang membuka kita, dan CORP menutup
    jalur penyematan no-cors yang CORS sendiri tidak tutup. Tidak ada kapabilitas yang
    hilang — repo ini tak pernah memancarkan Access-Control-Allow-Origin, gambar artikel
    disajikan origin R2 yang berbeda, dan Turnstile berjalan di frame anak yang tidak
    diatur COOP.

    Kedua perbaikan mutation-proven: mengembalikan aturan lama membuat test keadaan-ABSEN
    merah, dan asersi header menyasar NILAI-nya, bukan sekadar keberadaannya.

  • 3beee6c: Core Web Vitals kini diukur di LAB — Opsi D ADR-0067, nol data pengunjung.

    Spec baru tests/e2e/cwv-lab.e2e.ts (harness E2E Playwright yang sudah ada,
    bukan harness kedua) mengukur LCP dan CLS halaman /login via
    PerformanceObserver ber-buffered: true, dengan CLS dihitung per definisi
    session-window CWV. Ambang kelulusan = ambang "baik" CWV (LCP ≤ 2500 ms,
    CLS ≤ 0,1) — sebagai batas LAB satu mesin: detektor regresi, BUKAN p75
    lapangan. INP sengaja tidak diukur/diklaim (tanpa interaksi nyata ia tidak
    bermakna di lab).

    Gerbangnya env-gated (E2E_CWV_LAB=1, dinyalakan job CI e2e-smoke); saat
    env tidak diset ia MENCETAK pernyataan skip eksplisit, dan saat berjalan LCP
    yang tidak terekam adalah kegagalan — gerbang ini tidak pernah hijau senyap.
    Script baru: bun run perf:cwv:lab. Tidak ada skrip klien, endpoint, tabel,
    atau sentuhan pada visitor_analytics; keputusan RUM (Opsi B) tetap milik
    pemilik produk — status ADR-0067 tidak berubah.

  • 36d012f: Add the /admin/data-lifecycle console and put data_lifecycle in the admin sidebar.

    The module shipped its registry / legal-hold / dry-run / run-history API (ADR-0037) with no screen at all, so the entire surface was reachable only by curl and its own README recorded the screen as an open follow-up. The console renders the code-declared lifecycle registry, the legal-hold ledger with a place-hold form and per-hold release, the on-demand dry-run planner with its categorized counts, and the run history that is itself retention evidence.

    Reads reuse the same application functions the JSON endpoints call, inside one withTenantOrThrow transaction. Writes go to the guarded /api/v1/data-lifecycle/* endpoints — the two hold mutations with a fresh Idempotency-Key per click, the dry-run with none, because that endpoint mutates nothing and requires none. Real archive and purge stay job-only; the screen has no control for them because they have no HTTP surface.

    legal_hold.create and legal_hold.release are gated separately: data_lifecycle.legal_hold_maker_checker makes holding both a critical SoD conflict, so gating both controls on one permission — the tidier-looking choice — would be wrong for every real operator. tests/admin-data-lifecycle-page-contract.test.ts pins that, plus the page's six permission keys against what the routes enforce and the descriptor declares, so a plausible-but-unseeded key (legal_hold.delete, plan.read) cannot silently hide a panel from everyone including the owner.

  • 9800a0e: Buat penolakan pool database tak bisa lagi menyamar sebagai data, dan hentikan
    inversi backpressure yang sudah hidup di jalur job.

    withTenant<T>(...): Promise<T> mengembalikan 503 DATABASE_BUSY (breaker
    open / work-class saturasi) dan 409 idempotency lewat as T — cast yang
    artinya persis "berhenti memeriksa". Header-nya menyatakan "in practice every
    real call site uses T = Response"; itu sudah lama tidak benar. 58 berkas di
    src/+scripts/ yang bukan handler HTTP
    (15 di antaranya .astro) dan 24
    berkas test memakainya untuk mengambil DATA; begitu tipe-nya dijujurkan,
    compiler membuktikan 30 di antaranya benar-benar membaca field dari nilai yang
    bisa berupa Response.

    Kerusakannya nyata, bukan teoretis. purgeExpiredAuditEvents berjanji
    Promise<number>; di bawah work-class maintenance (SATU slot) ia
    mengembalikan Response. runBoundedBatches berhenti "sampai satu pass
    mengembalikan count: 0" — dan Response tak pernah === 0, sehingga job yang
    seluruh tujuannya mengalah justru menjalankan 50 pass penuh per tenant ke
    database yang baru saja menolak, lalu melaporkan totalCount sebagai string
    "0[object Response]…" (karena number + Response itu konkatenasi). Test
    mutasinya mereproduksi persis output itu.

    Sekarang ada dua bentuk, dan compiler yang memilihkan:

    • withTenant(...) → Promise<T | Response>. Jalur request meneruskan
      503-nya apa adanya, lengkap dengan Retry-After; 275 pemanggilan di 204
      berkas rute yang callback-nya memang sudah mengembalikan Response tidak
      berubah satu baris pun (Response | Response itu Response).
    • withTenantOrThrow<T>(...) → Promise<T>. Untuk semua yang bukan handler
      HTTP. Melempar DatabaseBusyError yang MEMBAWA response 503 yang sama
      (jadi kedua bentuk tak bisa menyimpang), dan kini diklasifikasi retryable
      oleh job runner alih-alih jatuh ke unknown.

    Tak ada lagi satu pun as T di modul itu.

    db:tenant-context:check (baru, di rantai check) menutup dua sisa yang tak
    terlihat compiler: hasil withTenant yang dibuang (await withTenant(...)
    sebagai statement — 503-nya lenyap tanpa jejak), dan pemanggilan dari .astro,
    yang tak pernah dibaca tsc --noEmit. Gate itu langsung menemukan tiga
    pembuangan nyata di jalur auth: dua di antaranya melewatkan audit event
    sso_account_linked/mfa_challenge_issued sambil tetap menjawab seolah sudah
    tertulis.

  • 4430aa4: The root discovery surfaces are edge-cacheable, and aggregate surfaces are now
    invalidated by the modules that author them (ADR-0061 §B).

    serveDiscovery accepts Astro's locals and publishes the resolved tenant after
    build(ctx) produces a payload; all six routes (/robots.txt, /sitemap.xml,
    /sitemap-{n}.xml, /feed.xml, /atom.xml, /feed.json) forward it. Three
    registry entries follow: seo-robots (600s, config-derived and the most stable),
    seo-sitemap (300s, index and child pages), seo-feed (300s, RSS/Atom/JSON with
    ?locale= as the only permitted parameter).

    Publishing after the payload check matters here even more than it did for
    /news/**: build returns null for "sitemaps disabled", "feeds disabled" and
    "page out of range", all of which collapse into the same generic 404 as an
    unknown host. It also means /sitemap-99999.xml matches the surface but never
    publishes a tenant, so walking page numbers cannot fill the cache.

    Discovery bodies turn out to have two authors, and only one of them owned the
    surface. PUT /api/v1/seo/config now enqueues a purge — the tenant-wide
    noindex switch alone rewrites /robots.txt. But the bodies are aggregated from
    every seo_facts provider, so publishing a post changes /sitemap.xml without
    touching anything seo_distribution writes, and a module purge tags
    t:<tenant>:m:<moduleKey>, so blog_content's purge could not reach it. Left
    alone that would have purged /blog/{code}/feed.xml on publish while /feed.xml
    — the same content — sat stale until TTL, with nothing reporting it.

    enqueueModuleContentPurge therefore also covers modules that declare a
    consumes dependency on the changing module and own a declared surface. It is
    read from the module registry, so blog_content never names seo_distribution;
    and it is limited to surface owners, because a ban on a key that tags no cached
    object matches nothing while the queue reports success.

    No migration, no permission, no OpenAPI change. With EDGE_CACHE_MODE unset the
    subsystem remains a no-op.

  • dfd8e64: Host-resolved public surfaces can be cached at the edge (ADR-0061 §A).

    ADR-0042 §8 defines two sources for the tenant a cached object is tagged with,
    and prefers the one a route publishes on locals.edgeCacheTenantId — the only
    source available to a surface whose tenant comes from the request rather than
    from a path segment. That branch had no writer anywhere in the repo, so it was
    unreachable and every host-resolved surface was uncacheable by construction:
    edge caching accelerated /blog/{tenantCode}/** (the legacy shape) and nothing
    of the /news/** family that ADR-0059 made the go-forward one.

    The four /news/** routes now publish their resolved tenant through
    publishEdgeCacheTenant, and the registry declares news-index,
    news-taxonomy and news-post — mirroring the TTLs and reasoning of their
    blog-* counterparts, owned by blog_content, whose existing module purge
    already invalidates them.

    Publication happens only on the path that actually serves the resource. A 404 is
    a cacheable status, so publishing before the "no such post/term" branch would
    annotate a missing-resource 404 with Surrogate-Control while an unknown-host
    404 gets private, no-store — answering "does this hostname map to a live
    tenant?" from one request, through a second channel over the question the
    route family's latency padding exists to close.

    No migration, no permission, no OpenAPI change. With EDGE_CACHE_MODE unset
    (every deployment's default) the whole subsystem remains a no-op.

  • a3d1dc2: Tegakkan bahwa setiap variabel env yang dibaca kode ada di .env.example.

    tests/env-required-vars-doc.test.ts sudah membandingkan daftar var WAJIB
    yang didokumentasikan dengan yang ditegakkan. Separuh yang lebih besar tak
    terjaga: var yang opsional tapi mengubah perilaku. Sebelas menumpuk di sana,
    termasuk:

    • TENANT_DOMAIN_DNS_PROVIDER — dua nilainya adalah "tak melakukan panggilan
      keluar sama sekali" dan "bicara ke API DNS sungguhan". Tak ada di
      .env.example, doc 18, maupun validate-env.ts.
    • R2_ACCOUNT_ID/R2_BUCKET/R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY —
      R2_ENABLED=false dikirim sendirian, jadi operator yang menyalakannya tak
      punya template untuk empat kredensial yang lalu diwajibkan uploader.
    • Empat SITE_SEARCH_*_RATE_LIMIT_* — kontrol penyalahgunaan pada dua
      endpoint publik anonim, bukan sekadar tuning.
    • FORM_DRAFT_RETENTION_DAYS, OBJECT_SYNC_UPLOAD_TIMEOUT_MS, dan blok
      TENANT_DOMAIN_CLOUDFLARE_*.

    Yang terakhir lebih buruk dari sekadar absen: .env.example merujuk "the
    TENANT_DOMAIN_CLOUDFLARE_* settings above" padahal tak ada satu pun di
    seluruh berkas itu.

    Nilai default yang dicatat diverifikasi ke kode, bukan ditebak — retensi form
    draft 30 hari (bukan 90) dan timeout object-sync 10000ms (bukan 30000).

    config:env:coverage:check (baru, di rantai check) menargetkan .env.example,
    bukan doc 18: berkas itulah yang disalin operator, sementara var yang hanya
    ada di prosa harus sudah diketahui lebih dulu untuk bisa dicari. Placeholder
    ber-komentar sudah cukup — pola yang sudah dipakai EMAIL_MAILKETING_* — jadi
    secret tetap tak masuk repo. Batas yang dinyatakan terbuka di header gate: ia
    hanya mencocokkan process.env.X, jadi modul config yang mengoper
    env: NodeJS.ProcessEnv lalu membaca env.X tak terlihat.

  • c244697: Postur standar keluarga dan kontrak konsumen: tiga celah asesmen putaran kedua
    ditutup, satu dinyatakan terblokir dengan alasan eksternal yang diverifikasi.

    Kontrak konsumen awcms-astro dipisah CONSUMED vs COMMITTED (§9.5).
    CONSUMER_PATHS semula membekukan enam permukaan karena diturunkan dengan
    mem-grep repo sebelah tanpa membuang komentar — tiga di antaranya prosa: sebuah
    docblock tipe, sebuah komentar yang menjelaskan kenapa build justru TIDAK memanggil
    /auth/session, dan sebuah pesan error yang memberi tahu manusia cara menerbitkan
    kredensial. Repo sebelah punya jawaban otoritatifnya dan menggerbanginya ("tepat
    tiga permukaan", komentar dibuang lebih dulu). Kini tiga permukaan yang benar-benar
    dipanggil dipisahkan dari dua yang dijanjikan ADR tetapi belum dipanggil
    (/auth/session, /access/machine-credentials — keduanya milik BFF ADR-0050 yang
    belum dibangun). /api/v1/blog/posts/{id} keluar dari kontrak sepenuhnya:
    ADR-0018 di repo sebelah menghapus fetch per-id, jadi membekukannya mengikat repo
    ini pada bentuk yang tak punya pembaca. Tiap entri COMMITTED wajib menyebut ADR
    yang menjanjikannya, dan sebuah test menegakkan bahwa ADR itu punya berkas.

    Dua lubang bun run skills:check ditutup (§9.6). Pembebasan
    ASPIRATIONAL_SKILLS dulu bersifat per-SKILL dan total: awcms-performance
    terdaftar dengan alasan yang menyebut PERINTAH sementara pembebasannya juga menutupi
    PATH, sehingga skill itu bisa berkata "perintah ini tidak ada" di banner-nya dan
    "gunakan suite yang sudah ada di src/lib/performance/" enam puluh baris kemudian —
    direktori yang tidak ada — tanpa gerbang berpendapat. Kini ada blok bertanda
    <!-- aspirational:mulai --> yang membatasi pembebasan ke passage yang memang
    memerlukannya; sisanya tetap digerbangi, dan awcms-performance keluar dari daftar.
    Lubang kedua mekanis: ekstraktor path hanya melihat path berbacktick satu baris,
    sehingga path yang terpotong pembungkusan markdown tak terlihat — aturan 1 sebenarnya
    berbunyi "path yang disebut DAN kebetulan muat satu baris wajib ada", dan selisih itu
    tak tertulis di mana pun. Keduanya mutation-proven.

    ADR-0068 menuliskan pin edisi standar dan tiga divergence keluarga.
    awcms-astro ADR-0028 menyatakan mengikuti edisi OWASP repo ini dan tidak
    mendahuluinya — sementara keputusan itu tidak pernah ada, karena pinnya datang lewat
    sebuah skill lalu diikuti karena sudah tertulis. intentionalDivergences yang kosong
    sejak ADR-0055 kini memuat tiga entri ber-reviewDate: HSTS includeSubDomains
    (benar di kedua sisi, alasan berbeda), .astro tak-terperiksa-tipe, dan pin edisi itu
    sendiri.

    astro check TIDAK bisa ditambahkan, dan itu diverifikasi bukan diasumsikan.
    @astrojs/check menuntut API programatik TypeScript 6.x; repo ini di 7.0.2, yang
    tidak menyediakannya. Dipasang, dijalankan, ditolak, lalu dependensinya dicabut lagi
    alih-alih meninggalkan 73 paket yang tak bisa berbuat apa-apa. Dicatat sebagai
    divergence bertanggal, bukan sebagai janji.

    ADR-0067 mendapat Opsi D — pengukuran lab. Ketiga opsi draf pertama semuanya RUM,
    sehingga seluruh keputusan bertabrakan dengan postur privasi visitor_analytics dan
    menunggu. Pengukuran lab (Playwright, sudah terpasang) mengumpulkan nol data
    pengunjung dan menjawab pertanyaan yang berbeda — "apakah perubahan ini membuat
    halaman lebih lambat" — jadi ia tidak perlu menunggu keputusan RUM.

  • 703f666: Foreign-key columns must be index-reachable — the repo's first performance gate
    (ADR-0064, sql/090).

    The 2026-08-04 assessment measured zero of 28 gates touching performance, so
    an unindexed foreign key lands with CI fully green and surfaces months later as
    "the admin screen got slow".

    Postgres indexes a foreign key's referenced side automatically and its
    referencing side not at all, so a bare FK column pays twice: every parent
    DELETE/UPDATE sequentially scans the child table to enforce the constraint,
    and the parent→child join has no index either. Measured here: 182 FK columns, 14
    unreachable, with awcms_blog_ads carrying no index at all beyond its primary
    key.

    The rule is tenant-aware — reachable means leading an index, or being the second
    column after tenant_id. The literal "must lead" rule is violated by 40 of 182,
    and forty migrations on the day a gate lands is not a gate but an exemption list
    waiting to be written. Since RLS FORCE guarantees every tenant-scoped query
    carries tenant_id, a (tenant_id, fk) composite is the index those joins
    actually use. The residual is stated rather than hidden: that composite does not
    help enforce the constraint on a parent delete. The relaxation is bounded and
    tested both ways — a second column after anything else does not count, and
    neither does a third column after tenant_id.

    sql/090 adds thirteen indexes (additive, IF NOT EXISTS, no data moved).
    awcms_setup_state.tenant_id is the single exemption: a hard singleton holding
    exactly one row.

    Zero permissions, zero OpenAPI change, zero runtime change.

  • 40f645a: feat(form-drafts): add the /admin/form-drafts ops screen and its sidebar entry

    form_drafts shipped a complete admin API but no screen and no navigation
    entry, so the module was invisible in the admin sidebar and the only way to see
    or clear a tenant's accumulated drafts was the JSON API or the daily
    form-drafts:purge job.

    Adds /admin/form-drafts: a filter bar (module key / wizard key / status)
    driving the same filters GET /api/v1/form-drafts accepts, the bounded
    newest-first list, a collapsed read-only payload inspector, and a per-row
    delete that calls DELETE /api/v1/form-drafts/{id}. Registered in the sidebar
    under System, gated on form_drafts.draft.read.

    Deliberately not included: a create form, a step editor, and a submit button.
    Drafts are produced by other modules' wizards, and submitting is a domain
    transition that wizard owns — a janitor screen that flipped a draft to
    submitted would report work as finished while nothing downstream ran.

    No schema, endpoint, or permission change.

  • 1922f79: Add the host-resolved public content family /news/** (ADR-0059), and make the SEO discovery base path follow the route family that actually serves.

    tenant_domain has mapped hosts to tenants since #219 and the discovery surfaces (robots.txt, sitemaps, feeds) and /search have been host-resolved since #223/#231 — but the content those surfaces point at could only be read through /blog/{tenantCode}/{slug}. A tenant on its own domain therefore published URLs carrying the very identifier the domain exists to remove. Four routes close that: /news, /news/{slug}, /news/category/{slug}, /news/tag/{slug}, resolving the tenant from the request through withHostResolvedBlogTenant — the same shape as site_search/comments, including the latency padding that keeps "unknown host" and "live tenant" indistinguishable in time as well as in body. The family has its own per-tenant switch, publicRouteMode, symmetric with the legacy family's legacyTenantRouteEnabled.

    The backlog asked for /blog/{slug}, and that shape was refused with evidence: probed in this repo, Astro reports the route "is defined in both" src/pages/blog/[slug].ts and src/pages/blog/[tenantCode]/index.ts, still builds, and lets one silently shadow the other — "a collision will result in a hard error in following versions of Astro". Resolving the ambiguity at runtime would be worse: whoever can write a post slug could shadow another tenant's listing URL. The archived publicBasePath/publicLabel settings are not adopted either, because they move a page's links without moving the route that serves them.

    seo_distribution now chooses its base path instead of assuming one: /news while the host-resolved family is live, /blog/{tenantCode} when a tenant switched that off but kept the legacy family, and no provider at all when both are off — an empty sitemap rather than one full of certain 404s. That invariant is mutation-proven against a real database.

    Also corrected, because it was recorded as a decision: the "every sitemap <loc> 404s for host-resolved tenants" defect in docs/PROJECT_STATE.md never existed. discovery-providers.ts has scoped the adapter to /blog/{tenantCode} since the module landed (#223), and the /blog default it was blamed on has zero callers in src/.

    Zero migrations, zero permissions, zero OpenAPI change. /news/** is deliberately not yet a declared edge-cache surface: its path is identical for every tenant, so the cache key has to carry the host first.

  • 2739d31: Tambah modul idn_admin_regions — master data wilayah administratif Indonesia
    yang ber-versi, ter-provenance, dan bisa di-rollback (ADR-0046).

    Hampir setiap aplikasi bisnis Indonesia di atas template ini butuh wilayah resmi:
    alamat pelanggan, cabang, wilayah kerja, agregasi laporan per provinsi. Tanpa
    modul bersama, setiap aplikasi menyalin CSV-nya sendiri — versi berbeda-beda,
    tanpa asal-usul, tanpa cara membuktikan versi mana yang sedang dipakai.

    Yang mendarat:

    • Skema ber-versi (sql/080): awcms_idn_region_datasets (satu baris per
      impor, dengan repo/commit/checksum/nomor Kepmendagri) dan
      awcms_idn_admin_regions (91.599 wilayah milik satu versi). Impor berikutnya
      menulis di samping, bukan menimpa — itulah yang membuat rollback jadi
      pembalikan status, bukan impor ulang.
    • Impor sebagai JOB (bun run idn-regions:import, dry-run default): mem-parse
      dump upstream sebagai TEKS (tanpa mesin SQL, tanpa MySQL, tanpa jaringan) dan
      menolak impor parsial — baris tak terparse, kode ganda, induk hilang, atau satu
      tingkat kosong semuanya menggagalkan impor. Dataset baru selalu mendarat
      validated, tak pernah langsung active.
    • Aktivasi/rollback sebagai aksi admin ter-audit (ABAC + Idempotency-Key),
      dengan aturan "hanya satu dataset aktif" ditegakkan partial unique index di
      database
      — bukan pemeriksaan aplikasi yang bisa disusupi dua request
      bersamaan.
    • Lookup API /api/v1/idn-regions/*: filter tingkat/induk/nama, paginasi
      keyset, default ke dataset aktif, dan ?dataset=<code> untuk membandingkan
      versi lama.
    • Dataset ter-vendor (data/idn-admin-regions/, ~4,2 MB): agar impor
      deterministik dan offline, dan agar "versi wilayah mana yang jalan di build
      ini" terjawab dari commit, bukan dari keadaan internet hari itu.

    Dua keputusan yang mengikat pembaca berikutnya:

    • Kedua tabel GLOBAL — tanpa tenant_id, tanpa RLS. Provinsi "Aceh" sama
      untuk semua tenant. Yang menggantikan RLS bukan kepercayaan: keduanya wajib
      terdaftar di GLOBAL_TABLE_FORBIDDEN_PRIVILEGES sehingga privilege tiap role
      dinyatakan eksplisit (awcms_app SELECT + UPDATE dataset saja, awcms_worker
      jalur tulis, nol DELETE untuk keduanya), dan setiap endpoint tetap melewati
      sesi + konteks tenant + ABAC default-deny. Yang global adalah BARISNYA, bukan
      izinnya.
    • Ini bukan API resmi Kemendagri. Dataset komunitas (cahyadsn/wilayah, MIT)
      yang mengemas Kepmendagri. Caveat itu dibawa di kode, di respons API, dan di
      layar admin — bukan hanya di dokumen. Nomor keputusan direkam per berkas
      dari header masing-masing: berkas yang diimpor menyebut 300.2.2-2138/2025,
      sementara awcms-mini merekam satu kalimat menyebut 2430 untuk semua berkas —
      koreksi yang digerbangi test provenance.

    Diverifikasi terhadap PostgreSQL 18.4 nyata: 81 migrasi bersih, impor 91.599
    baris (38 provinsi / 514 kabupaten-kota / 7.285 kecamatan / 83.762 desa-kelurahan),
    impor ulang byte identik = no-op, dan nilai turunan yang mudah salah terbukti
    benar pada baris nyata (Desa Adat di Papua, Kota Administrasi di DKI, jalur
    leluhur "Papua, Kabupaten Jayapura, Sentani, Desa Adat Yoboi").

  • fc138df: Kredensial mesin baca-saja + GET /api/v1/auth/session — dua kontrak yang
    menahan awcms-astro, dibangun sebagai satu desain (ADR-0049).

    Satu-satunya bearer yang repo ini terima adalah token sesi ber-hash, dan
    sebuah build tidak bisa memegangnya: sesi kedaluwarsa, dicabut seluruhnya saat
    password reset, dan dirotasi step-up MFA. .env.example milik awcms-astro
    menyuruh operator mengisi "a BUILD-TIME, READ-ONLY token" — instruksi untuk
    menerbitkan sesuatu yang tidak bisa diterbitkan siapa pun, di repo mana pun di
    keluarga ini.

    Kredensial MENGAUTENTIKASI; ia tidak pernah MENGOTORISASI. Setiap baris
    awcms_machine_credentials (sql/082, tenant-scoped, FORCE RLS, FK komposit)
    terikat pada satu awcms_tenant_users yang sudah ada. Setelah prinsipalnya
    resolve, rantai module-enabled → RBAC → ABAC → decision log → SoD berjalan apa
    adanya. Kredensial yang membawa daftar izinnya sendiri akan menjadi permukaan
    otorisasi KEDUA — persis yang ADR-0048 §1 larang.

    Tiga pembatas, semuanya fail-closed:

    • Menyempitkan, tak pernah melebarkan. Izin efektif = irisan
      allowed_permission_keys dengan izin service account. Menambah role ke akun
      itu tidak melebarkan kredensial yang sudah terbit; daftar kosong berarti tidak
      bisa apa-apa, bukan "tanpa batas".
    • Baca-saja, diputus SEBELUM izin dilihat dan terlepas dari apa pun yang
      dipegang akunnya — token yang bocor tak bisa mengubah apa pun walau diarahkan
      ke owner.
    • Kedaluwarsa wajib (maks 365 hari) dan pencabutan berlaku pada permintaan
      berikutnya, karena autentikasi membaca baris yang sama. Itulah yang token buram
      ber-hash beli dan JWT bertanda tangan tidak punya jawabannya.

    Token membawa tenant-nya sendiri (awcmsm_<tenantIdHex>_<rahasia>), jadi
    klien build cukup satu env var dan header tenant tidak lagi relevan untuknya —
    menutup cacat header ADR-0047 tanpa menambah alias X-Tenant-Code yang setiap
    rute masa depan harus ingat menghormatinya. Hash-nya ber-ruang-nama
    mc-sha256:, dan hashSessionToken() men-dispatch pada prefix token, sehingga
    183 rute yang sudah memanggilnya mendapat dukungan ini tanpa satu pun perubahan
    tanda tangan — dan satu jenis bearer tak pernah bisa dicari di ruang nama
    jenis lain.

    GET /api/v1/auth/session mengembalikan klaim aman saja untuk BFF lintas-origin
    (identityId/tenantId/displayName/roles/assuranceLevel/expiresAt/scopes)
    — bukan duplikat /auth/me, yang justru mengembalikan email mentah dan diam soal
    peran/assurance/kedaluwarsa. Satu bentuk 401 untuk setiap kegagalan, termasuk
    saat yang disodorkan kredensial mesin, supaya endpoint ini tak bisa dipakai
    mengklasifikasi bearer. Ia memperkenalkan defineSelfServiceTenantRoute —
    seam untuk rute terautentikasi yang subjeknya pemanggil itu sendiri, sehingga
    workClass tetap wajib dan api:tenant-route:check tetap satu-arah alih-alih
    menumbuhkan allowlist kedua.

    Decision log mendapat machine_credential_id nullable: beberapa kredensial boleh
    menunjuk service account yang sama, jadi tanpa kolom itu "token yang MANA yang
    membaca ini" tak punya jawaban.

    Diverifikasi terhadap PostgreSQL nyata (83 migrasi bersih, 18 test integrasi) —
    dan verifikasi itulah yang menemukan jebakan yang lolos typecheck: Bun.SQL
    tidak mem-bind array JS sebagai array Postgres
    . ${["a","b"]} sampai ke server
    sebagai teks a,b (22P02), dan bentuk satu elemen paling berbahaya karena tiba
    sebagai a yang terlihat seperti string biasa.

    Dicatat sebagai divergence keluarga di awcms-family-compatibility.yaml saat
    mendarat
    , sesuai ADR-0047 §4 — fitur fondasi pertama yang dirintis langsung di
    sini selama pembekuan awcms-mini/awcms-micro.

    Security review pra-merge menemukan dua hal dan menutup keduanya: deaktivasi
    service account kini langsung mematikan kredensialnya (jalur mesin
    mensyaratkan status tenant-user dan identitas aktif — sengaja lebih ketat
    dari jalur sesi, karena tak ada apa pun yang mencabut kredensial saat akun
    dinonaktifkan dan umurnya bisa setahun), dan respons penerbitan kini
    private, no-store karena badannya membawa kredensial hidup. Yang pertama
    dibuktikan dengan mengembalikan cacatnya: dua test integrasi merah, lalu hijau
    lagi setelah dipulihkan.

  • 1b852b8: Revoke media_library.media.attach and media_library.media.detach (ADR-0056 §A).

    Both were seeded into the global permission catalog by sql/052, and POST /api/v1/setup/initialize grants that catalog whole to every new tenant's owner role. Neither was ever checked: no route, no application function, no job. They named a write that stopped existing at ADR-0036 — before that inversion, awcms_news_media_objects.owner_resource_type/.owner_resource_id held the object→content relation and attach/detach were real operations on this module's own table; after it, a media object's attachment is stated by the consumer's FK (awcms_blog_posts.featured_media_id, awcms_news_portal_ad_placements.media_object_id), so attaching means updating the consumer's row under the consumer's permission.

    sql/087 deletes the grants first, then the catalog rows — reversed, the catalog delete hits the awcms_role_permissions FK. The two zero-caller functions (attachNewsMediaObject, detachNewsMediaObject) are deleted with them, and media-object-directory.ts keeps a marker where they were so the next reader learns why the module has no attach path rather than assuming one is missing.

    The attached status survives deliberately: sql/041's CHECK still admits it and isNewsMediaObjectSafeForPublicReference still treats it as safe to reference, so any row already in that state keeps resolving. What is gone is the ability to write it — which nothing did. verified is what the finalize flow produces and it is equally referenceable.

    This is a real authorization change, and it is the narrow half of the ADR: delete/restore/purge are equally ungated today and are deliberately left alone here, because unlike these two they describe operator needs that currently have no answer at all. §B gives them endpoints.

    Also corrects ADR-0056 §A, whose first edition said all five dead functions were deleted — contradicting §B, which uses three of them. Two are deleted; three are kept and given a surface.

  • ad5f1e6: Give media_library's delete/restore/purge permissions the endpoints they never had (ADR-0056 §B), and fix a Postgres error-code check that could never be true.

    All three permissions have been in the global catalog since sql/052, granted whole to every tenant owner, and enforced by nothing — no route, no application function, no job. The functions behind them were written and had zero callers. So an object uploaded by mistake, orphaned, or violating policy disappeared only if the reconciliation job happened to categorise it that way, on the job's own schedule; there was no way for an administrator to remove one, and no way to undo it if they were wrong.

    • DELETE /api/v1/media/objects/{id} — soft delete, body { reason } required and bounded at 500 characters. The reason is part of the request hash, so replaying one key with a different reason is a different request rather than a stored response describing a reason nobody sent.
    • POST /api/v1/media/objects/{id}/restore — the undo. A live object answers 404: "there was nothing to undo" and "it worked" must not share a response.
    • POST /api/v1/media/objects/{id}/purge — hard-deletes the registry row of an already soft-deleted object.

    All three are HIGH_RISK_ACTIONS and require Idempotency-Key, each under its own scope so a delete's key cannot collide with a purge's.

    Soft delete breaks live references, deliberately. resolveMediaReferences filters deleted_at IS NULL, so a post whose featured_media_id points at a deleted object resolves to nothing immediately. That is the intended outcome for the case this serves, and restore is what makes it recoverable. Nothing here scans for referencing rows first: that would make a System Foundation module know its own consumers.

    purge clears the registry, not the R2 bytes. The news-media:reconcile job owns the bucket; a second writer would mean two processes with different ideas of what is safe to remove. Accepted, stated cost: a window where the R2 object outlives its registry row, closed by the next reconciliation tick.

    awcms_news_portal_ad_placements.media_object_id is a hard NOT NULL FK, so purging a still-referenced object answers 409 MEDIA_OBJECT_REFERENCED. That path runs inside a savepoint — in PostgreSQL a 23503 aborts the whole transaction, so catching it without one turns a caller-actionable 409 into a 500 at the COMMIT withTenant performs. Verified against a real database rather than reasoned about.

    That verification turned up a second thing. The SQLSTATE is on error.errno, not error.code — Bun sets code to its own "ERR_POSTGRES_SERVER_ERROR" for every server error alike, so error.code === "23505" is not a subtly wrong check but one that can never be true, leaving everything downstream of it dead. Ten sites in this repo already used String(error.errno). One did not: tenant-provisioning.ts, where POST /api/v1/tenants promises 409 duplicate_tenant_code and served a 500 on the concurrent-duplicate race the savepoint exists for (its pre-check SELECT hid the ordinary case). Fixed here rather than filed, and tests/postgres-sqlstate-detection.test.ts now gates it repo-wide — mutation-proven by restoring the original defect.

    media_library now has zero ungated permissions. ADR-0056 §C (a list function and its own route) is what remains before the screen.

  • e5225e3: Add listMediaObjects and GET /api/v1/media/objects/list (ADR-0056 §C) — the last piece before /admin/media.

    Until now the application layer had only point lookups: fetchNewsMediaObjectById, fetchNewsMediaObjectsByIds, fetchNewsMediaObjectByObjectKey. There was no way to ask "what media does this tenant have", so a browse screen could not be built on the existing surface at all, whatever the permission catalog said.

    It gets its own route rather than a mode on the resolver. GET /api/v1/media/objects demands ?ids= — it is a batch resolver built for the awcms-astro build to swap ids for public URLs. Teaching it a "no ids means list everything" branch would turn a request that is a 400 today into a dump of the entire registry: a contract change wearing the clothes of an addition, and one no existing caller could opt out of.

    list cannot be read as an object id, because [id].ts and its children now require a uuid and answer 400 otherwise. That closes the path ambiguity from the other side, so Astro's static-before-dynamic precedence is not the only thing keeping /list and /{id} apart.

    The listing deliberately outgrows the resolver's safety rule. It returns rows in any status — pending_upload, failed, orphaned — and, with deletion=deleted|all, soft-deleted ones. isNewsMediaObjectSafeForPublicReference admits only verified/attached; an administrator opens this list precisely because of the objects that are not healthy, and §B's lifecycle endpoints would otherwise have no way to find their targets. media.read keeps it inside the tenant, and nothing returned here may be used as a public reference.

    deletion is three states rather than a boolean includeDeleted: "show me what I deleted" is the question restore and purge exist to answer, and a boolean cannot ask it. It defaults to live, so deleted objects are opt-in.

    Filters and cursors are refused when malformed, never ignored — a silently dropped filter answers 200 with a page nobody asked for, and a corrupt cursor treated as "no cursor" serves page 1 to a caller paging through page 4, forever.

    The cursor carries full-precision created_at text, never a JS Date. A media registry is one of the likeliest places to resurrect Issue #158, because a batch upload writes many rows inside a single millisecond. tests/integration/media-object-list.integration.test.ts inserts 107 rows in ONE statement — so they share a transaction timestamp exactly — and walks every page; reverting the cursor to Date loses 57 of them and turns four tests red.

    The projection omits bucket_name/storage_driver (deployment facts a browse screen has no use for) and owner_resource_type/owner_resource_id (vestigial since ADR-0036 moved attachment to the consumer's FK — shipping them would invite a screen to present them as current).

    ADR-0056 is now complete. What remains is the /admin/media screen itself.

  • 7ff6aa9: GET /api/v1/media/objects — resolusi referensi media batch, sehingga artikel
    terbit tidak lagi kehilangan gambarnya di konsumen luar.

    awcms_blog_posts membawa featured_media_id dan seo_image_media_id, tetapi
    media_library tidak mengekspos satu pun endpoint baca — hanya upload
    session dan flag enforcement. Konsumen di luar proses karena itu bisa melihat
    bahwa sebuah post PUNYA gambar tanpa cara apa pun mengetahui URL-nya. Itulah
    sebab article-images.ts di awcms-astro mengembalikan src: undefined dan
    setiap artikel terbit tanpa gambarnya, sementara tidak ada satu pun yang gagal.

    Logika resolusinya bukan hal baru: MediaLibraryPort.resolveMediaReferences
    sudah melakukannya untuk konsumen in-process sejak ADR-0036. Ini panggilan yang
    sama lewat HTTP, dengan aturan keamanan yang sama — hanya objek verified /
    attached, satu tenant, tidak soft-deleted, yang resolve. Tanpa migrasi:
    permission media_library.media.read sudah diseed sejak sql/052 sambil
    menunggu permukaannya (ADR-0026 langkah 5d).

    Dua keputusan bentuk yang tidak sepele:

    • Batch, bukan satu-per-id. Build feed me-resolve seluruh gambar satu halaman
      sekaligus; satu request per id membuat situs 200 post jadi ribuan round-trip,
      sementara query di bawahnya memang sudah satu id = ANY(...).
    • Id yang gagal DILAPORKAN, bukan dibuang. Mengembalikan hanya yang berhasil
      membuat "resource ini tidak punya gambar" dan "referensi gambarnya rusak"
      menjadi respons yang sama — ambiguitas yang membuat celah ini bertahan tanpa
      disadari. Semua sebab kegagalan dilebur ke satu ember (unresolved) supaya
      endpoint-nya tidak jadi oracle atas sebab mana; id yang bukan uuid ditolak 400
      karena "Anda mengirim sampah" adalah fakta yang berbeda.

    Read-only, jadi kredensial mesin (ADR-0049) boleh memegangnya — inilah yang
    melengkapi build feed.

    Diverifikasi terhadap PostgreSQL nyata (7 test): objek belum-terverifikasi,
    soft-deleted, dan milik tenant lain masing-masing TIDAK PERNAH resolve; batch
    campuran resolve sebagian alih-alih gagal utuh; dan objek yang sama tetap
    resolve dari tenant pemiliknya (memastikan kegagalan lintas-tenant itu memang
    tenant scoping, bukan baris rusak).

  • 2ac4708: GET /api/v1/media/public-origin — the origin media public URLs are served
    from, so a build client never holds a second copy of it.

    awcms-astro ships a strict CSP and must name the media host in img-src at
    BUILD time: an image resolved correctly still renders as nothing when
    img-src 'self' blocks the host it lives on. Reading the origin off a
    publicUrl does not help, because the policy is written before any object is
    fetched, and a build with no images would then emit no img-src at all. The
    only alternative left was copying NEWS_MEDIA_R2_PUBLIC_BASE_URL into the
    consumer by hand — two copies of one value that agree until one is edited, with
    a failure (images silently blocked) that names its cause nowhere.

    Reports origin (scheme + host + port, for the host-wide CSP form) and
    baseUrl (path included, for the tighter prefix form); neither choice is this
    API's to make.

    A deployment serving no public media answers 200 with configured: false
    rather than an error, so a LAN/offline build omits the entry instead of
    failing. A value that is set but unparseable — or on a scheme that cannot serve
    media, data: above all — is reported the same way and never echoed back:
    handing a consumer a malformed origin puts it in a CSP header, where a browser
    either rejects the whole policy or allows something nobody wrote down.

    Gated on media_library.media.read, the permission a build client already
    holds; no new authority on any credential, and machine credentials stay
    read-only (ADR-0049). No migration.

  • 68da201: Lebur news_portal ke blog_content — satu modul konten, tanpa fitur hilang.

    news_portal sudah berhenti membawa bebannya sendiri. 11 berkas melawan 59,
    3 tabel melawan 18, nol capability disediakan, nol rute publik, dan konsumen
    WAJIB public_content milik blog_content — setiap tipe section homepage-nya
    dibangun di atas data modul itu. Seam capability ada untuk menggambarkan
    hubungan dua modul yang masuk akal berubah sendiri-sendiri; dua ini tidak bisa.

    Yang lebih menentukan: keduanya mengapalkan sistem iklan, dan yang satu
    melemahkan kontrol keamanan yang lain. awcms_blog_ads.image_url menerima URL
    apa pun, sementara awcms_news_portal_ad_placements.media_object_id adalah FK
    ke objek media terverifikasi. Selama keduanya hidup, sebuah tenant bisa
    menyalakan enforcement managed-media (ADR-0036) dan tetap menerbitkan gambar
    remote sembarangan lewat pintu yang lain.

    Tapi keduanya bukan fitur sama dengan dua ejaan. Yang lama punya penargetan
    post/page yang tidak dimiliki yang baru; yang baru punya 12 slot penempatan,
    4 mode rotasi, dan prioritas yang tidak dimiliki yang lama. Mengganti salah satu
    dengan yang lain akan menghapus kemampuan tanpa suara — jebakan yang justru
    menjadi alasan perubahan ini ditulis sebagai UNION, dan alasan penyatuan tabel
    iklan dikerjakan terpisah setelah tabel tujuannya diperlebar lebih dulu.

    Perubahan ini:

    • memindahkan 8 berkas domain/+application/ ke src/modules/blog-content/;
    • mempertahankan nama tabel dan path API (awcms_news_portal_*,
      /api/v1/news-portal/*), mengikuti preseden ADR-0036 yang memindahkan
      registry media tanpa me-rename awcms_news_media_objects. Rename memakan
      setiap FK, policy, index, dan konsumen sambil tidak membeli apa pun yang
      descriptor dan inventori belum catat;
    • me-repoint 4 permission lewat sql/076 dengan urutan insert → pindahkan
      grant → hapus. Urutannya adalah keseluruhan poinnya: menghapus lebih dulu
      akan mencabut kapabilitas dari setiap tenant yang memilikinya, dengan semua
      gerbang tetap hijau;
    • menaikkan media_library dari optional menjadi capability wajib bagi
      blog_content, karena ad placement yang diserap memegang FK nyata — itulah
      alasan news_portal dulu mendeklarasikannya non-optional;
    • men-DROP awcms_news_portal_tenant_state (sql/077). Penulisnya tidak pernah
      diport, jadi tabel itu inert; tabel FORCE-RLS tanpa pemilik dan tanpa penulis
      adalah klaim palsu yang berdiri di depan setiap gerbang inventori;
    • mempertahankan preset news_portal dengan namanya. Preset menamai niat, bukan
      modul, dan niatnya tidak berubah.

    tests/news-portal-merge.test.ts menjaga janji "union, bukan pengurangan":
    setiap fitur yang selamat dipaku ke sesuatu yang bisa diamati — entri registry,
    permission terdeklarasi, prefix rute yang diklaim, berkas di disk, atau urutan
    statement di migrasinya.

  • 9ce56e2: Bandingkan dua registry job yang selama ini mendeskripsikan skrip yang sama tanpa
    ada yang membandingkannya.

    JOB_WORK_CLASS_REGISTRY menyatakan anggaran pool sebuah skrip, dan sudah
    ditegakkan ke ground truth — generatornya MENOLAK jalan saat peta dan disk
    berselisih. ModuleDescriptor.jobs menyatakan sebuah job untuk APA dan seberapa
    sering operator harus menjalankannya (recommendedSchedule), dan disajikan lewat
    GET /api/v1/modules/{moduleKey}/jobs. Yang pertama ditegakkan ke filesystem;
    yang kedua tidak ditegakkan ke apa pun.

    Akibatnya sebuah skrip worker bisa sepenuhnya masuk model kapasitas tapi tetap
    tak terlihat di satu-satunya permukaan yang dibaca operator untuk tahu bahwa job
    itu perlu dijadwalkan — dan dua memang begitu:

    • tenant-domain:dns:sync — modul tenant_domain tak mendeklarasikan jobs
      sama sekali. Deskriptornya ditambahkan (jadwal: tiap 15 menit; manual sebagai
      default tak melakukan panggilan keluar).
    • edge-cache:purge — tak ada modul edge_cache untuk menggantungkan
      deskriptornya: edge cache adalah infrastruktur src/lib/ (ADR-0043), sementara
      ModuleDescriptor.jobs di-key per modul. Dicatat sebagai pengecualian dengan
      alasan STRUKTURAL, bukan "belum sempat".

    modules:jobs:check (baru, di rantai check) menegakkan keduanya: tiap skrip di
    work-class registry wajib punya deskriptor dengan recommendedSchedule tak
    kosong. Job yang tak dijadwalkan tak pernah jalan dan tak ada yang memberi tahu —
    tak ada gate, tak ada health check, tak ada alarm.

    Tabel §Job registry di deployment-profiles.md dihapus alih-alih diperbarui: ia
    salinan tangan yang menua persis seperti yang diperkirakan, memuat tiga command
    ERP yang tak pernah ada sambil melewatkan sepuluh job yang benar-benar dikirim.
    §Shared worker runner juga dikoreksi — ia mengklaim ketujuh dispatcher memakai
    runJob, padahal email:dispatch dan sync:objects:dispatch memakai claim-lease
    per baris, yang justru MENGIZINKAN worker paralel; empat job lain belum memakai
    keduanya dan kini terdaftar apa adanya.

  • 285b73d: Tenant-module matrix and per-module audit summary — the rest of #261.

    GET /api/v1/tenant/modules/matrix returns every module with this tenant's
    enabled state, its protected flag, and two lifecycle warnings computed by
    re-running the REAL evaluateModuleEnable/evaluateModuleDisable rather than a
    UI-side re-derivation that would drift from the endpoints. Two queries total;
    the rest is pure.

    The warnings are one-directional on purpose — dependencyWarning only for a
    disabled module, reverseDependencyWarning only for an enabled one. The other
    combinations cannot arise, and asking evaluateModuleEnable about an
    already-enabled module short-circuits to MODULE_ALREADY_ENABLED: an answer
    that looks like a check and is not one.

    No health column, unlike awcms-micro's matrix: that one is fed by a batched
    health reader this base does not have, and a per-row read would be 21 queries
    inside one transaction.

    GET /api/v1/modules/{moduleKey}/audit returns recent module-management
    activity for one module, guarded by logging.audit_trail.read — these are
    audit-log rows, so the audit-log permission governs them. The caller-supplied
    ?limit= is clamped to 1..50, with NaN/Infinity falling back to the default.

  • 1ffb11c: Tenant module presets: named profiles a tenant can be brought to in one action.

    minimal, website, news_portal and back_office. A preset ENABLES what it
    lists and DISABLES every enabled, unlisted, unprotected module — enable-only
    would make presets useless as a way to REACH a profile, since a tenant that once
    enabled blog_content and then applied minimal would stay non-minimal
    forever.

    Ported from awcms-micro (Issue #261) with its planning logic intact, but not its
    preset set: back_office has no counterpart there, and micro's R2/SaaS presets
    are not reproduced because the subsystems that distinguished them do not exist
    in this base — a preset naming an absent module is a dead profile.

    GET /api/v1/tenant/modules/presets?preset=<name> returns a dry-run plan,
    because applying one disables things and an operator should see that list first.
    POST /api/v1/tenant/modules/presets/{presetName}/apply executes it through the
    existing lifecycle primitives, so each change runs the real validation and a
    rejection is reported per module rather than swallowed.

    No migration and no new permission: an apply is a sequence of enables and
    disables, so it guards on module_management.tenant_modules.disable.

  • 049e36d: Make route ownership derivable: ModuleApiContract.routes and
    modules:routes:check.

    basePath was the only ownership claim a descriptor could make, and
    tenant_admin declared basePath: "/api/v1" — a prefix of every route in the
    application. Resolving a route to its longest-matching basePath handed
    tenant_admin 36 routes it does not own (all of
    /api/v1/{access,roles,users,abac,identity}, which are identity_access, plus
    /api/v1/tenant/modules, which is module_management), while 30 public routes
    matched nothing at all.

    api.routes is a list of owned prefixes, longest-prefix wins — because
    ownership genuinely is not one prefix: /api/v1/tenant is split between
    tenant_domain and module_management, and public surfaces (/blog,
    /robots.txt, /search, /theming, /login) belong to modules too.

    bun run modules:routes:check (check chain + ci.yml) requires every file
    under src/pages outside /admin/** to resolve to exactly one module or be
    named in a reviewed PLATFORM_ROUTES allow-list. It also rejects /, /api
    and /api/v1 as claims outright — a coverage-only rule cannot see them, since a
    prefix matching everything leaves nothing uncovered.

    MODULE_CONTRACT_VERSION 2.3.0 -> 2.4.0 (additive; routes omitted means
    [basePath]). openapi_documented readiness now checks every owned prefix
    rather than the display basePath, which for tenant_admin had been matching
    any path at all.

  • f8d9c39: bun run identity-access:permissions:backfill — tutup celah yang dibuka SETIAP
    migrasi seed permission, tanpa menghidupkan kembali grant yang sengaja dicabut.

    Role owner sebuah tenant menerima izinnya sekali, saat tenant itu dibuat
    (platform-bootstrap.ts: INSERT … SELECT id FROM awcms_permissions). Migrasi
    seed berikutnya hanya memperluas katalog global. Jadi setiap tenant yang lebih
    tua dari sebuah modul akan menerima 403 ACCESS_DENIED pada permukaan admin
    modul itu, dan tidak ada satu pun yang mengatakannya. Ini sudah terjadi di
    produksi (2026-07-26: owner kehilangan 18 permission setelah migrasi 062–070) dan
    akan terjadi lagi pada sql/083.

    Kenapa "grant semua yang hilang" ditolak. Bentuk itulah yang dianjurkan
    environments.md sebelumnya (LEFT JOIN … WHERE rp.permission_id IS NULL), dan
    ia tidak bisa membedakan "belum pernah ada saat tenant dibuat" dari "dicabut
    admin dengan sengaja" — surface role admin memang menyediakan penghapusan itu.
    Ia akan mengembalikan persis grant yang seseorang putuskan untuk dihapus, di
    seluruh tenant sekaligus, tanpa jejak. Arah kegagalannya juga yang paling buruk:
    melewatkan sebuah permission terlihat sebagai 403 yang bisa dilaporkan; memberi
    permission yang tak seharusnya tidak terlihat sama sekali.

    Aturannya: hanya permission yang baris katalognya lebih baru dari role
    owner yang di-grant. Yang lebih tua tidak mungkin merupakan tambahan yang
    terlewat — ia ada saat seed pertama, jadi ketidakhadirannya adalah keputusan.
    Perbandingannya > bukan >=: bootstrap menulis role dan grant-nya dalam satu
    transaksi, sehingga permission ber-stempel sama dengan role-nya JUSTRU bagian
    dari seed asli.

    Dry-run default (--commit untuk menulis, --tenant <kode> untuk rollout
    bertahap), idempoten (ON CONFLICT DO NOTHING), satu entri audit per tenant yang
    benar-benar berubah — dan tidak ada entri saat tak ada perubahan, karena log
    pemeliharaan yang berbunyi di setiap no-op melatih pembacanya untuk
    mengabaikannya. Role kustom tidak pernah disentuh.

    Diverifikasi terhadap PostgreSQL nyata (6 test integrasi) termasuk hal yang
    paling penting: 403 yang jadi alasan tool ini ada benar-benar hilang setelah
    backfill, sementara permission yang sengaja dicabut tetap ditolak sesudahnya.
    Aturannya mutation-proven: mengganti seleksinya jadi "semua yang hilang"
    memerahkan 3 test unit dan 4 test integrasi.

  • 5702ab1: Ownership-based grants now run through the authorization chokepoint (ADR-0063).

    Three handlers — PATCH /api/v1/blog/posts/{id},
    POST /api/v1/blog/posts/{id}/submit-review and PATCH /api/v1/blog/pages/{id}
    — decided permissions themselves from fetchGrantedPermissionKeys plus a domain
    rule, never calling authorizeInTransaction. That skipped the ABAC evaluator,
    the ADR-0053 platform-scope gate, ADR-0060 business-scope facts and #181 SoD. The
    visible consequence: a tenant's explicit ABAC deny was honoured on some routes
    and silently ignored on these three.

    None of the three was sloppiness. They enforce the product rule that an author
    may edit their own unpublished content even without holding the permission —
    an authorization axis the permission catalogue cannot express — while the
    chokepoint returns denied before any domain rule is consulted. Putting it in
    front would have deleted the author path: a functional regression that looks like
    a security tightening.

    authorizeInTransaction therefore gains ownershipGrant, which widens the
    permission set being evaluated instead of short-circuiting the decision. Tenant
    isolation, an ABAC deny, business scope and SoD can all still refuse. Machine
    credentials are excluded, since a credential authenticates and never authorizes.
    The decision log labels ownership allows ownership_grant:<reason> so an auditor
    can tell them from RBAC allows.

    New gate bun run access:chokepoint:check holds the class: every handler calling
    fetchGrantedPermissionKeys must go through the chokepoint or be a reasoned
    exemption keyed <file>#<METHOD>. It slices per handler, because a per-file
    reading is what produced the original mis-analysis — blog/posts/[id].ts calls
    the chokepoint in GET and DELETE while PATCH did not. Two exemptions:
    pre-authentication login, and the self-introspection endpoint that calls
    evaluateAccess directly.

    Behaviour changes in one direction only: an action that previously slipped past
    ABAC can now be refused by a tenant's own policy.

    No migrations, no new permissions, no OpenAPI change.

  • 6ed60e0: Add email password reset — the flow this repo has shipped a template for since
    sql/014 and never had a caller for.

    email's auth.password_reset category, default template, and declared
    variables (userName/resetUrl/expiresInMinutes) have existed unused all
    along, so an operator who locked themselves out had no in-band recovery. Two
    public endpoints (POST /api/v1/auth/password/{forgot,reset}), two pages
    (/forgot-password, /reset-password), and one table (sql/073,
    awcms_password_reset_tokens, RLS FORCE, only a sha256 of a 256-bit CSPRNG
    token ever stored) close it. Adapted from awcms-micro Issue #496.

    Neither endpoint is an oracle. forgot returns one fixed 200 body for
    every outcome — unknown identifier, inactive identity or tenant-user, SSO-only
    identity, a non-mailable identifier, and a queued email are indistinguishable.
    reset returns one generic rejection for not-found, expired, already-used,
    deactivated-since-issue and password-login-disabled-since-issue. The specific
    reason survives only in the tenant-scoped, RLS-protected audit trail.

    Single use is enforced by the database, not by JavaScript. Redemption reads
    the token FOR UPDATE; without that lock two requests carrying the same link
    both observe used_at IS NULL and both reset the password. That is
    mutation-proven — removing the lock turns the concurrency test red.

    An SSO-only identity cannot recover a password, checked on the request path
    and re-read at redemption so a live link does not survive the tenant turning
    password login off. Without it, reset would be a supported, unauthenticated way
    to mint a working password on a tenant that deliberately disabled them.
    Break-glass identities are exempt, matching login.ts.

    A completed reset revokes every session of that identity, aal2 included,
    and clears the lockout counters — the link holder proved control of the mailbox.

    Delivery goes through a new auth_notification capability port, not an
    INSERT into awcms_email_messages. That table belongs to email (ADR-0013
    §6) and the micro original wrote into it directly; it also cannot be a
    dependencies edge, because email already depends on identity_access and
    the reverse would close a cycle. A tenant with no active template reports
    delivery_unavailable — logged and audited for the operator, invisible to the
    caller.

    Optional hardening: with AUTH_URL_PARAM_ENCRYPTION_KEY set, the emailed link
    carries one opaque AES-256-GCM ?p= value instead of ?token=…&tenantId=….
    Unset, it falls back to plain params — the token is a 256-bit CSPRNG value
    either way, so this tightens a deployment rather than gating the feature.

    Also: /login's auth styles move to a shared src/styles/auth.css and its
    tenant picker to tenant-admin's tenant-picker-directory.ts, both now used by
    all three auth pages instead of being copied twice.

  • a30eb06: Add PLATFORM-scoped permissions, and bring back the region-dataset console at /admin/idn-regions.

    ADR-0051 made a rule normative — an action whose effect crosses tenant boundaries must have a platform-scoped gate and must not sit in the catalogue seeded to tenant roles — but the primitive that rule needs did not exist. ADR-0052 therefore could not guard region-dataset activation/rollback; it could only delete them. This builds the gate (ADR-0053) and restores the surface behind it.

    awcms_permissions gains a scope column (tenant | platform, default tenant), declared in code as ModulePermissionDescriptor.scope (MODULE_CONTRACT_VERSION 2.5.0, additive). The blanket grant in bootstrapPlatformTenant — SELECT id FROM awcms_permissions, which is what handed cross-tenant authority to every tenant owner in the first place — now filters on it, so the next platform permission is safe the moment it is declared rather than the moment someone remembers. The owner backfill excludes them too.

    Platform authority belongs to the platform tenant, resolved PLATFORM_TENANT_ID → PUBLIC_DEFAULT_TENANT_ID → PUBLIC_DEFAULT_TENANT_CODE → awcms_setup_state.tenant_id, and held by that tenant's owner role. authorizeInTransaction refuses a platform-scoped permission unless the acting tenant is that tenant — decided before permissions are looked up, and fail-closed when none resolves, so a grant row that reached the wrong tenant is inert rather than sufficient. The trigger is read from the code declaration, not the database column: were both the database, one UPDATE would remove the gate along with the grant filter and nothing would go red.

    Tenancy mode (single/multi) is derived from the active-tenant count, never configured — a stored flag would have to be flipped by whoever provisions tenant number two, and forgetting means the deployment keeps behaving as if one tenant owned everything. The mode never relaxes a gate.

    While PLATFORM_TENANT_ID is unset, PUBLIC_DEFAULT_TENANT_ID is a security control: repointing which site renders on an unmatched host also repoints platform authority. That is a deliberate trade-off, made separable without a migration by the dedicated variable, and made visible by a new security:readiness check that reports which tenant holds the authority and warns when it is not the bootstrap tenant.

  • bc0ab66: POST /api/v1/profiles/{id}/restore — the counterpart DELETE /api/v1/profiles/{id} shipped without (ADR-0058 §A).

    sql/003 gave awcms_profiles restored_at/restored_by and an index on
    (tenant_id, deleted_at), and party-directory.ts exported softDeleteParty
    with nothing to undo it. Nothing in the repo could write either column, so a
    soft-deleted profile was permanent while profile_management.restore sat
    seeded in the catalogue and enforced by nothing.

    The precondition is the WHERE … deleted_at IS NOT NULL, not a read before the
    write: two concurrent restores that both read first would both proceed and
    audit two restorations of one profile. delete_reason is kept — why the
    profile was deleted stays true after it is restored. A profile that does not
    exist and a profile that is not soft-deleted answer the same 404, so the route
    cannot be used to probe which profile ids exist.

    Permission-enforcement coverage moves from 201/205 with 4 exceptions to 202/205
    with 3.

  • a21f684: Beri --dry-run pada dua job retensi destruktif yang selama ini tak punya, dan
    hentikan headernya mengklaim kemiripan yang tidak ada.

    form-drafts:purge menghapus baris secara fisik; comments:retention
    meng-NULL-kan kolom identitas penulis secara tak terbalikkan lalu menghapus
    langganan yang tak pernah dikonfirmasi. Keduanya menyatakan di headernya sendiri
    bahwa mereka meniru scripts/audit-log-purge.ts — yang sudah punya pratinjau
    sejak dikirim. Keduanya tidak. Jadi satu-satunya cara mengetahui radius ledakan
    run pertama adalah menjalankannya.

    Dua hal yang membuat pratinjau ini bukan sekadar penghitung:

    • Satu fungsi cutoff, dipakai bersama. resolveFormDraftRetentionCutoff dan
      resolveCommentsRetentionCutoff diekstrak, lalu jalur nyatanya ikut memakai —
      dua salinan now - days * 86400000 akan menyimpang begitu salah satunya
      diedit, dan pratinjau yang tak sepakat dengan run yang dipratinjaunya lebih
      buruk daripada tak ada.
    • Legal hold ditanya, dan dilaporkan. Deskriptor yang di-hold membuat run
      nyata tak menyentuh apa pun; pratinjau yang mengabaikannya akan melaporkan
      backlog yang tak akan pernah disentuh run mana pun — justru angka yang paling
      mungkin ditindaklanjuti operator. comments:retention melaporkan
      heldTenants supaya "tak ada yang perlu dikerjakan" bisa dibedakan dari
      "sedang di-hold".

    Header keduanya juga dikoreksi: alih-alih mengklaim meniru job yang memakai
    runJob, keduanya kini menyatakan apa yang memang TIDAK mereka punya (advisory
    lock, telemetry JobResult, cancellation kooperatif) dan bahwa karenanya
    keduanya harus dijadwalkan dari SATU entri cron. Migrasi ke runJob tetap
    dilacak di isu #291.

  • 5935dc7: Revoke blog_content.seo.configure and blog_content.posts.export
    (ADR-0058 §C/§D, sql/089), completing the ADR and emptying the
    permission-enforcement exception list.

    Both were seeded by sql/036 and declared by the descriptor, and neither ever
    had an enforcer. They are revoked rather than surfaced for different reasons:
    seo.configure is a second authorisation axis over
    seo_default_title/seo_default_description, which
    blog_content.settings.configure already governs through
    PATCH /api/v1/blog/settings; posts.export has no export machinery anywhere
    in the repo, so building one to justify a catalogue row would be the tail
    wagging the dog.

    Because POST /api/v1/setup/initialize grants the whole catalogue to each new
    tenant's owner role, every tenant owner has been holding authority over two
    actions nothing checks. No behaviour changes — nothing ever read them.

    The migration deletes the role grants before the catalogue rows (the FK runs
    that way), is idempotent, and ships no rollback: restoring the grants would
    re-advertise a surface that does not exist.

    bun run access:permissions:enforcement:check now reports 203/203 with zero
    exceptions
    — every declared permission in the repo has an enforcer.

  • ac7503d: Add admin-approved self-registration — off by default, and it never stores a
    credential.

    POST /api/v1/auth/register records a request; /admin/registrations reviews
    it; approval creates the account. Two migrations (sql/074 schema, sql/075
    permissions), one public page (/register), three guarded admin endpoints.

    Off unless AUTH_SELF_REGISTRATION_ENABLED=true, and a disabled deployment
    answers 404 — the same answer a nonexistent route gives, so the switch is not
    discoverable by probing. An always-on public endpoint that writes a row is a
    spam surface every deployment would otherwise inherit. It is a deployment-level
    gate like AUTH_MFA_ENABLED, so turning it on opens registration for every
    tenant; per-tenant granularity is recorded as a follow-up rather than implied.

    The public path creates no account and accepts no password. It writes a
    pending row and nothing else, rejects every privilege field (roleIds,
    status, tenantUserId), and the validator returns exactly two keys — proven
    twice, at runtime by asserting the returned key set and structurally by
    enumerating which fields are read off the untrusted body. Mutation-proven:
    leaking roleIds through the validator turns both red.

    Approval issues a credential the applicant must claim, which is a deliberate
    departure from awcms-micro.
    micro stores an argon2id hash chosen by an
    unverified stranger for an account that may never exist. Here the identity is
    created with an unusable password — the hash of 32 CSPRNG bytes discarded
    immediately — and the applicant receives a password-reset link through the same
    flow /forgot-password uses. So no anonymous submitter's secret is ever stored,
    a rejected or abandoned request leaves no credential behind, a spam flood costs
    an INSERT rather than an argon2id hash, and the applicant proves mailbox control
    before the account works. The cost is stated rather than hidden: approve
    returns delivery: "queued" | "unavailable" so the admin screen can say when
    the link could not be sent instead of showing a success for an account nobody
    can get into.

    Enumeration-safe. An address that already has an account, one with a request
    already pending, an inactive tenant and a fresh request all return the identical
    200 — "this address is already registered" is the single most useful sentence an
    attacker could be handed here. The audit event records which it was, without the
    submitted address on a miss.

    approve and reject are separate permissions under a new
    registration_requests activity. access_control is the RBAC catalog, not the
    authority to admit a person, and /api/v1/users in this repo is read-only — so
    approval is the first admin path that materializes an identity at all, and
    clearing spam should not require the ability to admit anyone. roleIds is
    optional and defaults to none; an unknown role refuses the whole approval rather
    than granting the subset that resolved.

    Approval is race-safe, with FOR UPDATE on a status = 'pending'
    predicate. Mutation-proven: without the lock two concurrent reviewers trip
    awcms_identities_tenant_login_key mid-transaction and the second gets a 500;
    with it, a clean 404. Correctness was never at risk — the failure mode was.

    Rejection notifies nobody: a rejection email would confirm to an anonymous
    submitter that this tenant exists and reviewed them, which is exactly the
    disclosure the submit endpoint refuses to make.

    Reviewed rows are purged by the existing data_lifecycle GENERIC engine (90d
    default, 7d floor so the registration_approved audit row still points at
    something); the worker grant is SELECT, DELETE only — one able to write here
    could manufacture an approved registration.

  • 2b92a68: Admin screen for seo_distribution at /admin/seo, plus the sidebar entry that makes it reachable.

    The module shipped a complete admin API (tenant SEO defaults, redirect rules, redirect policy, 404 governance) but no screen, and declared no navigation — so every one of its permissions was routed while the module stayed invisible in the sidebar. One page now carries four panels: SEO defaults, redirect policy, redirect rules (create with a read-only dry run, inline edit, activate/deactivate/archive, soft delete, and an id-addressed restore/purge panel because soft-deleted rules are excluded from the list), and the privacy-minimized 404 log (resolve / dismiss).

    Reads run server-side through the same application-layer functions the JSON routes use, inside one tenant transaction; every write goes out over fetch to the guarded endpoints, with a fresh Idempotency-Key per click on the four high-risk mutations. Permission gates are UX-only — notably the lifecycle endpoint's dynamic guard is honored: Purge is gated on seo_distribution.redirect.delete and activate/deactivate/archive/restore on seo_distribution.redirect.update. Bulk import and URL-change capture stay API-only.

  • f0d90a6: Build the awcms half of ADR-0050: a BFF obtains a human session with a one-time handoff code, never by proxying a password.


Catatan rilis dipotong. Isi lengkapnya 186449 karakter, di atas batas 125000 karakter milik GitHub.

Teks utuh: CHANGELOG.md pada v7.0.0