Public Roadmap

Building the Future

Open, transparent, and driven by our community. See what we're shipping today and what's coming tomorrow.

Recently Shipped

0

Nothing recently shipped.

Check back soon for new updates.

In Progress

1
KARO-248
Live Dev

P2 umbrella: Settings cleanup (Phase 5)

## P2 umbrella: Settings cleanup (Phase 5) Settings audit (2026-05-11) found \~40 ghost controls (UI saves a column nothing reads), \~3 ghost readers (code reads a column nothing writes), 3 unmounted form components (\~600 LOC of dead code), and an RBAC fallback (`.limit(1).single()` in `getOrganizationSettings`) that becomes a tenant-isolation footgun the moment we onboard a second host. **Phase 0 already handled:** KARO-239 (audit log), KARO-240 (Stripe Secret Key dropped), KARO-241 (KARO-223 Xero auto-send toggles UI built). ### Detailed plan Full breakdown at `docs/plans/karo-246-phase5-settings-cleanup.md` (571 lines): per-field DELETE/WIRE/KEEP/DEFER verdicts for every ghost control, RBAC fallback fix spec, prod-row verification protocol before any DELETE. ### Sub-tickets * C1: DELETE batch — remove the 35+ ghost public/member-page toggles (UI + zod schema + DB column drops after per-key verification) — 1d * C2: WIRE-UP batch — `tax_id`, `registration_number`, `billing_name`, `billing_address` flow into Xero invoices — 1.5d * C3: Bank fields DEFER decision (feature-flag hide, keep columns for future direct-debit) — 0.25d * C4: Delete 3 unmounted forms + companion DB columns (SettingsInvoicing/Accounting/PurchaseFlows \~600 LOC) — 0.5d * **C5: RBAC fallback safety fix (**`.limit(1).single()`) — HARD REQUIREMENT before Glanbia onboards — 1d * C6: Settings page reorganization (group remaining controls logically) — 0.75d * Optional: C7 — Convert sentinel-field FormData pattern to per-page actions — 0.5d ### Total effort: 5 days, + 1 day buffer for external-consumer / Zapier survey before C1 and C4 ship ### Critical path **C5 first** to unblock Glanbia onboarding. Then C1 and C4 after the external-consumer survey verifies no Zapier or out-of-band reader exists. C2/C3 can ship in parallel. ### Dependencies * KARO-239 (settings_audit_log) — already shipped; means every change in Phase 5 is logged * KARO-241 (Xero auto-send UI) — already shipped; sets pattern for new UI sections ### Acceptance (umbrella) * 35+ ghost controls verified + removed * 4 WIRE-UP fields appear on synthetic Xero invoice * 3 unmounted forms deleted (with DB column drops) * RBAC fallback removed; cross-tenant probe test passes * No prod data lost (survey + backup verified before every DELETE) ### Related * Source audit: `/tmp/karo-settings-audit.md` * KARO-235 followups (privacy audit) intersected with this work

Up Next

19
KARO-261

P0 SECURITY: workspace creation silently grants platform-admin (god-mode)

