Spec Resolution API
Version: 0.1.0 · Status: Draft
Draft: isi di bawah kontrak yang berlaku. §7 mencatat status implementasi hari ini terhadap kontrak ini — bagian itu boleh berubah tanpa mengubah kontrak.
1. Peran
Seam runtime antara engine dan Shell manapun: kontrak internal yang dipanggil interpreter Shell untuk mendapat representasi siap-render dari App/Page/Entity. Rendering adalah interpretasi runtime — Shell di-deploy sekali dan membaca spec saat runtime; tidak ada build artifact per-app.
2. Endpoint
Shell boot dari satu payload bundle (GET .../_meta/ui) sebagai bahan render, ditambah endpoint pelengkap untuk daftar App, identitas, dan schema entity granular. Keempatnya hidup di permukaan UI /{ws}/_ui/_meta/* — bukan/{ws}/api/v1/_meta/*: /api/v1 adalah permukaan eksternal yang deny-by-default (spec.expose) dan tidak me-mount _meta sama sekali, jadi permintaan ke sana dijawab 404.
GET /{ws}/_ui/_meta/apps
→ daftar App yang resolved di workspace ini (name, root_url, access,
accepts_login) — Shell mencocokkan window.location.pathname terhadap
tiap root_url untuk menentukan App mana yang aktif, lalu memakai
`accepts_login` untuk memutuskan apakah App itu punya rute login.
Identitas pemanggil tidak mengubah daftar ini (bukan permission-filtered),
jadi satu workspace = satu jawaban.
GET /{ws}/_ui/_meta/ui?app={name}
→ Bundle: seluruh manifest visual App itu dalam satu payload — entity
schema, Page, Form, Table, Dashboard, Widget, Report, Wizard, Kanban,
Calendar, ApprovalInbox, NotificationCenter, Timeline, Print, Theme,
dan Menu (sudah resolved) — permission-filtered
per caller (§4), scoped ke App tersebut (manifest dari Module yang tidak
di-depends_on App tidak ikut). `?app=` boleh dilewatkan kalau workspace
cuma punya satu App ATAU kalau caller membawa sesi (sesi mengikat App —
lihat di bawah). ETag di response body men-dukung conditional GET
(304 kalau bundle tidak berubah) — mekanisme caching, bukan mekanisme
kompatibilitas versi (lihat §6).
→ Sesi **mengikat App**: token yang di-issue untuk satu App menolak
`?app=` yang berbeda dengan **403** (`APP_MISMATCH`). App bukan pilihan
bebas bagi pemanggil terautentikasi; ia ditentukan saat login
(`docs_internal/plan/app-scoped-login.md` D7).
→ Varian unscoped-App `?admin=true` (permukaan `_admin`, gerbang biner
`_admin.access`) **sudah tidak ada** — permukaan panel admin derived
dipensiunkan (`app-scoped-login.md` D4); permintaan dibalas 400
`ADMIN_BUNDLE_REMOVED`.
→ Varian `?grants=true` (editor grant role) mengirim bundle yang tetap
App-scoped tetapi **sengaja TIDAK permission-filtered**, supaya admin
dapat memberi akses ke hal yang belum ia pegang sendiri. Digerbangi
permission manajemen role (`formspec.core.roles.create|update`) — 403
tanpa itu.
GET /{ws}/_ui/_meta/me
→ identitas caller: user_id, workspace, roles, effective permissions —
sumber gating client-side (§4).
GET /{ws}/_ui/_meta/entities/{module}/{name}
→ satu entity schema penuh, untuk lazy-load form berat yang tidak perlu
ikut bundle awal. Tunduk aturan visibilitas yang sama dengan §4 (404
kalau caller tidak punya permission list/view entity itu — bukan 403,
supaya keberadaan entity tidak bocor ke caller yang tidak berhak).Caching & kompresi. _meta/ui dan _meta/apps membawa ETag (digest atas data, bukan envelope — meta memuat timestamp per-request yang akan membuat validator unik tiap kali dan mematikan 304), Cache-Control: private, no-cache, dan Vary: Authorization. no-cache disengaja: bundle berubah saat spec di-reload, dan revalidasi membuat perubahan itu murah tanpa pernah menyajikan spec basi. Seluruh respons JSON di permukaan ini juga di-gzip bila klien mengirim Accept-Encoding: gzip dan body ≥ 1 KiB.
Catatan — endpoint data UI. /_meta/ menyediakan type information (schema, field, action, permission). Untuk operasi data (CRUD dan custom action), Shell menggunakan endpoint permukaan UI /_ui/entity/{module}/{entity} (../backend/01-core-basic.md §8.1) — tersedia untuk semua Entity tanpa memerlukan spec.expose, digerbangi permission. Kedua grup (/_meta/ dan /_ui/) memakai auth session yang sama.
Bentuk EntitySchema yang dikirim tiap endpoint: field (tipe, validasi, relasi), state_machine (kalau ada), daftar actions (tiap action membawa permission-nya sendiri untuk gating client-side, §4), lifecycle (plain_crud | two_step_autosave), label_field (natural key → name → title → number → id, urutan fallback), dan authorized_actions.
authorized_actions — himpunan aksi yang boleh dilakukan PEMANGGIL ini. Di-resolve server dengan checker yang sama yang memutuskan entity itu ikut bundle, memakai kosakata RESOURCE (list, find, create, update, delete, plus aksi lifecycle bila entity memilikinya). Absen = belum di-resolve (klien memakai daftar permission pemanggil sendiri); [] = di-resolve dan tidak ada yang boleh.
Field ini ada karena Shell tidak boleh menebak: tanpa itu, Shell menurunkan route CRUD dan tombol aksi dari lifecycle entity saja, sehingga App publik yang hanya di-grant [list, find] tetap merender modal create yang submit-nya 401, dan pemanggil tanpa create tetap ditawari tombol "New". Shell juga tidak bisa menghitungnya sendiri: grant App publik tidak muncul di daftar permission pemanggil (tamu tidak memegang permission apa pun), jadi hanya server yang tahu jawabannya.
3. Wajib Backend-Agnostic
API menyerahkan bentuk data (field/type/validation/permission) — bukan query result mentah. Dilarang membocorkan detail PersistBackend (nama kolom fisik, path JSONB). Ini syarat agar Shell tidak perlu tahu backend apa di baliknya.
4. Permission Filtering
Filtering terjadi pada tiga granularitas, semuanya di sisi resolusi (bukan di renderer):
- Entity — schema sebuah entity (dan seluruh manifest yang mereferensikannya: Form, Table, Kanban, Timeline, Print, Report) hanya ikut terkirim kalau caller punya permission
listatauviewentity itu. Kalau tidak, entity itu (dan manifest turunannya) tidak ada sama sekali di payload — bukan dikirim lalu disembunyikan. - Page — kalau Page mendeklarasikan
permissionseksplisit, Page itu ikut cuma kalau caller punya salah satu (any-of). Page tanpapermissionsselalu ikut. - Action — tiap
Actionmembawapermission-nya sendiri di payload (bukan disaring hilang) — ini disengaja: Shell perlu tahu permission apa yang dibutuhkan untuk merender kontrol yang tepat (tombol aktif/nonaktif), bukan cuma tahu boleh-tidaknya. Pengecekan sebenarnya tetap terjadi di/_meta/me(katalog permission caller) yang dicocokkan Shell client-side, dan — yang mengikat — di resource saat action itu benar-benar dipanggil.
Kind navigasi-saja (Dashboard, Theme, Wizard) selalu ikut di bundle; elemen di dalamnya (widget individual, langkah wizard) digerbangi client-side terhadap /_meta/me, bukan disaring di endpoint ini.
Kenapa bukan otorisasi berbasis halaman. Model "bisa lihat halaman → implisit bisa simpan entity-nya" ditolak sebagai mekanisme enforcement: asal UI (apakah request benar-benar datang dari halaman yang berwenang) tidak bisa diverifikasi server — masalah confused deputy klasik — dan client unmanaged (Flutter, API mentah) tidak pernah melewati "halaman" sama sekali. Karena itu enforcement selalu di resource (required_permission, lihat spec/backend/01-core-basic.md §5), tidak pernah di lapisan UI. Endpoint ini hanya menyediakan derivasi footprint kapabilitas per Page (dari komposisinya: Form → action, Table → list, component → deklarasi needs: eksplisit) supaya Shell bisa merender kontrol yang tepat — bukan sumber otorisasi itu sendiri.
Administrasi berbasis tugas: grant-per-halaman termaterialisasi jadi permission. Penolakan otorisasi berbasis halaman di atas soal enforcement, bukan soal UX granting. Ketika Workspace Owner/admin memberi user "akses ke halaman X" lewat UI admin (pengalaman granting yang task-based / berorientasi halaman), framework wajib mematerialisasikan grant itu menjadi permission resource konkret, biasa, auditable, dan revocable — string required_permission yang sama yang dipakai di mana-mana (../backend/01-core-basic.md §5) — tidak pernah sebagai flag opaque "boleh lihat halaman ini". Dengan begitu UX admin tetap sederhana (grant per halaman, footprint kapabilitas Page di atas jadi bahan derivasinya) sementara enforcement di baliknya tetap seragam dan auditable (string permission, argumen confused deputy di atas tetap berlaku).
5. Realtime
Realtime adalah kapabilitas inti Spec Resolution API — standar websocket untuk browser shell yang didefinisikan di sini bagian dari kontrak, bukan ekstensi opsional; implementasi renderer boleh mendarat bertahap (§7) tapi kontraknya inti.
Subskripsi deklaratif terhadap perubahan entity, terpisah dari /_meta/ui:
- Konvensi channel:
entity:{module}.{name}dengan eventcreated | updated | deleted, payload = field event, selalu tenant/workspace-scoped. - Filter sisi-server: caller menerima sebuah event hanya kalau ia punya permission
viewentity itu — dievaluasi per pesan, bukan sekali saat koneksi dibuka (permission caller bisa berubah selama koneksi hidup). - Pemakaian deklaratif:
realtime: truepada Table/Dashboard/Kanban = auto-subscribe + patch baris di tempat. - Pemakaian programatik:
formspec.subscribe("billing.order", cb)di component custom. - Realtime non-durable by definisi — client yang reconnect refetch lewat
/_meta/ui//_meta/entities/..., tidak ada replay. - Heartbeat: satu heartbeat per sesi (satu koneksi websocket = satu tab), dimiliki transport — bukan per subscription/komponen. Server mengirim pulse aplikasi
{"op":"hb"}pada interval tetap dan client membalas{"op":"hb_ack"}; pulse-nya frame teks (bukan ping protokol) karena ping/pong level-protokol tidak terlihat JavaScript, sehingga tanpa itu koneksi "sehat tapi sunyi" tak bisa dibedakan dari koneksi mati. Sisi client menutup socket yang diam melewati budget → reconnect. - Indikator status: Shell boleh menampilkan status koneksi secara non-intrusif (bukan modal/toast) hanya kalau halaman yang dibuka memang memakai realtime; halaman yang tidak men-subscribe apa pun tidak boleh diberi tahu tentang koneksi yang putus.
- Handshake auth: browser tidak bisa men-set header
Authorizationpada handshake WebSocket, jadi kredensial tidak boleh ditaruh di URL sebagai JWT full-lifetime. Client meminta ticket single-use lewatPOST /{workspace}/_ui/_ws/ticket(headerAuthorization: Bearer), lalu connect dengan?ticket=<opaque>. Ticket opaque, sekali pakai, terikat ke identity + workspace penerbit, TTL 30 detik — access log tidak lagi merekam kredensial yang bisa dipakai ulang.
6. Versi & Kompatibilitas
Versi API ini ada di path (/api/v1/...) — Shell resmi dibangun terhadap satu versi mayor, breaking change menaikkan segmen versi. ETag pada /_meta/ui (§2) adalah mekanisme caching (conditional GET, 304 kalau bundle tak berubah) — bukan mekanisme kompatibilitas, jangan dicampur artinya dengan versi API.
7. Status Implementasi Hari Ini (Gap)
- Filter per-pesan pada Realtime (§5) sebagian sudah ditutup oleh implementasi resmi (lihat
../renderers/realtime.md): hub websocket memfilter per subscription (client mendaftarkan resource/event yang diinginkan lewat frame subscribe/unsubscribe) dan per permission{module}.{plural}.view(2.6.6). Sisa gap yang belum ditutup: target{scope: user}(saat ini hanya{scope: workspace}). Heartbeat dan indikator status sudah terpasang sesuai §5 (heartbeat satu per sesi; indikator di chrome hanya saat halaman memakai realtime). - Endpoint hari ini (§2) sudah sesuai bentuk kontrak ini (bundle per-App, bukan per-page
view-specseperti draft awal dokumen ini sebelum direvisi).
8. Referensi
| Dokumen | Isi |
|---|---|
01-visual-hierarchy.md | Shell, App/Page/Component renderer |
02-visual-spec-kind.md | VisualSpecKind, slot system |
spec/backend/01-core-basic.md §5 | required_permission di resource — enforcement sebenarnya |