| name | awcms-mini-service-catalog |
| description | Kerjakan bagian mana pun dari modul service_catalog AWCMS-Mini (Issue |
AWCMS-Mini — Service Catalog Module
service_catalog (src/modules/service-catalog, Issue #870, epic #868 SaaS
control plane Wave 1, ADR-0022) adalah modul control-plane PERTAMA —
Official Optional Business Foundation, opt-in per tenant, default-disabled.
Katalog plan/offer SaaS berversi provider-neutral. Bukan ERP item/product
master (ADR-0013 §3). Baca src/modules/service-catalog/README.md +
docs/adr/0022-*.md sebelum mengubah.
Invariant yang WAJIB dijaga (dari ADR-0022 — dipakai #871-#877)
-
Default-disabled = MEKANISME + GATE, bukan prosa (§7/Medium-3). Modul ini
memperkenalkan ModuleDescriptor.defaultTenantState +
isModuleTenantEnabledByDefault (_shared/module-contract.ts), dibaca di
EMPAT titik resolusi: resolveModuleEnabled (auth-context, gate API/route),
SSR permission gate (lib/auth/ssr-session.ts), nav registry, tenant-module
matrix. Tanpa baris awcms_mini_tenant_modules eksplisit, ketujuh key
control-plane resolve disabled. Gate:
tests/unit/module-governance-default-disabled.test.ts — GAGAL bila salah
satu control-plane key resolve enabled tanpa baris. Modul control-plane baru
WAJIB set defaultTenantState: "disabled" + masuk daftar gate.
-
Boundary control-plane ↔ tenant-plane (ADR Consequences).
tests/unit/module-boundary.test.ts menegakkan: (a) tak ada modul lain
meng-import service-catalog/application|domain (tenant-plane baca lewat
port service_catalog_read saja, di-wire di composition-root-nya sendiri);
(b) no-shared-table-write — hanya modul service_catalog menulis
awcms_mini_service_catalog_*; (c) port file = neutral ground. Pola ini
registry-wide; modul control-plane #871+ tinggal tambah key/prefix-tabelnya.
-
RLS-free HANYA baris published + effective-dated (§3/Medium-1). SEMUA
tabel katalog GLOBAL (no tenant_id, terdaftar di RLS_FREE_TABLES +
ALLOWED_GLOBAL_TABLE_GRANTS di scripts/security-readiness.ts). TAPI:
Tier A (authoring: plans/versions/features/quotas/prices — termasuk draft &
harga internal) operator-only + default-deny; Tier B
(awcms_mini_service_catalog_published_offers) tenant-readable, secara
fisik tak bisa memuat draft/harga-internal (hanya published + subset harga
public). service_catalog_read HANYA baca Tier B. Least-privilege: DELETE
di-revoke pada ..._plans + ..._published_offers.
-
Offer published IMMUTABLE (§3). Ditegakkan 2 lapis: application
(plan-directory.ts tolak edit versi non-draft) + DB BEFORE trigger
(sql/079: guard_version_immutability + guard_child_immutability).
Koreksi = versi BARU (createDraftVersion → edit → publish), bukan edit
in-place. Mutation-guard test membuktikan gate nyata.
-
Fail-closed key registry (§security). Sejak #874 domain/key-registry.ts
me-RE-EXPORT SINGLE SOURCE src/modules/_shared/saas-contract-registry.ts
(bukan lagi agregasi privat). module key = listModules() keys; feature/meter
key = union rich descriptor ModuleDescriptor.serviceCatalog.{features,meters}.
Key tak dikenal → DITOLAK (bukan diterima diam) di draft-edit/validate/publish.
Kontribusi lewat modul domain di src/modules/ (registry base) — descriptor
serviceCatalog di module.ts masing-masing, jangan edit base registry. Untuk
bentuk descriptor lengkap (quota/commercial-event, validasi fail-closed,
versioning) pakai skill awcms-mini-saas-contracts.
-
Uang EXACT minor-unit. amount_minor bigint, limit_value bigint — NO
float/double. Validasi Number.isInteger + bound. Harga per komponen wajib
currency == currency versi.
-
Idempotency + audit + event. create/versions/publish/retire wajib
Idempotency-Key (hash BER-resource-id: planKey[+version]). publish/retire
emit awcms-mini.service-catalog.offer.{published,retired} (v1.0) via
appendDomainEvent dalam tx yang sama + recordAuditEvent. Event terdaftar
di domain-event-runtime/domain/event-type-registry.ts + AsyncAPI channel +
operation + module.ts events.publishes (parity di-gate).
Pola concurrency SERAGAM (template Wave-1, dipakai #871-#877)
SETIAP operator write path di application/plan-directory.ts wajib pola SAMA —
tak ada satu pun check-then-write tanpa lock:
- Row-lock SEBELUM check-then-write.
SELECT ... FOR UPDATE pada row yang
akan ditransisikan DULU: publish/retire lock row version
(loadVersionByPlanKeyForUpdate); updatePlanDraft lock draft version
(loadDraftVersionForUpdate) SEBELUM sentuh plan header (Codex-D: cegah
projeksi published simpan plan_name/type LAMA saat header di-PATCH konkuren);
createDraftVersion lock row plan (loadPlanIdForUpdate) sebelum cek
draft_exists (Codex-E: cegah 2 draft konkuren → unique violation 500).
- UPDATE ter-predikat status.
UPDATE ... WHERE id=? AND status=<expected>;
0-row = konflik idempoten bersih → caller kembalikan 409 deterministik,
TANPA event/audit kedua.
- INSERT-ber-uniqueness →
ON CONFLICT DO NOTHING RETURNING. Bila belum ada
row utk di-lock (createPlan): concurrent same-key → 0-row → duplicate_key
(409) bersih, bukan raw 23505 (500).
Deteksi unique violation manual (jika perlu catch): String(error.errno) === "23505" (pola repo, profile-identity/application/identifier-directory.ts).
Tiap path punya test konkurensi (1 pemenang + 1 409 bersih; publish/retire tepat
1 event + 1 audit) di service-catalog.integration.test.ts.
PELAJARAN KUNCI keamanan/integritas (template #871-#877)
Dari audit adversarial 5-lensa (10 defect di PR #885). Terapkan ke SEMUA modul control-plane:
- Fingerprint/hash tenant-facing = HANYA atas bentuk projeksi tenant-visible.
offerHash disimpan di projeksi + dikembalikan service_catalog_read → JANGAN hash
data operator-only (harga internal) lalu ekspos = ORACLE brute-force. Hash atas
publicPrices+features+quotas+metadata saja. Properti: hash berubah IFF offer
tenant-visible berubah.
- DB immutability trigger COVER SEMUA tabel+kolom+reparent+identity-key, bukan hanya konten.
Grant REVOKE DELETE TAK cukup (publish/retire perlu UPDATE) → pakai BEFORE trigger.
Bekukan: plan_versions (status backward + konten + provenance published__/retired__),
child (frozen non-draft + LARANG reparent
version_id change + cek KEDUA parent OLD&NEW),
plan_key (immutable), published_offers (semua kolom kecuali retired_at).
Child trigger pakai COALESCE(NEW,OLD) bug: WHERE id=COALESCE(NEW.version_id,OLD.version_id)
pada UPDATE selalu resolve NEW → OLD parent (published) tak dicek → reparent bypass.
- Idempotency replay HARUS menang atas business-conflict pada race same-key.
DB row-lock/ON CONFLICT selesaikan race SEBELUM saveIdempotencyRecord → framework
IdempotencyRaceLostError.replay tak terpicu → loser dapat 409 padahal operasi SUKSES
(oleh winner same-key). FIX: di cabang business-conflict, panggil
replayConcurrentIdempotentWinner (re-query findIdempotencyRecord) SEBELUM 409 → replay
200 winner bila hash cocok. Wire di SEMUA route mutasi. Helper di _shared/idempotency.ts.
- Fail-closed present-but-invalid utk SEMUA field, bukan sebagian.
asBool(x, true) kembalikan true utk non-boolean → feature yang dimaksud OFF tersimpan ON.
x || default coerce empty-string → default. FIX: default HANYA saat ABSENT ("key" in record);
nilai HADIR diteruskan verbatim → validator domain tolak (typeof boolean / enum includes).
Terapkan enabled/trialEnabled/isUnlimited (boolean) + interval/resetPolicy/planType/visibility/
featureKind (enum). Pakai strict === true di logika (truthy string jangan lolos).
- Partial-column UPDATE utk PATCH header (lost-update): dua PATCH field beda, yang kedua
merge dari read pra-lock STALE → menimpa. FIX:
SET col = CASE WHEN <provided> THEN <new> ELSE col END — tulis hanya kolom yang disediakan (ELSE baca nilai live di bawah row-lock UPDATE).
- Audit action DISKRIMINATIF: createDraftVersion action='create' (bukan 'update' identik
updatePlanDraft) → bisa dibedakan di trail (resource_type+action = diskriminator).
- Hash tenant-facing WAJIB cover SEMUA kolom projeksi tenant-visible (bukan subset). Fix B1
sempat mempersempit ke konten version → LUPA plan_name/plan_type (juga di projeksi) → 2 offer
beda header emit hash SAMA. FIX: hash cover SETIAP kolom
published_offers yang tenant-visible
(header+konten+publicPrices); GATE PROJECTION_COLUMN_TO_HASH_FIELD vs kolom nyata (test
information_schema) + behavioral (ubah tiap field → hash beda). Tambah kolom projeksi = paksa
keputusan hash. Pelajaran: cakupan hash di-GATE vs kolom nyata, jangan diasumsikan.
- Fail-closed utk SEMUA bentuk input termasuk COLLECTION/OBJECT — present-malformed JANGAN pernah
wipe. PATCH
{"prices":{...}} (objek bukan array) → parseArray coerce [] → field PRESENT →
updatePlanDraft DELETE prices existing + insert nol = DATA LOSS diam. FIX: parseCollectionPresent
teruskan non-array verbatim (validator Array.isArray tolak 400) + present-key detection (absent=[]
create/keep patch, present-valid=replace, present-malformed=REJECT jangan []). Metadata non-object
juga (isPlainObject). Bedakan ABSENT vs PRESENT-MALFORMED di SEMUA field (scalar+enum+collection+object).
- Write-once one-way di trigger. projeksi retired_at DULU boleh rewrite apa pun (termasuk →NULL) →
app-role SQL RE-AKTIFKAN offer retired di read tenant (
retired_at IS NULL=active) walau version
'retired'. FIX: trigger izinkan HANYA NULL→non-null; tolak non-null→NULL & non-null→beda. Audit
field write-once lain (published_at/by, retired_at/by, status) — pastikan semua one-way.
- Fail-closed TRI-STATE utk field NULLABLE (varian terakhir kelas fail-closed). Helper
asStringOrNull/asNumberOrNull coerce present-wrong-type→null → di PATCH dianggap explicit
CLEAR → HAPUS data (availability/description/notes) diam. FIX tri-state KETAT tiap nullable
(description/market/availableFrom/availableTo/notes/trialDays/limitValue): ABSENT(!(key in))→keep,
explicit null→clear, PRESENT wrong-type→REJECT 400 (keep raw, validator cek typeof). isValidTimestamp
- market/notes/description WAJIB cek
typeof==="string" (Date.parse/.test/.length coerce non-string
diam). Setelah ini SEMUA tipe (scalar/enum/boolean/collection/object/nullable) fail-closed tri-state.
- Projeksi tenant-readable: TUTUP KETIGA DML. FK hanya buktikan version ADA, bukan published/identity.
INSERT → BEFORE trigger: source
published + plan_key/version COCOK source (cegah insert offer utk
DRAFT/mismatch = bocor draft ke tenant). UPDATE → hanya retired_at one-way. DELETE → revoke grant.
Konfirmasi 3 DML tertutup saat modul tenant-readable-projection mendarat.
Lifecycle offer
draft → (validate) → published → retired → (archived). Satu draft per plan
(partial unique index ...one_draft_idx). Publish: validasi → freeze status +
offer_hash + published_at → INSERT snapshot ke projection (public price
saja). Retire: status + retired_at + projection retired_at (baris tetap
readable — "existing published versions remain readable").
Verifikasi (JANGAN skip DB)
bun run check PENUH dengan PostgreSQL nyata (DB terisolasi, lihat memory
scratch-db-verify-when-shared-db-poisoned + local-postgres-connection-details).
Jangan lupa regen: openapi:bundle, api:docs:generate, i18n:extract,
repo:inventory. Doc 01/13/21 + module-doc-reconciliation + module-skill- coverage di-gate saat menambah modul/migration.