` handoff and profile restoration; translation coverage remains application-owned and does not imply that all existing UI copy is translated.
`src/i18n/config.ts` defines the supported locales, fallback locale, and cookie name:
```ts
export const SUPPORTED_LOCALES = ['en', 'fr'] as const;
export const DEFAULT_LOCALE = 'en';
export const LOCALE_COOKIE_NAME = 'supacharger_locale';
```
Keep a matching `messages/.json` file for every entry in `SUPPORTED_LOCALES`. The request configuration validates the cookie before loading a catalogue, so an unknown or malformed locale falls back to `DEFAULT_LOCALE` instead of attempting an arbitrary dynamic import.
## Request and client configuration
`src/i18n/request.ts` reads the locale preference and returns the locale and messages to `next-intl`. The root layout uses `getLocale()` and `getMessages()` and passes both to `NextIntlClientProvider`, which makes translations available to Server and Client Components.
Supacharger enables Next.js Cache Components in every application's merge-managed Next configuration. Since the validated locale cookie determines both `` and the messages provider before the document can render, the developer-owned root layout exports `instant = false`. Next validates each route segment independently, so pages and nested layouts that directly perform request-time authentication also need an explicit boundary: `instant = false` for deliberately blocking identity/access checks, or local `Suspense` for streamable work. Cache Components makes request-time data dynamic by default; do not restore `dynamic = 'force-dynamic'` or cache user-specific authentication reads with `use cache`. The shared `test:auth-instant` contract checks these route segments.
The `/api/intl` endpoint changes the preference. Send a supported locale as JSON:
```ts
await fetch('/api/intl', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({locale: 'fr'})
});
```
The endpoint validates the locale and stores it in an HTTP-only, same-site cookie. For an authenticated user, it also stores the code in the profile. Reload or refresh the current route after a successful change. The pathname does not change.
## Profile persistence and precedence
The canonical profile column is `app.profiles.language_code`. It accepts lowercase two-letter codes (`^[a-z]{2}$`). The database constraint is deliberately language-agnostic; `SUPPORTED_LOCALES` is the application-level allowlist that controls which catalogues can actually be selected and loaded.
Supacharger resolves preferences in this order:
1. A valid `?lang=` marketing handoff.
2. A valid `supacharger_locale` cookie created by an explicit selection.
3. The signed-in user's saved `language_code` when the browser has no valid locale cookie.
4. `DEFAULT_LOCALE`.
When a signed-out visitor arrives with a valid handoff, Supacharger stores the cookie and marks it for profile synchronisation. After the visitor signs in, the preference is written to the profile. On another browser or device with no locale cookie, the supported saved profile value is restored into the cookie.
Supacharger, SnapScreen, Wakekeeper, and Specdrive all persist this contract in `app.profiles.language_code`. Specdrive also exposes the language choice in Account Settings.
## Using translations
Use a namespace from the active application's catalogue:
```tsx
import {useTranslations} from 'next-intl';
export function SaveNotice() {
const t = useTranslations('GlobalUI');
return {t('buttonSaved')}
;
}
```
Use `getTranslations` from `next-intl/server` in async Server Components, Server Actions, metadata, and route handlers.
## Authoring and translating copy
Always add or update the English source value in `messages/en.json` when a localised key or its wording changes. English is the authoring language and must be complete for the affected surface.
Before introducing or changing `useTranslations` or `getTranslations`, verify that its namespace and every statically referenced key exist with non-empty values in `messages/en.json`. Commit the consuming code and English catalogue together; otherwise `next-intl` raises `MISSING_MESSAGE` at runtime even when a secondary catalogue contains the namespace.
Do not invent, machine-translate, copy the English wording into, or otherwise fill French or another secondary-language value unless translation into that language is explicitly requested. If catalogue structure or a validation check requires the new key in a secondary catalogue, add the key with an empty string; otherwise leave that catalogue untouched. Empty strings are untranslated placeholders, not completed translations, and should be reported as pending translation.
Where a protected component supports older or incomplete developer-owned catalogues, keep an intentional English fallback so an empty or unavailable value does not expose a raw key or `MISSING_MESSAGE` error. Do not describe a locale or surface as translation-complete while placeholders remain.
## Adding a language
1. Add the locale to `SUPPORTED_LOCALES`.
2. Add `messages/.json` with every required namespace and key.
3. Add the locale to the application's language selector.
4. Test signed-out and signed-in screens, number/date formatting, and fallback behaviour.
Because catalogues are not CLI-managed, a core upgrade cannot insert newly required strings. Review the release notes, add the required English values, and add empty secondary placeholders only where catalogue structure requires them. Fill those placeholders only as part of explicitly requested translation work.
---
## Login redirects and subscription paywalls
Supacharger has three related settings, but they do different jobs:
| Setting | What it controls | What it does not control |
| --- | --- | --- |
| `USER_REDIRECTS.AUTHED_USER.LOGIN_REDIRECT_DESTINATION` | The normal destination after password sign-in and after a successful magic-link or social OAuth callback | General “home” links and access control |
| `USER_REDIRECTS.AUTHED_USER.HOME_PATH` | The authenticated home used by general navigation and flows such as completed password reset | The PKCE callback destination |
| `BILLING_ACCESS.REQUIRED` | Whether the magic-link/social callback checks billing access and diverts a user without access | A site-wide route guard or database authorisation policy |
Protected server routes use three access levels:
| Boundary | Checks | Appropriate routes |
| --- | --- | --- |
| `requireVerifiedUser()` | Verified, non-anonymous identity according to configuration | Profile setup and other identity recovery |
| `requireOnboardedUser()` | Verified identity, then configured profile completion | Subscription acquisition |
| `requireAppAccess()` | Verified identity, onboarding first, then configured billing access | Full product routes |
A recovery destination must not inherit the guard for the condition it recovers. Route groups can give `/account/setup-profile` a verified-only layout and `/account/billing/subscribe` an onboarded layout without changing either public URL.
## Redirect order in the current core
For a magic link or social provider, Supabase first returns the browser to the allow-listed application callback, normally `/auth/callback`. Supacharger then processes the result in this order:
1. Exchange the PKCE code for a session and verify that a user exists.
2. If `POST_SIGN_IN_ONBOARDING.REQUIRED` is enabled and the profile is incomplete, redirect to `POST_SIGN_IN_ONBOARDING.REDIRECT_PATH`.
3. If `BILLING_ACCESS.REQUIRED` is enabled, call the server-side billing-access function. A user without access is redirected to `BILLING_ACCESS.REDIRECT_PATH`.
4. Otherwise, redirect to `LOGIN_REDIRECT_DESTINATION` and add the one-time successful-login notice.
In compact form:
```text
Supabase callback
-> valid session?
-> required profile complete?
-> required billing access present?
-> LOGIN_REDIRECT_DESTINATION
```
Password sign-in navigates directly to `LOGIN_REDIRECT_DESTINATION`; it does not run callback policy in `/auth/callback`. The matched protected server layout then enforces the appropriate verified, onboarded, or full-app boundary before rendering. Proxy remains claims-only and performs neither database check.
`BILLING_ACCESS.REQUIRED: true` protects the canonical authenticated layout and callback journey, but it is not a substitute for handler-specific authorisation. Enforce entitlement checks in protected APIs and Server Actions that do not render through that layout, and keep Supabase Row Level Security as the final database boundary where appropriate.
## Recommended configurations
### One authenticated landing page, no forced subscription
Use this when users may enter the product without paying:
```ts
USER_REDIRECTS: {
AUTHED_USER: {
HOME_PATH: '/app',
AUTHGUARD_REDIRECT_DESTINATION: '/app',
LOGIN_REDIRECT_DESTINATION: '/app',
},
},
BILLING_ACCESS: {
REQUIRED: false,
FEATURE_LOOKUP_KEY: null,
REDIRECT_PATH: '/account/billing/subscribe?full=1',
},
```
This is the simplest default. All normal authenticated navigation and successful sign-ins converge on `/app`. Paid features should check their own entitlement when used.
### Public marketing home and authenticated product home
If `/` is a public marketing page, do not use `/` as the authenticated destination unless returning signed-in users to marketing is deliberate:
```ts
USER_REDIRECTS: {
UNAUTHED_USER: {
HOME_PATH: '/',
AUTHGUARD_REDIRECT_DESTINATION: '/account/login',
LOGOUT_REDIRECT_DESTINATON: '/',
},
AUTHED_USER: {
HOME_PATH: '/dashboard',
AUTHGUARD_REDIRECT_DESTINATION: '/dashboard',
LOGIN_REDIRECT_DESTINATION: '/dashboard',
},
},
```
Keeping the three authenticated destinations aligned avoids surprising differences between login, “home”, and attempts to revisit the login page.
`LOGOUT_REDIRECT_DESTINATON` must be an application-relative path beginning with one `/`, never an absolute or protocol-relative URL. The managed logout button receives this path as JSON from `POST /account/logout` after the SSR session is cleared. A developer-owned direct link may use `GET /account/logout`, which clears the same session and responds with `303` plus that relative path in `Location`. The browser resolves both forms against the origin it opened rather than `NEXT_PUBLIC_SITE_URL`.
### Callback subscription detour
Use this only when passwordless/social callbacks should send users without the required access to the subscription page:
```ts
BILLING_ACCESS: {
REQUIRED: true,
FEATURE_LOOKUP_KEY: 'product_access',
REDIRECT_PATH: '/account/billing/subscribe?full=1',
},
```
`FEATURE_LOOKUP_KEY` should be a stable entitlement lookup key. A value of `null` uses the compatibility rule of any `trialing` or `active` Subscription. The redirect path must exist beneath an onboarded-only boundary and must remain reachable by a profile-complete authenticated user who does not yet have access. Placing it beneath `requireAppAccess()` creates a self-redirect loop.
If the whole product must be paywalled, also add server-side entitlement enforcement to protected page layouts, route handlers, Server Actions, and APIs. Apply the same policy to password sign-in so every authentication method behaves consistently. A successful Stripe Checkout return is not itself proof of access; use the local entitlement projection populated by webhooks and reconciliation.
### Different post-login and home destinations
The two paths may deliberately differ:
```ts
AUTHED_USER: {
HOME_PATH: '/app',
AUTHGUARD_REDIRECT_DESTINATION: '/app',
LOGIN_REDIRECT_DESTINATION: '/welcome',
},
```
Use this only for an unconditional post-login landing page. For conditional profile setup, use `POST_SIGN_IN_ONBOARDING` instead; it runs before the billing check and normal callback destination. Ensure `/welcome` does not redirect an authenticated user back to login or create a loop.
## Supabase URL Configuration is a separate layer
These Supacharger values are application paths after a session has been established. They do not replace the hosted Supabase settings under **Authentication → URL Configuration**.
- Set the Supabase **Site URL** to the production origin.
- Add the exact production callback URL, such as `https://example.com/auth/callback`, to **Redirect URLs**.
- Add the password-reset and confirmation destinations used by the project.
- Add localhost and preview URLs only for the environments that need them; prefer exact production paths over broad wildcards.
- Keep `NEXT_PUBLIC_SITE_URL` aligned with the deployed origin so Supacharger builds the same callback origin that Supabase allows.
Supabase validates the full `redirectTo` URL before returning a passwordless or social authentication flow. After the browser reaches `/auth/callback`, Supacharger applies the onboarding, billing, and login-destination decisions described above. See the official [Supabase Redirect URLs guide](https://supabase.com/docs/guides/auth/redirect-urls).
## Release checklist
- Every configured path starts with `/` and exists in the application.
- The onboarding destination inherits only `requireVerifiedUser()` and the subscription destination inherits only `requireOnboardedUser()`.
- No recovery response repeats the same effective URL after one recovery hop.
- All enabled authentication methods reach the intended product destination.
- Password, magic-link, OTP, and social login are tested separately because they do not all use `/auth/callback`.
- Paid pages and APIs reject missing entitlements on the server, independently of browser redirects.
- Supabase RLS protects paid or private database data where client access is possible.
- The hosted Supabase callback allow-list and `NEXT_PUBLIC_SITE_URL` match production.
---
## Account security and TOTP MFA
The managed `/account/security` page shows the user's Auth providers and supplies real email, password, and authenticator-app controls. Email and password mutations reauthenticate password users at the server boundary. OAuth-only users see those controls disabled because they do not have a password identity to reauthenticate.
## Configure authenticator MFA
```ts
AUTHENTICATION: {
MFA_TOTP: {
REQUIRED_FOR_SIGN_IN: true,
},
},
```
Enrolment and factor management are always displayed. `REQUIRED_FOR_SIGN_IN` sends an enrolled user whose session is at AAL1 through `/account/mfa` before completing a password, OTP, magic-link, or OAuth sign-in destination. If no verified factor exists, the session has no AAL2 step to perform; the user may enrol from Security.
For local development, set both `enroll_enabled = true` and `verify_enabled = true` under `[auth.mfa.totp]` in `supabase/config.toml`, then restart the local Supabase stack. Hosted projects use their separate Auth MFA setting in the Supabase Dashboard; changing the local TOML file does not change a hosted project.
Supacharger uses Supabase Auth's `listFactors`, `enroll`, `challengeAndVerify`, and `unenroll` operations. It never stores a TOTP secret in application tables. An unverified enrolment can be cancelled. A verified factor requires a current six-digit code when the session needs to step up before removal.
## Email and password changes
`POST /api/account/email` parses the request with Zod, verifies the current user and password identity, reauthenticates the current password, and asks Supabase Auth to send the secure email-change confirmation. The redirect returns to `/account/security`.
`POST /api/account/update-password` applies the configured password policy, verifies the current password, and calls `auth.updateUser`. OAuth-only accounts keep both forms visibly unavailable instead of exposing a control that cannot succeed.
## Product presentation
The security route and non-visual behaviour are managed. A developer-owned `src/supacharger.adapters/account/security-page.tsx` starter may preserve an authorised product presentation. The CLI installs this adapter only when missing and never overwrites it. Specdrive uses this seam; its restricted presentation is not copied into the open-source Core.
---
## Mobile login verification with deep links
Supacharger supports verified HTTPS links for mobile authentication. iOS calls these Universal Links; Android calls them App Links. The same email URL opens the installed app when the platform association succeeds and falls back to the web route when the app is unavailable.
This guide covers magic-link sign-in, link-based email confirmation, and password-reset callbacks. An email OTP is different: the user copies or autofills the configured-length code into the Supacharger form, so that flow does not need to leave and reopen the app.
## How the callback works
```text
app → Supabase Auth → email client
↓
installed app ← verified HTTPS callback → browser fallback
↓
same initiating client exchanges the one-use PKCE code
```
Supacharger's `/auth/callback` Route Handler exchanges the `code` for a session. Supabase PKCE codes expire quickly, can be exchanged only once, and require the verifier stored by the client that started the flow. Therefore:
- a wrapped Supacharger web app must load the incoming URL in the same persistent web view and cookie store that requested the email;
- a fully native app must start and complete the flow with the same native Supabase client and secure storage adapter; and
- neither implementation should copy access tokens, refresh tokens, or PKCE verifiers into logs, analytics, application metadata, or another URL.
See Supabase's [PKCE flow](https://supabase.com/docs/guides/auth/sessions/pkce-flow), [native mobile deep linking](https://supabase.com/docs/guides/auth/native-mobile-deep-linking), and [redirect URL](https://supabase.com/docs/guides/auth/redirect-urls) guidance.
## Configure Supacharger
Edit the developer-owned `src/supacharger.config.ts` (`supacharger.config.ts` in Specdrive). Replace every example value with identifiers belonging to the application being signed:
```ts
MOBILE_DEEP_LINKING: {
ENABLED: true,
ASSOCIATED_PATHS: [
'/auth/callback',
'/auth/confirm',
],
IOS: {
APP_IDS: ['ABCDE12345.com.example.myapp'],
},
ANDROID: {
APPS: [
{
PACKAGE_NAME: 'com.example.myapp',
SHA256_CERT_FINGERPRINTS: [
'',
],
},
],
},
},
```
`IOS.APP_IDS` uses `.`, not the numeric App Store ID. `ANDROID.APPS` can contain separate debug, staging, or production packages. A package can contain multiple fingerprints during an intentional signing-key transition.
The example Apple prefix, bundle identifier, Android package, domain, and fingerprint are placeholders. Public documentation must never reproduce an application's real values merely to provide an example.
When a platform has no configured identifiers, its well-known endpoint returns `404`. Once enabled and configured, Supacharger generates current platform documents at:
```text
https://app.example.com/.well-known/apple-app-site-association
https://app.example.com/.well-known/assetlinks.json
```
Each URL must respond directly over HTTPS with status `200`, `Content-Type: application/json`, and no redirect. Configure every production subdomain independently. The generated iOS document uses Apple's current `appIDs` and `components` format; the Android document uses `delegate_permission/common.handle_all_urls`.
## Configure Supabase Auth
In each hosted Supabase environment:
1. Set **Authentication → URL Configuration → Site URL** to the canonical production web origin, such as `https://app.example.com`.
2. Add exact production redirect URLs for every enabled flow:
```text
https://app.example.com/auth/callback
https://app.example.com/auth/confirm
```
3. Use broad `/**` patterns only for local development or deployment previews. Prefer exact production paths.
4. If an email template constructs its own confirmation URL while the application supplies `emailRedirectTo`, use Supabase's `{{ .RedirectTo }}` variable as documented. Keep `{{ .Token }}` for the OTP template.
5. Configure hosted settings separately from `supabase/config.toml`; the repository file affects only the local stack.
Supacharger's magic-link operation sends users to `/auth/callback`. Password recovery uses `/auth/callback?flow=recovery`, so it is covered by the same callback path and does not associate the internal `/account/reset-password/new` page. Link-based sign-up confirmation uses `/auth/confirm` with `token_hash` and `type`. OTP sign-up calls `verifyOtp` with the entered email and token and does not use the well-known endpoints.
## iOS Universal Links
Follow Apple's [associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains) and [Universal Link](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) guidance:
1. In Xcode, select the native target and add **Signing & Capabilities → Associated Domains**.
2. Add the exact host without a scheme, path, query, or trailing slash:
```text
applinks:app.example.com
```
3. Confirm that the signed target's application identifier exactly matches an `IOS.APP_IDS` entry.
4. Accept only the expected HTTPS host and paths when continuing the user activity. A web-wrapper bridge can use this shape:
```swift
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL,
url.scheme == "https",
url.host == "app.example.com",
["/auth/callback", "/auth/confirm"]
.contains(url.path)
else { return }
authWebView.load(URLRequest(url: url))
}
```
Use the web view and data store that initiated authentication. A native Swift client should instead give the verified URL to its native auth coordinator and complete the code exchange there.
Apple fetches the association through its CDN and may cache it. The file must be named `apple-app-site-association` without a `.json` extension. Apple's [Universal Link diagnostics](https://developer.apple.com/documentation/technotes/tn3155-debugging-universal-links) recommend:
```bash
sudo swcutil dl -d app.example.com
sudo swcutil verify -d app.example.com -j ./apple-app-site-association \
-u 'https://app.example.com/auth/callback'
```
On a device, paste the link into Notes and long-press it. Typing the URL directly into Safari's address bar intentionally remains browser navigation and is not a valid Universal Link test.
## Android App Links
Follow Android's [App Link intent-filter](https://developer.android.com/training/app-links/add-applinks), [website association](https://developer.android.com/training/app-links/configure-assetlinks), and [verification](https://developer.android.com/training/app-links/verify-applinks) guidance.
Use the application ID from the native module's Gradle configuration. When Google Play App Signing is enabled, use the app-signing certificate fingerprint shown by Play Console—not the local upload-key fingerprint. Fingerprints are uppercase, colon-separated SHA-256 values.
Declare verified HTTPS paths in `AndroidManifest.xml`. Separate filters avoid accidental combinations when hosts or path rules later diverge:
```xml
```
The generated `assetlinks.json` proves the package/domain relationship. On Android versions before dynamic App Links, the native manifest remains responsible for path restrictions, so keep it aligned with `ASSOCIATED_PATHS`.
Handle both a cold start and a new intent, then validate the URL again before loading or exchanging anything:
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleAuthLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleAuthLink(intent)
}
private fun handleAuthLink(intent: Intent) {
val uri = intent.data ?: return
val allowedPaths = setOf(
"/auth/callback",
"/auth/confirm",
)
if (uri.scheme != "https" ||
uri.host != "app.example.com" ||
uri.path !in allowedPaths
) return
authWebView.loadUrl(uri.toString())
}
```
A fully native Kotlin client should pass the validated URI to its native auth coordinator instead of a web view.
After installing the signed build, wait for verification and run:
```bash
adb shell pm set-app-links --package com.example.myapp 0 all
adb shell pm verify-app-links --re-verify com.example.myapp
adb shell pm get-app-links com.example.myapp
adb shell am start -W -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d 'https://app.example.com/auth/callback?code=test'
```
The host should report `verified`. A `legacy_failure`, browser chooser, or browser-only result usually means the deployed file redirected, the package did not match, the wrong signing certificate was used, or the manifest host/path differed.
## Deployment and security checklist
- Deploy the well-known routes before shipping a native build that declares the domain.
- Use exact production hosts and callback paths; do not accept arbitrary `next`, host, scheme, or path values.
- Preserve the full callback query string, but never log it. Auth codes and token hashes are short-lived credentials.
- Keep the callback in the client that initiated PKCE. Do not attempt a second exchange after the code has been consumed.
- Test installed and uninstalled behaviour. Without the app, the same HTTPS URL must complete safely in the browser.
- Test cold start, warm start, expired links, cancelled sign-in, staging and production signing, and password reset separately.
- Do not enable Android with a guessed package or fingerprint. Do not enable iOS with another application's App ID.
- Re-test after changing domains, native application identifiers, signing certificates, callback paths, email templates, or Supabase redirect settings.
---
## Organisation management
Supacharger installs an optional, private organisation backend in every aligned application. Set `ORGANISATIONS.ENABLED` to expose product routes; disabling the feature leaves the schema installed but dormant.
## Data and roles
The reusable contract contains organisations, `owner`/`admin`/`member` memberships, hashed invitations, reviewable access requests, versioned per-session context, and a private media bucket. Owners and admins manage the organisation. The last owner cannot be removed or demoted.
Organisation creation also creates or synchronises the organisation billing subject. Membership removal and role changes invalidate session contexts that are no longer valid.
## Authenticated RPC
Call `api.organisations(input_payload)` with a verified Supabase access token. The RPC derives the current user, Auth email, and `session_id`; never send those values in the payload.
Supported actions are:
- discovery and context: `list`, `handle.available`, `create`, `switch`, and `read`;
- organisation settings: `update`;
- team management: `members.list`, `members.add`, `members.updateRole`, and `members.remove`;
- invitations: `invites.list`, `invites.create`, `invites.accept`, and `invites.revoke`; and
- access review: `access.request`, `access.list`, `access.approve`, and `access.reject`.
The Bruno request in `docs/bruno/supacharger-rpc/organisations.bru` documents the payload fields. Context-changing results include `requiresSessionRefresh`; refresh the browser session before relying on active-organisation claims.
## Handles, invitations, and access requests
Handles are normalised to lowercase route-safe values. `handle.available` returns false for invalid values, existing organisations, and reserved application routes such as `account`, `api`, `auth`, `pricing`, and `settings`.
Invitation creation returns the raw token once and stores only its SHA-256 hash. Tokens expire, are revocable and single use, and acceptance requires the signed-in Auth email to match the normalised recipient email.
`access.request` is available only when the organisation's access policy is `request`. It creates a pending request; it never grants membership. An owner or admin must approve or reject it.
## Organisation media
The private `organisation-logos` bucket accepts JPEG, PNG, WebP, and GIF files up to 5 MB. Store objects under the organisation UUID. Members can read their organisation's objects, while owners and admins control writes. Persist object paths, not signed URLs.
## Specdrive compatibility
Specdrive remains a semantic consumer because its product schema and licensed presentation predate the Core layout. Its forward migration preserves product-only organisation actions and maps canonical `member` to the existing `contributor` value. Core does not adopt that product-specific role name, usage reporting, agent licensing, or licensed UI.
## Managed routes and interface
When `ORGANISATIONS.ENABLED` is true, the account navigation exposes `/account/organisation`. The managed chooser lists memberships and active context, creates organisations after a handle-availability check, switches context, accepts one-time invitation links, and submits reviewed access requests. Context selection and invitation acceptance refresh the Supabase session before navigation so the next request receives current organisation claims.
Root-handle mode exposes:
- `/{handle}/settings` for name, handle, bio, colour, access policy, logo, and header image;
- `/{handle}/settings/team` for roster, roles, invitations, and access review; and
- `/{handle}/settings/billing` when organisation billing is enabled.
The interface includes keyboard-visible controls, mobile layouts, disabled/busy feedback, empty states, safe errors, and semantic `sc-organisation-*` and shared `sc-control-*` class hooks. If organisations are disabled, the routes return a controlled not-found response and the navigation item is absent.
```text
/account/organisation
└─ switch/create/accept/request → refresh session → /{handle}/settings
├─ /team
└─ /billing → /billing/portal
```
Use the canonical option shape while keeping values product-owned:
```ts
ORGANISATIONS: {
ENABLED: false,
AUTHENTICATION_HANDLE: 'disabled',
CHOOSER_PATH: '/account/organisation',
ROUTE_MODE: 'root-handle',
PROFILE_MEDIA: true,
},
BILLING: {
ACCOUNT_SUBJECTS: {
PERSONAL: true,
ORGANISATION: false,
},
},
```
## Product profile extensions
The managed profile form owns `FormProvider`, canonical validation, dirty state, and save feedback. Add product fields in `src/supacharger.adapters/organisations/profile-fields.tsx` with `useFormContext()`. Define the matching JSON-serialisable schema, initial-value loader, and mutation in `profile-extension.ts`.
Core owns the `/account/organisation` and `/{handle}/settings` public routes. Do not leave product `page.tsx` files for those same URLs in another route group: Next.js treats route groups as URL-transparent and rejects the duplicate pages during a production build. Use the developer-owned organisation adapters instead:
- `pages.tsx` can preserve product chooser, team, or billing behaviour and register product-only settings sections;
- `navigation.ts` registers links for those extra sections using unique kebab-case IDs that do not replace `profile`, `team`, or `billing`;
- `chrome.tsx` wraps managed settings in the product application chrome and may enforce the product's stronger access boundary; and
- `src/styles/supacharger-organisations.css` styles the managed semantic classes without editing CLI-managed markup.
The CLI installs missing starter adapters once and preserves established product implementations on later updates.
The server parses both canonical and extension values with Zod before calling the database. It invokes extension persistence only after the canonical owner/admin update succeeds. Product code cannot weaken role checks, handle validation, or organisation Storage paths.
Style the complete shared surface through project tokens and its stable semantic hooks in `src/styles/supacharger-organisations.css`. The CLI installs this developer-owned starter when absent and preserves it thereafter. Keep it unlayered so it can override Tailwind-layer defaults; do not edit managed route/components or copy Specdrive's licensed Untitled UI implementation into Core.
## Upgrade and troubleshooting
Run `supacharger coreupdate --plan` before updating. The plan lists the managed routes/tests, missing developer adapter starters, disabled-safe config additions, English catalogue additions, and the forward organisation migration. An application with a reviewed adapted migration under a different immutable name must declare it in `.supacharger/migration-aliases.json`.
After updating, run `supacharger doctor`. A failure normally names an actionable state: two route-group pages resolve to the same public URL; an obsolete `/organisation` or `/auth/*` page still exists; account/organisation config is incomplete; the managed routes/tests or adapter starters are missing; or a migration alias points to a file that does not exist. A disabled organisation feature returning not found is expected and is not a failed installation.
## Specdrive route transition
Specdrive uses the Core-owned route tree and shell. Its authorised chooser, team and billing behaviour, application chrome, usage page, and agent-licence page remain developer-owned adapter implementations. Its licensed presentation stays in Specdrive's developer CSS and components and is not redistributed through the open-source Core.
---
## Roles and custom claims
Supacharger separates three concepts:
- `role` is Supabase’s database role, normally `authenticated`; do not replace it with an application role.
- `user_role` is the shared, global application role emitted by the custom access-token hook.
- resource roles, such as an organisation owner or member, remain authoritative in membership tables and RLS.
## Enable the canonical hook
The committed configuration is:
```toml
[auth.hook.custom_access_token]
enabled = true
uri = "pg-functions://postgres/app/custom_access_token_hook"
```
The migration grants only `supabase_auth_admin` permission to read `app.user_roles` and execute the hook. `anon`, `authenticated`, and `public` cannot read the role source or call the hook.
For a hosted project, deploy the migration first, then enable the hook separately in that project's Supabase dashboard:
1. Sign in as an organisation or project **Owner** or **Administrator**. A Developer or Read-Only account cannot update Auth configuration; the dashboard or Management API may return `403`.
2. Open the intended hosted project and go to **Authentication → Hooks**.
3. Find **Custom Access Token**, choose **Postgres Function** (also labelled **SQL** in some dashboard versions), and select `app.custom_access_token_hook`.
4. Enable and save the hook. Do not select a similarly named function in `public` and do not create another function when the canonical migration is already deployed.
5. Sign out of the application completely and sign in again, or explicitly refresh the session, so Supabase issues a new access token.
6. Call `supabase.auth.getClaims()` and confirm that `claims.user_role` is present. An existing token can remain stale even after the hook is enabled.
A local `config.toml` controls the local stack; it does not update the hosted dashboard selection automatically. If the migration ledger is current but a fresh hosted token still lacks `user_role`, re-check the selected project, hook type, schema, function, and the permissions of the dashboard account used to save the setting.
## Extend claims without forking Core
Core owns the hook, Supabase-required claims, `user_role`, and the active-organisation claims. Applications add compact product claims through this developer extension function:
```sql
app.custom_access_token_claims_extension(event jsonb, canonical_claims jsonb)
```
The default function returns `{}`. To extend it, create a **new forward migration** and replace only that function body. A developer-owned example is installed at `supabase/templates/custom-access-token-claims-extension.sql`.
```sql
create or replace function app.custom_access_token_claims_extension(
event jsonb,
canonical_claims jsonb
)
returns jsonb
language sql
stable
security invoker
set search_path = ''
as $$
select jsonb_build_object('product_plan', coalesce(plan.lookup_key, 'free'))
from app.product_user_plans plan
where plan.user_id = (event ->> 'user_id')::uuid;
$$;
```
Return only an object of additional product-owned claims. The Core hook rejects attempts to replace Supabase claims, the global role, or active-organisation context. This keeps the contract DRY: Core maintains the secure merge and reserved names while the application owns its product query.
The function runs as `supabase_auth_admin` with `security invoker`. Grant that role only the table access and RLS policy the extension genuinely needs. Preserve the narrow execute grant and revoke browser roles. Keep claims small, avoid personal data, never use user-editable `user_metadata` for authorisation, and remember that values remain stale until the token refreshes.
## Assign a global application role
Roles are administrative data, so change them through a reviewed migration or another separately designed trusted boundary. For example:
```sql
update app.user_roles
set role = 'admin'::app.application_role,
updated_at = timezone('utc', now())
where user_id = '00000000-0000-0000-0000-000000000000';
```
Do not add `admin` to `user_metadata`; signed-in users can edit that metadata. Do not expose a generic browser RPC that lets a caller choose their own role.
The new claim appears only in newly issued access tokens. After a role change, sign in again or refresh the session:
```ts
const { data, error } = await supabase.auth.refreshSession();
if (error) throw error;
const { data: claimData } = await supabase.auth.getClaims();
console.log(claimData?.claims.user_role);
```
## Use the claim in RLS
Claims can provide a fast coarse-grained check:
```sql
create policy admin_can_read_audit_log
on app.audit_log
for select
to authenticated
using ((select auth.jwt() ->> 'user_role') = 'admin');
```
JWT claims are cached until the access token is refreshed. For access that must be revoked immediately, query authoritative database state in RLS or a server-side domain function instead of relying only on the claim.
## Organisation roles
An organisation role is not a single global user role. One person can own one organisation and be a member of another. Core's canonical roles are `owner`, `admin`, and `member`; keep that source of truth in the membership table:
```sql
create type app.organisation_role as enum ('owner', 'admin', 'member');
create table app.organisation_members (
organisation_id uuid not null references app.organisations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role app.organisation_role not null default 'member',
primary key (organisation_id, user_id)
);
```
Authoritative RLS reads the membership row for the resource being accessed:
```sql
create policy organisation_admins_manage_settings
on app.organisation_settings
for all
to authenticated
using (
exists (
select 1
from app.organisation_members member
where member.organisation_id = organisation_settings.organisation_id
and member.user_id = (select auth.uid())
and member.role in ('owner', 'admin')
)
);
```
The canonical session context stores a selected organisation against the current Auth `session_id`. The hook emits only that compact context:
```json
{
"active_organisation_id": "3ae1…",
"active_organisation_handle": "acme",
"active_organisation_role": "admin",
"organisation_context_version": 4
}
```
When the user switches organisation, update the server-owned session context, refresh the JWT, and verify the returned claims. Keep the membership lookup in RLS even when the active context claim is used for navigation or an early rejection. Do not put every organisation membership into the JWT: the claim becomes stale and SSR cookies have practical size limits.
Consumer-specific role names must be mapped at a product boundary or migrated to the canonical enum. They must not silently change the reusable Core claim contract.
## TypeScript claim helper
Narrow custom values before using them:
```ts
type ApplicationRole = 'user' | 'admin';
export async function getApplicationRole(supabase: SupabaseClient) {
const { data, error } = await supabase.auth.getClaims();
if (error) throw error;
const role = data?.claims.user_role;
const normalisedRole: ApplicationRole = role === 'admin' ? 'admin' : 'user';
return normalisedRole;
}
```
Use this for display or coarse server routing. Keep final data authorisation in RLS and trusted server code.
---
## Auth email and SMTP
Supabase's default mail service is intended for exploration. It sends only to authorized project-team addresses, has a low quota, and provides no delivery SLA. A public application using email signup, OTP, magic links, invitations, email changes, or password recovery needs custom SMTP.
Follow [Configure production email](../guides/hosted-setup/06-production-email.md) during first deployment. This page records the longer-term operating policy.
## Provider setup
Supabase accepts standard SMTP credentials. Brevo, Postmark, Resend, SendGrid, and Amazon SES are common choices; use the provider that matches the project's delivery, regional, and support requirements.
For Brevo, follow its current [Supabase SMTP configuration guide](https://help.brevo.com/hc/en-us/articles/7924908994450-Send-transactional-emails-using-Brevo-SMTP), then enter the generated credentials under **Supabase → Authentication → SMTP Settings**.
Use:
- a dedicated transactional subdomain such as `auth.example.com`;
- a recognizable sender such as `no-reply@auth.example.com`;
- production-specific SMTP credentials;
- SPF and DKIM records supplied by the provider; and
- a monitored DMARC policy.
Keep Auth mail separate from marketing broadcasts where practical. Do not store Supabase-hosted SMTP credentials in browser environment variables or commit them to the repository.
## Rate limits
After custom SMTP is enabled, Supabase initially applies a conservative hourly email limit. Set a sustainable project-wide limit under **Authentication → Rate Limits**.
Do not confuse:
- **Email OTP expiration**, which controls how long OTPs, magic links, confirmations, recovery links, email changes, and invitations remain valid;
- **per-user resend cooldown**, normally 60 seconds;
- **project-wide email quota**, shared by several email-producing operations; and
- **OTP endpoint quota**, which is configured separately.
The application resend timer must never be shorter than the hosted cooldown.
## Abuse and deliverability
Public email endpoints can be abused to exhaust quotas or damage sender reputation. Apply CAPTCHA or Turnstile where the threat model requires it, monitor the sending provider's suppression and complaint lists, and plan capacity increases before a launch spike.
Disable click tracking in Auth emails when it rewrites one-time links. Some mail security scanners also prefetch links; use OTP entry where link prefetching is common among the intended users.
Inspect real delivered headers periodically and confirm SPF, DKIM, and DMARC alignment. Test with recipients outside the Supabase organization—a successful message to a project owner can otherwise hide that the default mailer is still active.
## Template availability
New Free plan projects using Supabase's default SMTP cannot customize Auth email templates. Configure custom SMTP before installing the project's [transactional email templates](../Marketing-and-Analytics/email-templates/index.md).
---
## Styling
Supacharger uses Tailwind CSS as its styling foundation. The root layout imports one CSS entrypoint:
```ts
import '@/supacharger/styles/globals.css';
```
That entrypoint loads Tailwind, the shared Supacharger styles, the developer-owned authentication presentation, and the application's general developer-owned stylesheet in that order. Do not import them again from the layout.
## Style ownership
| File | Owner | Purpose |
| --- | --- | --- |
| `src/supacharger/styles/globals.css` | Supacharger CLI | Tailwind entrypoint and import order only |
| `src/supacharger/styles/supacharger.css` | Supacharger CLI | Reusable Supacharger element and component rules |
| `src/supacharger/styles/project.example.css` | Supacharger CLI | Unimported reference for the developer stylesheet |
| `src/styles/supacharger-auth.css` | Application developer | Presentation for managed authentication `sc-auth-*` hooks |
| `src/styles/project.css` | Application developer | Project theme tokens, global defaults, overrides, and product-specific classes |
The CLI may replace files under `src/supacharger/styles/` during a core update. It installs `src/styles/supacharger-auth.css` when absent, then preserves it alongside `src/styles/project.css` and `src/supacharger.config.ts`.
Keep the `Project: ...` header in `project.css` updated with the application name. This makes the ownership of copied or compared styles explicit.
## Tailwind conventions
Use Tailwind utilities in markup for most styling. Add CSS only when a reusable semantic rule, an element default, a theme token, or a project-wide override is genuinely clearer than repeated utilities.
Place custom CSS in Tailwind's layers:
- `@layer base` for project theme variables and element defaults;
- `@layer components` for reusable semantic component classes; and
- `@layer utilities` for small, single-purpose project utilities.
Tailwind's Preflight already supplies the normal reset through `@import 'tailwindcss'`. Do not reproduce Preflight rules in project CSS, and do not add another Tailwind import to `project.css`.
The merge-managed `tailwind.config.ts` retains its TypeScript filename, application font choices, and current CommonJS export. The exact-managed `postcss.config.mjs` uses an explicit ESM export so Next.js and Turbopack can evaluate the Tailwind PostCSS plugin reliably. Keep the `@config` reference unchanged and do not add a package-wide `"type": "module"` solely for either file.
## Shared Supacharger rules
Rules that every Supacharger application should receive belong in the CLI-managed core stylesheet. Supacharger centrally gives enabled native and ARIA interactive controls a pointer cursor on hover:
```css title="src/supacharger/styles/supacharger.css"
@layer base {
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
:where(
a[href],
button:not(:disabled),
input[type='button']:not(:disabled),
input[type='submit']:not(:disabled),
input[type='reset']:not(:disabled),
input[type='checkbox']:not(:disabled),
input[type='radio']:not(:disabled),
label[for],
select:not(:disabled),
summary,
[role='button']:not([aria-disabled='true']),
[role='link']:not([aria-disabled='true']),
[role='menuitem']:not([aria-disabled='true']),
[role='menuitemcheckbox']:not([aria-disabled='true']),
[role='menuitemradio']:not([aria-disabled='true']),
[role='option']:not([aria-disabled='true']),
[role='tab']:not([aria-disabled='true'])
) {
cursor: pointer !important;
}
}
```
The root rule is the native fallback for same-page fragment links. The installed root layout also mounts `SmoothAnchorNavigation` from `src/supacharger/components/layout/smooth-anchor-navigation.tsx`, because Next.js `` can otherwise perform an immediate fragment jump before CSS animates it. Together they make `` and `` work automatically. Modified clicks, downloads, non-self targets, and missing fragments retain normal browser behaviour; add `data-smooth-scroll="false"` to opt out for one link. Visitors who request reduced motion receive immediate navigation. The `!important` cursor declaration intentionally keeps that interaction affordance authoritative when a component library supplies `cursor-default`. Disabled controls are excluded so they do not misleadingly advertise an available action. Ordinary components therefore should not repeat `cursor-pointer` or page-level smooth-scroll utilities.
Change this file in the canonical Supacharger core first, then distribute the same file through the CLI. Do not add product branding or application-specific selectors to it.
## Project styles
Put the application's colors, typography defaults, visual effects, and overrides in `src/styles/project.css`:
```css title="src/styles/project.css"
/**
* Project: Example Application
*
* Developer-owned. The Supacharger CLI must preserve this file.
*/
@layer base {
:root {
--primary: 174 49% 50%;
--primary-foreground: 222 47% 11%;
}
}
```
Tailwind color mappings in `tailwind.config.ts` consume these space-separated HSL channels. For example, `#42bfb1` becomes `174 49% 50%`.
`--primary` is the application's main brand action colour and `--primary-foreground` is the content colour placed on top of it. Prefer semantic utilities such as `bg-primary`, `text-primary-foreground`, `text-foreground`, `text-muted-foreground`, `bg-accent`, and `ring-ring`. They automatically follow the named project's light and dark token values; a shared component should not copy a product hex value.
Application-specific classes may override a shared Supacharger class because `project.css` is imported after `supacharger.css`. Keep overrides intentional and document why the consumer differs from the core.
## Theme selector
The shared `ModeToggle` opens a three-position selector ordered System, Light, and Dark. Use `appearance='marketing'` beside the application-owned locale switcher when both controls appear in marketing navigation or a footer. The marketing appearance gives both triggers the same control height, padding, text size, and small radius. The application-owned root layout must mount a compatible theme provider with system mode enabled so every choice can resolve correctly.
## Responsive SVG components
Keep UI SVGs under the developer-owned `src/` path belonging to their feature or surface, with demo-only artwork under `src/components/sc_demo/`. Import them as React components through SVGR and use `public/` only when an asset genuinely needs a URL.
Every responsive SVG must have a valid `viewBox` so it preserves its aspect ratio. When CSS or Tailwind controls the rendered size, remove `width` and `height` from the root `