Repository navigation
v7.0.0
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}/activateandPOST /api/v1/idn-regions/datasets/rollbackboth swapped the Indonesia administrative-region dataset served to every tenant — those tables are global, with notenant_idand no RLS. Butsql/081seeded their permissions (idn_admin_regions.dataset.configure/.restore) into the global ABAC catalogue, andPOST /api/v1/setup/initializegrants the whole catalogue to each new tenant'sownerrole. 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>andbun run idn-regions:rollback, both dry-run by default and writing only with--commit, running asawcms_worker. This matchesbun 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/084revokes both permissions and any role grants that already reference them. Two permissions remain for this module, both genuinely read-only:region.readanddataset.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_eventsrow. 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_placementskini
punya targeting (target_typeglobal/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_contentmemiliki DUA sistem iklan,
masing-masing punya kemampuan yang tidak dimiliki lawannya. Yang lama menerima
image_urlbebas — URL apa pun, tanpa registry media — tetapi bisa menarget
post dan page. Yang baru mengikatmedia_object_idsebagai foreign key ke objek
media terverifikasi, tetapi setiap barisnya efektif site-wide.Yang berbasis media adalah yang bertahan, karena
image_url textpersis
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_urlkemedia_library(dengan laporan residu yang bisa
di-dry-run) dan penghapusan kedua tabel lama adalah langkah terpisah
berikutnya, dalam urutan itu.placement_keytetap 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 iklanglobaluntuk slot yang sama — perbaikan yang disengaja
atas sistem lama yang mencocokkan satu scope persis dan menyerahkan
penggabungan ke pemanggil. - Aturan berpasangan (
target_idwajib 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_idpolimorfik (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/approvalsinbox and putworkflow_approvalin 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-Keyper click — unlike/admin/reportingthere 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.tspins 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 isworkflow, notworkflow_approval(the directory, README and descriptor name all say the latter), and approve/reject share one permission —approval.approveis the ability to decide, not its direction, andapproval.rejectis 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 bare500in five separate files — moves toworkflow-approval/domain/reason-bounds.ts, imported by all of them and by the form that renders it asmaxlength.Also corrects
workflow-approval/README.md, which described an/admin/workflowspage 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=deletedlists the bin. Control placement follows what each
endpoint accepts — Restore on bin rows, Publish/Archive/Delete on live rows,
Purge on both.listBlogPagesForAdmingains thedeletedOnlyfilter that makes
the bin reachable.pages.updateis 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 nopages.scheduleand no review queue.Sidebar gains a second
blog_contententry, gated onpages.readrather than
posts.read. -
c0163b1: Add the
/admin/blogpost lifecycle console and putblog_contentin the admin sidebar.blog_contentis 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/purgeplusrevisions.read/restore. Reads go throughlistBlogPostsForAdminandlistBlogRevisionsinside onewithTenantOrThrow, 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.listBlogPostsForAdminis 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.tsasserts both rather than leaving them to look like gaps:posts.exportis declared and seeded bysql/036, and no endpoint anywhere enforces it. The test proves this by scanning every route undersrc/pages/api/v1/blog/, so a future export endpoint fails it and forces the screen question to be answered instead of missed.search.readhas a route and the page still does not use it. The admin list already searches by titleILIKE, which tolerates the empty query that thewebsearch_to_tsquerysurface behindsearch.readrejects.
There is also no body/content editor: authoring a post body needs a rich-text surface plus SEO fields, terms and featured media.
posts.updateis still driven, through "submit for review".The module-specific trap the contract test pins:
submit-reviewis gated onposts.update, not aposts.submitorposts.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 anIdempotency-Key, whilePOST /api/v1/blog/postsrequires 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_LENGTHare now exported fromcontent-validation.tsso the form'smaxlengthcomes 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.configuremust 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*.restorepermission to build one against.
keyis sent on create and never on update, because the update inputs have no
keyfield. - menu ITEMS are not editable.
-
e20c942:
/admin/blog-taxonomy— the categories-and-tags console, third sibling of
/admin/blogand/admin/blog-pages.Drives both
taxonomies.*permissions.configuregates create, update AND
delete together, becausesql/036seeds no per-verb rows — the permission is
the capability "manage taxonomy", not one flag per verb, and a screen that
inventedtaxonomies.createwould 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, notaxonomies.restoreto 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.
- no bin view and no Restore. Term soft delete is one-way BY DESIGN (no
-
5368c23: Add the
/admin/domain-eventsoperator console and putdomain_event_runtimein 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:
replaysends anIdempotency-Keybecause each call does new work (it enqueues another attempt), whilepauseandresumesend none because setting a flag twice has the same end state —resumetakes no body at all. Sending a key topausewould imply a replay contract that endpoint does not have; omitting it onreplaywould render a button that always fails withIDEMPOTENCY_REQUIRED.tests/admin-domain-events-page-contract.test.tspins 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.pauseandconsumers.resumeread 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 todomain-event-runtime/domain/reason-bounds.ts. -
05c247c: Add
/admin/mediaand putmedia_libraryin 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/detachwere revoked (§A),delete/restore/purgegot 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 throughlistMediaObjectsinside onewithTenantOrThrow; writes post to the guarded endpoints, each with a freshIdempotency-Key. Unlike/admin/blogthere is no opt-out, and unlike/admin/syncthere is no endpoint that declines the header: all three here require it.Three deliberate absences, each pinned by
tests/admin-media-page-contract.test.tsso 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 apending_uploadrow 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/securitywith the other policy controls; offering it beside a row of files would misrepresent its blast radius.- No
<img>preview. A registry row can bepending_uploadorfailed— 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
maxlengthcomes from the constant the validator enforces. Purge is the only irreversible action and is the only one behind aconfirm. It is also the only failure this screen names specifically:MEDIA_OBJECT_REFERENCEDgets "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-regionsis 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 withoutnavigationturns CI red instead of quietly becoming a second exception.Mutation-proven four ways: gating a control on the revoked
media.detach, dropping one mutation'sIdempotency-Key, rendering a preview<img>, and removing the navigation entry each turn it red. - Upload (
-
821387b: Add the
/admin/reportingconsole and putreportingin the admin sidebar.reportinghad seven permissions and, between them, one page:/adminrenders 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 didemail-health, the fifth dashboard view. All of it was reachable only bycurl. 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
/adminalready shows; a projection links to its owndrillDownPathinstead.Reads reuse this module's own application functions inside one
withTenantOrThrowtransaction, awaited sequentially.listProjectionSummariesForTenantis handed the caller's real granted-permission set, so the per-descriptorrequiredPermissionfilter stays honest on this path too. Writes go to the guarded/api/v1/reports/*endpoints — five with a freshIdempotency-Keyper click,reconcilewith none, because that endpoint mutates no business state and requires none.tests/admin-reporting-page-contract.test.tspins 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.cancelfor cancelling a rebuild (it isprojections.rebuild),projections.readfor reconciling (it isprojections.analyze), andexports.configurefor triggering an export (it isexports.export).MIN_EXPORT_INTERVAL_MINUTES/MAX_EXPORT_INTERVAL_MINUTES/MIN_REASON_LENGTH/MAX_REASON_LENGTHmove toreporting/domain/operator-input-bounds.tsand are now imported by both the three routes that validate them and the form that renders them asmin/max/maxlength, so the browser cannot accept what the server rejects.Also corrects
reporting/README.md, which described an/admin/reporting/projectionspage and asubmitJsonhelper 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-writingcurl. 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.resetas the MFA read gate, which reads like a mistake and is
precisely whatGET /api/v1/auth/mfa/policyrequires. Inventing a friendlier
mfa_admin.readthat 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.tsextracts the guard triples
from the route sources and thepermissionKey(...)triples from the page and
requires the second to be a subset of the first; mutation-proven — swapping in
mfa_admin.readturns three tests red.Deployment posture is shown because the tenant policy cannot be judged without
it.ssoRequiredwithAUTH_SSO_ENABLED=falseproduces 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. NewlistBreakGlassCandidatesuses 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_REQUIREDsurfaces 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.navigationwas already synced toawcms_module_navigation
and served byGET /api/v1/modules, whileAdminLayout.astrorendered 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.tsbinds
declarations to the filesystem in both directions.AdminLayout'sactiveprop is gone — the current entry derives from the
request path, which cannot disagree with itself the way/admin/commentsdid
(it never passed one and was never highlighted). -
16cf031: Add the
/admin/syncoperator console and putsync_storagein 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.tshas named "the future/admin/syncSSR 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
failedentries, keyset-paginated. Reads go through this module's own application functions inside onewithTenantOrThrow, awaited sequentially.fetchSyncConflictsis new insync-directory.ts, andGET /api/v1/sync/conflictsnow 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:fetchSyncConflictsreturnsnullfor an unresolved conflict's resolution fields, and the route maps them back toundefinedso they stay absent from the JSON rather than becomingnull— 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.tspins 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. Bothconflict_resolution.resolveand.updateread better than the permission that exists, and neither is seeded anywhere. -
2c722ee: Add the
/admin/audit-trailviewer and putloggingin the admin sidebar.logginghas 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 bycurl. 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 plainmethod="get"form that works with JavaScript disabled.listAuditEventsclamps 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-astroconsumes (ADR-0065).The existing frozen snapshot is the pre-#182-migration monolith, and every
surface that repo actually calls landed after it —/auth/sessionand
/access/machine-credentials(ADR-0049),/media/objects(#318),
/media/public-origin(#370), the/blog/postscursor 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:checkfreezes 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$refand 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:ingestyang
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 barisverifieduntuk byte yang tidak pernah
ia ambil, sniff, atau batasi ukurannya — persis pernyataan yang menjadi alasan
keberadaan pipeline unggah. Peranawcms_workerbahkan tidak diberi INSERT
yang memungkinkannya (sql/079).
Rincian lain:
--applywajib 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_iddi bawah unique index PARSIAL dengan
NULLS NOT DISTINCT(sql/079). Keduanya load-bearing: tanpaNULLS NOT DISTINCTsebuah run kedua menggandakan seluruh iklanglobal; 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_adsadalah 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 wajibmedia_library. Sekarang terdokumentasi. - Mengambil URL eksternal dari server. Itu primitif SSRF, dan tempat
-
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/adsdanPATCH /api/v1/blog/ads/{id}sekarang menjawab
410 ENDPOINT_RETIRED, tanpa auth dan tanpa sentuhan basis data. Keduanya
menyimpanimageUrlteks 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
POSTsaja tidak cukup:PATCHbisa menulis ulangimageUrlpada
iklan yang sudah ada — bypass yang sama lewat rute yang lebih senyap, dan yang
tidak menghasilkan baris baru untuk diperhatikan siapa pun.GETdanDELETEsengaja 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-readinessmenghitung iklan yang
soft-delete sebagai sudah-diputuskan.bun run blog:ads:drop-readinessmenjawab "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.restoreandpages.purgehave been
seeded sincesql/036and enforced by nothing. That was not a spare catalogue
row:createBlogPagewrote a literal'draft',updateBlogPagenever 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 nopages.schedulepermission 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 anauthorizeInTransactionguard 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/postsdapat 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. Adapterawcms-astromelempar 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-nyaupdated_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_atmemilih 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 urutupdated_at.nextCursordicetak di lapisan yang masih memegang teks presisi mikrodetik,
tidak pernah diturunkan ulang dariDateJS di rute. Itu bukan kehati-hatian
teoretis:timestamptzmenyimpan mikrodetik,Datehanya milidetik, dan driver
MEMBULATKAN KE BAWAH — cursor dariDatemenunjuk 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 berbagicreated_atsampai mikrodetik.
Mutation-proven — mengganti sumber cursor jadinew Date(row.created_at)
memerahkan 3 dari 5 test.BlogPostSummarymendapat fieldcreatedAt(aditif). -
300a407: Add
?locale=toGET /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:
endoes not sweep inen-GB. ALIKE '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.localeis plaintext 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 onview/orderand a filter wired into two of the three would stay invisible until someone changed a query string.listBlogPostscollapses its two-branchstatus ? … : …into the single${param}::text IS NULLstatement 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 theview=fulldefect 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_hierarchya real provider:tenant_adminresolvesofficescopes againstawcms_offices(ADR-0060).POST /api/v1/identity/business-scope/assignmentsis 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 toresolved: false, and the reservedtenantscope 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, sobusinessScopeFactswas never populated, the expiry job never had anything to expire, and SoD'ssame_scope_onlymatching 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
providedBynamedorganization_structure, a module ADR-0016 accepted and nobody ever wrote here. What was missing was never the hierarchy:awcms_officeshas hadparent_office_idsincesql/002, FORCE RLS sincesql/017, and a composite cross-tenant-proof parent FK sincesql/020.The new adapter resolves the
officescope type and nothing else. Only LIVE rows resolve — not soft-deleted, notinactive, 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 claimsresolved: 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:
resolveBusinessScopeFactsminted a covers-everything fact fromscope_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: truestays 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).applyModerationActionhas 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.deletenow 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 withdeletebefore.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_jsonsebagai 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 luartsc) dan satu kalimat prosa
di salah satu dari lima kemunculancontentJsondi OpenAPI — empat sisanya
hanya menyebuttype: 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-astromenurunkan ulang kosakata itu
dengan membaca, lalu keliru dalam tiga hal sekaligus — mengarang tipe
ordered_listyang tak ada, dan menjatuhkangallerysertavideo_newskarena
keduanya tak punya fieldtextsementara fallback-nya merendertext. 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
ContentBlocklewat 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+BlogContentJsondi OpenAPI:oneOfenam varian
lengkap dengan field-nya, dirujuk dari kelima kemunculancontentJson.
Dua bentuk yang paling mudah salah tebak diberi catatan eksplisit — urutan
adalah FIELD padalist(bukan tipeordered_list), dangallery/
video_newsTIDAK punya fieldtext. tests/content-block-contract.test.tsmemaku kontrak OpenAPI danswitch
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), satucontentJsonkembalitype: object
polos (1 merah), renderer berhenti menanganigallery(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_SECUREtidak lagi gagal-terbuka saat tidak diset. Aturan produksi
scripts/validate-env.tsdulu hanya menolak string literal"false", sementara
runtime menyetelsecure: process.env.AUTH_COOKIE_SECURE === "true". Ejaan salah
("1"/"TRUE"/"yes") memang sudah ditolak aturan tipebool— 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 atributSecuresambil
bun run config:validatemelaporkan konfigurasi bersih. Aturannya kini!== "true"
dengan pesan yang menyebutkan nilai terbaca. Non-produksi sengaja tidak dituntut:
dev berjalan dihttp://, danenvironments.mdsudah mencatat itu sebagai selisih
per-environment yang disengaja.Cross-Origin-Opener-PolicydanCross-Origin-Resource-Policykini 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 penyematanno-corsyang CORS sendiri tidak tutup. Tidak ada kapabilitas yang
hilang — repo ini tak pernah memancarkanAccess-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/loginvia
PerformanceObserverber-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 CIe2e-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 padavisitor_analytics; keputusan RUM (Opsi B) tetap milik
pemilik produk — status ADR-0067 tidak berubah. -
36d012f: Add the
/admin/data-lifecycleconsole and putdata_lifecyclein 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
curland 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
withTenantOrThrowtransaction. Writes go to the guarded/api/v1/data-lifecycle/*endpoints — the two hold mutations with a freshIdempotency-Keyper 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.createandlegal_hold.releaseare gated separately:data_lifecycle.legal_hold_maker_checkermakes holding both acriticalSoD 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.tspins 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>mengembalikan503 DATABASE_BUSY(breaker
open / work-class saturasi) dan409idempotency lewatas T— cast yang
artinya persis "berhenti memeriksa". Header-nya menyatakan "in practice every
real call site usesT = 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 berupaResponse.Kerusakannya nyata, bukan teoretis.
purgeExpiredAuditEventsberjanji
Promise<number>; di bawah work-classmaintenance(SATU slot) ia
mengembalikanResponse.runBoundedBatchesberhenti "sampai satu pass
mengembalikancount: 0" — danResponsetak pernah=== 0, sehingga job yang
seluruh tujuannya mengalah justru menjalankan 50 pass penuh per tenant ke
database yang baru saja menolak, lalu melaporkantotalCountsebagai string
"0[object Response]…"(karenanumber + Responseitu 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 denganRetry-After; 275 pemanggilan di 204
berkas rute yang callback-nya memang sudah mengembalikanResponsetidak
berubah satu baris pun (Response | ResponseituResponse).withTenantOrThrow<T>(...)→Promise<T>. Untuk semua yang bukan handler
HTTP. MelemparDatabaseBusyErroryang MEMBAWA response503yang sama
(jadi kedua bentuk tak bisa menyimpang), dan kini diklasifikasiretryable
oleh job runner alih-alih jatuh keunknown.
Tak ada lagi satu pun
as Tdi modul itu.db:tenant-context:check(baru, di rantaicheck) menutup dua sisa yang tak
terlihat compiler: hasilwithTenantyang dibuang (await withTenant(...)
sebagai statement — 503-nya lenyap tanpa jejak), dan pemanggilan dari.astro,
yang tak pernah dibacatsc --noEmit. Gate itu langsung menemukan tiga
pembuangan nyata di jalur auth: dua di antaranya melewatkan audit event
sso_account_linked/mfa_challenge_issuedsambil 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).serveDiscoveryaccepts Astro'slocalsand 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/**:buildreturnsnullfor "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.xmlmatches 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/confignow enqueues a purge — the tenant-wide
noindexswitch alone rewrites/robots.txt. But the bodies are aggregated from
everyseo_factsprovider, so publishing a post changes/sitemap.xmlwithout
touching anythingseo_distributionwrites, and a module purge tags
t:<tenant>:m:<moduleKey>, soblog_content's purge could not reach it. Left
alone that would have purged/blog/{code}/feed.xmlon publish while/feed.xml
— the same content — sat stale until TTL, with nothing reporting it.enqueueModuleContentPurgetherefore also covers modules that declare a
consumesdependency on the changing module and own a declared surface. It is
read from the module registry, soblog_contentnever namesseo_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_MODEunset 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 onlocals.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 declaresnews-index,
news-taxonomyandnews-post— mirroring the TTLs and reasoning of their
blog-*counterparts, owned byblog_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 withSurrogate-Controlwhile an unknown-host
404 getsprivate, 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_MODEunset
(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.tssudah 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, maupunvalidate-env.ts.R2_ACCOUNT_ID/R2_BUCKET/R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY—
R2_ENABLED=falsedikirim 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.examplemerujuk "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 rantaicheck) 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 dipakaiEMAIL_MAILKETING_*— jadi
secret tetap tak masuk repo. Batas yang dinyatakan terbuka di header gate: ia
hanya mencocokkanprocess.env.X, jadi modul config yang mengoper
env: NodeJS.ProcessEnvlalu membacaenv.Xtak 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-astrodipisahCONSUMEDvsCOMMITTED(§9.5).
CONSUMER_PATHSsemula 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 entriCOMMITTEDwajib menyebut ADR
yang menjanjikannya, dan sebuah test menegakkan bahwa ADR itu punya berkas.Dua lubang
bun run skills:checkditutup (§9.6). Pembebasan
ASPIRATIONAL_SKILLSdulu 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 disrc/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, danawcms-performancekeluar 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-astroADR-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.intentionalDivergencesyang kosong
sejak ADR-0055 kini memuat tiga entri ber-reviewDate: HSTSincludeSubDomains
(benar di kedua sisi, alasan berbeda),.astrotak-terperiksa-tipe, dan pin edisi itu
sendiri.astro checkTIDAK bisa ditambahkan, dan itu diverifikasi bukan diasumsikan.
@astrojs/checkmenuntut 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 privasivisitor_analyticsdan
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/UPDATEsequentially scans the child table to enforce the constraint,
and the parent→child join has no index either. Measured here: 182 FK columns, 14
unreachable, withawcms_blog_adscarrying no index at all beyond its primary
key.The rule is tenant-aware — reachable means leading an index, or being the second
column aftertenant_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 RLSFORCEguarantees every tenant-scoped query
carriestenant_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 aftertenant_id.sql/090adds thirteen indexes (additive,IF NOT EXISTS, no data moved).
awcms_setup_state.tenant_idis 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-draftsops screen and its sidebar entryform_draftsshipped a complete admin API but no screen and nonavigation
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:purgejob.Adds
/admin/form-drafts: a filter bar (module key / wizard key / status)
driving the same filtersGET /api/v1/form-draftsaccepts, the bounded
newest-first list, a collapsed read-only payload inspector, and a per-row
delete that callsDELETE /api/v1/form-drafts/{id}. Registered in the sidebar
under System, gated onform_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
submittedwould 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_domainhas mapped hosts to tenants since #219 and the discovery surfaces (robots.txt, sitemaps, feeds) and/searchhave 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 throughwithHostResolvedBlogTenant— the same shape assite_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'slegacyTenantRouteEnabled.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].tsandsrc/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 archivedpublicBasePath/publicLabelsettings are not adopted either, because they move a page's links without moving the route that serves them.seo_distributionnow chooses its base path instead of assuming one:/newswhile 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 indocs/PROJECT_STATE.mdnever existed.discovery-providers.tshas scoped the adapter to/blog/{tenantCode}since the module landed (#223), and the/blogdefault it was blamed on has zero callers insrc/.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 langsungactive. - 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 diGLOBAL_TABLE_FORBIDDEN_PRIVILEGESsehingga privilege tiap role
dinyatakan eksplisit (awcms_appSELECT + 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,
sementaraawcms-minimerekam 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 Adatdi Papua,Kota Administrasidi DKI, jalur
leluhur "Papua, Kabupaten Jayapura, Sentani, Desa Adat Yoboi"). - Skema ber-versi (
-
fc138df: Kredensial mesin baca-saja +
GET /api/v1/auth/session— dua kontrak yang
menahanawcms-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.examplemilikawcms-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,FORCERLS, FK komposit)
terikat pada satuawcms_tenant_usersyang 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_keysdengan 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
keowner. - 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 aliasX-Tenant-Codeyang setiap
rute masa depan harus ingat menghormatinya. Hash-nya ber-ruang-nama
mc-sha256:, danhashSessionToken()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/sessionmengembalikan 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 memperkenalkandefineSelfServiceTenantRoute—
seam untuk rute terautentikasi yang subjeknya pemanggil itu sendiri, sehingga
workClasstetap wajib danapi:tenant-route:checktetap satu-arah alih-alih
menumbuhkan allowlist kedua.Decision log mendapat
machine_credential_idnullable: 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 teksa,b(22P02), dan bentuk satu elemen paling berbahaya karena tiba
sebagaiayang terlihat seperti string biasa.Dicatat sebagai divergence keluarga di
awcms-family-compatibility.yamlsaat
mendarat, sesuai ADR-0047 §4 — fitur fondasi pertama yang dirintis langsung di
sini selama pembekuanawcms-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-storekarena badannya membawa kredensial hidup. Yang pertama
dibuktikan dengan mengembalikan cacatnya: dua test integrasi merah, lalu hijau
lagi setelah dipulihkan. - Menyempitkan, tak pernah melebarkan. Izin efektif = irisan
-
1b852b8: Revoke
media_library.media.attachandmedia_library.media.detach(ADR-0056 §A).Both were seeded into the global permission catalog by
sql/052, andPOST /api/v1/setup/initializegrants that catalog whole to every new tenant'sownerrole. 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_idheld 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/087deletes the grants first, then the catalog rows — reversed, the catalog delete hits theawcms_role_permissionsFK. The two zero-caller functions (attachNewsMediaObject,detachNewsMediaObject) are deleted with them, andmedia-object-directory.tskeeps a marker where they were so the next reader learns why the module has no attach path rather than assuming one is missing.The
attachedstatus survives deliberately:sql/041's CHECK still admits it andisNewsMediaObjectSafeForPublicReferencestill 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.verifiedis 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/purgeare 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_ACTIONSand requireIdempotency-Key, each under its own scope so a delete's key cannot collide with a purge's.Soft delete breaks live references, deliberately.
resolveMediaReferencesfiltersdeleted_at IS NULL, so a post whosefeatured_media_idpoints at a deleted object resolves to nothing immediately. That is the intended outcome for the case this serves, andrestoreis what makes it recoverable. Nothing here scans for referencing rows first: that would make a System Foundation module know its own consumers.purgeclears the registry, not the R2 bytes. Thenews-media:reconcilejob 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_idis a hard NOT NULL FK, so purging a still-referenced object answers409 MEDIA_OBJECT_REFERENCED. That path runs inside a savepoint — in PostgreSQL a23503aborts the whole transaction, so catching it without one turns a caller-actionable 409 into a 500 at the COMMITwithTenantperforms. Verified against a real database rather than reasoned about.That verification turned up a second thing. The SQLSTATE is on
error.errno, noterror.code— Bun setscodeto its own"ERR_POSTGRES_SERVER_ERROR"for every server error alike, soerror.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 usedString(error.errno). One did not:tenant-provisioning.ts, wherePOST /api/v1/tenantspromises409 duplicate_tenant_codeand 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, andtests/postgres-sqlstate-detection.test.tsnow gates it repo-wide — mutation-proven by restoring the original defect.media_librarynow has zero ungated permissions. ADR-0056 §C (a list function and its own route) is what remains before the screen. -
e5225e3: Add
listMediaObjectsandGET /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/objectsdemands?ids=— it is a batch resolver built for theawcms-astrobuild to swap ids for public URLs. Teaching it a "noidsmeans 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.listcannot be read as an object id, because[id].tsand 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/listand/{id}apart.The listing deliberately outgrows the resolver's safety rule. It returns rows in any status —
pending_upload,failed,orphaned— and, withdeletion=deleted|all, soft-deleted ones.isNewsMediaObjectSafeForPublicReferenceadmits onlyverified/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.readkeeps it inside the tenant, and nothing returned here may be used as a public reference.deletionis three states rather than a booleanincludeDeleted: "show me what I deleted" is the question restore and purge exist to answer, and a boolean cannot ask it. It defaults tolive, 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_attext, never a JSDate. 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.tsinserts 107 rows in ONE statement — so they share a transaction timestamp exactly — and walks every page; reverting the cursor toDateloses 57 of them and turns four tests red.The projection omits
bucket_name/storage_driver(deployment facts a browse screen has no use for) andowner_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/mediascreen itself. -
7ff6aa9:
GET /api/v1/media/objects— resolusi referensi media batch, sehingga artikel
terbit tidak lagi kehilangan gambarnya di konsumen luar.awcms_blog_postsmembawafeatured_media_iddanseo_image_media_id, tetapi
media_librarytidak 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
sebabarticle-images.tsdiawcms-astromengembalikansrc: undefineddan
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 objekverified/
attached, satu tenant, tidak soft-deleted, yang resolve. Tanpa migrasi:
permissionmedia_library.media.readsudah diseed sejaksql/052sambil
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 satuid = 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). - Batch, bukan satu-per-id. Build feed me-resolve seluruh gambar satu halaman
-
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-astroships a strict CSP and must name the media host inimg-srcat
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
publicUrldoes not help, because the policy is written before any object is
fetched, and a build with no images would then emit noimg-srcat all. The
only alternative left was copyingNEWS_MEDIA_R2_PUBLIC_BASE_URLinto 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
200withconfigured: 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_portalkeblog_content— satu modul konten, tanpa fitur hilang.news_portalsudah berhenti membawa bebannya sendiri. 11 berkas melawan 59,
3 tabel melawan 18, nol capability disediakan, nol rute publik, dan konsumen
WAJIBpublic_contentmilikblog_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_urlmenerima URL
apa pun, sementaraawcms_news_portal_ad_placements.media_object_idadalah 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/pageyang 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/kesrc/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-renameawcms_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/076dengan 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_librarydarioptionalmenjadi capability wajib bagi
blog_content, karena ad placement yang diserap memegang FK nyata — itulah
alasannews_portaldulu 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_portaldengan namanya. Preset menamai niat, bukan
modul, dan niatnya tidak berubah.
tests/news-portal-merge.test.tsmenjaga 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. - memindahkan 8 berkas
-
9ce56e2: Bandingkan dua registry job yang selama ini mendeskripsikan skrip yang sama tanpa
ada yang membandingkannya.JOB_WORK_CLASS_REGISTRYmenyatakan anggaran pool sebuah skrip, dan sudah
ditegakkan ke ground truth — generatornya MENOLAK jalan saat peta dan disk
berselisih.ModuleDescriptor.jobsmenyatakan 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— modultenant_domaintak mendeklarasikanjobs
sama sekali. Deskriptornya ditambahkan (jadwal: tiap 15 menit;manualsebagai
default tak melakukan panggilan keluar).edge-cache:purge— tak ada moduledge_cacheuntuk menggantungkan
deskriptornya: edge cache adalah infrastruktursrc/lib/(ADR-0043), sementara
ModuleDescriptor.jobsdi-key per modul. Dicatat sebagai pengecualian dengan
alasan STRUKTURAL, bukan "belum sempat".
modules:jobs:check(baru, di rantaicheck) menegakkan keduanya: tiap skrip di
work-class registry wajib punya deskriptor denganrecommendedScheduletak
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.mddihapus 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, padahalemail:dispatchdansync:objects:dispatchmemakai 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/matrixreturns every module with this tenant's
enabled state, its protected flag, and two lifecycle warnings computed by
re-running the REALevaluateModuleEnable/evaluateModuleDisablerather 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 —
dependencyWarningonly for a
disabled module,reverseDependencyWarningonly for an enabled one. The other
combinations cannot arise, and askingevaluateModuleEnableabout an
already-enabled module short-circuits toMODULE_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}/auditreturns recent module-management
activity for one module, guarded bylogging.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_portalandback_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
enabledblog_contentand then appliedminimalwould stay non-minimal
forever.Ported from awcms-micro (Issue #261) with its planning logic intact, but not its
preset set:back_officehas 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}/applyexecutes 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 onmodule_management.tenant_modules.disable. -
049e36d: Make route ownership derivable:
ModuleApiContract.routesand
modules:routes:check.basePathwas the only ownership claim a descriptor could make, and
tenant_admindeclaredbasePath: "/api/v1"— a prefix of every route in the
application. Resolving a route to its longest-matchingbasePathhanded
tenant_admin36 routes it does not own (all of
/api/v1/{access,roles,users,abac,identity}, which areidentity_access, plus
/api/v1/tenant/modules, which ismodule_management), while 30 public routes
matched nothing at all.api.routesis a list of owned prefixes, longest-prefix wins — because
ownership genuinely is not one prefix:/api/v1/tenantis split between
tenant_domainandmodule_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
undersrc/pagesoutside/admin/**to resolve to exactly one module or be
named in a reviewedPLATFORM_ROUTESallow-list. It also rejects/,/api
and/api/v1as claims outright — a coverage-only rule cannot see them, since a
prefix matching everything leaves nothing uncovered.MODULE_CONTRACT_VERSION2.3.0 -> 2.4.0 (additive;routesomitted means
[basePath]).openapi_documentedreadiness now checks every owned prefix
rather than the displaybasePath, which fortenant_adminhad 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
ownersebuah 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 menerima403 ACCESS_DENIEDpada 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 padasql/083.Kenapa "grant semua yang hilang" ditolak. Bentuk itulah yang dianjurkan
environments.mdsebelumnya (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 (
--commituntuk 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-reviewandPATCH /api/v1/blog/pages/{id}
— decided permissions themselves fromfetchGrantedPermissionKeysplus a domain
rule, never callingauthorizeInTransaction. 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 ABACdenywas 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 returnsdeniedbefore any domain rule is consulted. Putting it in
front would have deleted the author path: a functional regression that looks like
a security tightening.authorizeInTransactiontherefore gainsownershipGrant, 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 allowsownership_grant:<reason>so an auditor
can tell them from RBAC allows.New gate
bun run access:chokepoint:checkholds the class: every handler calling
fetchGrantedPermissionKeysmust 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].tscalls
the chokepoint inGETandDELETEwhilePATCHdid not. Two exemptions:
pre-authentication login, and the self-introspection endpoint that calls
evaluateAccessdirectly.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/014and never had a caller for.email'sauth.password_resetcategory, 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, RLSFORCE, only asha256of a 256-bit CSPRNG
token ever stored) close it. Adapted from awcms-micro Issue #496.Neither endpoint is an oracle.
forgotreturns 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.
resetreturns 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 tokenFOR UPDATE; without that lock two requests carrying the same link
both observeused_at IS NULLand 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, matchinglogin.ts.A completed reset revokes every session of that identity,
aal2included,
and clears the lockout counters — the link holder proved control of the mailbox.Delivery goes through a new
auth_notificationcapability port, not an
INSERTintoawcms_email_messages. That table belongs toemail(ADR-0013
§6) and the micro original wrote into it directly; it also cannot be a
dependenciesedge, becauseemailalready depends onidentity_accessand
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_KEYset, 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 sharedsrc/styles/auth.cssand its
tenant picker totenant-admin'stenant-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_permissionsgains ascopecolumn (tenant|platform, defaulttenant), declared in code asModulePermissionDescriptor.scope(MODULE_CONTRACT_VERSION2.5.0, additive). The blanket grant inbootstrapPlatformTenant—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'sownerrole.authorizeInTransactionrefuses 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, oneUPDATEwould 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_IDis unset,PUBLIC_DEFAULT_TENANT_IDis 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 newsecurity:readinesscheck that reports which tenant holds the authority and warns when it is not the bootstrap tenant. -
bc0ab66:
POST /api/v1/profiles/{id}/restore— the counterpartDELETE /api/v1/profiles/{id}shipped without (ADR-0058 §A).sql/003gaveawcms_profilesrestored_at/restored_byand an index on
(tenant_id, deleted_at), andparty-directory.tsexportedsoftDeleteParty
with nothing to undo it. Nothing in the repo could write either column, so a
soft-deleted profile was permanent whileprofile_management.restoresat
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_reasonis 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-runpada dua job retensi destruktif yang selama ini tak punya, dan
hentikan headernya mengklaim kemiripan yang tidak ada.form-drafts:purgemenghapus 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 meniruscripts/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.
resolveFormDraftRetentionCutoffdan
resolveCommentsRetentionCutoffdiekstrak, lalu jalur nyatanya ikut memakai —
dua salinannow - days * 86400000akan 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:retentionmelaporkan
heldTenantssupaya "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, telemetryJobResult, cancellation kooperatif) dan bahwa karenanya
keduanya harus dijadwalkan dari SATU entri cron. Migrasi kerunJobtetap
dilacak di isu #291. - Satu fungsi cutoff, dipakai bersama.
-
5935dc7: Revoke
blog_content.seo.configureandblog_content.posts.export
(ADR-0058 §C/§D,sql/089), completing the ADR and emptying the
permission-enforcement exception list.Both were seeded by
sql/036and declared by the descriptor, and neither ever
had an enforcer. They are revoked rather than surfaced for different reasons:
seo.configureis a second authorisation axis over
seo_default_title/seo_default_description, which
blog_content.settings.configurealready governs through
PATCH /api/v1/blog/settings;posts.exporthas 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/initializegrants the whole catalogue to each new
tenant'sownerrole, 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:checknow 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/registerrecords a request;/admin/registrationsreviews
it; approval creates the account. Two migrations (sql/074schema,sql/075
permissions), one public page (/register), three guarded admin endpoints.Off unless
AUTH_SELF_REGISTRATION_ENABLED=true, and a disabled deployment
answers404— 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 likeAUTH_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
pendingrow 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:
leakingroleIdsthrough 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-passworduses. 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
returnsdelivery: "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.approveandrejectare separate permissions under a new
registration_requestsactivity.access_controlis the RBAC catalog, not the
authority to admit a person, and/api/v1/usersin 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.roleIdsis
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 UPDATEon astatus = 'pending'
predicate. Mutation-proven: without the lock two concurrent reviewers trip
awcms_identities_tenant_login_keymid-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_lifecycleGENERIC engine (90d
default, 7d floor so theregistration_approvedaudit row still points at
something); the worker grant isSELECT, DELETEonly — one able to write here
could manufacture an approved registration. -
2b92a68: Admin screen for
seo_distributionat/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
fetchto the guarded endpoints, with a freshIdempotency-Keyper click on the four high-risk mutations. Permission gates are UX-only — notably the lifecycle endpoint's dynamic guard is honored: Purge is gated onseo_distribution.redirect.deleteand activate/deactivate/archive/restore onseo_distribution.redirect.update. Bulk import and URL-change capture stay API-only. -
f0d90a6: Build the
awcmshalf 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