Skip to content
DevRecall
← Roadmap
36

Mock: Internal Business Platform on Next.js + Supabase/Vercel

drafted

Bộ mock theo một JD thật — build internal business platform từ đầu trên Next.js + Node/TS + PostgreSQL, auth/RBAC/multi-tenant, Supabase (RLS, Auth, pooler) + Vercel là bắt buộc, agentic coding (Claude Code/Cursor), và ownership chất lượng end-to-end (schema, security, test, CI/CD, observability).

0/50 rated · mastery 0%

Bài học

5 bài · ~69 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~17 phút· 5 câuverify

    Supabase từ góc nhìn bảo mật: roles, API keys và RLS

    Supabase = Postgres + PostgREST + Auth: role anon/authenticated/service_role, publishable vs secret key, vì sao RLS là ranh giới thật, USING/WITH CHECK, helper security definer, view và materialized view. Mọi output chạy thật trên supabase/postgres 17.11 + PostgREST v16.4 + GoTrue v2.197.

  2. 02~14 phút· 5 câuverify

    Supabase Auth, JWT claims và data model tenant + RBAC

    JWT của Supabase Auth, app_metadata vs user_metadata, Custom Access Token Hook, claim stale, bảng memberships, composite FK, enum role → permission, UUID và timestamptz. Chạy thật với GoTrue v2.197 + hook trên supabase/postgres 17.11.

  3. 03~13 phút· 6 câuverify

    RLS ở production: hiệu năng, test matrix và incident leak

    Vì sao policy làm chậm, (select auth.uid()) và index, EXPLAIN dưới đúng role, pgTAP test matrix trong CI, soft delete với RLS, giờ đầu tiên của một incident leak. Số liệu chạy thật trên 2 triệu dòng, supabase/postgres 17.11.

  4. 04~12 phút· 6 câuverify

    Next.js App Router + Supabase: session, proxy, Server Actions và OAuth

    @supabase/ssr trong Server/Client Component, proxy.ts refresh session, getSession vs getUser vs getClaims (chạy thật với token giả mạo), Server Action là public endpoint, chọn browser/action/handler, Zod + generated types, Google OAuth PKCE và redirect allow-list.

  5. 05~13 phút· 4 câuverify

    Supavisor, pooler mode và state trên serverless

    Vì sao serverless làm cạn connection, direct vs session vs transaction mode, prepared statement và SET trong transaction mode (PgBouncer 1.26 chạy thật), state module-scope trên Fluid compute, cache theo tenant, job dài.

Tóm tắt & cheat sheet của track

TL;DR

  • JD này chấm 4 thứ: (1) dựng internal platform từ đầu (data model + auth + permissions), (2) Supabase/Vercel là firm requirement, (3) dùng agent (Claude Code/Cursor) có kiểm soát, (4) tự chịu trách nhiệm chất lượng: schema integrity, security, test, CI/CD, observability, production readiness.
  • Trục xuyên suốt là tenant isolation nhiều lớp: tenant lấy từ token đã verify → app check permission → Postgres RLS chặn ở DB → cache key/Storage path/Realtime cũng scoped theo tenant.
  • Supabase = Postgres + Auth (GoTrue, JWT) + PostgREST + Realtime + Storage. Client gọi thẳng DB qua PostgREST bằng anon/publishable key → RLS là ranh giới bảo mật thật, không phải "defense in depth tùy chọn".
  • Vercel = serverless/Fluid functions + preview deployment theo PR. Hệ quả: dùng connection pooler, không giữ state trong memory, job dài đưa ra queue/cron.
  • Agentic coding: nói được workflow + guardrails (context file, plan trước, task nhỏ, test/validator làm gate, human review cho auth/migration/tiền).

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
Row Level Security (RLS)Postgres tự thêm điều kiện vào mọi query theo policy của role hiện tạiUSING (tenant_id = (select private.current_tenant()))
USING vs WITH CHECKUSING lọc row được thấy/sửa/xoá; WITH CHECK chặn row mới/sau update không hợp lệChặn user đổi tenant_id của row sang tenant khác
auth.uid() / auth.jwt()Hàm Supabase đọc claim sub / toàn bộ claims của JWT request hiện tạiauth.jwt() -> 'app_metadata' ->> 'tenant_id'
anon/publishable vs service_role/secret keyKey public cho client (bị RLS) vs key server (bypass RLS)service_role chỉ ở server, không bao giờ NEXT_PUBLIC_
app_metadata vs user_metadataapp_metadata chỉ server sửa; user_metadata user tự sửa đượcRole/tenant không đặt trong user_metadata
Custom Access Token HookPostgres function chạy khi phát JWT, thêm claim (tenant, role)Claim active_tenant_id (verify API hiện tại)
Membership tableBảng nối user ↔ tenant ↔ role, nguồn sự thật cho RBACmemberships(user_id, tenant_id, role) PK kép
Security definer functionFunction chạy với quyền owner → bỏ qua RLSHelper is_member(tenant) để tránh policy đệ quy; đặt ở schema private
security_invoker viewView (PG15+) áp RLS theo người gọi thay vì ownercreate view ... with (security_invoker = true)
Supavisor poolerPooler Postgres của Supabase; transaction mode cho serverlessPort 6543 transaction mode (verify)
Preview deploymentMỗi PR có URL deploy riêng với env PreviewQA test PR trước khi merge
Expand → contractĐổi schema qua nhiều release tương thích ngượcAdd column → dual-write → backfill → switch read → drop

