This file provides guidance to AI agents (Claude Code, Cursor, Copilot, etc.) when working with code in this repository.
bun serve:dev- Start local development server with local environmentbun serve- Start development server with default configurationbun build- Build production version of the web appbun mobile- Build for mobile and copy to Capacitor platformsbun dev-build- Build with development branch configurationbun run cli:build- Build the CLI workspace incli/bun run cli:test- Run the CLI workspace test suitebun run cli:check- Lint, typecheck, build, and test the CLI workspace
bun test:all- Run all backend testsbun test:backend- Run backend tests excluding CLI testsbun test:cli- Run CLI-specific testsbun test:local- Legacy alias for the default monorepo backend test runbun test:front- Run Playwright frontend testsbun test:all:local- Legacy alias forbun test:allbun test:cli:local- Legacy alias forbun test:cli
bun test:cloudflare:all- Run all tests against Cloudflare Workersbun test:cloudflare:backend- Run backend tests against Cloudflare Workersbun test:cloudflare:updates- Run update tests against Cloudflare Workers./scripts/start-cloudflare-workers.sh- Start local Cloudflare Workers for testing
Note: Cloudflare test suite is currently unstable and may not pass reliably.
See CLOUDFLARE_TESTING.md for detailed information on testing against Cloudflare Workers.
bun lint- Lint Vue, TypeScript, and JavaScript filesbun lint:fix- Auto-fix linting issuesbun lint:backend- Lint Supabase backend filesbun typecheck- Run TypeScript type checking with vue-tscbun types- Generate TypeScript types from Supabase
bun run supabase:start- Start local Supabase instance (worktree-isolated)bun run supabase:cleanup- Stop local Supabase and delete this worktree's Supabase volumesbun run supabase:db:reset- Reset and seed local databasebun backend- Start Supabase functions locallybun reset- Reset Supabase database
- Framework: Vue 3 with Composition API and
<script setup>syntax - Build Tool: Vite with custom Rolldown integration
- Routing: File-based routing with unplugin-vue-router
- State Management: Pinia stores
- Styling: TailwindCSS with DaisyUI components
- Mobile: Capacitor for native mobile functionality
- Database: PostgreSQL via Supabase
- Edge Functions: Supabase Edge Functions (Deno runtime)
- API Deployment: Multi-platform deployment:
- Cloudflare Workers (primary, handles 99% of traffic)
- Supabase Functions (internal tasks, CRON jobs)
supabase/functions/_backend/- Core backend logicplugins/- Public plugin endpoints (updates, stats, channel_self)private/- Internal API endpoints (auth required)public/- Public API endpoints (app, bundle, device management)triggers/- Database triggers and CRON functionsutils/- Shared utilities and database schemas
-
Hono v4 HEAD routing: do not add HEAD routes with
app.on. Hono v4 removedapp.head()becauseGEThandlers implicitly serveHEAD; keep shared GET/HEAD logic in theapp.get(...)handler and branch onc.req.raw.methodonly when the behavior must differ. -
For understanding the current DB schema, prefer
supabase/schemas/prod.sql(schema dump) instead of scanning all migrations. -
For schema changes, always edit or add files under
supabase/migrations/and treatsupabase/schemas/prod.sqlas read-only reference.- Migration files must be created via CLI only.
- Never create migration files manually.
- Always use
bunx supabase migration new <feature_slug>. - Manual creation of migration files is not allowed.
-
Supabase admin client + sign-in pitfall: if you call
supabaseAdmin.auth.signInWithPassword(...), that client becomes authenticated as the user (it is no longer a pure service-role client). Always use a separate admin client for sign-in, and keep a clean admin client for service-role writes. Example:const loginAdmin = supabaseAdmin(c)→ use forsignInWithPasswordconst adminClient = supabaseAdmin(c)→ use for admin writes
-
Backend DB access style: prefer
getPgClient()/getDrizzleClient()for multi-step SQL, transactions, joins, schema-backed writes, or code that benefits from explicit pool lifecycle handling. Do not force every simple one-statement internal helper write into Drizzle just for consistency. A small service-role helper that is already a single clearsupabaseAdmin(c)call may stay onsupabaseAdminwhen that keeps the code smaller and equally correct.
All API endpoints must follow these response patterns:
- Success with data:
return c.json(data)orreturn c.json(data, 200) - Success without data:
return c.json(BRES)whereBRES = { status: 'ok' }(import fromutils/hono.ts) - Errors: Use
return simpleError()orreturn quickError(status, ...)(import fromutils/hono.ts)
Do NOT use c.body(null, 204) for success responses. Always return JSON for consistency.
Capgo relies on two layered caches for plugin endpoints (/updates, /stats, /channel_self) and they depend on specific response codes/body shapes. Do not change these without updating the Cloudflare snippet + app status logic.
- Edge on-prem cache (Cloudflare Snippet):
cloudflare_workers/snippet/index.jscaches responses when it detects:429+{ error: 'on_premise_app' }(from/updatesor/channel_self), or{ isOnprem: true }(from/stats). The snippet stores cached responses using the worker'sCache-ControlTTL and serves them before routing.
- Edge plan-upgrade cache (Cloudflare Snippet): same file caches responses when it detects:
429+{ error: 'need_plan_upgrade' }.
- App status cache (Worker runtime):
supabase/functions/_backend/utils/appStatus.tsstoresonprem/cancelled/cloudfor 60s using the Cache API to short-circuit DB lookups.
Implication: Keep the 429 + error payloads for on-prem and plan-upgrade responses; otherwise the edge caches and status cache effectiveness are broken.
src/components/- Reusable Vue componentssrc/pages/- File-based route pagessrc/services/- API clients and external service integrationssrc/stores/- Pinia state management storessrc/layouts/- Page layout components
- Bun - Package manager and JavaScript runtime
- Docker - Required for Supabase local development
- Supabase CLI - Database and functions management
-
Front-facing instructions (docs, onboarding, tooltips, demos, and customer-facing help):
- Use
npxfor runnable command examples and keepnpxin those public snippets. - Do not mention internal execution tooling preferences in this customer-facing context.
- Use
-
Internal tooling and internal documentation:
- Prefer Bun tooling (
bun/bunx) for repository maintenance, scripts, and internal workflows.
- Prefer Bun tooling (
-
Capgo CLI references:
- When explicitly discussing the Capgo CLI command itself, always use
@latest. - Use the public shape like
npx @capgo/cli@latest ...for customer-facing command examples. - Use internal execution equivalents (for example,
bunx @capgo/cli@latest ...) only in internal tooling context.
- When explicitly discussing the Capgo CLI command itself, always use
supabase/templates/invite_new_user_to_org.htmlandsupabase/templates/invite_existing_user_to_org.htmlare Bento templates.- Every other file in
supabase/templates/is a Supabase auth or notification template. - Supabase templates use Supabase template syntax.
- Bento templates use Bento template syntax.
- Updating templates in the repository does not upload them anywhere automatically.
- Supabase email templates must be uploaded manually to Supabase.
- Bento email templates must be uploaded manually to Bento.
- Install dependencies:
bun install - Start Supabase:
bun run supabase:start - Reset database with seed data:
bun run supabase:db:reset - Start frontend:
bun serve:dev
- Demo User:
test@capgo.app/testtest - Admin User:
admin@capgo.app/adminadmin
- Located in
tests/directory - Use Vitest test runner with custom configuration
- Require running Supabase instance
- Tests modify local database state
- Capgo CLI tests resolve the local
cli/workspace by default in this monorepo
- API endpoint tests (CRUD operations)
- CLI functionality tests
- Database trigger tests
- Integration tests with external services
ALL TEST FILES RUN IN PARALLEL. Tests within the same file run sequentially (unless explicitly configured otherwise), but different test files execute simultaneously. You MUST design tests accordingly.
Maximize parallelism: Use it.concurrent() instead of it() when possible, to run tests in parallel within the same file. More parallel tests = faster CI/CD.
When creating tests that interact with shared resources (users, apps, orgs, devices, channels, bundles, etc.), follow these rules:
You CAN reuse existing seed data IF:
- You only READ the data, not modify it
- You create your OWN child resources under it (e.g., reuse a user but create your own app/org for that user)
- The parent resource is not modified by your test or other tests
You MUST create dedicated seed data IF:
- Your test MODIFIES the resource (update, delete, change settings)
- Other tests also modify that same resource
- The resource state matters for your test assertions
Guidelines:
- Create dedicated seed data when needed - Add new test-specific entries in
supabase/seed.sqlwith unique identifiers - Use unique naming conventions - Prefix test data with the test file name or feature being tested (e.g.,
test_my_feature_user@capgo.app,com.test.myfeature.app) - Clean up is NOT enough - Even with cleanup, parallel test files might try to use the data simultaneously
Examples of what breaks parallel test files:
- Modifying the default
test@capgo.appuser's settings - Deleting or updating the default app
com.demo.app - Changing org settings on the shared test org
- Using hardcoded IDs that other test files also modify
Examples of safe reuse:
- Using
test@capgo.appto create a NEW app specific to your test (user is not modified) - Reading from shared orgs without modifying them
- Creating new channels/bundles under your own dedicated app
When you need isolation, create dedicated seed data:
-- In seed.sql, add dedicated test data for your test file:
INSERT INTO auth.users (id, email, ...) VALUES
('unique-uuid-for-my-test', 'my_feature_test@capgo.app', ...);
INSERT INTO public.apps (app_id, owner_org, ...) VALUES
('com.test.myfeature.app', 'my-test-org-id', ...);Then in your test file, use ONLY these dedicated resources for modifications.
If your test breaks other tests in CI/CD, it is YOUR responsibility to fix it by creating isolated seed data.
- Uses
@antfu/eslint-configwith custom rules - Single quotes, no semicolons
- Vue 3 Composition API preferred
- Ignores: dist, scripts, public, supabase generated files
- Strict mode enabled
- Path aliases:
~/maps tosrc/ - Auto-generated types for Vue components and routes
- Supabase types auto-generated via CLI
- All code comments must be in English, regardless of the chat language.
- Never pass inline fallback text as the second argument to translation calls such as
t('key', 'English text'). - Always use translation keys only, for example
t('key'). - When text is missing, add or update the key in
messages/en.jsoninstead of putting English fallback text in code.
- Follow Conventional Commits v1.0.0 (https://www.conventionalcommits.org/en/v1.0.0/).
- Use a clear type and scope when helpful (e.g.,
docs: ...,feat(api): ...,fix(frontend): ...).
- Always cover database changes with Postgres-level tests and complement them with end-to-end tests for affected user flows.
- Use the Supabase CLI for every migration and operational task whenever possible; avoid manual changes through the dashboard or direct SQL.
- When a feature requires schema changes, create a single migration file with
the Supabase CLI (
bunx supabase migration new <feature_slug>) and keep editing that file until the feature ships; never edit previously committed migrations. - If a migration file is newly created and not yet committed, it may be modified.
- Keep amending the same migration file while the pull request is still open.
- Never create a second migration file for the same schema change set. If not yet merged, do not split work into multiple migration files for one change.
- Use CLI for migrations. Never manually create migration files.
- Updating
supabase/seed.sqlto back new or evolved tests is expected; keep fixtures focused on current behavior while leaving committed migrations unchanged. - A migration that introduces a new table may include seed inserts for that table, but treat that seeding as part of the current feature and do not modify previously committed migrations.
- Investigate failing Supabase tests by reviewing the Docker container logs and any other relevant service logs before retrying.
- Before validating any backend or frontend task, run the project lint/format command to ensure consistent formatting.
- Leave
CHANGELOG.mdentries and theversionfield inpackage.jsonto the release automation; CI/CD updates them during tagged releases. - Do not create new cron jobs it's bad pattern instead update process_all_cron_tasks function in a new migration file to add your job if needed.
- For runtime feature flags and security-related toggles, use runtime config from Vault-backed settings and avoid mutable singleton tables in application code.
- Do not store environment-driven behavior in singleton tables.
- Use Vault-backed configuration values as the source of truth and runtime environment values only for deployment-time overrides.
- Never use the Supabase admin SDK (with service key) for user-facing APIs. Always use the client SDK with user authentication so RLS policies are enforced. The admin SDK should only be used when accessing data that is not user-accessible or for internal operations (triggers, CRON jobs, etc.). When admin access is unavoidable for a user-facing endpoint, sanitize all user inputs carefully—the SDK is susceptible to PostgREST query injection (not SQL injection, but filter/modifier injection via crafted parameters).
- Prefer claim-based auth lookups for performance: use
supabase.auth.getClaims()(frontend) or auth context from middleware (backend) instead ofgetUser()unless you explicitly need the full user record from the Auth API.
- Avoid introducing new PostgreSQL extensions if an existing feature or SQL approach can solve the same requirement.
- If an extension is truly unavoidable, add it only with explicit user consent and never by default.
- If there is no practical alternative, add a migration with a clear fallback plan.
- Never enable a new PostgreSQL extension without explicit user consent before applying it.
ALWAYS set an empty search path in every PostgreSQL function.
Every function must set search_path = '' and use fully qualified names for all references:
-- CORRECT: Empty search_path with fully qualified names
CREATE OR REPLACE FUNCTION "public"."my_function"()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
SELECT * FROM "public"."my_table";
-- All table/type references must be fully qualified
END;
$$;
-- WRONG: Missing search_path - vulnerable to attacks
CREATE OR REPLACE FUNCTION public.my_function()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
SELECT * FROM my_table;
END;
$$;Every PostgreSQL function call added to a policy, view, trigger, RPC,
PostgREST-exposed query path, or hot backend endpoint must be proven to scale
before it ships. Treat this as mandatory for RLS helpers such as
check_min_rights, get_identity_org_appid, get_identity_org_allowed, and
any new wrapper around them.
Before adding or changing a function call, document the execution model:
- Where the function runs: RLS
USING/WITH CHECK, SQL view, trigger, RPC, backend query, or plugin endpoint. - How often it can run: once per statement, once per request, once per row, or once per candidate row from another table.
- Which roles can reach it:
anon,authenticated,service_role, API key, or public PostgREST traffic. - The production cardinality of every table it touches, using
supabase/schemas/prod.sql, production estimates, or staging data with production-like row counts. - The exact indexes expected to bound each lookup.
RLS function calls are dangerous by default:
- Assume every function inside an RLS policy can run per row unless
EXPLAIN (ANALYZE, BUFFERS)proves otherwise. - Never fix an unbounded table scan by adding an "allowed ids" helper that scans another large table. Allowed-list helpers are only acceptable when they start from caller-scoped, indexed identity data and stay bounded before touching the protected resource.
- Never create a helper that scans a broad production table and calls
check_min_rights,get_identity*, RBAC checks, API-key checks, logging, or other SQL functions once per scanned row. - If a table has
app_id, prefer a policy shape that constrains by that row's indexedapp_id/owner_orgvalues. Do not precompute visibility by scanning all apps, all versions, all channels, or all org resources.
Before opening a PR with a PostgreSQL function or RLS policy change, run and
paste the relevant EXPLAIN (ANALYZE, BUFFERS) summary in the PR notes for the
worst cases:
- Public
anonrequest with no auth and no API key. - Invalid API key.
- Valid API key with broad access.
- Authenticated user with many orgs/apps.
- Unfiltered PostgREST query with
limit=1. - Filtered control query on the normal indexed filter path.
- At least 8 parallel unfiltered requests plus an unrelated lightweight query
such as
/orgs?select=id&limit=1.
The plan must show bounded index lookups, stable latency, and no sequential scan over large production tables. Nested loops are acceptable only when the outer side is already bounded and the inner side uses indexes. If production-scale testing is not possible, choose the conservative safer design: deny or restrict the path, require an indexed filter, split the endpoint, or ask for review before shipping. Do not guess.
For RPCs and helper functions, apply minimum privileges explicitly:
- Start from deny-by-default and grant only required roles.
- Set
OWNERexplicitly for each new function. - Use
REVOKE ALL ... FROM PUBLICto prevent public access drift from default ACLs. - If
uuid-based checks exist, do not grantanonorauthenticatedunless there is a strict user-facing requirement. - Prefer granting only
service_roleforuuidoverloads and keep user-context variants (()) on authenticated access only where needed.
Prevent RPCs from becoming an oracle system.
- Do not expose RPCs that let unauthorized users infer whether sensitive data exists, matches, or belongs to another user or org.
- Default to denying access unless the caller already has the rights required to read the underlying data through the normal permission model.
- If an RPC returns data, metadata, booleans, counts, or different error shapes, treat all of those as potential data leaks and gate them the same way.
- Never use a publicly callable RPC to answer sensitive existence checks, membership checks, entitlement checks, or status checks for records the caller cannot already access.
- Only allow broader access when the information is genuinely non-critical and the UX benefit is material.
- In that exceptional case, add an English code comment directly next to the function, endpoint, or policy explaining why the exposure is acceptable, what is intentionally revealed, and why restricting it would significantly harm UX.
WHEN ADDING AN ADMIN/PLATFORM-RBAC CHECK FUNCTION:
- DEFINE ONE SERVICE-ROLE-ONLY
uuidOVERLOAD FOR INTERNAL LOOKUPS. - DEFINE ONE USER-CONTEXT
()OVERLOAD FOR CLIENT USAGE. - APPLY
REVOKE ALL ... FROM PUBLICTO EVERY OVERLOAD. - GRANT
service_roleTOuuidONLY; GRANTauthenticatedONLY TO()IF NEEDED. - KEEP
SET search_path = ''ANDSECURITY DEFINEREXPLICIT. - COMMENT THE BEHAVIOR (E.G., LEGACY VS PLATFORM SECRET CHECK) TO PREVENT REGRSSION.
ALTER FUNCTION public.is_platform_admin(userid uuid) OWNER TO "postgres";
REVOKE ALL ON FUNCTION public.is_platform_admin(userid uuid) FROM PUBLIC;
GRANT ALL ON FUNCTION public.is_platform_admin(userid uuid) TO "service_role";Platform admin is NOT a general-purpose superuser capability.
- The only allowed platform-admin user action is spoofing/impersonating another user.
- The admin dashboard must stay read-only and limited to admin statistics, observability, and similar reporting.
- Never build a platform-admin write path that can change org membership, RBAC bindings, roles, permissions, billing state, app ownership, or any other privilege-bearing state.
- Never use platform admin as a shortcut around normal auth/RLS for mutating APIs. If an action could cause privilege elevation, do not expose it behind platform admin.
- Platform admins are defined from runtime Vault-backed configuration, not from mutable database state.
- There must be no API, UI, or database path to grant/revoke platform admin dynamically. The only supported way to change platform admins is to publish a new runtime version with updated environment configuration.
- If a privileged operational action is needed beyond impersonation or reading admin stats, implement it as a strictly internal service-role-only path, not as a platform-admin feature.
Rule 1: One policy per table per operation.
Never create duplicate policies for the same operation on a table. Multiple policies on the same operation create OR conditions that hurt query performance. Merge all conditions into a single policy:
-- WRONG: Multiple SELECT policies on the same table
CREATE POLICY "policy_1" ON public.my_table FOR SELECT USING (condition_1);
CREATE POLICY "policy_2" ON public.my_table FOR SELECT USING (condition_2);
-- CORRECT: Single merged policy
CREATE POLICY "Allow select on my_table" ON public.my_table
FOR SELECT USING (condition_1 OR condition_2);Rule 1.5: Never rely on implicit deny for table operations.
If an operation is intentionally forbidden, add an explicit deny policy for that operation instead of relying on the absence of a policy.
- Do not use "no INSERT policy means INSERT is blocked" as the final design.
- Do not use "no DELETE policy means DELETE is blocked" as the final design.
- The expected repository style is explicit allow or explicit deny for each operation you intentionally care about.
- This is especially important for security-sensitive tables and system-managed
tables such as
manifest.
When an operation must be impossible for user-facing roles, prefer an explicit deny policy with a clear name, for example:
-- Example: system-managed table, users must never insert rows directly
CREATE POLICY "Deny insert on my_table"
ON public.my_table
AS RESTRICTIVE
FOR INSERT
TO authenticated
WITH CHECK (false);
-- Example: users must never delete rows directly
CREATE POLICY "Deny delete on my_table"
ON public.my_table
AS RESTRICTIVE
FOR DELETE
TO authenticated
USING (false);If API key traffic must also be denied through user-context RLS, make that intent explicit in the policy design and naming. Do not leave the operation blocked only because no policy happened to exist.
Rule 2: Call auth.uid() only once using a subquery.
The auth.uid() function should never be called multiple times in a policy. Use a SELECT * subquery pattern to call it once and reference the result:
-- WRONG: Multiple auth.uid() calls - poor performance
CREATE POLICY "my_policy" ON public.my_table
FOR SELECT USING (
user_id = auth.uid()
OR owner_id = auth.uid()
OR created_by = auth.uid()
);
-- CORRECT: Single auth.uid() call with subquery
CREATE POLICY "my_policy" ON public.my_table
FOR SELECT USING (
(SELECT * FROM (SELECT auth.uid() AS uid) AS auth_check
WHERE user_id = auth_check.uid
OR owner_id = auth_check.uid
OR created_by = auth_check.uid)
);
-- ALSO CORRECT: Using a CTE-style approach in the check
CREATE POLICY "my_policy" ON public.my_table
FOR SELECT USING (
EXISTS (
SELECT 1
FROM (SELECT auth.uid() AS uid) AS auth_user
WHERE my_table.user_id = auth_user.uid
OR my_table.owner_id = auth_user.uid
)
);NEVER use get_identity() directly in RLS policies.
ALWAYS use get_identity_org_appid() when app_id exists on the table.
public.get_identity_org_appid(
'{read,upload,write,all}'::public.key_mode[],
owner_org, -- or org_id
app_id
)get_identity_org_allowed() is an ABSOLUTE LAST RESORT. Only use it when:
- The table genuinely has NO app_id column
- There is NO way to join to get an app_id
- You have exhausted all other options
If you find yourself reaching for get_identity_org_allowed(), STOP and ask:
"Is there ANY way to get an app_id here?" If yes, use get_identity_org_appid().
-- CORRECT: Table has app_id - use get_identity_org_appid
CREATE POLICY "Allow org members to select build_requests"
ON public.build_requests
FOR SELECT
TO authenticated, anon
USING (
public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_appid(
'{read,upload,write,all}'::public.key_mode[],
owner_org,
app_id
),
owner_org,
app_id,
NULL::BIGINT
)
);
-- CORRECT: Table has no app_id but can JOIN to get it
CREATE POLICY "Allow org members to select daily_build_time"
ON public.daily_build_time
FOR SELECT
TO authenticated, anon
USING (
EXISTS (
SELECT 1 FROM public.apps
WHERE apps.app_id = daily_build_time.app_id
AND public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_appid(
'{read,upload,write,all}'::public.key_mode[],
apps.owner_org,
apps.app_id
),
apps.owner_org,
apps.app_id,
NULL::BIGINT
)
)
);
-- LAST RESORT: Table has NO app_id and NO way to get one (e.g., build_logs)
CREATE POLICY "Allow org members to select build_logs"
ON public.build_logs
FOR SELECT
TO authenticated, anon
USING (
public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed(
'{read,upload,write,all}'::public.key_mode[],
org_id
),
org_id,
NULL::CHARACTER VARYING,
NULL::BIGINT
)
);Key points:
- Use both
authenticatedandanonroles (anon enables API key auth) - Pass app_id to BOTH
get_identity_org_appid()ANDcheck_min_rights() - Reference apps, channels, app_versions tables for more examples
- The web client is built with Vue.js and Tailwind CSS; lean on utility classes and composition-friendly patterns rather than bespoke CSS.
- Use DaisyUI (
d-prefixed classes) for buttons, inputs, and other interactive primitives to keep behavior and spacing consistent. - Konsta components are reserved for the safe area helpers. Avoid importing
konstaanywhere else in the app. - Capgo's look centers on deep slate bases with the "Extract" azure highlight
(
--color-azure-500: #119eff) and soft radii; mirror the palette fromsrc/styles/style.css(e.g.,--color-primary-500: #515271) when introducing new UI.
- We intentionally route auth email links through
/confirm-signupto avoid mailbox link prefetchers triggering Supabase verification/recovery links. confirm-signup.vueonly allows redirects to the console host and the Supabase host; if you changeVITE_APP_URLorVITE_SUPABASE_URL, update both allow-lists accordingly.
- Cover customer-facing flows with the Playwright MCP suite. Add scenarios under
playwright/e2eand run them locally withbun run test:frontbefore shipping UI changes.
- App ID:
ee.forgr.capacitor_go - Build command:
bun mobile(builds and copies to platforms) - iOS/Android projects in respective platform directories
- Uses Capacitor Updater plugin for OTA updates
Our main database is hosted on Supabase. We use custom replica hosted in Planetscale.
We have 5 read replicas for our main database to ensure high availability and low latency for read operations. These replicas are synchronized with the primary database using logical replication. We have one replica by continent:
- North America (Ohio)
- Europe (Frankfurt)
- Asia (Seoul)
- Australia (Sydney)
- South America (Sao Paulo)
Applications are configured to read from the nearest replica based on the user's
location. This repartition is done by Cloudflare snippets at
cloudflare_workers/snippet/index.js.
When backend code uses the plugin read-path (/updates, /stats, /channel_self), it must only query what exists in the read replicas.
- Logical replication replicates table data, not derived objects like views and SQL functions.
- Treat the read replica (what you see in PlanetScale) as the source of truth for what is queryable from plugin endpoints.
- Do not query credits ledger tables/views from the replica (e.g.
usage_credit_*/usage_credit_balances). If plugin logic needs a “has credits” signal, materialize it into a replicated column/table (example: an org-level boolean flag that is refreshed by primary-side jobs). /updates,/stats, and/channel_selfare extremely hot paths and can be called hundreds of times per second.- Those endpoints must not call the primary Supabase/Postgres database in-request or through
backgroundTask()side effects unless there is no other practical option. - Background work is not an exception: do not enqueue primary-DB RPCs, writes, or lookups from these plugin endpoints just because the response is returned first.
- If an unavoidable primary write remains for one of these endpoints, keep it minimal, document the reason inline, and treat it as an exception that requires extra review.
Every pull request MUST include the following sections:
- Summary - Brief description of what changed
- Motivation - Why this change is needed
- Business Impact - How this affects Capgo's business, users, or revenue
- Test Plan - Checklist for testing the changes
CRITICAL: ALL sections in a PR created by AI agents MUST be marked with "(AI generated)".
Example:
## Summary (AI generated)
- Fixed the build system RLS policies
## Motivation (AI generated)
The native build system needed consistent RLS patterns...
## Business Impact (AI generated)
This enables revenue growth by providing a working build system...
## Test Plan (AI generated)
- [ ] Verify authenticated users can access build requests
- [ ] Verify API key authentication worksWARNING: Failure to mark AI-generated sections is a violation of transparency requirements. If you do not mark sections as "(AI generated)", you are doing it wrong and this is unacceptable behavior. You will be punished for not being transparent about AI-generated content. ALWAYS mark every section with "(AI generated)".
## Summary (AI generated)
- [Bullet points of changes]
## Motivation (AI generated)
[Why this change is needed]
## Business Impact (AI generated)
[How this affects Capgo - revenue, users, experience, etc.]
## Test Plan (AI generated)
- [ ] [Testing checklist]
Generated with AICRITICAL: All changes to public APIs and plugin interfaces MUST be backward compatible.
Customers take time to update their apps and plugins. Breaking changes cause production issues for users who haven't updated yet. Follow these rules:
- New fields: Can be added freely - old clients will ignore them
- Existing fields: Never remove or change the type/meaning
- New error codes: Fine to add, but don't remove existing ones
- Response format: Must remain compatible with older plugin versions
When behavior must differ between plugin versions, use version detection:
const pluginVersion = body.plugin_version || '0.0.0'
let isNewVersion = false
try {
const parsed = parse(pluginVersion)
isNewVersion = !isDeprecatedPluginVersion(parsed, MIN_V5, MIN_V6, MIN_V7, MIN_V8)
}
catch (error) {
// If version parsing fails, assume old version for safety
}
if (isNewVersion) {
// New behavior for updated plugins
}
else {
// Legacy behavior for old plugins
}- Adding a new optional response field: Old plugins ignore it, new plugins use it
- Changing error to success with new flag: Return success with
unset: trueinstead of error - old plugins see success, new plugins handle the flag - New endpoint: Doesn't affect existing clients
- Removing a response field that old plugins depend on
- Changing the meaning of an existing field
- Returning different HTTP status codes for the same scenario
- Removing support for old request formats
When in doubt, support both old and new behavior based on plugin version detection.
The deployment happens automatically after GitHub CI/CD on main branch.
You are not allowed to deploy on your own, unless if asked. Same for git you never git push on main branch, add or commit unless asked. You can do it in others branches