ธีม
STATE archive — 2026-07-30 (pruned at the ทีม SAMO portraits / ปีการศึกษา clear)
Moved verbatim out of STATE.md to keep it near the ~200-line budget CLAUDE.md asks for. All of it is APPLIED AND DEPLOYED — it is history, not in flight. Chronology: git log --oneline. Architecture/RLS: docs/CONTEXT.md.
ทีม SAMO is the grant engine (0081–0088, ALL APPLIED + DEPLOYED)
The org tree grants REAL access. Full narrative: docs/state-archive/2026-07-25-team-grants.md.
Nav restructure (2026-07-30, 07b9beb): the public-facing "ทีม SAMO" top-level navbar tab was MERGED into the "เกี่ยวกับเรา" tab. The dynamic org chart (from get_public_org_chart() RPC) now renders inside #about-team in tab-about.html, replacing the old static placeholder cards. Key details:
tab-team-public.htmlis still on disk but no longer included inindex.html.pills-team-public-tabpill removed from desktop + mobile offcanvas innavbar.html.enterOrgChart()now triggers onpills-about-tabactivation (main.js)./teamURL route maps topills-about-tab(backward compat for bookmarks).- Footer
goToAbout('about-team')still works — activates about tab + scrolls to org chart. - The admin-side ทีม SAMO management tab is UNCHANGED (separate
tab-team.htmlin/admin/).
Model. A node or member carries permissions plus, per feature, a SCOPE binding. Everything resolves at login (sync_my_team_permissions(), called in auth.js buildCurrentUser) and live on any tree edit (statement triggers), into server-managed, guarded public.users columns:
| dimension | tree column(s) | users column | RLS helper |
|---|---|---|---|
| app perms | permissions[] | managed_permissions[] | current_user_has_permission() |
| VitalSound dept | vs_dept | managed_vs_depts[] | current_user_vs_scope() |
| หนังสือโครงการ seat | project_seat (vpa/staff/prof) | managed_project_seats[] | current_user_project_seats() |
| SAMO Passport | passport_dept_id / passport_sub_dept_id | managed_passport_scopes[] (d:<id>/s:<id>) | passport_admin_context() |
The rule that keeps biting — SCOPED IS NOT FULL. A blanket permission key (vs, passport) is an unconditional OR-branch in RLS and swallows any narrower check, so a scoped grant stores the BINDING and drops the key. Consequences already paid for (all in mistakes.md): the UI must tick the checkbox from EITHER signal (permTicked() — a miss silently wipes the grant on the next save); the scope picker's index 0 must be a non-choice, never "ทุกฝ่าย"; and a new access channel must be threaded through EVERY gate the old one used.
Seats vs scopes. projects is the exception: the seat does NOT drop the permission, because there the seat picks WHICH of three workflows (projectSeatRole() maps it to the role string the module already branches on). prof is deliberately not a project actor.
Verification. Ten self-provisioning proof scripts, each running in rolled-back transactions, independent of live config — re-run after ANY change to these RLS paths: tools/vs0083-scope.mjs 16 · tools/proj0086-seats.mjs 24 · tools/pass0087-scope.mjs 10 · tools/team0089-manage.mjs 5 · tools/proj0092-seat-parity.mjs 13 · tools/grant0093-reads.mjs 15 · tools/prof0095-seat-parity.mjs 10 · tools/vs0072-isolation.mjs 23 · tools/vs0096-remark-vis.mjs 37 · tools/shop0100-buyer-guard.mjs 12 · tools/pass-hardening.mjs 60 (passport, applies its lockdown in a rolled-back txn). Plus tools/pass-anon-probe.mjs 9 — the only one that leaves the database and tests the real anon key over HTTPS. 234 checks total. All 12 re-run and green at 2026-07-30 session end (the tally below is what they printed, not what they printed last month): 16 · 24 · 10 · 5 · 13 · 15 · 10 · 23 · 37 · 12 · 60 · 9. node tools/security-sweeps.mjs — three standing sweeps in one command, each encoding a bug class already shipped. Run after ANY policy / RLS / definer change. Exits non-zero on a finding; its allow-lists carry the deliberate exceptions. Also node tools/vs-remark-vis-mirror.mjs (SQL↔JS ladder diff). Still manual: the attribute-handler sweep (data-projects-role / data-admin-side / data-perm-only values in the markup vs. the JS that toggles them — commands in mistakes.md). Not a test: tools/proj-handover.mjs (dry-run by default) transfers a SHARED workflow account's uid-bound state — read state, and optionally the bell and signature assignments — to a personal kkumail account during the migration.
Passport enforcement is DONE (NEXT #3). Shared → personal migration is optional read-state cosmetics only — see NEXT #4.
Settled grant-channel decisions (0093–0095) — full write-up in docs/state-archive/2026-07-25-grant-channel-detail.md. The three that a future change must not undo:
- SAMO Shop is ONE role — the 0093 per-source scope was REVERTED by 0094 (orders can't be scoped, so a product-only scope isolates nothing). The
shop_source/managed_shop_sourcescolumns remain but are inert. Do not re-add a source scope without being asked. - Never widen
current_user_is_staff()—users_self_update_guardtrusts it for privileged-column writes, so widening it lets any grantee self-promote todev. 0093 repointed the three affected READ policies individually instead. Three role-only policies REMAIN BY DESIGN — do not "fix" them:users_update_staff,notify_log_select_staff,reserved_staff_usernames_read_staff. The sweep's expected count is 3. - The อาจารย์ seat grants the อาจารย์ ROLE (0095) — prof gates ask "am I อาจารย์, and was this sent for signature?", not
prof_id = auth.uid(), so a seat holder sees the same 11 of 26 assaprof. Still NOT an actor. Tradeoff: every อาจารย์ sees every signature request.
Public org chart (0086). team_nodes.is_public (อาจารย์ + เจ้าหน้าที่คณะแพทย์ = false). The flag is NOT the privacy boundary: get_public_org_chart() is a definer PROJECTION (name/nickname/structure only, recursive so hiding a parent hides the subtree) and is the ONLY sanctioned publisher. Never add a public SELECT policy to team_members — anon reads 0 rows from it today and must keep doing so.
VITALSOUND 0096–0099 · project_files seat parity (0097)
Full write-up: docs/state-archive/2026-07-29-vs-remark-visibility.md. Shipped + deployed: the บันทึกข้อความ visibility ladder, the board's ความคืบหน้าจากทีมงาน stream, หมวดหมู่/แท็ก delete, a self_public board banner for a canonical's own submitter, VS sub-state in the URL, and the staff ticket modal no longer closing on save.
The invariants a future change must not break:
- The ladder is
staff < ticket < thread < public, normalized byvs_remark_vis()(SQL) andremarkVis()(utils.js) — mirrors, keep them in step. A missingvisreads asticket,internal:trueasstaff. The server is the boundary: a submitter can never write aboveticket. vs_ticketshas a column guard (vs_tickets_self_update_guard, 0096). Without it a submitter PATCHesis_public/public_titleand self-publishes to the board, routing aroundvs_set_public(). It fires ONLY whenauth.uid() = submitter_idand the caller is not a VS handler, so server contexts are untouched. Column comparison isto_jsonb(row) - allowed_keysso a FUTURE column is guarded by default.- Both submitter read paths are sanitized server-side —
get_my_vs_tickets()andget_vs_ticket_by_id(). Never re-introduce a rawselect=…,remarks,…owner read:remarkscarries the canonical id in the TEXT of its 0071 internal entries. - An unresolvable
vs_categoriesid fails CLOSED in all SEVEN readers (0098 + 0099). หมวดหมู่ is deletable, so dangling ids are reachable. Re-run the audit as a QUERY after any change here — see mistakes.md for the exactpg_get_functiondefsweep. get_vs_linked_context()is the ONLY sanctioned way to tell a submitter about the board. Do not addis_public/public_titleto the submitter projection — that is a second path to keep sanitized.
Browser-verified (public half, Chrome, prod + local): mode↔hash both ways, cold deep-load, back links, tab round-trip, the self_public banner, and ความคืบหน้าจากทีมงาน rendering escaped — the full checklist is in the archive write-up. NOT browser-verified: everything behind the admin login (see NEXT #1).
PRE-/CLEAR SECURITY SCAN (2026-07-29) — 4 real bugs found, all FIXED
Narrative + proofs: docs/state-archive/2026-07-29-pre-clear-scan.md. In short, all four proven live in rolled-back transactions, fixed in 0100/0101, and each now carries a mistakes.md entry: a buyer could zero their own order's total (third table with an unguarded per-row owner UPDATE, after users and vs_tickets); get_pr_ticket_by_id matched with ILIKE, making the ticket id a pattern instead of a capability; the ten team resolvers were anon-callable, an anonymous grant oracle; and the vis ladder's SQL and JS implementations disagreed on 3 of 26 inputs.
Two conclusions from that scan are still live constraints:
Knowingly ACCEPTED, not missed — two per-row owner UPDATE policies have no column guard: project_doc_views_update_own (own read state; user_id pinned by the check) and project_notifications_update (own bell rows — a user can reword a notification only they can see). Both are self-defacement with no cross-user reach. They are allow-listed in tools/security-sweeps.mjs; if that sweep ever reports a THIRD, it is new.
Not done: no XSS re-audit of the anon-INSERTable tables this round (the escHtml rule from mistakes.md). The renderers touched this session (updatesHtml, renderTimeline chips, the board banner) do escape — verified in-browser with an <img onerror> payload — but the older PR/VS renderers were not re-checked.