Cơ chế hoạt động

Một request từ Next.js tới Supabase, qua các lớp kiểm soát

Điểm mấu chốt: proxy (middleware cũ) chỉ refresh session và redirect sớm; quyết định bảo mật nằm ở Server Component/Action và ở RLS. Nếu app check sót, RLS vẫn chặn. Nếu dùng service_role, RLS bị tắt, nên toàn bộ trách nhiệm quay về code.

Mô hình dữ liệu tenant + RBAC tối thiểu

Pipeline chất lượng cho một PR

Trade-offs

Quyết địnhLựa chọn ALựa chọn BKhi nào chọn
Truy cập DBClient gọi Supabase trực tiếp (PostgREST + RLS)Mọi thứ qua API server (Route Handler/Server Action)A: CRUD đơn giản, realtime. B: logic nghiệp vụ phức tạp, nhiều bước, cần transaction
Chỗ enforce tenantChỉ app layerApp layer + RLSSupabase gần như luôn B, vì client có thể gọi DB trực tiếp
Tenant trong JWT claimNhanh (không query), nhưng stale đến khi token refreshTra bảng membership mỗi queryClaim cho tenant đang active; membership table cho quyền nhạy cảm/cần revoke ngay
Tenancy modelShared schema + tenant_idSchema/DB riêng mỗi tenantInternal platform nhiều tenant nhỏ: shared + RLS. Tenant lớn/compliance: tách
RBACRole cố định (enum)Permission-based (role → permissions)Bắt đầu enum đơn giản, chuyển permission khi có >3–4 role hoặc custom role
Chạy logic serverServer ActionRoute HandlerAction: mutation từ form UI. Handler: webhook, API cho client ngoài, public contract
Function runtimeNode.js runtimeEdge runtimeNode: mặc định, đầy đủ lib, DB driver. Edge: logic nhẹ gần user (verify khuyến nghị hiện tại)

Pitfalls

  • ❌ Tạo bảng mới mà quên enable row level security → bảng mở toàn bộ cho anon key. ✅ Migration template luôn có RLS + policy; CI chạy query kiểm tra bảng public nào chưa bật RLS.
  • ❌ Đặt role/tenant trong user_metadata. ✅ app_metadata, membership table, hoặc access token hook.
  • ❌ Dùng service_role trong Server Component "cho tiện" → bypass RLS. ✅ Client theo user cho request của user; admin client chỉ cho job hệ thống, luôn filter tenant tường minh.
  • ❌ Chỉ check auth trong proxy.ts/middleware. ✅ Check lại trong từng Server Action/Route Handler (Server Action là POST endpoint public).
  • ❌ Tin getSession() ở server. ✅ getUser()/getClaims() để verify token (verify theo docs Supabase hiện tại).
  • ❌ Policy gọi auth.uid() trên từng row, không index tenant_id. ✅ (select auth.uid()) và index cột trong policy.
  • ❌ View thường trên bảng có RLS → view chạy quyền owner, lộ data. ✅ security_invoker = true hoặc revoke quyền view.
  • ❌ Kết nối Postgres trực tiếp từ mỗi serverless invocation. ✅ Pooler transaction mode, pool nhỏ mỗi instance.
  • ❌ Để agent sinh migration/RLS rồi merge vì test pass. ✅ Đọc từng dòng, chạy test matrix cross-tenant.

