M0 · Monorepo move
Rewritten on 2026-10-05 for D25. M0 is rebuilt from an empty folder, in the order of [Runbook] v2 timeline: from empty folder to cutover. Packs A to E below fill the timeline's rows 24, 25, 26, 28 and 29. Updated 2026-10-07 for D28 and D29: packs C and D are written.
Applies
Section titled “Applies”- D3 · apps/web composes pages, forms and labels. Rules and access live in Postgres; pages ask for them, as with
my_permissions()in pack C. - D4 · Types come only from
@opleet/db-types, from pack B on. No type is written by hand. - D5 · apps/web is the Next.js 16 app in the pnpm and Turborepo workspace, deployed to Vercel in Singapore (timeline row 33).
- D6 · Conventional Commits with the web scope. Biome checks all of apps/web except
design/opleet, whichapps/web/biome.jsonexcludes so the design system's copies stay byte for byte. - D7 · Indonesian first: html lang is id unless the user picks English (pack D), every string a user sees goes through
t()with its Indonesian default, and money is written Rp 4.250.000. - D8 ·
design/opleetandcomponents/opleetstay in apps/web behind@ui/(pack A). - D10 · Refusals from the database reach the user through the error-key mapper (pack D). Sign-in failures are worded from Supabase Auth's error code, never from its message (pack B).
- D12 · Sign-in is proven on the local Supabase stack (FE-M0-08).
- D13 · Only the publishable key in apps/web.
proxy.tschecks sessions withgetClaims(), verified with the stack's ES256 key (pack B). - D19 · Playwright covers the smoke specs and the per-role menu check (pack E).
- D22 · apps/web makes its own Supabase clients with
@supabase/ssr(pack B). - D25 · M0 is rebuilt from an empty folder in the timeline's order. The design system comes from the Opleet Design System artifact; the delivered M0 is background, and nothing is copied from it.
- D27 · apps/docs is the source of truth: rules with IDs first, then the tests that prove them, then the code. Playwright specs live in
apps/web/e2e, and every test title starts with the ID of the rule it proves;pnpm docs:ruleschecks the link in CI (TL-M0-26). FE changes only a rule's Status. M0's FE steps stay runbook steps that zuki runs; from the data phase on, packs arrive as pull requests in the repository (D28). - D28 · Opleettop/monorepo is on GitHub Team: a ruleset on main requires a pull request with the checks and pr-title jobs green and allows squash merges only; zuki merges. Every FE pack reaches main through a pull request, and no FE step commits on main or pushes to it. Agents reach the repository only through the Claude GitHub app, on branches and pull requests. Once TL-M0-27 has merged, this runbook moves into
apps/docsbuild/ unchanged, in one pull request; until that merges, this Google doc is in force. - D29 · Option A1: every string a user sees is
t('module.screen.element', 'Indonesian default'). The catalog is plain TypeScript inapps/web/i18nbehind@i18n, with no library: the Indonesian catalog is collected from the calls, English sits beside it under the same keys, and typecheck fails on two defaults for one key or an out-of-date catalog. Enum labels are an exhaustive record per language, and badge tones a separate map. The language isprofiles.locale, with theopleet-localecookie as the fast path. One mapper turns a refusal into text. All of this is pack D; until it runs, pack C'st()returns the Indonesian default. - OQ24 · Open, BE's call in pack B: the error keys as a Postgres enum (E1), or as a list in apps/web that typecheck compares with
supabase/migrations(E2). Pack D works with either; FE-M0-13 has a block for each (FE-R7).
Needs from backend
Section titled “Needs from backend”@opleet/db-typeswith generated types (FE-R1) · Answered: TL-M0-10 creates the package and BE pack E (row 22, BE-M0-26) writes the first types. Whether apps/web needstranspilePackagesis answered in BE-M0-26. Needed by FE-M0-06.- The local ES256 signing key, so
getClaims()verifies locally (FE-R4) · BE pack A (row 18, BE-M0-10 and BE-M0-11): Done 2026-10-06. Needed by FE-M0-08. - E2E logins on the local stack (FE-R2) · BE pack E (row 22, BE-M0-25): planned. Needed by FE-M0-08 and pack E.
my_permissions()(FE-R3) · Answered:rpc('my_permissions')with no arguments returns one row per pool and permission: organization_id, pool_id, pool_code and permission_code. Built in BE pack B (row 19, BE-M0-13). Needed by pack C.- profiles.locale and
set_my_locale()(FE-R5) · BE pack B (row 19, BE-M0-13): final 2026-10-07; row 19 is Ready after row 14c. Needed by pack D (FE-M0-14). - The E2E logins' names for Playwright (FE-R6) · Agreed 2026-10-06, in BE pack E (row 22, BE-M0-25): the logins script writes E2E_PASSWORD and one E2E_<ROLE>_EMAIL per role to the git-ignored
.env.e2eat the repository root. It makes a random password on the first run and reuses it after that, so no value appears in a runbook or a chat. Needed by pack E and the CI end-to-end job. - The error-key list's source (FE-R7, OQ24) · Asked 2026-10-07 in [Runbook] v2 backend: with E1, the enum's name in the generated types (pack D reads
public.error_key); with E2, how a key is registered inapp.error_keysinsupabase/migrations. Needed by FE-M0-13.
Needs from docs
Section titled “Needs from docs”The rules pack E's specs will cite (D27). The Docs Engineer writes them in apps/docs, with IDs and status planned; the sentences below are FE's proposal, not the rules. Asked 2026-10-06. The rules about roles may come with DOC pack A; pack E is written once all six exist.
- Signed out · A visitor without a session who opens any page except
/loginis sent to/login. - Wrong password · A wrong email or password is refused with “Email atau kata sandi salah.”, and the email stays filled in.
- Sign-out · After Keluar the session ends, and the next page opened is
/login. - Landing page · After sign-in each role lands on its page: finance
/collection, satpam/gate, background_check/screening, every other role/dashboard([Ref] Roles, permissions and row-level security). - Menu · Each role's menu shows the modules its permissions allow, the same as its v1 role sees.
- Design preview ·
/design-previewstays off unlessNEXT_PUBLIC_DESIGN_PREVIEW=1: a signed-in user gets 404 and a signed-out visitor is sent to/login, so it never shows on staging or production.
Each pack fills one timeline row and runs when its row says so. A step is done when its Runs line shows zuki's clean run. A pack ships as a zip from the FE chat: its first step checks the zip's SHA-256 and unzips it to ~/opleet-v2/packs, and each step then copies only its own folder into the repository, so every file is reviewed and committed in a step. Commands are for zsh on zuki's Mac, run in ~/opleet-v2/opleet.
Withdrawn on 2026-10-05 by D25: FE-M0-01 (copy the delivered M0's web code into apps/web; never run). Its ID isn't reused.
Pack A · The Opleet design system in apps/web (timeline row 24)
Section titled “Pack A · The Opleet design system in apps/web (timeline row 24)”Delivers the design system's tokens, op-* stylesheet, logos and app icons as unchanged copies with their checksums; tokens.css generated from tokens.json; twelve components ported to React (Avatar, Badge, Button, DataTable, GlassCard, Icon, IconButton, Input, Logo, Select, StatCard, Toast) behind @ui/; Lucide icons through a typed registry; lib/format (Rp 4.250.000, 1.248, B 1234 TOP) with tests; t() returning the Indonesian default until pack D; and /design-preview, which is off unless NEXT_PUBLIC_DESIGN_PREVIEW=1. No database. Four steps on one branch, merged as one change.
Two departures from the design system's React components, both for keyboard access: DataTable leaves out whole-row click, and GlassCard and StatCard leave out onClick. Row click comes back with the first page that opens a Sheet (M1), reachable by keyboard; a clickable card is wrapped in a link or a button. Icon names are checked against the registry when the code is typechecked.
Ships as fe-pack-a.zip, SHA-256 7b351a95bbfb66ef0a9e6cde81777dfc9b039842d85b8a6926b572b4cd21f8ed. Status: Done (timeline row 24).
- FE-M0-02 · The design system's files, unchanged
- FE-M0-03 · Tokens, the root layout and the @ui/ aliases
- FE-M0-04 · Components, icons, number formats and t()
- FE-M0-05 · The design preview, then merge pack A
Pack B · Supabase clients, proxy.ts, sign-in and sign-out (timeline row 25)
Section titled “Pack B · Supabase clients, proxy.ts, sign-in and sign-out (timeline row 25)”Delivers the server client and the session refresh in proxy.ts with @supabase/ssr and the publishable key (D22, D13); getClaims() on every request, verified with the local stack's ES256 key; /login in Indonesian, with error text chosen from Supabase Auth's error code; sign-out; and .env.example with the variable names. The home page shows who is signed in until pack C replaces it with the role's landing page.
Ships as fe-pack-b.zip, SHA-256 322bc5739498f9ea114c2e771852999fc775bfe6a44b4b799c1464a43ed046a7. Runs after timeline rows 18 and 22. Status: written; row 18 is Done (2026-10-06), so it is Ready once row 22 is Done.
- FE-M0-06 · Supabase packages and the env variables
- FE-M0-07 · Clients, proxy.ts, sign-in and sign-out
- FE-M0-08 · Sign in on the local stack, then merge pack B
Pack C · The shell (timeline row 26)
Section titled “Pack C · The shell (timeline row 26)”Delivers the access model, the page list and the app shell. my_permissions() is read once per request and grouped by pool; lib/nav.ts lists the 27 pages, each with its label through t(), icon, permission, module and the v1 menus it replaces, and the menu, each role's landing page and the refusal page all come from that one list. The shell is the design system's: the floating sidebar (a menu button at phone width) and the top bar with the pool switcher (opleet-pool cookie), the theme toggle (opleet-theme cookie, data-theme on the html element) and Keluar. A page not built yet shows a placeholder, a page outside the role shows the refusal page, and a login with no pool gets /no-access. t() now comes from getT() in server code and useT() in client components, and still returns the Indonesian default until pack D. Three steps on one branch, merged as one pull request.
A role lands on the first of /dashboard, /collection, /screening and /gate that it may open: finance /collection, satpam /gate, background_check /screening, every other role /dashboard ([Ref] Roles, permissions and row-level security). Each role's menu matches its v1 role's, through the permission renames; v1's Backup menu has no v2 page. The pool switcher shows pool codes, since my_permissions() returns no pool names.
Checked in the FE workspace against stand-ins: types generated from a local copy of the Interface, and a stand-in for the local stack's Auth and my_permissions(). FE-M0-09's typecheck is the first check against the real generated types (D4).
Ships as fe-pack-c.zip, SHA-256 075051072dc506a1a6869c5eb145e26cd1aaf1ba91dc2d28f755fc32124bdc3e. Runs after timeline row 25 (pack B). Status: written 2026-10-07; Ready once row 25 is Done.
- FE-M0-09 · Permissions per pool, the page list and each role's landing page
- FE-M0-10 · The shell: menu, pools, theme, refusal page and placeholders
- FE-M0-11 · Each role on the local stack, the shell in the preview, then merge pack C
Pack D · Languages and the error-key mapper (timeline row 28)
Section titled “Pack D · Languages and the error-key mapper (timeline row 28)”Delivers D29 as accepted (option A1). The translator moves to apps/web/i18n behind @i18n, with the language list, pickName() for the four master tables with name_en, the Indonesian catalog that pnpm --filter web i18n writes from every t() call, and English in i18n/messages.en.ts under the same keys. typecheck fails when the catalog is out of date, when one key has two defaults, when an English text has other placeholders than the Indonesian, or when a strict prefix lacks English (i18n/strict.json: auth, error, locale, nav, shell). A module's prefix turns strict when its pages are done.
lib/errors maps a database refusal to text: a known key, filled from the JSON detail; else its SQLSTATE class (42501, 23505, 23503, 23514); else error.unknown, reported with the code only, never the message or details (D10). The report goes to the server log until the Sentry SDK is wired (timeline row 34). Enum labels sit in i18n/enums.ts, an exhaustive record per language over the generated enums, and badge tones in lib/labels.ts.
The language is profiles.locale, with the opleet-locale cookie as the per-request fast path. The ID | EN toggle in the top bar saves both through set_my_locale() (FE-R5); sign-in copies the profile's language into the cookie; client components get one language from a provider. Three steps on one branch, merged as one pull request.
OQ24 is BE's call in pack B, and only lib/errors/keys.ts depends on it. With E1 the keys are the generated enum's union; with E2 they are a list in apps/web, and scripts/error-keys.mjs makes typecheck compare it with the keys registered in app.error_keys in supabase/migrations. Either way, a key without text fails typecheck. FE-M0-13 has a block for each; run the one OQ24 chose (FE-R7).
Checked in the FE workspace on both paths, against the same stand-ins as pack C and a stand-in migration that registers the 22 keys listed in [Runbook] v2 backend's Interface.
Ships as fe-pack-d.zip, SHA-256 ecbff7abc49e03d945be36d1dcddca04a0ad1de11ebff81188202537690565cc. Runs after pack C, with BE pack B's profiles.locale, set_my_locale() and error keys in the generated types (row 22). Status: written 2026-10-07; Ready once row 26 is Done and OQ24 is decided.
- FE-M0-12 · The catalog: Indonesian from every t() call, English beside it
- FE-M0-13 · The error-key mapper and enum labels
- FE-M0-14 · The user's language: profile, cookie and the ID | EN toggle; then merge pack D
Pack E · Playwright (timeline row 29)
Section titled “Pack E · Playwright (timeline row 29)”Status: not written; written after packs B and C, once the six rules under Needs from docs are in apps/docs. Planned:
@playwright/testin apps/web, with the specs inapps/web/e2e, wherepnpm docs:rulesreads them (TL-M0-26), and apnpm --filter web e2escript.- Every test title starts with the ID of the rule it proves, as in
test('R-XX-01: …', …).pnpm docs:rulestakes any rule ID in a .ts file underapps/web/e2eas a citation, comments included, so a spec names only IDs that exist. - One spec per rule under Needs from docs, against the local stack with the E2E logins (D19, FE-R6). The landing page and menu specs run once for each role and compare its menu with what the v1 role sees, through the permission renames.
- The same change sets those six rules' Status from planned to built, and edits nothing else in apps/docs (D27).
For the CTO: what CI and Vercel need (timeline rows 13, 30 and 33)
Section titled “For the CTO: what CI and Vercel need (timeline rows 13, 30 and 33)”TL-M0-13, TL-M0-19 and TL-M0-21 are the CTO's steps. From the frontend side, they need the following.
CI end-to-end job (TL-M0-19)
Section titled “CI end-to-end job (TL-M0-19)”- Runs on pull requests that touch apps/web, supabase or tools/db, once pack E exists.
pnpm install --frozen-lockfile, then the database the way BE's job starts it:pnpm db:signing-key,pnpm supabase start,pnpm supabase db reset.- The E2E logins from BE's script (FE-R6), which writes
.env.e2eat the repository root; Playwright reads it. apps/web/.env.localwritten frompnpm supabase status -o env, as in FE-M0-06 block 5.NEXT_PUBLIC_DESIGN_PREVIEWstays unset: a spec proves the preview is off.pnpm --filter web build,pnpm --filter web exec playwright install --with-deps chromium, thenpnpm --filter web e2e, which starts the built app itself.- Upload apps/web/playwright-report when a spec fails, and run
pnpm supabase stop --no-backupat the end, also after a failure. - No repository secret: the local stack's keys and the E2E logins exist only inside the job.
pnpm docs:rulesin the checks job already readsapps/web/e2e(TL-M0-26); this job doesn't repeat it.
The checks job (TL-M0-13), from packs C and D on
Section titled “The checks job (TL-M0-13), from packs C and D on”- apps/web's unit tests don't run in CI: number formats, the access model, each role's menu and landing page, the translator and the error mapper (33 tests after pack D).
pnpm --filter web testin the checks job, or a test task inturbo.json, would catch a menu or money regression on every pull request. - With E2 (OQ24), apps/web's typecheck also compares
lib/errors/keys.tswithsupabase/migrations. Turborepo hashes only apps/web's files for that task, so a change tosupabase/migrationsalone can replay a cached pass; adding$TURBO_ROOT$/supabase/migrations/**to web's typecheck inputs closes that. With E1 nothing is needed: the generated types change with the enum.
Vercel (TL-M0-21)
Section titled “Vercel (TL-M0-21)”- Root directory
apps/web, framework Next.js, function region Singapore (sin1). Node 24 comes from engines. Vercel follows packageManager (pnpm 12.8.1) only with Corepack on: setENABLE_EXPERIMENTAL_COREPACK=1and check that the build log shows pnpm 12.8.1. - The default build works:
apps/web's build needs no database, because the pages that read one render on demand. NEXT_PUBLIC_values are compiled into the build, so changing one needs a redeploy.- Staging's Auth site URL (TL-M0-20) is the Vercel URL. Password sign-in doesn't use it; admin invites will (OQ2).
Env variables apps/web reads, names only
Section titled “Env variables apps/web reads, names only”NEXT_PUBLIC_SUPABASE_URL: <STAGING_SUPABASE_URL> on Vercel; the local stack's URL on a Mac and in CI.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: <STAGING_PUBLISHABLE_KEY> on Vercel. Never the secret key: apps/web refuses a key that starts with sb_secret_.NEXT_PUBLIC_DESIGN_PREVIEW: 1 on a Mac only, to look at the preview; unset in CI and on Vercel.E2E_PASSWORDandE2E_<ROLE>_EMAIL: Playwright only, from.env.e2e; never on Vercel.- Sentry (row 34): the names come with the pack that wires the SDK. The usual ones are
NEXT_PUBLIC_SENTRY_DSN, andSENTRY_AUTH_TOKEN,SENTRY_ORGandSENTRY_PROJECTat build time for source maps.