## P0 SECURITY: Workspace creation silently grants LiteHQ platform-admin (god-mode) ### What happened today (real incident, 2026-05-11) `jvickerman@glanbia.com` created a Glanbia workspace at 14:34 NZST while setting up a booking. The workspace-creation flow at `src/app/create-workspace/actions.ts:159` ran: ```ts await supabase.from('profiles').update({ role: 'admin' }).eq('id', user.id) ``` Combined with `ROLES.PLATFORM_ADMIN = 'admin'` in `src/lib/constants/roles.ts`, this means **every self-service workspace creator gets god-mode access to all LiteHQ data across all tenants.** For \~6 hours jvickerman had platform-admin access. Detected during the verification audit \~20:30. Role manually revoked to 'member'. **Zero malicious use found in** `booking_logs` but the exposure window was real. ### Immediate mitigation (already shipped) * Revoked jvickerman's role: `UPDATE profiles SET role='member' WHERE email='jvickerman@glanbia.com'` * Patched `create-workspace/actions.ts:159` — removed the profile.role='admin' elevation. New workspace creators no longer get god mode. ### Root cause The `profiles.role` enum has only 'admin' and 'member' values, with `ROLES.PLATFORM_ADMIN === ROLES.HOST_ADMIN === 'admin'`. Code that intends "god mode" checks `profile.role === 'admin'` — same predicate that legitimate host admins satisfy. The auth surface can't distinguish: * LiteHQ platform staff (true god mode — should be \~2 specific accounts) * Host operators (admin of their OWN host only — should be many accounts) Today these collapse to one role string, and any code path that elevates someone to "admin" elevates them to PLATFORM admin. ### Required fix (this ticket) 1. **Add a distinct** `platform_admin` enum value to `profiles.role` (migration: extend the `user_role` enum) 2. **Migrate** `is_platform_admin()` SQL helper to check `role = 'platform_admin'` instead of `role = 'admin'` 3. **Migrate every** `profile?.role === 'admin'` check in the codebase (\~25 sites — found via grep) to use the proper signal: * "God mode / platform admin" → check for `'platform_admin'` * "Host admin / host operator" → check via `host_members.role='admin'` or `company_members.role='admin'` joined to the relevant host 4. **Backfill existing 'admin' profiles**: * `info@thinkspace.nz` → keep as platform_admin (legit LiteHQ staff) * All other future 'admin' profiles (none today) → migrate to 'member' + proper host_members row 5. **Update** `ROLES` constants in `src/lib/constants/roles.ts` to use distinct strings + remove the misleading alias ### Acceptance - [ ] `user_role` enum has 'platform_admin', 'admin', 'member' - [ ] `is_platform_admin()` checks role = 'platform_admin' - [ ] All 25+ `profile?.role === 'admin'` call sites migrated to the right tier check - [ ] Backfill complete: [info@thinkspace.nz](<mailto:info@thinkspace.nz>) = platform_admin, no other platform_admins - [ ] Test: a new workspace creator does NOT get is_platform_admin() = true - [ ] Test: an existing host admin retains all admin-of-their-host capabilities - [ ] CLAUDE.md invariant added: "profile.role='platform_admin' is reserved for LiteHQ staff; workspace creation MUST NOT elevate to it" ### Related * KARO-237 (multi-tenant blockers — already fixed) * KARO-239 (settings audit log — will catch any future role escalations) * KARO-242 (Phase 1 plan-gate enforcement — depends on this fix being in place) ### Effort \~1-2 days. Migration + 25 call-site updates + test sweep.

KARO-260

Decide on 5 unindexed FK indexes on sticker tables (revisit at >5k rows)

Performance advisor flags 5 unindexed_foreign_keys on sticker_orders / sticker_order_items / sticker_free_claims. Current rows: <30 across all sticker tables, so seq-scan is fast and index overhead would dominate. Decision: defer the indexes until sticker_orders.n_live_tup > 5,000. From 2026-05-11 DB perf inspection.

KARO-259

Drop sticker_free_claims duplicate unique index (after KARO-215 window)

unique_business_free_claim is byte-identical to sticker_free_claims_business_profile_id_key. Drop after the KARO-215 14-day observation window (2026-05-25) so it doesn't pollute the Day-1+ snapshots. Saves 16 kB. From 2026-05-11 DB perf inspection.

KARO-258

Add CDN/HTTP cache headers for hot business_profiles id lookup

business_profiles WHERE id = $1 was the most-called application query (8,100 calls in 1h 25m post-upgrade) at 0.12 ms mean. Adding HTTP Cache-Control headers on the corresponding edge function response (private, short TTL) lets the browser/CDN serve repeat lookups without a round-trip. From 2026-05-11 DB perf inspection.

KARO-257

Audit SECURITY DEFINER function EXECUTE grants to anon + authenticated (31 each)

31 SECURITY DEFINER functions grant EXECUTE to anon, 31 to authenticated. Review per-function whether anon/authenticated grants are needed; revoke where service_role suffices. Each unnecessary EXECUTE grant is a small auth-bypass surface. From 2026-05-11 DB perf inspection.

KARO-256

Harden SECURITY DEFINER function search_path on 5 functions