Cheat sheet

  • Tenant isolation = token verify → app permission → RLS → cache key/Storage path/Realtime channel có tenant.
  • Mọi bảng public: tenant_id NOT NULL + FK + index + RLS bật + policy SELECT/INSERT/UPDATE/DELETE rõ ràng.
  • Policy INSERT/UPDATE cần WITH CHECK; UPDATE còn cần row nhìn thấy được qua SELECT policy.
  • Helper security definer đặt ở schema không expose qua API, set search_path = ''.
  • Next.js: Server Action = public endpoint; proxy chỉ refresh session; NEXT_PUBLIC_* lộ ra browser.
  • Vercel: env theo Production/Preview/Development; preview dùng DB riêng, không dùng DB prod; instant rollback chỉ an toàn khi migration là expand-only.
  • Migration Postgres an toàn: lock_timeout, CREATE INDEX CONCURRENTLY, constraint NOT VALID → VALIDATE, backfill theo batch.
  • Test: RLS test matrix (role × action × same/other tenant) chạy trên Postgres thật trong CI.
  • Observability: log có request_id, tenant_id, user_id; alert theo error rate + p95 + slow query.
  • Agent: context file → plan → task nhỏ → test/validator gate → human review cho auth/migration/tiền.

Interview Questions

Showing 50 / 50

-- CI check: bảng public nào chưa bật RLS?
select relname from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity;
create policy "members update own tenant rows" on projects
  for update to authenticated
  using (private.is_member(tenant_id))
  with check (private.is_member(tenant_id));
create function private.has_role(t uuid, roles text[])
returns boolean language sql stable security definer set search_path = '' as $$
  select exists (
    select 1 from public.memberships m
    where m.user_id = (select auth.uid()) and m.tenant_id = t and m.role = any(roles)
  );
$$;

alter table projects enable row level security;
create policy "read" on projects for select to authenticated
  using (private.has_role(tenant_id, '{owner,admin,member,viewer}'));
create policy "write" on projects for insert to authenticated
  with check (private.has_role(tenant_id, '{owner,admin}'));
create policy "update" on projects for update to authenticated
  using (private.has_role(tenant_id, '{owner,admin}'))
  with check (private.has_role(tenant_id, '{owner,admin}'));
create policy "tenant read" on orders for select to authenticated
  using (
    tenant_id in (
      select tenant_id from memberships where user_id = auth.uid()
    )
  );
-- orders has no index on tenant_id
-- app query: select * from orders order by created_at desc limit 50;
create view public.order_totals with (security_invoker = true) as
  select tenant_id, date_trunc('day', created_at) d, sum(total) s
  from orders group by 1, 2;
'use server'
import { z } from 'zod'
import { requireMember } from '@/server/auth'

const Input = z.object({ projectId: z.uuid(), name: z.string().min(1).max(120) })

export async function renameProject(raw: unknown) {
  const input = Input.parse(raw)
  const { supabase } = await requireMember({ permission: 'project:update' })
  const { error } = await supabase.from('projects')
    .update({ name: input.name }).eq('id', input.projectId)
  if (error) throw error
}
begin;
select plan(2);
set local role authenticated;
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
select is_empty($$ select * from invoices where tenant_id = 'tenant-b-uuid' $$, 'A cannot read B');
select throws_ok($$ insert into invoices(tenant_id, total) values ('tenant-b-uuid', 10) $$, '42501');
select * from finish();
rollback;
-- 20261001_add_external_ref.sql
alter table tasks add column external_ref uuid not null default gen_random_uuid();
create index tasks_external_ref_idx on tasks (external_ref);
alter table tasks add constraint tasks_project_fk
  foreign key (tenant_id, project_id) references projects (tenant_id, id);
create extension if not exists btree_gist;
alter table shifts add constraint no_overlap exclude using gist (
  tenant_id with =, employee_id with =,
  tstzrange(starts_at, ends_at, '[)') with &&
);
// lib/stats.ts (server)
let cached: { at: number; data: Stats } | null = null

export async function getStats(tenantId: string) {
  if (cached && Date.now() - cached.at < 60_000) return cached.data
  const data = await db.query('select ... where tenant_id = $1', [tenantId])
  cached = { at: Date.now(), data }
  return data
}
export async function POST(req: Request) {
  const body = (await req.json()) as { tenantId: string; role: 'member' | 'admin'; email: string }
  const admin = createAdminClient() // service_role
  await admin.from('memberships').insert({
    tenant_id: body.tenantId, role: body.role, email: body.email,
  })
  return Response.json({ ok: true })
}
export async function approve(requestId: string) {
  const { data: req } = await supabase.from('expense_requests')
    .select('*').eq('id', requestId).single()
  if (req.status !== 'pending') throw new Error('Already processed')
  await supabase.from('expense_requests').update({ status: 'approved' }).eq('id', requestId)
  await createPayout(req) // inserts into payouts
}