KB-1B58 rev 2

Thiết kế khuôn A (route Nuxt) + DOT B (publish view) cho cụm Reports — rev2

27 min read Revision 2
laws-newpg-read-pgthiet-kekhuon-A-route-nuxtdot-B-report-publishreports-clusteranti-hardcode0-code-after-castdesign-onlyrev2bien-the-1bootstrap-homeguardrailan-ninh-view-sqlverify-shared-directus-table2026-06-25

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.md Revision: 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 source web/components/shared/DirectusTable.vue qua 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)

  1. 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ỉ mount SharedDirectusTable + một resolver tra table_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).
  2. 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; .vue khô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.
  3. Row-link — ĐÃ VERIFY (rev2, CHỈNH). Live xác nhận table_registry17 cột, KHÔNG có cột link. Đọc source SharedDirectusTable.vue (§8): component đã có cơ chế row-link nhưng theo PROP HÀM rowLink:(item)=>string do .vue cha truyền, CHƯA đọc row_link_template từ config registry. ⇒ (a) thêm cột row_link_template qua DOT-063 dot-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ào SharedDirectusTable: đọc rc.row_link_template + thế cột-của-dòng → tự dựng rowLink. KHÔNG hardcode link trong .vue, KHÔNG tạo ReportsTable riêng. (rev1 ghi "component đọc row_link_template từ config" là SAI — rev2 sửa, xem §8.)
  4. Quyền đọc catalog. Live kiểm has_table_privilege(current_user, …, 'SELECT') = true cho pg_catalog.pg_trigger / pg_class / pg_constraint / pg_proc (role context_pack_readonly). → View census 12 loại (trigger đọc qua pg_catalog.pg_trigger, KHÔNG information_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_originkhô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_registry của tbl_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):
    1. const route = useRoute()
    2. Resolver: readItems('table_registry', filter[page_url][_eq]=<route.path>, status≠archived) → lấy table_id. (Chỉ resolve dòng có page_url LIKE '/reports/%' — guardrail §5A.)
    3. <SharedDirectusTable :table-id="resolvedTableId" />.
  • Output: render đúng báo cáo ứng route. Không có bản đồ param → tableId cứ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_url làm khoá route: cột đã tồn tại, đúng nghĩa "URL trang"; DOT-B ghi page_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. SharedDirectusTable giữ NGUYÊN (vẫn nhận table-id); resolver page→table_id nằm trong File 2 nên không phải sửa component dùng chung.
  • Cột mới table_registry.row_link_template (text), thêm qua DOT-063 (ràng buộc #3). Ví dụ ở dòng tbl_report_home: row_link_template = "{route}" (hoặc "/reports/{slug}"), trong đó {route}/{slug}một cột do view Home trả về (xem B, view Home project cột route = page_url).
  • Cơ chế đích muốn (lúc render): SharedDirectusTable đọc row_link_template từ 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 trong table_registry (data).
  • VERIFY (rev2 — §8): đã đọc source. Component HIỆN chỉ có row-link qua prop hàm rowLink do .vue cha truyền, CHƯA đọc row_link_template từ config. ⇒ Lượt đúc sau phải thêm đúng MỘT năng lực generic vào SharedDirectusTable: (1) mở rộng interface TableRegistryConfig thêm row_link_template; (2) tính resolvedRowLink từ template (thế {col} bằng giá trị cột của dòng, tái dùng getNestedValue); (3) nạp vào handleRowClick/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ạo ReportsTable riêng, KHÔNG hardcode link trong reports/index.vue.

A-4. Vì sao SẠCH và NGOÀI /registries

  • Ngoài /registries: route mới /reports/, không mượn host registries/[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ào navigation_itemsnavigation('main') đọc — SẠCH.
  • CẤM sửa staticNavItems cứng trong TheHeader.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):

  1. DOT-063 dot-schema-apply thêm 3 cột nullable vào table_registry: view_sql, view_name, row_link_template.
  2. Seed dòng tbl_report_home vào table_registry (mang view_sql củ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).
  3. Chạy dot-report-publish cho tbl_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.
  4. Thêm nav Reports qua data trong navigation_items (§2.A-5).
  5. Sau các bước trên, /reports mớ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 đặt registryError khi 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 (vd v_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:

  1. Validate view_sqlmột câu SELECT read-only (validator v1 + chạy thử READ ONLY — chi tiết §7A.2). Hỏng → từ chối, không tạo gì.
  2. CREATE OR REPLACE VIEW <view_name> AS <view_sql> — idempotent (replace); với security_invoker=true nếu PG hỗ trợ + verify pass (§7A.1).
  3. Đăng ký Directus collection trên view (tái dùng dot-collection-create) — idempotent. collection PHẢI bằng view_name (guardrail §5A).
  4. Upsert dòng table_registry theo table_id (collection=view_name, fields, page_url, row_link_template, flags) — idempotent.
  5. Đó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ý collection IF NOT EXISTS + upsert table_registry theo 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-publish KHÔNG có câu SELECT nào — nó đọc view_sql từ data rồi CREATE 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.
  • .vue KHÔNG có tableId/route-map cứng — File 2 resolve table_id qua page_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-146 dot-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_home trong reports/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_id phải unique.
  • table_registry.page_url phải unique với các dòng status='active'page_url IS NOT NULL (partial unique index).
  • view_name phải khớp regex: ^v_report_[a-z0-9_]+$.
  • page_url của báo cáo phải bắt đầu bằng /reports/.
  • Với report do dot-report-publish tạo, collection phải bằng view_name.
  • File generic /reports/[report]/index.vue chỉ 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 theo status='active' là chặt hơn (an toàn); lúc đúc cần thống nhất 2 chỗ — hoặc partial index theo status='active', hoặc nới component đọc đúng active. 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):

  1. 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.
  2. 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.
  3. 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 cho context_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ải context_pack_readonly; nhưng pg_catalog cấp cho PUBLIC ⇒ 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) → ghi READ_BLOCKED, không suy diễn.
  • Bắt buộc pg_catalog, không information_schema: information_schema.triggers lọc theo quyền → trả 0 giả; mọi view_sql đếm trigger phải pg_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 đọc row_link_template từ config (chỉ có prop hàm rowLink). ⇒ đúc-khuôn-một-lần phải thêm năng lực link generic (đọc row_link_template + thế cột-của-dòng) — KHÔNG hardcode từng báo cáo, KHÔNG ReportsTable riêng.
  • Resolver theo page_url: Directus cho filter table_registry theo page_url (đã filter theo table_id trong source ⇒ filter theo cột khác cũng OK). Cần thêm status≠archived như 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ỉ resolve page_url LIKE '/reports/%'. Đừng để thành /registries v2.
  • Cấm sửa nav cứng: thêm 'Reports' chỉ qua navigation_items; không đụng staticNavItems, không đổi tên Workflows/Registries.

7A. AN NINH view_sql — QUYỀN là chính, validator là phụ (rev2, MỚI)

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_sqlview_namecột nhạy cảm. Chỉ role quản trị hoặc DOT-governed writer được ghi view_sql / view_name. User vận hành / Directus UI thường KHÔNG được sửa view_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 SELECT trên report views.
  • Phải REVOKE rõ 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:
    CREATE VIEW ... WITH (security_invoker=true) AS ...
    
    để quyền bảng nền được kiểm theo user đọc (PG ≥ 15 hỗ trợ 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 WITH hoặc SELECT.
  • 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_name bằ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? 73–92: readItems('table_registry', filter[table_id]={_eq}, status={_neq:'archived'}, limit 1)
Filter/search/pagination từ config? fieldsrc.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; handleRowClickrouter.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.
  • SharedDirectusTable logic (đã đọ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).