เครื่องมือ verification
ไม่มีเครื่องมือไหนด้านล่างที่ทับซ้อนกันสมบูรณ์แบบ และไม่มีตัวไหนใกล้เคียงกับความสมบูรณ์ด้วยตัวเอง — บรรทัด “วิธีสังเกตตอน review” ของแต่ละ entry ทั้ง 27 ตัวใน AI Bug Catalog ระบุตรง ๆ ว่าเครื่องมือไหนด้านล่าง (ถ้ามี) ที่จะจับมันได้ และส่วนใหญ่ไม่มีตัวไหนเลยตอนที่บั๊กถูกใส่เข้ามา ทุก output ด้านล่างจริง จับสดสำหรับโมดูลนี้กับ local stack ที่คอร์สนี้แชร์กันเองและ namespace rd_ ของโมดูลนี้เอง
flowchart TD A["supabase db lint<br/>plpgsql type-checking, NOT RLS/security advisories"] --> B["RLS-coverage query + pg_policies<br/>which tables/commands actually have a policy"] B --> C["explain analyze under a real JWT<br/>does the policy also perform?"] C --> D["supabase test db (pgTAP)<br/>a real, repeatable, role-scoped assertion"] D --> E["supabase gen types + tsc / deno check<br/>types and module resolution, never intent"] E --> F["curl anon vs service_role<br/>the one comparison that actually reveals an RLS gap"] F --> G["MCP server<br/>a real schema inspector -- and a real superuser risk"] G --> H["Studio + this course's harness<br/>visual inspection, and CI-grade fence verification"]
supabase db lint — ตรวจ type ของ plpgsql ไม่ใช่ advisor ของ RLS หรือความปลอดภัย
หัวข้อที่มีชื่อว่า “supabase db lint — ตรวจ type ของ plpgsql ไม่ใช่ advisor ของ RLS หรือความปลอดภัย”ความเข้าใจผิดที่พบบ่อยที่สุดเกี่ยวกับ tool นี้: ชื่อของมันชวนให้คิดว่ามันจับช่องว่าง RLS, policy column ที่ไม่มี index หรือ search_path ที่หายไปได้ — มันไม่ทำ ตาม --help ของมันเอง มัน “checks local database for typing error[s]” — เจาะจงคือ type mismatch ข้างในตัวฟังก์ชัน plpgsql ที่ Postgres เองไม่ validate ตอน create function (function body จะถูก parse และ type-check ครั้งแรกตอนมันรันจริงเท่านั้น เว้นแต่ tool แบบนี้จะทำล่วงหน้าให้)
$ psql -c "create or replace function public.rd_lint_demo()returns text language plpgsql as \$\$declare v integer;begin select display_name into v from public.rd_profiles limit 1; -- text into integer return v;end;\$\$;"CREATE FUNCTION
$ supabase db lint --level warningLinting schema: extensionsLinting schema: public{"results":[{"function":"public.rd_lint_demo","issues":[{"level":"warning","message":"target type is different type than source type","statement":{"lineNumber":"4"},"query":{"text":"select display_name from public.rd_profiles limit 1"},"hint":"The input expression type does not have an assignment cast to the target type.","detail":"cast \"text\" value to \"integer\" type","sqlState":"42804"}]}],"message":"db lint"}คำเตือน 42804 ประเภทไม่ตรงกันจริงและเฉพาะเจาะจง จับได้ก่อนที่ฟังก์ชันจะถูกเรียกเลยด้วยซ้ำ — มีประโยชน์จริง แค่แคบกว่าที่คำว่า “lint” ชวนให้คิดมาก ไม่มี catalog entry ไหนใน 4-8 (กลุ่ม RLS) ที่กระตุ้นอะไรที่นี่เลย db lint กับ schema ในเครื่องนี้เป๊ะ ที่มีบั๊ก RLS ของ catalog นี้ทุกตัวอยู่จริงในบางช่วง คืน "No schema errors found" ทุกครั้ง — ช่องว่าง RLS ต้องใช้สอง tool ถัดไปแทน
RLS-coverage query — table ไหนมี policy สำหรับคำสั่งไหนจริง ๆ
หัวข้อที่มีชื่อว่า “RLS-coverage query — table ไหนมี policy สำหรับคำสั่งไหนจริง ๆ”query เดียวที่จับ catalog entry 4 กับ 8 ได้ตรง ๆ: เช็คไขว้ pg_class.relrowsecurity กับ pg_policies ต่อ table
select c.relname as table_name, c.relrowsecurity as rls_enabled, count(p.policyname) as policy_countfrom pg_class cjoin pg_namespace n on n.oid = c.relnamespaceleft join pg_policies p on p.schemaname = n.nspname and p.tablename = c.relnamewhere n.nspname = 'public' and c.relkind = 'r' and c.relname like 'rd_%'group by c.relname, c.relrowsecurityorder by 1;
table_name | rls_enabled | policy_count---------------+-------------+-------------- rd_notes | t | 0 <- RLS เปิด, 0 policy: บั๊กของ entry 8 เป๊ะ rd_posts | t | 2 rd_posts_perf | f | 0 <- ไม่เคยเปิด RLS บน table นี้เลย rd_profiles | f | 0 rd_rt_items | t | 2 rd_secrets | t | 1 rd_settings | f | 0 rd_widgets | f | 0query เดียว สองผลจริงที่ต่างกันบน namespace ของโมดูลนี้เอง: rd_notes เปิด RLS แล้วไม่มี policy เลย (สถานะจริง สด และว่างเปล่าแบบเงียบของ catalog entry 8 เป๊ะ) และอีกสี่ table ไม่เคยเปิด RLS เลยตั้งแต่แรก — คำถามการตรวจสอบแยกต่างหาก (“table นี้ควรมี RLS ไหม?”) ที่ query เดียวกันนี้เผยให้เห็นฟรี ๆ
pg_policies — อ่าน using/with check ตรง ๆ ไม่ใช่เดาจากพฤติกรรม
หัวข้อที่มีชื่อว่า “pg_policies — อ่าน using/with check ตรง ๆ ไม่ใช่เดาจากพฤติกรรม”เมื่อยืนยันแล้วว่า table มี policy ให้อ่านว่ามันพูดอะไรจริง ๆ — qual คือ expression using, with_check คือสิ่งที่มันบอกตรงตัว และ with_check ที่ว่างเปล่าบน policy update/all คือสัญญาณสดของการขโมยใน catalog entry 5:
select tablename, policyname, cmd, qual, with_check from pg_policieswhere schemaname='public' and tablename like 'rd_%' order by 1,2;
tablename | policyname | cmd | qual | with_check-------------+--------------------+--------+-----------------------------------------+----------------------------------------- rd_posts | rd_posts_select | SELECT | true | rd_posts | rd_posts_update | UPDATE | (user_id = ( SELECT auth.uid() AS uid)) | (user_id = ( SELECT auth.uid() AS uid)) rd_rt_items | rd_rt_items_insert | INSERT | | true rd_rt_items | rd_rt_items_select | SELECT | true | rd_secrets | rd_secrets_select | SELECT | (user_id = ( SELECT auth.uid() AS uid)) |with_check ของ rd_posts_update สะท้อน qual ของมันเห็นชัด — ฉบับแก้จาก catalog entry 5 ทั้งสอง column มีอยู่และเหมือนกันโดยตั้งใจ policy ที่มี qual เต็มแต่ with_check ว่างเปล่า บน for update/for all คือรูปร่างที่ต้อง flag ตรง ๆ
explain analyze ภายใต้ JWT จริง — policy ทำงานได้ดีด้วยหรือเปล่า?
หัวข้อที่มีชื่อว่า “explain analyze ภายใต้ JWT จริง — policy ทำงานได้ดีด้วยหรือเปล่า?”policy อาจถูกต้องสมบูรณ์แบบแล้วยังช้าอยู่ได้ — set request.jwt.claims ข้างใน transaction จำลอง JWT ของผู้เรียกจริงสำหรับ explain analyze โดยไม่ต้องมี HTTP round trip จริง:
begin;set local request.jwt.claims to '{"sub":"f8cd7f9e-6dd4-482a-888b-b92184c98d07","role":"authenticated"}';explain analyze select * from public.rd_posts_perf where user_id = auth.uid(); -- unwrapped-- Execution Time: 32.899 ms
explain analyze select * from public.rd_posts_perf where user_id = (select auth.uid()); -- wrapped-- Execution Time: 3.296 mscommit;ตัวเลขจริงเป๊ะที่พิสูจน์ไว้ใน catalog entry 6 — set local request.jwt.claims ต้อง รันข้างใน begin/commit ชัดเจน (SET LOCAL นอก transaction block จะ revert เงียบ ๆ ก่อน statement ถัดไปจะรัน เป็นปัญหาจริงที่พบระหว่างสร้าง proof นี้เป๊ะ)
supabase test db (pgTAP) — assertion จริง ที่ทำซ้ำได้ scope ตาม role
หัวข้อที่มีชื่อว่า “supabase test db (pgTAP) — assertion จริง ที่ทำซ้ำได้ scope ตาม role”ทุกอย่างข้างบนคือ query แบบครั้งเดียวด้วยมือ — pgTAP เปลี่ยน check เดียวกันให้เป็น test suite จริงที่ทำซ้ำได้ รวมถึงการสลับ role กลาง test เพื่อยืนยันว่าผู้เรียกที่เจาะจง (ไม่ใช่แค่ postgres) เห็นอะไรจริง:
-- supabase/tests/database/rd_rls_test.sqlbegin;select plan(2);
select ok( (select count(*)::int from pg_policies where schemaname = 'public' and tablename = 'rd_posts') = 2, 'rd_posts has exactly 2 RLS policies (select + update)');
set local role anon;select is( (select count(*)::int from public.rd_notes), 0, 'anon sees 0 rows on rd_notes -- RLS enabled, zero policies, silently empty');
select * from finish();rollback;$ supabase test dbtests/database/rd_rls_test.sql .. okAll tests successful.Files=1, Tests=2, 0 wallclock secsResult: PASSTAP output จริงต่อ assertion (ok 1 - ..., ok 2 - ...) หาได้จากการรันไฟล์เดียวกันตรงผ่าน psql -f; supabase test db ห่อผลนั้นด้วยสรุป pass/fail ที่เหมาะกับ CI pgtap ไม่ได้ติดตั้งมา default บนโปรเจกต์ที่เพิ่ง supabase init — ต้อง create extension pgtap with schema extensions; ก่อน
supabase gen types + tsc — type เท่านั้น ไม่ใช่ schema สด
หัวข้อที่มีชื่อว่า “supabase gen types + tsc — type เท่านั้น ไม่ใช่ schema สด”ครอบคลุมเต็มใน catalog entry 3: tsc --noEmit กับไฟล์ database.types.ts เป็นปัจจุบันแค่เท่าที่ครั้งล่าสุดที่ไฟล์นั้นถูก regenerate การรันทั้งสองครั้งด้านล่างรายงาน exit=0 — ครั้งที่สองรันกับ database ที่มี column ถูก rename ไปแล้วโดยที่มันไม่รู้:
$ tsc --noEmit -p tsconfig.json && echo exit=0 # type ตรงกับ schema จริงexit=0$ psql -c "alter table public.rd_widgets rename column qty to quantity;"$ tsc --noEmit -p tsconfig.json && echo exit=0 # type เดิมที่ยังไม่ regenerate, schema เปลี่ยนไปแล้วexit=0$ curl "$API_URL/rest/v1/rd_widgets?select=id,qty" ...{"code":"42703","message":"column rd_widgets.qty does not exist"}tsc ผ่านคือหลักฐานว่าโค้ดตรงกับ database.types.ts — ไม่เคยเป็นหลักฐานว่า database.types.ts ตรงกับ database ตอนนี้ รัน supabase gen types typescript --local ซ้ำหลัง migration ที่แก้ชื่อ column ทุกครั้ง ก่อนจะเชื่อว่า tsc เขียวมีความหมายอะไร
deno check — tool เดียวที่ verify import จริงของ edge function
หัวข้อที่มีชื่อว่า “deno check — tool เดียวที่ verify import จริงของ edge function”harness ของคอร์สนี้เองไม่เคยรัน tsc กับไฟล์ใต้ supabase/functions/**/index.ts เลย — tsc กับ deno check resolve module ด้วยกติกาคนละชุดกันเลย มีแค่ตัวเดียวเท่านั้นที่ตรงกับสิ่งที่เกิดขึ้นจริงตอน supabase functions serve/deploy:
$ curl "$FUNCTIONS_URL/rd-bare-import" -H "Authorization: Bearer $ANON_KEY" -H "apikey: $ANON_KEY"{"code":"BOOT_ERROR","message":"Worker failed to boot (please check logs)"}HTTP=503
worker boot error: failed to bootstrap runtime: failed to create the graph:Relative import path "@supabase/supabase-js" not prefixed with / or ./ or ../bare specifier แบบนี้ type-check ผ่านสบายภายใต้ tsc สไตล์ Node ธรรมดา (ไม่มีอะไรใน tsc เองที่รู้ว่าไฟล์นี้จะไปอยู่บน Deno) — มีแค่ deno check หรือการ deploy จริงด้วย supabase functions serve เท่านั้นที่จับได้ ตรงกับ catalog entry 16 เป๊ะ
curl ด้วย anon เทียบ service_role — การเทียบที่เผยช่องว่าง RLS ได้จริง
หัวข้อที่มีชื่อว่า “curl ด้วย anon เทียบ service_role — การเทียบที่เผยช่องว่าง RLS ได้จริง”วิธีที่เร็วที่สุดที่แยก “ยังไม่มีข้อมูล” ออกจาก “policy กำลังปฏิเสธทุกคนแบบเงียบ ๆ”: ยิง request เดียวกันเป๊ะด้วยทั้งสอง key แล้ว diff ผลลัพธ์
# anon key -- RLS บังคับใช้:$ curl "$API_URL/rest/v1/rd_secrets?select=*" -H "apikey: $ANON_KEY" -H "Authorization: Bearer $ANON_KEY"[]HTTP=200
# service_role key -- ข้าม RLS ทั้งหมด table เดียวกัน row เดียวกัน:$ curl "$API_URL/rest/v1/rd_secrets?select=*" -H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY"[{"user_id":"f8cd7f9e-6dd4-482a-888b-b92184c98d07","secret":"alice-only-secret"}]HTTP=200ถ้า service_role คืน row จริงและ anon คืน [] table นั้นมีข้อมูลและมีช่องว่าง policy — ไม่ใช่ “ยังไม่มีข้อมูล” ตรงกับประเด็นของ catalog entry 25 เกี่ยวกับ key คู่นี้เป๊ะ
MCP server — schema inspector จริง และความเสี่ยง superuser จริง
หัวข้อที่มีชื่อว่า “MCP server — schema inspector จริง และความเสี่ยง superuser จริง”supabase start print MCP_URL จริงที่ใช้งานได้ (http://127.0.0.1:55321/mcp บน stack นี้) — endpoint แบบ JSON-RPC ที่ AI agent ที่รู้จัก MCP ตัวไหนก็ได้ (รวมถึงตัวนี้) เชื่อมต่อตรงได้ เปิดเผย tool อย่าง list_tables, list_extensions, get_advisors, และ execute_sql:
$ curl -X POST "$MCP_URL" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize", ...}'{"result":{"protocolVersion":"2024-11-05", ..., "serverInfo":{"name":"supabase", ...,"version":"0.11.0"}, ...}}
$ curl -X POST "$MCP_URL" ... -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'tools: search_docs, list_tables, list_extensions, list_migrations, apply_migration, execute_sql, query_logs, get_advisors, get_project_url, get_publishable_keys, generate_typescript_typesอันตราย พิสูจน์ตรง ๆ แทนที่จะแค่กล่าวอ้าง — execute_sql เรียกโดยไม่มี credential ของตัวเองเลยนอกจาก connection ของ MCP เอง:
$ curl -X POST "$MCP_URL" ... -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"execute_sql","arguments":{"query":"select current_user, session_user, (select count(*) from public.rd_secrets) as rd_secrets_rows_visible;"}}}'[{"current_user":"postgres","session_user":"postgres","rd_secrets_rows_visible":1}]execute_sql รันในฐานะ postgres — database superuser จริง ที่ข้าม RLS เหมือนกับ service_role ทุกประการ — ไม่ใช่ anon หรือ authenticated AI coding agent ที่เชื่อมต่อกับ MCP server นี้อ่าน (และเขียน) อะไรใน database ก็ได้โดยไม่สนใจ RLS เลย เหมือนความเสี่ยงของ service_role ใน catalog entry 25 เป๊ะ แค่ไปถึงผ่านประตูคนละบาน มีประโยชน์ในฐานะ schema inspector ระหว่างพัฒนาบนโปรเจกต์ในเครื่องหรือโปรเจกต์ที่ทิ้งได้ — เป็นเหตุผลจริงที่ต้องคิดให้รอบคอบก่อนจะเอา MCP connection แบบนี้ไปชี้ที่ database production
Studio — สำหรับการดูแบบ visual สำรวจ
หัวข้อที่มีชื่อว่า “Studio — สำหรับการดูแบบ visual สำรวจ”Studio (http://127.0.0.1:55323 บน stack นี้ ยืนยันแล้วว่าเข้าถึงได้ — curl คืน 307 redirect เข้าแอปจริง) คือ tool เดียวใน list นี้ที่ไม่ได้ตั้งใจให้ script — Table Editor, ตัวแก้ policy ของ RLS (ที่เขียน SQL create policy เดียวกันเป๊ะกับที่โมดูลนี้แสดงตลอด แค่มาจากฟอร์ม), SQL Editor, และแผง Advisors มีไว้สำหรับการสำรวจด้วยมนุษย์ — เห็น table ที่ไม่มี policy ได้ด้วยสายตาเดียว หรืออ่าน clause using ของ policy โดยไม่ต้องเขียน query pg_policies ข้างบนเอง มันเสริมมากกว่าแทนที่ทุก tool แบบ script ในหน้านี้
harness ของคอร์สนี้เอง — ชั้นระดับ CI ที่อยู่ข้างใต้ทุกอย่าง
หัวข้อที่มีชื่อว่า “harness ของคอร์สนี้เอง — ชั้นระดับ CI ที่อยู่ข้างใต้ทุกอย่าง”tools/verify-snippets.mjs (อ่าน header comment ของไฟล์เองสำหรับ design เต็ม) คือคำตอบของคอร์สนี้ต่อคำถาม “ใครตรวจว่าทุกอย่างข้างบนเกิดขึ้นจริง ในทุกบท อัตโนมัติ” มีรายละเอียดที่ต้องระบุตรง ๆ อยู่บ้าง:
- local stack ที่แชร์กันตัวเดียว ไม่ใช่ตัวใหม่ต่อ test ต่างจาก CI ทั่วไปที่ tear down แล้วสร้าง database ใหม่ต่อการรัน stack
tools/probeของ harness นี้ถูก start ครั้งเดียวแล้วนำมาใช้ซ้ำข้ามทุกโมดูลและทุก session การเขียนบท — รวมถึงบทนี้ด้วย นั่นแหละคือเหตุผลที่ object SQL ทุกตัวใน catalog นี้อยู่ใต้ prefixrd_: ทางเลือกอื่น (object ของผู้เขียนแต่ละคนชนกันใน schemapublicที่แชร์กัน) ไม่ใช่สมมติฐาน มันคือข้อจำกัดจริงที่ design นี้ทำงานรอบ ๆ มัน npm run verifyเก็บทุก fencesql/ts/bashที่มี path comment จริงแล้วตรวจ: fence sql รันรวมกัน ตามลำดับในเอกสาร ข้างในBEGIN … ROLLBACK(พิสูจน์ syntax กับ semantics โดยไม่ persist อะไรเลย); fence ts ใต้src/**ผ่านtsc, fence ts ใต้supabase/functions/**ผ่านdeno check; fence bash ได้bash -n(syntax เท่านั้น ไม่เคยถูกรันจริง)--apply <module>/<lesson>ถอด wrapperROLLBACKสำหรับ fence sql ของบทเฉพาะบทหนึ่ง —COMMITจริง และ fence migration จริงใด ๆ ถูก persist ไปที่tools/probe/supabase/migrations/เพื่อให้supabase db resetครั้งถัดไป replay มันได้ นี่ทำลายล้างจริงถ้าชี้ไปที่บทผิด นั่นคือเหตุผลตรง ๆ ที่ harness scope มันไว้แค่บทเดียวต่อการเรียก--run <module>/<lesson>tsx-execute fence ts ที่ติด// @runของบทนั้นกับ stack ที่รันอยู่จริง — โหมดเดียวที่พิสูจน์จริงว่า call ของsupabase-jsทำงานตามที่อ้าง ไม่ใช่แค่ type-check ผ่าน- สิ่งที่มันทำไม่ได้: ไม่มีโหมดไหนในนี้แทนที่มนุษย์ที่ใช้ Review Checklist ได้ —
verifyพิสูจน์ว่า fence ถูกต้องตาม syntax และ (สำหรับ fence--run) ถูกต้องตามพฤติกรรมแบบแยกส่วน มันไม่รู้ว่าusing(true)ของ policy คือบั๊กความปลอดภัย รู้แค่ว่ามันเป็น SQL ที่ valid
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”The verification ladder, roughly cheapest/narrowest to most expensive/broadest:
supabase db lint -> plpgsql type errors only, NOT RLS/security (common misconception)RLS-coverage query -> which tables/commands have zero policies, in one querypg_policies -> read using/with check directly, don't guess from behaviorexplain analyze + JWT -> does the (correct) policy also perform?supabase test db (pgTAP) -> the same checks, repeatable, role-scoped, CI-readygen types + tsc -> types only, only as current as the last regenerationdeno check -> the ONLY tool that validates an edge function's real importscurl anon vs service_role -> the fastest real/no-data-yet vs RLS-gap distinguisherMCP server -> a real schema inspector, and a real postgres-superuser riskStudio -> the human, exploratory pass -- not scripted, not replaced by onethis course's harness -> ties every fence above to a real, shared, CI-grade local stack
No single tool on this ladder covers more than a few of the AI Bug Catalog's 27 entries --reading the catalog's own "how to spot it in review" line per entry is what tells youwhich rung actually catches a specific pattern.ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
supabase db lintจับช่องว่าง RLS ได้” พิสูจน์แล้วว่าไม่จริง: รันกับ schema ในเครื่องนี้เป๊ะ ที่มีบั๊กของ catalog entry 4/5/8 อยู่จริง มันรายงาน"No schema errors found"— มันคือ type-checker ของplpgsqlไม่ใช่ linter ของความปลอดภัยหรือ RLS - “ถ้า
explain analyzeดูโอเคโดยไม่ต้องตั้ง JWT policy ก็ทำงานได้ดีสำหรับ user จริงเหมือนกัน”auth.uid()อ่านrequest.jwt.claimsซึ่งว่างเปล่าสำหรับ sessionpsqlธรรมดาที่ไม่มีset request.jwt.claims— ต้นทุนต่อ row จริงของ policyauth.uid()ที่ไม่ wrap มองไม่เห็นเลยเว้นแต่จะมี identity ของผู้เรียกจริง (หรือจำลองผ่านset local request.jwt.claims) อยู่ใน session - “MCP server เป็นแค่ inspector แบบอ่านอย่างเดียว”
execute_sqlรัน SQL อะไรก็ได้จริงในฐานะpostgres— พิสูจน์ตรง ๆ ข้างบน ความเสี่ยง superuser/RLS-bypass เดียวกันเป๊ะกับservice_rolekey ที่รั่วไหล ไปถึงผ่าน connection คนละแบบเลย - “harness ของคอร์สนี้พิสูจน์ว่าบทถูกต้อง” มันพิสูจน์ว่า fence ถูกต้องตาม syntax และ (สำหรับ fence
--run) ทำงานตามที่อ้างแบบแยกส่วน — มันไม่มีความเห็นว่าusing (true)เป็นความคิดที่ดีหรือไม่ นั่นคือสิ่งที่ Review Checklist มีไว้เพื่อสิ่งนั้นโดยเฉพาะ