Performance advisor flags 5 functions with mutable search_path — classic privilege-escalation surface. Single migration adding to each. Low-risk additive change. From 2026-05-11 DB perf inspection.

KARO-255

Apply PROPOSED_scope_permissive_rls.md to clear 30 auth_rls_initplan lints

Supabase performance advisor flags 30 auth_rls_initplan + 22 multiple_permissive_policies on RLS policies. The drafted migration in docs/system-docs/PROPOSED_scope_permissive_rls.md addresses the same set. Apply (after review) to consolidate RLS evaluation per query. Improves query plan caching + reduces per-row overhead. From 2026-05-11 DB perf inspection.

KARO-254

Remove __CSP_NONCE__ placeholder from Vite plugin (Edge Function reverted)

KARO-224 Phase 2 Edge Function was reverted today (commit c859727) but the Vite plugin in vite.config.ts still adds nonce="**CSP_NONCE**" to every <script> tag. Browsers see literal **CSP_NONCE** as a nonce value; harmless on src= scripts but messy in the deployed HTML. Remove the cspNoncePlaceholder() plugin OR keep it inert by exporting a marker that future Edge Function work can pick up — see KARO-224. From 2026-05-11 frontend perf inspection.

KARO-253

Tune useLatestSyncTime 30s background refetch

Hook refetches sync metadata every 30 seconds across the SPA. Likely overkill — sync runs at most every 5 min now (post-KARO-IO-reduce). Raise the refetch interval to 5 min or switch to manual + on-focus. Reduces both Supabase request volume and React-Query cache churn. From 2026-05-11 frontend perf inspection.

KARO-252

Split EditBusinessProfile chunk (178 KB) — lazy-load insurance docs sub-form

EditBusinessProfile.tsx bundles 178 KB into the main chunk. The insurance documents sub-form (rarely opened) is a substantial portion. Convert to lazy() + React.Suspense so the sub-form only loads when the user opens that tab. From 2026-05-11 frontend perf inspection.

KARO-251

Audit 3rd-party tags firing on /login (GTM, GA, Brevo, Cloudflare beacon)

Pre-auth pages fire Google Tag Manager, Google Analytics, Brevo, Cloudflare insights. Privacy + performance concern: anonymous visitors are profiled before they sign in. Review which tags need to be pre-auth vs post-consent, and which can be deferred until after authentication. From 2026-05-11 frontend perf inspection.

KARO-250

Trim directory query columns — directory needs ~10 fields, ships ~60

Directory query uses select=\* on business_profiles and serializes the full row (\~60 columns) for each of 2,800+ profiles. Directory cards display \~10 fields. Replace \* with the explicit column list. Drops payload size + parsing cost substantially. From 2026-05-11 frontend perf inspection.

KARO-249

Decouple per-id profile fetch from full directory load

On , the per-id profile hook depends on the full directory query. Loading one profile pulls all 2,800 business_profiles. Refactor the per-id path to fetch only the requested record (single-row API call) and stop blocking on the directory waterfall. From 2026-05-11 frontend perf inspection.

KARO-247

P2 umbrella: Multi-tenant scale polish (Phase 4)

