Thiết kế khuôn A (route Nuxt) + DOT B (publish view) cho cụm Reports — rev2
THIẾT KẾ — Khuôn A (route Nuxt) + DOT B (publish view) cho cụm Reports
File:
knowledge/dev/laws-new/pg-read-pg/thiet-ke/thiet-ke-khuon-reports.mdRevision: rev2 (rev1 → rev2, 2026-06-25) · Trạng thái: THIẾT KẾ (trên giấy) — CHƯA tạo file, CHƯA chạy DDL, CHƯA tạo view, CHƯA sửa nav, CHƯA đăng ký gì. Thuộc đề bài:de-bai-pg-read-pg.md(v1.1) · Báo cáo căn cứ:bao-cao-pg-read-pg.md(rev2) Mục tiêu: đúc một lần 2 thứ (A: ổ cắm route báo cáo; B: DOT publish view từ spec) để sau đó thêm báo cáo MỚI = 0-code (chỉ thêm data). Provenance live (read-only): DB=directus· role=context_pack_readonly· MCP query_pg READ ONLY · giờ đọc 2026-06-25. Verify component (rev2): đã ĐỌC ĐƯỢC sourceweb/components/shared/DirectusTable.vuequa filesystem local (…/web-test/web/components/shared/DirectusTable.vue). Kết quả ở §7A.5 + §8.
0.0. PHẠM VI — đây là pha ĐÚC KHUÔN REPORTS (không phải Read Card thuần đọc)
Tài liệu này là pha ĐÚC KHUÔN REPORTS dùng lại lâu dài, không phải Read Card PG read PG v1.0 thuần đọc.
Vì vậy, ngoại lệ được Owner duyệt là tạo 2 file Nuxt generic một lần:
- /reports/index.vue
- /reports/[report]/index.vue
Sau khi khuôn A+B hoàn tất, mọi báo cáo mới quay lại nguyên tắc 0-code:
thêm data + chạy DOT, không sửa Nuxt.
Vì sao cần đoạn này: đề bài PG read PG v1.1 (Read Card thuần đọc) cấm code Nuxt mới. Pha này KHÁC — là pha đúc khuôn (Điều 28 "đúc khuôn một lần"), nên ngoại lệ "2 file Nuxt generic + (nếu thiếu) 1 năng lực generic row-link trong SharedDirectusTable" được Owner duyệt một lần. Mọi báo cáo lẻ về sau KHÔNG được mở lại ngoại lệ này.
0. Khẳng định trước (4 ràng buộc — trả lời DỨT ĐIỂM)
- A LÀ CODE NUXT — gọi đúng tên. A = 2 file Nuxt generic, đúc một lần (
pages/reports/index.vue+pages/reports/[report]/index.vue), lõi mỗi file chỉ mountSharedDirectusTable+ một resolver tratable_registry. A KHÔNG phải "0-code" — A là khuôn phải viết tay một lần (đúng nghĩa "đúc khuôn" của Đ28). Chỉ SAU khi A+B đã đúc xong, báo cáo MỚI mới là 0-code (thêm data, không chạm Nuxt). - Chống hardcode trá hình. Câu SELECT / định nghĩa view của DOT-B sống trong DATA (cột
table_registry.view_sql), DOT chỉ ĐỌC spec rồi thực thi. Bin của DOT-B không chứa SELECT cứng;.vuekhông chứa tableId/route-map cứng — đều tra từtable_registry. Bảng "mỗi mẩu cấu hình → nằm ở data nào" ở §5. - Row-link — ĐÃ VERIFY (rev2, CHỈNH). Live xác nhận
table_registrycó 17 cột, KHÔNG có cột link. Đọc sourceSharedDirectusTable.vue(§8): component đã có cơ chế row-link nhưng theo PROP HÀMrowLink:(item)=>stringdo.vuecha truyền, CHƯA đọcrow_link_templatetừ config registry. ⇒ (a) thêm cộtrow_link_templatequa DOT-063dot-schema-apply(data, idempotent, chỉ-thêm); (b) lượt đúc sau phải thêm đúng MỘT năng lực generic vàoSharedDirectusTable: đọcrc.row_link_template+ thế cột-của-dòng → tự dựngrowLink. KHÔNG hardcode link trong.vue, KHÔNG tạoReportsTableriêng. (rev1 ghi "component đọc row_link_template từ config" là SAI — rev2 sửa, xem §8.) - Quyền đọc catalog. Live kiểm
has_table_privilege(current_user, …, 'SELECT')= true chopg_catalog.pg_trigger / pg_class / pg_constraint / pg_proc(rolecontext_pack_readonly). → View census 12 loại (trigger đọc quapg_catalog.pg_trigger, KHÔNGinformation_schema→ 0 giả) ĐỦ quyền đọc catalog, KHÔNG READ_BLOCKED. Caveat owner-role ở §7.
1. Phát hiện khảo sát đã chốt (nền của thiết kế này)
| Mảnh | Sự thật (đã khảo sát/khảo lại live) | Hệ quả thiết kế |
|---|---|---|
| Khuôn bảng sạch | SharedDirectusTable (web/components/shared/DirectusTable.vue) đọc table_registry lúc RUNTIME: readItems('table_registry', filter[table_id]=…, status≠archived, limit 1) → config (collection, fields, sort, filter, flags) → GET {collection} → render <UTable> (đã đọc source — §8) |
A chỉ mount component này; không viết bảng mới |
| Trang sạch mẫu | /knowledge/workflows = .vue ~44 dòng → <SharedDirectusTable table-id="tbl_workflow_list"> |
A đi theo đúng kiểu trang sạch này |
table_registry cột |
17 cột: id, table_id, name, collection, fields(jsonb), default_sort, default_filter(jsonb), page_url, enable_insert_marks, enable_proposals, enable_search, enable_pagination, rows_per_page, status, module, description, _dot_origin — không có cột link |
row-link THIẾU → thêm 1 cột (ràng buộc #3) |
| Thêm cột | DOT-063 dot-schema-apply idempotent, additive-only |
dùng cho row_link_template + view_sql + view_name |
| View-từ-spec | THIẾU — chỉ có DOT-129 đếm bespoke; không có DOT generic create+register view | DOT-B là tool MỚI cần đúc |
| Đăng ký collection | DOT-COL-CREATE dot-collection-create (đăng ký Directus collection, idempotent) |
DOT-B tái dùng bước này |
| Gán loài | DOT-146 dot-species-register ("có loài là vào bảng") |
dán nhãn A+B (§4) |
| Nav | HYBRID hardcode trong TheHeader.vue (navigation('main') + merge staticNavItems cứng: Workflows/Registries/Pivot/Laws). Thêm 'Reports' qua data navigation_items = sạch; sửa .vue = bẩn → STOP |
A thêm nav qua DATA (§2.A-5) |
| Loại trừ | Block /knowledge/registries = hand-coded, BUGGY/abandoned; host generic 0-.vue (registries/[entityType]) nằm trong đó → KHÔNG tái dùng |
A đặt ngoài /registries, route mới /reports/ |
2. THIẾT KẾ A — Ổ cắm route báo cáo generic (CODE NUXT, đúc một lần)
A-1. Hai file (toàn bộ phần Nuxt mới của cụm Reports)
File 1 — web/pages/reports/index.vue (Reports Home = BẢNG 0 master-list)
- Input: không (route tĩnh
/reports). - Mount:
<SharedDirectusTable table-id="tbl_report_home" />— một dòng lõi, không cấu hình cột trong.vue. - Output: danh sách mọi báo cáo (mỗi báo cáo = 1 dòng), có search + filter mọi cột (đến từ config trong
table_registry), bấm dòng → mở báo cáo đó (row-link, xem A-3). - Mọi cột (STT, Tên báo cáo, Loài) đến từ
table_registrycủatbl_report_home, không hardcode.
File 2 — web/pages/reports/[report]/index.vue (Host báo cáo generic — MỘT file phục vụ MỌI báo cáo)
- Input: route param
report(vd/reports/pg-census). - Lõi (generic, ~ chục dòng):
const route = useRoute()- Resolver:
readItems('table_registry', filter[page_url][_eq]=<route.path>, status≠archived)→ lấytable_id. (Chỉ resolve dòng cópage_url LIKE '/reports/%'— guardrail §5A.) <SharedDirectusTable :table-id="resolvedTableId" />.
- Output: render đúng báo cáo ứng route. Không có bản đồ
param → tableIdcứng — ánh xạ nằm ởtable_registry.page_url(data). Thêm báo cáo mới ⇒ không sửa file này. - Lý do dùng
page_urllàm khoá route: cột đã tồn tại, đúng nghĩa "URL trang"; DOT-B ghipage_url='/reports/<key>'⇒ ánh xạ route↔bảng 100% trong data.
A-2. Tra table_registry thế nào
- File 1: cố định
table-id="tbl_report_home"(đây là hằng hạ tầng của khuôn, không phải data báo cáo — Home là điểm vào duy nhất; chấp nhận 1 hằng khuôn, KHÔNG phải bản đồ report). - File 2: không có hằng nào theo báo cáo — tra động bằng
page_url = route.path.SharedDirectusTablegiữ NGUYÊN (vẫn nhậntable-id); resolver page→table_id nằm trong File 2 nên không phải sửa component dùng chung.
A-3. Row-link lấy từ đâu (DATA nào) — rev2 ĐÃ VERIFY
- Cột mới
table_registry.row_link_template(text), thêm qua DOT-063 (ràng buộc #3). Ví dụ ở dòngtbl_report_home:row_link_template = "{route}"(hoặc"/reports/{slug}"), trong đó{route}/{slug}là một cột do view Home trả về (xem B, view Home project cộtroute = page_url). - Cơ chế đích muốn (lúc render):
SharedDirectusTableđọcrow_link_templatetừ config + thế giá trị cột của dòng ⇒ mỗi dòng thành link → trang báo cáo. Link không nằm trong.vue; nằm trongtable_registry(data). - VERIFY (rev2 — §8): đã đọc source. Component HIỆN chỉ có row-link qua prop hàm
rowLinkdo.vuecha truyền, CHƯA đọcrow_link_templatetừ config. ⇒ Lượt đúc sau phải thêm đúng MỘT năng lực generic vàoSharedDirectusTable: (1) mở rộng interfaceTableRegistryConfigthêmrow_link_template; (2) tínhresolvedRowLinktừ template (thế{col}bằng giá trị cột của dòng, tái dùnggetNestedValue); (3) nạp vàohandleRowClick/tableUiđã có sẵn. Đây là năng lực generic (dùng cho mọi báo cáo), KHÔNG hardcode theo từng báo cáo, KHÔNG tạoReportsTableriêng, KHÔNG hardcode link trongreports/index.vue.
A-4. Vì sao SẠCH và NGOÀI /registries
- Ngoài
/registries: route mới/reports/, không mượn hostregistries/[entityType](nằm trong block hand-coded BUGGY/abandoned → cấm tái dùng). - Sạch: mỗi file chỉ mount
SharedDirectusTable+ resolver động; 0 cấu hình cột, 0 SELECT, 0 bản đồ report trong.vue. Mọi cấu hình ởtable_registry(§5). - Ranh giới chống phình:
/reports/chỉ chứa báo cáo đọc-để-giám-sát (view read-only từ PG). KHÔNG phải nơi chứa mọi collection nghiệp vụ. Thực thi ranh giới: (a) File 2 chỉ resolve dòng cópage_url LIKE '/reports/%'; (b) DOT-B chỉ publish view read-only (validate ở §7A) — không cho biến/reports/thành "/registries phiên bản 2".
A-5. Nav: thêm 'Reports' qua DATA
- Thêm mục nav 'Reports' (→
/reports) bằng cách thêm row vàonavigation_itemsmànavigation('main')đọc — SẠCH. - CẤM sửa
staticNavItemscứng trongTheHeader.vue, CẤM đổi tên/xoá Workflows/Registries (= sửa.vue= bẩn → STOP).
2A. BOOTSTRAP HOME — tránh route /reports trắng (rev2, MỚI)
Thứ tự bootstrap tối thiểu cho Home (chạy ở lượt đúc sau, không phải lượt này):
- DOT-063
dot-schema-applythêm 3 cột nullable vàotable_registry:view_sql,view_name,row_link_template. - Seed dòng
tbl_report_homevàotable_registry(mangview_sqlcủa Home master-list +view_name='v_report_home'+collection='v_report_home'+fields+page_url='/reports'+row_link_template+ flags +status='active'+ loài). - Chạy
dot-report-publishchotbl_report_homeđể:- tạo view
v_report_home; - đăng ký Directus collection cho
v_report_home; - đồng bộ cấu hình Home trong
table_registry.
- tạo view
- Thêm nav
Reportsqua data trongnavigation_items(§2.A-5). - Sau các bước trên,
/reportsmới có data để mount và hiển thị master-list.
Nếu thiếu bootstrap Home, route /reports có thể mở được nhưng TRẮNG
hoặc báo thiếu config (registryError: 'Table registry entry "tbl_report_home" not found').
Lưu ý khớp source (§8):
SharedDirectusTableđặtregistryErrorkhi không tìm thấy dòng registry ⇒ thiếu bootstrap = báo lỗi rõ, không crash; nhưng vẫn phải seed Home trước khi để người dùng vào/reports.
3. THIẾT KẾ B — DOT generic publish view từ spec
B-1. Tên & nhiệm vụ
- Tên:
dot-report-publish(loài = DOT). Nhiệm vụ: từ một dòng spec trong data, tạo + đăng ký một báo cáo read-only đểSharedDirectusTableđọc — generic, dùng cho MỌI báo cáo.
B-2. Input spec — CHỐT Biến thể 1 (rev2)
CHỐT pha triển khai đầu tiên = CHỈ Biến thể 1. Spec sống ngay trong table_registry; thêm 3 cột nullable qua DOT-063:
view_sql(text) — câu SELECT read-only định nghĩa view (đây là "CÂU HỎI" — đề bài §4 cho phép lưu câu hỏi).view_name(varchar) — tên PG view sẽ tạo (vdv_report_pg_census).row_link_template(text) — đã nêu ở A-3.
⇒ Một báo cáo = MỘT dòng table_registry mang cả spec-view (view_sql,view_name) lẫn config-UI (collection,fields,flags,page_url,row_link_template). Dòng nghiệp-vụ-thường để view_sql NULL ⇒ DOT-B bỏ qua.
CHỐT Biến thể 1: ĐƠN GIẢN TRƯỚC, 0 BẢNG CONFIG MỚI.
- KHÔNG tạo bảng report_view_spec trong pha này.
- Spec sống trong table_registry (3 cột nullable thêm qua DOT-063).
- Chỉ xem xét tách bảng spec riêng VỀ SAU nếu table_registry quá tải
hoặc cần phân quyền phức tạp hơn.
Biến thể 2 (CHỈ tham khảo, KHÔNG làm pha này): bảng spec riêng
report_view_spec(report_key, view_name, select_sql, source_note, warnings, species, status, _dot_origin); tách "DDL view" khỏi "config UI". Tốn 1 bảng config. Hoãn tới khi có lý do thật (table_registry quá tải / phân quyền phức tạp). Cả hai biến thể: SELECT đều sống trong data, bin không chứa SELECT.
Quy ước nội dung mọi view_sql: read-only SELECT; trigger phải pg_catalog.pg_trigger; có now() AS refreshed_at (đề bài §3). Cảnh báo đặc thù (vd "đọc pg_catalog") ghi ở table_registry.description.
B-3. Output (đều là thao tác GHI có governed — lượt này CHƯA chạy)
DOT-B đọc 1 dòng spec rồi, idempotent:
- Validate
view_sqllà một câuSELECTread-only (validator v1 + chạy thửREAD ONLY— chi tiết §7A.2). Hỏng → từ chối, không tạo gì. CREATE OR REPLACE VIEW <view_name> AS <view_sql>— idempotent (replace); vớisecurity_invoker=truenếu PG hỗ trợ + verify pass (§7A.1).- Đăng ký Directus collection trên view (tái dùng
dot-collection-create) — idempotent.collectionPHẢI bằngview_name(guardrail §5A). - Upsert dòng
table_registrytheotable_id(collection=view_name,fields,page_url,row_link_template, flags) — idempotent. - Đóng dấu
_dot_origin = 'dot-report-publish'(vết loài/nguồn).
B-4. Idempotent ra sao
CREATE OR REPLACE VIEW+ đăng ký collectionIF NOT EXISTS+ upserttable_registrytheo khoátable_id⇒ chạy lại N lần = cùng một trạng thái.- Phép thử vàng (đề bài §1.4): DROP view → chạy lại DOT-B từ spec ⇒ view dựng lại y hệt, đọc lại ra cùng số (view tự tính từ catalog/nguồn). Lưu câu hỏi (
view_sql), không lưu câu trả lời ⇒ không có gì phải đồng bộ.
B-5. Chống-hardcode — chứng minh
- Bin của
dot-report-publishKHÔNG có câu SELECT nào — nó đọcview_sqltừ data rồiCREATE OR REPLACE VIEW. Cùng một bin chạy cho mọi báo cáo. Đổi báo cáo = đổi data, không đụng bin. .vueKHÔNG có tableId/route-map cứng — File 2 resolvetable_idquapage_url(data); cột/cờ/sort/filter/link đều từtable_registry(data).- Mọi mẩu cấu hình truy ngược về một cột data (§5).
4. DÁN NHÃN cho chính A + B (loài = DOT)
- Đăng ký A (khuôn route Reports — hạ tầng Nuxt) và B (
dot-report-publish) với loài = DOT + mô tả nhiệm vụ, qua DOT-146dot-species-register⇒ chúng tự liệt kê trong báo cáo DOT (BẢNG 3) theo cơ chế "có loài là vào bảng". - Ghi rõ sắc thái: B là DOT thực thụ; A là mold hạ tầng Nuxt được gán loài=DOT để hiện diện trong census tự-thấy (hệ nhìn thấy chính công cụ của mình). Các cột khác (chuyên môn 7 tầng, lớp hoạt động 6 lớp) khai sau — lượt này chỉ cần loài để vào bảng.
5. BẢNG "mỗi mẩu cấu hình → nằm ở DATA nào" (chứng minh 0 hardcode)
| Mẩu cấu hình | Sống ở (DATA) | Ai đọc | KHÔNG nằm cứng ở |
|---|---|---|---|
| Câu SELECT định nghĩa view | table_registry.view_sql (Biến thể 1 — CHỐT) |
DOT-B (CREATE OR REPLACE VIEW) |
bin dot-report-publish |
| Tên PG view | table_registry.view_name |
DOT-B | bin |
| Collection bảng đọc | table_registry.collection (= view_name) |
SharedDirectusTable |
.vue |
| Cột / nhãn / sortable / filterable | table_registry.fields (jsonb) |
SharedDirectusTable |
.vue |
| Sort / filter mặc định | table_registry.default_sort / default_filter |
SharedDirectusTable |
.vue |
| Search / pagination / rows_per_page | table_registry.enable_* / rows_per_page |
SharedDirectusTable |
.vue |
| Ánh xạ route → bảng | table_registry.page_url (= /reports/<key>) |
resolver trong reports/[report]/index.vue |
.vue (không bản đồ cứng) |
| Row-link master-list | table_registry.row_link_template (cột MỚI, DOT-063) |
SharedDirectusTable (sau khi thêm năng lực generic — §A-3) |
.vue |
| Tên / tiêu đề báo cáo | table_registry.name |
Home view / SharedDirectusTable |
.vue |
| Loài của báo cáo (cột BẢNG 0) | view Home project (từ data) | tbl_report_home |
.vue |
| Mục nav 'Reports' | row trong navigation_items |
TheHeader.vue qua navigation('main') |
staticNavItems (cấm sửa) |
| Cảnh báo đọc (vd trigger qua pg_catalog) | table_registry.description |
doc/operator | bin |
Hằng khuôn duy nhất được phép:
tbl_report_hometrongreports/index.vue(điểm vào Home). Đây là hạ tầng khuôn, KHÔNG phải bản đồ-theo-báo-cáo. Mọi báo cáo lẻ resolve động 100%.
5A. GUARDRAIL TỐI THIỂU (rev2, MỚI — ghi vào thiết kế, KHÔNG chạy DDL lượt này)
Guardrail áp dụng ở lượt đúc sau (constraint/index/regex). Lượt này chỉ ghi.
table_registry.table_idphải unique.table_registry.page_urlphải unique với các dòngstatus='active'vàpage_url IS NOT NULL(partial unique index).view_namephải khớp regex:^v_report_[a-z0-9_]+$.page_urlcủa báo cáo phải bắt đầu bằng/reports/.- Với report do
dot-report-publishtạo,collectionphải bằngview_name. - File generic
/reports/[report]/index.vuechỉ resolve các dòng cópage_url LIKE '/reports/%'.
Lượt này CHỈ ghi guardrail vào thiết kế rev2; KHÔNG chạy DDL/constraint/index.
Khớp source (§8): component lọc registry bằng
status != 'archived'(không phải= 'active'). Guardrail uniqueness chốt theostatus='active'là chặt hơn (an toàn); lúc đúc cần thống nhất 2 chỗ — hoặc partial index theostatus='active', hoặc nới component đọc đúngactive. Ghi nhận để lượt đúc xử lý, KHÔNG sửa lượt này.
6. MẢNG MỞ RỘNG — thêm 1 báo cáo MỚI về sau (chứng minh 0-code)
Sau khi A+B đã đúc, thêm báo cáo X (vd /reports/index-bloat):
- INSERT 1 dòng
table_registry(DATA):table_id='tbl_report_X',name,collection='v_report_X',view_name='v_report_X',view_sql='<SELECT read-only, dùng pg_catalog nếu cần, có now() AS refreshed_at>',fields=[…],page_url='/reports/index-bloat',row_link_template(nếu là master-list), flags,status='active', loài. - Chạy
dot-report-publish --report tbl_report_X(DOT run governed): validate →CREATE OR REPLACE VIEW v_report_X→ đăng ký collection → đồng bộtable_registry. - Nav: KHÔNG cần đổi — báo cáo lẻ hiện ra ở Home (master-list) vì view Home đọc
table_registry. Chỉ mục 'Reports' (đã thêm lúc đúc) là tĩnh.
Kết quả: 0 .vue mới · 0 DDL viết tay · 0 sửa component. Route /reports/index-bloat chạy được vì File 2 generic (resolve qua page_url); Home tự liệt kê báo cáo mới vì v_report_home đọc table_registry. ⇒ 0-code đúng nghĩa: chỉ thêm DATA + chạy DOT.
7. RỦI RO + chỗ READ_BLOCKED
- READ_BLOCKED (catalog): KHÔNG với 4 quan hệ đã kiểm (
pg_trigger/pg_class/pg_constraint/pg_proc= SELECT true chocontext_pack_readonly). Caveat owner-role: view chạy bằng quyền của role sở hữu view (xem §7A.1), không phảicontext_pack_readonly; nhưngpg_catalogcấp choPUBLIC⇒ owner-role gần như chắc đọc được. Việc verify trước build: xác nhận owner-role có catalog read (gần chắc yes). Nếu báo cáo tương lai cần object catalog không cấp PUBLIC (hiếm) → ghiREAD_BLOCKED, không suy diễn. - Bắt buộc
pg_catalog, khônginformation_schema:information_schema.triggerslọc theo quyền → trả 0 giả; mọiview_sqlđếm trigger phảipg_catalog.pg_trigger(đúng = 410). Đây là quy ước trong data spec, không phải trong khuôn. - Row-link rendering (ĐÃ VERIFY — rev2): đã đọc source
SharedDirectusTable.vue(§8). Component chưa đọcrow_link_templatetừ config (chỉ có prop hàmrowLink). ⇒ đúc-khuôn-một-lần phải thêm năng lực link generic (đọcrow_link_template+ thế cột-của-dòng) — KHÔNG hardcode từng báo cáo, KHÔNGReportsTableriêng. - Resolver theo
page_url: Directus cho filtertable_registrytheopage_url(đã filter theotable_idtrong source ⇒ filter theo cột khác cũng OK). Cần thêmstatus≠archivednhư component đang làm. - Governed-write:
CREATE VIEW+ đăng ký collection + thêm cột = thao tác GHI ⇒ đi đường DOT governed (Owner/dry-run gate theo cơ chế DOT hiện có). Lượt này design-only, CHƯA chạy. - Chống phình
/reports/: chỉ view read-only (validate ở §7A) + File 2 chỉ resolvepage_url LIKE '/reports/%'. Đừng để thành /registries v2. - Cấm sửa nav cứng: thêm 'Reports' chỉ qua
navigation_items; không đụngstaticNavItems, không đổi tên Workflows/Registries.
7A. AN NINH view_sql — QUYỀN là chính, validator là phụ (rev2, MỚI)
Vì view_sql sống trong data (Directus ghi được) và DOT-B (privileged) thực thi nó, đây là mặt tấn công SQL-injection. Thứ tự ưu tiên phòng thủ:
7A.1. Lớp chặn CHÍNH = QUYỀN (không phải validator)
view_sqlvàview_namelà cột nhạy cảm. Chỉ role quản trị hoặc DOT-governed writer được ghiview_sql/view_name. User vận hành / Directus UI thường KHÔNG được sửaview_sql/view_name.- DOT-B tạo view bằng role chuyên dụng least-privilege, ví dụ
report_view_owner. Role tạo view này:- không phải superuser;
- không có quyền ghi/xóa production table;
- chỉ có quyền đọc những nguồn cần thiết để tạo report view.
- Directus/runtime role chỉ được
GRANT SELECTtrên report views. - Phải
REVOKErõ các quyền ghi trên report views khỏi runtime roles:INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER. - Nếu PostgreSQL hỗ trợ và verify pass, ưu tiên:
để quyền bảng nền được kiểm theo user đọc (PG ≥ 15 hỗ trợCREATE VIEW ... WITH (security_invoker=true) AS ...security_invoker; live = PG16.13 ⇒ khả dụng — verify lại lúc đúc). - Nếu không dùng
security_invoker, bắt buộc owner của view vẫn phải là role least-privilege, không được là role có quyền ghi/xóa dữ liệu nghiệp vụ.
PostgreSQL view mặc định kiểm quyền bảng nền theo VIEW OWNER.
Vì vậy, "view read-only" chỉ đúng khi owner/runtime grants được thiết kế đúng.
Validator KHÔNG phải lá chắn cuối; quyền least-privilege mới là lá chắn cuối.
7A.2. Lớp PHỤ = validator v1 đơn giản (KHÔNG xây parser lớn)
Validator v1 chỉ cần mức tối thiểu:
- Trim SQL.
- Chỉ nhận câu mở đầu bằng
WITHhoặcSELECT. - Cấm dấu
;giữa câu; chỉ cho phép tối đa một dấu;cuối câu (nếu có). - Cấm keyword ghi nguy hiểm:
INSERT,UPDATE,DELETE,DROP,ALTER,CREATE,TRUNCATE,GRANT,REVOKE,COPY,CALL,DO. - Bọc kiểm tra bằng: transaction
READ ONLY+statement_timeout+ chạy thử cóLIMIT+ rollback sau kiểm tra. - Whitelist
view_namebằng regex:^v_report_[a-z0-9_]+$.
Không xây SQL parser lớn ở v1.
Validator chỉ là lớp lọc sơ bộ.
Lá chắn cuối là quyền least-privilege (§7A.1).
8. PHỤ LỤC — VERIFY component + Kiểm chứng live (read-only, 2026-06-25)
8.1. VERIFY SharedDirectusTable.vue (rev2 — đã ĐỌC ĐƯỢC source)
File: …/web-test/web/components/shared/DirectusTable.vue (đọc qua filesystem local — KHÔNG qua VPS MCP read_file; allowlist VPS không liên quan vì file có sẵn local).
| Câu hỏi verify | Kết quả | Bằng chứng (dòng) |
|---|---|---|
Đọc table_registry lúc runtime? |
CÓ | 73–92: readItems('table_registry', filter[table_id]={_eq}, status={_neq:'archived'}, limit 1) |
| Filter/search/pagination từ config? | CÓ | fields←rc.fields (97); default_sort (98–101); rows_per_page (102); enable_search (103); default_filter (109–115); filterableFields từ fields (194–196); pagination dùng resolvedPageSize/totalCount/currentPage (448–462) |
| Có row-link generic (data-driven)? | CHƯA | Chỉ có prop hàm rowLink:(item)=>string (46) do .vue cha truyền; handleRowClick→router.push(props.rowLink(row)) (206–211); tableUi cursor-pointer khi có rowLink (178–187). Interface TableRegistryConfig (15–31) không có field link; rc không bao giờ được hỏi cho link. ⇒ row_link_template chưa được đọc. |
Kết luận verify: THIẾU row-link generic (data-driven). Component đã có cơ chế row-link (function-prop) + đọc registry runtime + config-driven filter/search/pagination, nhưng chưa map table_registry.row_link_template → link. Lượt đúc sau phải thêm đúng MỘT năng lực generic vào SharedDirectusTable (mở rộng TableRegistryConfig + đọc rc.row_link_template + thế {col} qua getNestedValue + nạp vào handleRowClick/tableUi đã có). CẤM ReportsTable riêng; CẤM hardcode link trong reports/index.vue.
Đính chính rev1: rev1 (A-3, §8) ghi "SharedDirectusTable đọc row_link_template từ config" là SAI. rev2 sửa theo source: cơ chế hiện tại là prop hàm, năng lực data-driven phải đúc thêm.
8.2. Kiểm chứng live PG (read-only)
table_registry= 17 cột (liệt kê §1) — không có cột link ⇒ ràng buộc #3 xác lập.has_table_privilege('context_pack_readonly', 'pg_catalog.pg_trigger'|'pg_class'|'pg_constraint'|'pg_proc', 'SELECT')= true / true / true / true ⇒ ràng buộc #4: census view đủ quyền, NOT READ_BLOCKED.SharedDirectusTablelogic (đã đọc source §8.1):setup → (nếu có tableId) GET table_registry → config → useDirectusTable → render UTable⇒ A chỉ mount, không viết bảng.
9. LƯỢT NÀY TUYỆT ĐỐI KHÔNG (đã giữ)
Tạo .vue · chạy DDL · tạo view · thêm cột thật · tạo constraint/index · sửa nav · thêm row navigation_items · đăng ký Directus collection · seed tbl_report_home · chạy dot-report-publish · sửa SharedDirectusTable.vue · tái dùng /registries · commit/push. Lượt này chỉ: (1) sửa thiết kế lên rev2; (2) verify source SharedDirectusTable.vue; (3) báo cáo.
Bước kế (cần Owner duyệt thiết kế rev2 trước khi đúc): (1) đúc A (2 file) + thêm 1 năng lực generic row-link vào SharedDirectusTable + thêm 3 cột qua DOT-063 + đúc DOT-B; (2) bootstrap Home (§2A); (3) seed 1 báo cáo mẫu (census) để chứng minh đường 0-code; (4) áp guardrail (§5A) + least-privilege role (§7A).