## P2 umbrella: Multi-tenant scale polish (Phase 4) Scale audit (2026-05-11) found that beyond the P0s already fixed (KARO-237 cron-billing-engine fallback, KARO-238 supabase_realtime publication), several "will degrade at 10×, fail at 100×" issues need attention before host #3 or #4 onboard. ### Detailed plan Full breakdown lives at `docs/plans/karo-245-phase4-scale-polish.md` (826 lines): 7 atomic sub-tickets each with quantifiable trigger thresholds (cron p95 duration, event counts, CPU sustained %), risk register, index review. **Note:** Phase 4's plan originally included a C8 (RLS helper conversion) added on self-review; **dropped** because Phase 2 / KARO-244 owns that work exactly. ### Sub-tickets * C1: Shard `cron-billing-engine` per-host (fan-out via `net.http_post`). Trigger: cron p95 > 60s — 1d * C2: Parallelise `cron-xero-sync` per-integration. Trigger: same — 1d * C3: Resend quota planning + upgrade trigger. Trigger: monthly volume approaching Pro 50k — 0.5d * C4: Sentry quota planning + sample-rate review. Trigger: monthly events > quota — 0.5d * C5: Supabase compute tier review (Postgres-Small → Medium decision criteria). Trigger: DB CPU sustained > 60% / 24h — 0.5d * C6: Stripe per-host rate-limit monitoring (multiple Connect accounts = multiple 100req/sec ceilings). Trigger: any 429 from Stripe — 0.5d * C7: Xero per-tenant rate-limit token bucket (already a real 60/min ceiling). Trigger: 429s seen today — 1d * Centralised `facilityVisibilityFilter()` helper (defers from KARO-235) — 1.5d ### Total effort: 6.5 days, spread over 2-3 weeks pre-host-#3 ### Trigger model Each item carries a measurable signal that promotes it from "deferred" to "P0". Goal: ship the alert before the fix. ### Dependencies * Each item independent. Order opportunistic based on trigger signals. ### Related * Source audit: `/tmp/karo-scale-audit.md` * KARO-244 (Phase 2 RLS perf — owns the helper conversion this plan originally tried to duplicate)

KARO-246

P1 umbrella: RLS perf for multi-tenant (Phase 2)

## P1 umbrella: RLS perf for multi-tenant (Phase 2) Scale audit (2026-05-11) found that all RLS helper functions are `plpgsql VOLATILE` — the planner can't inline them, so they're recomputed per row on every RLS-protected query. At 10× current scale this degrades; at 100× it fails. Phase 2 converts them to `sql STABLE PARALLEL SAFE` so the planner can cache + inline. ### Detailed plan Full breakdown lives at `docs/plans/karo-244-phase2-rls-perf.md` (494 lines): helper inventory + per-helper conversion plan, index review (no critical missing indexes — defer additions to Phase 3 unless benchmark surfaces a hot spot), 5 atomic sub-tickets (C1-C5), test strategy extending `supabase/tests/tenant_isolation.test.sql` + new `phase2_rls_regression.test.sql`, performance benchmark gated at ≥30% improvement on ≥4 of 10 canonical queries. ### Sub-tickets (file as children after this umbrella lands) * C1: Convert `get_user_company_id` to sql STABLE PARALLEL SAFE (KEYSTONE — pgTAP must pass identically) — 0.5d * C2: Convert `is_admin` + `is_my_company_billing_contact` — 0.5d * C3: Convert remaining 5 helpers (`is_platform_admin`, `is_member_of_company`, `get_user_role`, `check_is_company_admin_or_billing`, `get_my_company_role`) — 1d * C4: Index verification pass (don't add prematurely; baseline first) — 1d * C5: Performance benchmark (before/after EXPLAIN ANALYZE) + CLAUDE.md update — 0.5d ### Total effort: 3.5 days focused / 4 days realistic ### Dependencies * KARO-237 (done, multi-tenant blockers fixed) — required precursor * Blocks: any future host onboarding at scale; CLAUDE.md "keystone of RLS" invariant updates after each convert ### Acceptance (umbrella) * All 8 RLS helpers converted to sql STABLE PARALLEL SAFE * pgTAP keystone test passes identically pre/post on `get_user_company_id` * Benchmark shows ≥30% Execution Time improvement on ≥4 of 10 canonical queries * Schema snapshot regenerated and committed ### Related * Source audit: `/tmp/karo-scale-audit.md` * Phase 4 self-review (KARO-245) initially duplicated this as its C8; dropped — Phase 2 owns it.

KARO-245

supabase/config.toml SMTP password sync risk — use env() pattern to prevent wipe

Tooling risk surfaced during KARO-236 / rate-limit work. **Symptom:** `npx supabase config push --project-ref bgxcdooblbsnzfadfelr` fails with: ``` unexpected status 401: {"message":"Custom SMTP required to configure SMTP_SENDER_NAME or RATE_LIMIT_EMAIL_SENT. Missing SMTP_PASS fields."} ``` **Why:** `supabase/config.toml` has `pass = ""` in `[auth.email.smtp]`. The CLI sends the entire SMTP block on push; the empty `pass` field gets interpreted as "wipe SMTP_PASS", which Supabase blocks because the rate_limit + sender_name fields require a live password. **Risk if not fixed:** * Any future `supabase config push` will fail until the rate-limit / SMTP block is resolved. * Worse: if someone modifies the validation logic on Supabase's side, the push could SUCCEED and wipe the SMTP password — instantly breaking signup emails again (same incident as KARO-226). **Fix:** Use the `env()` pattern Supabase CLI supports for secrets: ```toml [auth.email.smtp] enabled = true host = "smtp-relay.brevo.com" port = 587 user = "info@totika.org" pass = "env(SUPABASE_AUTH_EMAIL_SMTP_PASSWORD)" admin_email = "admin@totika.org" sender_name = "Tōtika" ``` Then set `SUPABASE_AUTH_EMAIL_SMTP_PASSWORD` in Doppler (`prd` config), reference it locally via `doppler run -- supabase config push --project-ref ...`. Same env pattern can be used for any other sensitive auth fields (Apple/Google OAuth client secrets, etc.) when those come up. Acceptance: `supabase config push` runs cleanly with no SMTP-related errors; the live SMTP password remains intact post-push.

KARO-244

Lean the remaining 4 auth email templates (recovery / invite / magic-link / email-change)

KARO-236 covers the bloated **confirmation** email (30 KB). The other 4 auth email templates have the same shape and similar deliverability risk: | Template | Size | Notes | | -- | -- | -- | | `mailer_templates_recovery_content` (password reset) | 4.0 KB | Same heavy-HTML pattern | | `mailer_templates_invite_content` (viewer invitations) | 4.0 KB | Same | | `mailer_templates_magic_link_content` (passwordless) | 4.3 KB | Same | | `mailer_templates_email_change_content` (change email) | 4.7 KB | Same; security-critical | These are 4x typical transactional size (target: \~1 KB). They likely have the same: * 100+ inline `style=` attributes * Marketing-heavy HTML * Possibly broken `cid:` image references Recommendation: apply the same lean template shape from KARO-236 to all four. Each becomes a single CTA + 2-3 sentences of explanation + plain-text-friendly fallback. Same dashboard edit path (Authentication > Email Templates). Acceptance: each template <= 1.5 KB, single primary CTA, no external links beyond the action URL, no images.

KARO-243

P1 umbrella: Stripe Connect platform fee implementation (Phase 3)

## P1 (umbrella): Stripe Connect platform fee implementation (Phase 3) Stripe Connect audit (2026-05-11) — `/tmp/karo-stripe-fee-audit.md` for full doc. ### Goal Charge 3% platform fee on free-tier hosts' Stripe Connect transactions, 0% for paid tiers. Funds appear in the LiteHQ platform Stripe account; hosts see the fee as a separate line in their Stripe activity. ### Children 1. **Add** `platformFeePercent` to SubscriptionPlan (`src/lib/subscription.ts`) * free: 3 * community/connect/business: 0 * Expose via `resolvePlanGate(...).platformFeePercent` 2. **Helper** `computeApplicationFee(charge_amount_cents, host_company_id)` * Returns integer cents (Stripe API takes cents) * Reads plan via `resolvePlanGate` * Logs to Sentry with surface tag 3. **Inject at all 4 charge sites:** * `wizard-actions.ts:489` (guest PaymentIntent path) * `wizard-actions.ts:760` (guest Checkout Session path) * `integration-stripe/index.ts` (generic Checkout — must gate on `mode === 'payment'` to avoid 400 on subscription mode) * `pay-invoice-stored/index.ts` (REMOVE the hardcoded 1% and replace with plan-driven) 4. **Refund handling** — `refund_application_fee: true` on all refund paths so the fee reverses with the charge. 5. **platform_fee_ledger table** — record every applied fee: * `id, host_company_id, stripe_payment_intent_id, charge_amount_cents, fee_amount_cents, plan_at_time, applied_at` * Populated by `payment_intent.succeeded` webhook handler 6. `cron-platform-fee-invoicing` — monthly cron that issues one Xero invoice per host from "LiteHQ Platform Fees" supplier, summing that host's `platform_fee_ledger` rows for the month. So the host's accountant can reconcile. 7. **Communication** — 30-day advance email to existing free hosts (currently 0 in our prod; only Thinkspace exists and they're on connect). Set up the template anyway for future free-tier signups. 8. **Admin UI** — show host their effective platform fee rate on the billing page. ### Acceptance - [ ] All 4 charge sites inject `application_fee_amount` - [ ] Per-plan fee is plan-driven (no hardcoded %) - [ ] platform_fee_ledger populated by webhook - [ ] Monthly invoicing cron produces Xero invoices per host - [ ] Refunds reverse the fee correctly - [ ] Integration test: create a $100 booking on free tier → Stripe charges $100 (plus Stripe's fee) → $3 lands in LiteHQ platform account → Xero ledger row inserted - [ ] Subscription-mode charges in `integration-stripe` do NOT get an app fee (Stripe API rejects it) ### Dependencies * Plan-gate server enforcement (KARO-240 umbrella) — must land first so `platformFeePercent` exists on `resolvePlanGate` * KARO-239 (settings audit log) — for any plan-tier changes that affect a host's fee ### Out of scope * Tax registration for LiteHQ Platform Fees (separate operations workstream) * Subscription-mode platform fees (Stripe Connect Subscriptions are a separate API surface; the 4 charge sites here are all `mode='payment'`)

KARO-242

P1 umbrella: Plan-gate server-side enforcement (Phase 1)

## P1 (umbrella): Plan-gate server-side enforcement (Phase 1) Plans audit (2026-05-11) found that `resolvePlanGate` is centralised but has only 6 call sites — all UI-only (blur overlay, disabled input). **Zero server-side enforcement.** Any authenticated admin can bypass every plan limit by hand-crafting an HTTP POST. This blocks the entire free-tier strategy: free plan is meaningless if there's no real lock behind it. ### Scope Add server-side plan checks to every gated feature. Children: 1. **Free plan slug** — add `free` to `PLANS` array in `src/lib/subscription.ts` with the 3% platform fee and the agreed limits (1 location, 25 active members, 200 bookings/month). Normalise the existing prod row that has `plan='free'` in JSONB (silently falls through to community today). 2. **Data import lockdown** — `import-members` + `import-data` edge functions reject if `resolvePlanGate(...).canImportData === false`. Includes admin server actions that invoke them. 3. **Integration connect-flow gates**: * Stripe Connect onboarding (`integration-stripe`) * Xero OAuth start * Slack OAuth start * ezeep / Salto config * Webhook subscription endpoints 4. **Custom domain gate** — `domain-manager/add_domain` action checks `canUseCustomDomain()` (helper exists, zero callers today). 5. **Member-count enforcement** — when adding a member, server action checks `currentCount >= plan.memberLimit`. 6. **Location-count enforcement** — same shape for locations. 7. **Bookings/month enforcement** — `booking-manager.create` for member path checks the host's MTD booking count. Soft for in-flight customer transactions (per Phase 1 design decision), hard for admin-initiated. ### Hard wall vs soft nag (per design decision) * **Hard wall** on signup-side actions (create member #26, location #2, import CSV) — server returns 402/403 with upgrade CTA * **Soft** on in-flight customer bookings (don't break a paying customer's checkout because the host overshot their limit). Send a Sentry alert + email the host that they're over. ### Acceptance - [ ] Each child ticket lands with server-side gate + test - [ ] CI test: stub free-tier host, attempt curl-bypass on every gated endpoint, all return 402/403 - [ ] UI matches: when a free user hits a limit, the upgrade modal explains exactly what's blocked - [ ] Admin audit log (KARO-239) records gate-rejection events ### Dependencies * KARO-239 (settings audit log) — for recording plan-related events * KARO-237 (multi-tenant blockers) — must land first so per-host plan reads work correctly ### Out of scope * Stripe Connect platform-fee implementation → separate umbrella (KARO-242) * Centralised `facilityVisibilityFilter()` helper (KARO-235 followup)

Have an idea?

We build for our users. If you have a suggestion or found a bug, let us know directly.

No credit card required • Cancel anytime • Free migration support