- Registered internal apps consume cross-app login tokens generated by
apps/web, then store a Tuturuuu-managed app-session JWT cookie. They must not create app-local Supabase Auth sessions. - The
ttrCLI also exchanges central-login handoff tokens for Tuturuuu-managed gateway JWTs. It must not store Supabase Auth access or refresh tokens. - External apps must be registered in the Infrastructure dashboard before they can exchange a central-login token for API access.
- App coordination bearer tokens are never minted from a cross-app token alone.
The exchange route requires a registered
appIdplus app secret, and external-project APIs require explicit API scopes. - Platform admins issue or rotate external app secrets from
Infrastructure -> External Apps. The secret is shown once; Tuturuuu stores only a hash in root workspace secrets.
Registered internal app flow
Registered internal apps are the apps listed inpackages/utils/src/internal-domains.ts, such as cms, calendar, nova,
rewise, tasks, drive, finance, inventory, track, learn, teach,
chat, mail, mind, meet, and hive.
- The app redirects unauthenticated users to
apps/weblogin with areturnUrlpointing back to the app’s/verify-token?nextUrl=...route. That return URL must resolve to the satellite app origin, not the central web origin. Registered apps should useresolveInternalAppUrl()for theirBASE_URL/app URL constants so shared environment variables such asBASE_URL=https://tuturuuu.comare rejected for satellite hosts instead of minting the host-only app-session cookie on the wrong domain. Ifapps/webreceives a registered internal appreturnUrlthat points at/loginor a protected product route, it must normalize the token handoff back through that app’s/verify-tokenroute before redirecting. apps/webvalidates the return target and mints the normal one-time cross-app handoff token.- The app
/verify-tokenroute posts only that handoff token to/api/auth/verify-app-token. Registered app proxies should perform this step withconsumeVerifyTokenRequest()before page rendering; the shared React verifier page is only a fallback. - The app-local verifier validates the handoff token and sets host-only
tuturuuu_app_sessionandtuturuuu_app_session_refreshcookies. Satellites that require rewrittenapps/webAPI access must callcreatePOST('<app>', { verificationBaseUrl: WEB_APP_URL })so the central verifier also returns the Web-issuedtuturuuu_web_app_sessionandtuturuuu_web_app_session_refreshcookies. The cookies areHttpOnly,SameSite=Lax,Path=/, andSecurein production. - The verifier trusts the
Set-Cookieresponse and redirects tonextUrl. It must not callsupabase.auth.setSession()and must not receive Supabase access or refresh tokens.
sub, email,
origin_app, target_app, scopes, iat, exp, and jti. Verification
rejects expired tokens. Target app and scope checks are enforced by the caller,
so every API route that opts into app-session auth must bind the token to the
audience and scope it expects. Access tokens carry internal-app:session.
Refresh tokens carry only internal-app:refresh; they are rotation material and
must not be accepted as API credentials or bearer access tokens.
For internal API calls, apps forward the app-session cookie to apps/web.
Routes that opt into app-session auth use the verified claims as the actor and
must keep authorization explicit. Do not pass ttr_app_... HS256 JWTs to
Supabase as user access tokens.
Satellite API proxies should treat the host-only tuturuuu_app_session cookie
as an authenticated API signal before applying anonymous proxy rate limits; a
signed-in app user doing normal dashboard work must not share anonymous
read/mutation buckets with public traffic.
Every central apps/web API consumed by a registered internal app must accept
tuturuuu_app_session before falling back to Supabase Auth. Routes using
withSessionAuth should opt into app-session auth; legacy route handlers should
use resolveSessionAuthContext(request, { allowAppSessionAuth: true }) so
local satellite apps do not loop between /login and product pages or
accumulate api_auth_failed IP blocks.
apps/drive follows the centralized-API satellite model: the standalone Drive
app owns the workspace shell and Drive explorer UI, while storage list,
analytics, upload, delete, share, export, and migration APIs remain in
apps/web /api/v1/workspaces/:wsId/storage/*. Those routes authenticate
Drive’s app-session cookie through the shared storage route auth helper before
checking manage_drive.
Use route-specific app-session constraints instead of accepting any registered
internal app token. The legacy allowAppSessionAuth: true shorthand is
audience-bound in apps/web/src/lib/api-auth.ts: it maps known internal API
path prefixes to their expected satellite app audience and falls back to the
platform audience for unmapped routes. For example, CLI-only platform APIs
should configure withSessionAuth with targetApp: 'platform' and
requiredScope: 'cli:access'; satellite APIs should use that satellite’s
target app and only the scopes intended for that route. When adding a new
boolean app-session route for a satellite API, update the shared audience map or
use an explicit { targetApp, requiredScope } object in the route.
When a registered app forwards request cookies to apps/web, it must strip
stale sb-*-auth-token Supabase cookies whenever tuturuuu_app_session is
present. Registered app proxies should also call the shared
clearSupabaseAuthCookies() helper on API, redirect, and normal middleware
responses so stale Supabase session cookies are expired on the satellite host.
If both tuturuuu_app_session and tuturuuu_web_app_session are forwarded, the
Web-issued cookie is the preferred actor for central apps/web API auth; the
local app cookie remains the fallback for routes that cannot verify the Web
cookie.
Registered app proxies must consume /verify-token handoff requests before page
rendering. Use the shared consumeVerifyTokenRequest() helper from
@tuturuuu/auth/proxy so the proxy validates the handoff token through the
app-local /api/auth/verify-app-token route, copies the verifier’s
Set-Cookie headers onto the redirect, and sends the browser directly to the
safe nextUrl. The shared React verifier page remains a fallback for runtimes
where proxy middleware is unavailable; it should not be part of the normal
registered-app login path.
Registered app proxies must run app-session refresh before page redirects and
before API proxy guards. When access is close to expiry, or already expired but
a refresh cookie is still valid, the proxy posts to the app-local
/api/auth/refresh-app-session endpoint. The app-local endpoint asks
apps/web to validate and rotate the Web-issued refresh token, then sets fresh
local and Web-issued cookie pairs. The proxy must forward those refreshed cookie
values into the current request headers before the API route or page handler
runs.
A valid refresh cookie must recover an expired access token on the satellite
origin. Do not redirect the browser back to apps/web for a new cross-app
handoff unless both local refresh credentials and Web-issued refresh credentials
are missing or invalid.
Gateway API routes for registered apps should resolve the app-session actor
before any central-web Supabase Auth fallback, and admin-backed checks must use
no-cookie admin clients so satellite responses do not inject Supabase Auth
cookies back onto the app host. Shared Supabase server-client helpers must treat
requests carrying tuturuuu_app_session or Authorization: Bearer ttr_app_...
as app-session requests and avoid constructing cookie-backed Supabase clients for
those requests.
When an app-session or CLI route needs an admin-backed Supabase client for
shared helpers such as normalizeWorkspaceId(), wrap that admin client with
attachSupabaseAuthUser() from @tuturuuu/auth/app-session using the verified
actor first. This keeps personal workspace resolution, permission checks, and
shared route helpers on the same authenticated path without duplicating
per-route personal-workspace fallback queries.
CLI app-session flow
The nativettr CLI uses the same gateway JWT model without a browser cookie:
ttr loginopensapps/webat/api/cli/auth/start, or prints that URL in--copymode.- The authenticated web session mints the normal short-lived cross-app handoff
token for target app
platformand origincli. - The CLI posts the handoff token to
/api/cli/auth/verify. - The verify route returns a Tuturuuu-managed access JWT plus a longer-lived refresh JWT. It does not call Supabase magic-link, OTP, or refresh-session APIs.
- CLI API requests send the access JWT as
Authorization: Bearer ttr_app_.... App-session-aware internal API routes resolve the actor from verified claims and use explicit authorization. - The CLI refreshes shortly before access-token expiry and retries once after a
401. Refresh rotates both JWTs and updates the saved config.
platform target app and
the cli:access scope before passing the request to admin-backed workspace or
task handlers. Do not rely on the generic internal-app:session scope for
CLI-only APIs.
External app flow
- Register the external app ID, allowed origins, and allowed API scopes in the Infrastructure dashboard.
- Issue an app secret and store it in the external app runtime environment.
- Send users to the centralized web login with a
returnUrlpointing back to the registered external app origin (and registered callback path, when configured), plus the PKCE parameters described in PKCE-bound handoffs. - The external app receives a short-lived cross-app
tokenon its return URL. - The external app server calls
POST /api/v1/auth/app-token/exchangewithappId,appSecret,token, optionalrequestedScopes, andworkspaceIdwhenever the requested or issued scopes includeexternal-projects:*,external-projects:manage,external-projects:publish, orexternal-projects:read. This server-to-server route must bypass browser challenges and WAF managed challenges. A Cloudflare response withcf-mitigated: challengeis an edge block, not an app-token validation failure, and the external app cannot complete it withfetch. - For external-project scopes, Tuturuuu validates that the workspace has an
enabled external-project binding, that the binding’s canonical adapter
matches the registered external app ID, that the user is a direct
MEMBERof the linked workspace, and that the user has the required EPM permission in that workspace. Root EPM admins can manage app registrations and bindings from the platform infrastructure UI, but they cannot enter or call a linked external app unless they are also members of that linked workspace. - If the user is not yet a workspace member but has a pending
workspace_invitesorworkspace_email_invitesrow for the requested workspace, the exchange returns403withcode: "PENDING_WORKSPACE_INVITE",workspaceId, andinvitationUrl. External apps must handle this code before showing generic no-access copy so the user can accept or reject the invitation in Tuturuuu first. - Tuturuuu validates the app secret and cross-app token, then returns a
short-lived bearer token for Tuturuuu APIs plus the normalized authorized
workspaceIdand, when known, the session’saalandamr(see Session assurance).
401, 403, or 404, the app must clear its local
session and send the user through the central login flow again.
PKCE-bound handoffs
The cross-apptoken is a one-time bearer credential. To bind it to the browser
that started the login, an external app can add an RFC 7636 S256 challenge:
- Before redirecting, generate a
code_verifier(43-128 characters fromA-Z a-z 0-9 - . _ ~) and keep it in the browser’s own session (for example anHttpOnlycookie set by the app’s server). - Add
code_challenge=BASE64URL(SHA256(code_verifier))(43 characters, no padding) andcode_challenge_method=S256to the centralized login URL next toreturnUrl. The login page forwards both toPOST /api/v1/auth/cross-app-return, which stores the challenge with the handoff token. OnlyS256is accepted;plain, a missing method or a malformed challenge returns400withcode: "INVALID_CODE_CHALLENGE". - On the callback, send the stored verifier as
codeVerifierin thePOST /api/v1/auth/app-token/exchangebody withappId,appSecretandtoken. A wrong or missing verifier fails like an invalid token (401) and does not consume the handoff; a verifier for a token minted without a challenge is also refused.
ttr external apps save --file app.json or
POST /api/v1/admin/external-apps; each save stamps updatedAt/updatedBy,
and omitted fields keep their stored value):
Rollout rule: Never set
requirePkce=true for any app until migration
20261101090000 is applied in production; record it in the release receipt.
Until then generate_cross_app_token_pkce and
validate_cross_app_token_with_session_pkce do not exist, so every login for
that app would fail.
The challenge applies only to registered external apps; internal app verifiers
never receive a challenge-bound token. In the database,
private.cross_app_tokens.code_challenge stores the challenge,
generate_cross_app_token_pkce mints it, the anonymous
validate_cross_app_token_with_session refuses challenge-bound tokens, and the
service_role-only validate_cross_app_token_with_session_pkce verifies the
verifier. The PKCE variants have distinct names rather than overloads: extra
overload unions in the generated Database types push Supabase client generics
past TypeScript’s instantiation depth (TS2589). Regression
coverage lives in apps/database/supabase/tests/cross-app-handoff-pkce.sql,
apps/web/src/app/api/v1/auth/cross-app-return/route-pkce.test.ts and
apps/web/src/app/api/v1/auth/app-token/exchange/route-pkce.test.ts.
Session assurance
The exchange response (and every refresh response, which has the same body) reports how strongly the handed-off Tuturuuu session was authenticated:
Both fields are derived server-side from the verified Supabase session claims
when
POST /api/v1/auth/cross-app-return mints the handoff. They travel in a
signed, five-minute transfer token bound to the user and target app (token type
session_assurance_transfer, which verifyAppCoordinationToken and every app
session, bearer and refresh consumer reject; only readSessionAssuranceTransfer
accepts it, enforced by
packages/utils/src/app-coordination-token-consumers.test.ts),
then as signed aal/amr claims on the issued access and refresh tokens, so a
refresh reports the original session’s level. Request bodies and caller-written
handoff session data are never trusted. When the level is unknown (for example
a handoff or refresh token issued before this field existed) both fields are
omitted.
Apps must treat a missing aal as aal1. An app that requires MFA must check
aal === "aal2" and otherwise send the user back through central login.
aal reflects the Supabase session itself; an account-level MFA policy satisfied
another way (for example a mobile approval) is still enforced by Tuturuuu but
does not raise the reported aal. Regression coverage lives in
packages/utils/src/session-assurance.test.ts,
apps/web/src/app/api/v1/auth/cross-app-return/route-assurance.test.ts and
apps/web/src/app/api/v1/auth/app-token/exchange/route-assurance.test.ts.
App-owned Drive attachments
Registered external apps can requestworkspace:drive:read and
workspace:drive:write without granting their members the broad manage_drive
workspace permission. The app must be enabled and linked to the requested
workspace, and every call still requires the token actor to be a direct
MEMBER. These scopes expose only the external-app Drive boundary:
POST /api/v1/workspaces/{wsId}/external-apps/drive/upload-urlPOST /api/v1/workspaces/{wsId}/external-apps/drive/finalizePOST /api/v1/workspaces/{wsId}/external-apps/drive/read-urlDELETE /api/v1/workspaces/{wsId}/external-apps/drive
external-apps/{targetApp}/chat/{conversationId}/{attachmentId}/{filename}.
The upload route accepts metadata only and returns a short-lived direct-upload
payload for the workspace’s configured Supabase Storage or R2 provider. The app
must call finalize after upload; Tuturuuu reads the actual object metadata,
deletes mismatched objects, and returns canonical metadata. Signed URLs are
transient response values and must never be logged or persisted. Use
@tuturuuu/internal-api/external-app-drive for the shared request and direct
upload helpers.
The centralized login page validates external and internal returnUrl targets
before it renders the normal sign-in form or account-confirmation handoff. If the
target origin is not registered or cannot be resolved, the page shows a warning
and asks the user to clear the broken return URL instead of silently falling back
to the generic login form.
Nova at https://nova.ai.vn is a registered internal satellite even though
its production domain is outside tuturuuu.com. Central login, cross-app
return validation, and token targeting accept that exact HTTPS origin alongside
its registered Tuturuuu subdomain. Rewise is served only at
https://rewise.tuturuuu.com. Tuturuuu released rewise.me and
rewise.school, so @tuturuuu/utils/retired-app-hosts rejects them and every
subdomain in return validation, configured external apps, registered external
app origins, CSP sources and the internal API origin, even when a stale
environment value or database row still names them. Both stay reserved
managed-project hostnames so no customer project can claim them; reserved is
not trusted.
The external production origin requires an exact origin match: HTTP, custom
ports, subdomains, hostname look-alikes, and URLs containing user credentials
are rejected. Unregistered preview hosts such as cms-tuturuuu.vercel.app remain
blocked; CMS uses https://cms.tuturuuu.com. Localhost and Portless development
registrations retain their existing behavior. Regression coverage lives in
packages/utils/src/internal-domains.test.ts,
packages/auth/src/cross-app/index.test.ts, and
apps/web/src/app/api/v1/auth/cross-app-return/route.test.ts.
Operational notes
Server-side internal API calls identify themselves withUser-Agent: Tuturuuu-Internal-API/1.0 unless a caller supplies an agent.
Cloudflare Workers do not supply Node’s default fetch agent; a missing agent can
produce a middleware 400 Bad Request before route authentication runs. If an
SSO handoff succeeds but a satellite workspace page fails with an internal API
400, inspect the outgoing request headers as well as the token exchange.
Browser calls keep their native agent. This header does not grant access or
replace authentication, authorization, or abuse-policy checks.
App-session and external app bearer tokens are signed with
TUTURUUU_APP_COORDINATION_SECRET.
Token lifetimes are configured from the root workspace Infrastructure app at
/{wsId}/app-coordination on INFRA_APP_URL. The older web and TanStack
infrastructure routes are removed rather than redirected. The policy is stored in
workspace_secrets on ROOT_WORKSPACE_ID as
APP_COORDINATION_SESSION_POLICY and cached in process for at most 60 seconds
on auth paths. Defaults and caps are:
- Internal app access TTL: 28,800 seconds default, min 300, max 86,400.
- Internal app refresh TTL: 2,592,000 seconds default, min 86,400, max 7,776,000.
- Internal app refresh-early window: 900 seconds default, min 60, max 7,200.
- Browser refresh replay grace: 30 seconds default, min 0, max 300.
- External app bearer TTL: 28,800 seconds default, min 300, max 86,400.
- CLI access TTL: 28,800 seconds default, min 300, max 86,400.
- CLI refresh TTL: 7,776,000 seconds default, min 86,400, max 7,776,000.
tuturuuu_app_session must share the same signing material.
Prefer setting TUTURUUU_APP_COORDINATION_SECRET on apps/web and every
registered satellite. For compatibility with existing Vercel satellite
deployments, the runtime also accepts the server-side Supabase secret as a
fallback verifier so satellites without the explicit coordination secret do not
fail immediately after cross-app token validation.
When rotating an app secret, deploy the new external app environment first, then
rotate from Infrastructure. Existing short-lived bearer tokens keep working
until they expire, but future exchanges require the new app secret.
Cross-app login tokens are user-bound handoff tokens. The database RPC only
mints a token for the authenticated caller’s own auth.uid(); do not use it as a
server-side impersonation primitive or as a replacement for app credentials.
Registered apps should clear tuturuuu_app_session on local logout and expire
stale sb-*-auth-token cookies on the app host. Browser form logout should
redirect back to the app’s landing/login page or to the central /logout
continuation after local cleanup instead of leaving the browser on
/api/auth/logout. Logout and browser-state recovery redirects must use the
app’s configured public URL as the fallback when the request reaches the app
through a wildcard listener such as 0.0.0.0, because that listener address is
not a browser destination.
Shared satellite UI must not assume every app publishes the full apps/web
public/media tree. Shared brand images such as TuturuuLogo should use the
canonical hosted Tuturuuu asset URL so apps without local /media/logos/*
files do not produce repeated 404s.
All Next apps configured with next-intl are checked by
bun i18n:setup-check (and therefore bun validate). Locale-rooted apps must
resolve and validate the explicit locale override or awaited requestLocale
segment in src/i18n/request.ts, then resolve the document language from
next/root-params in the locale root layout and validate it with
resolveRootLocale. Avoid the legacy setRequestLocale path. Do not import
next/root-params from request configuration: Next supports it in Server
Components such as the root layout, while next-intl request configuration also
runs for Route Handlers. Do not call request-bound getLocale() from the root
layout because it opts otherwise static locale roots into runtime rendering.
Apps without a [locale] root segment use requestLocale directly; the checker
discovers this architecture from the route tree rather than maintaining an app
exception list.
Satellite shell patterns
Workspace-scoped satellite apps (drive, calendar, tasks, finance, and
similar) should reuse the shared satellite provider shell from
@tuturuuu/satellite/providers. That shell mounts next-themes with
system, light, and dark, wraps NextIntlClientProvider, and wires the
shared TanStack Query client. Public or gateway-only apps such as apps/tools and
apps/apps should use the same provider re-export so theme toggles and shared
UI chrome behave consistently even when the app does not own workspace auth.
Every user-facing browser app that should appear in the Apps gateway or the
global Ctrl/Cmd+K launcher must be registered in
packages/utils/src/launchable-apps.ts. Keep the registry entry current with
the app title, slug, aliases, category, production URL, Portless/dev origin,
default path, package root, and workspace path resolver when the app has
workspace-scoped routes. apps/apps/src/lib/apps-registry.ts reads from that
shared registry so the gateway and launcher do not drift.
Workspace sidebar apps should keep an app-local structure.tsx file, but that
file should be a thin wrapper around
@tuturuuu/satellite/sidebar-structure. The shared structure owns the
collapsible sidebar behavior, SidebarProvider cookies, workspace switcher
placement, mobile header, footer actions, hover expansion, back navigation, and
user-nav slots. App wrappers should only provide app-specific navigation links,
workspace-select routing, billing URL shape, or small brand/content additions
such as Mind boards and Rewise branding. This applies to workspace product apps
such as Calendar, CMS, Drive, Finance, Inventory, Mind, Rewise, Tasks, Track,
Mail, Chat, and Meet. Learn and Teach intentionally keep their
education-specific shells.
Meet has a public /:planId route surface, so workspace-scoped Meet routes must
live under /workspace/:wsId instead of /:wsId. Use
/workspace/:wsId/plans for Tuturuuu Meet plans and
/workspace/:wsId/meetings for meeting-room operations, with
/workspace/:wsId redirecting to the plans route. Keep public plan-detail URLs
on the root plan-id surface.
For server-side workspace picker data, do not call
@tuturuuu/ui/lib/workspace-actions from satellite apps. That helper expects
Supabase Auth cookies on the app host and returns empty lists when only
tuturuuu_app_session is present. Instead, use
fetchSatelliteWorkspaces() from @tuturuuu/satellite/workspace-actions, which
forwards the incoming request auth to GET /api/v1/workspaces on apps/web.
When the workspace picker resolves a selected workspace, client code may call
the legacy detail route GET /api/workspaces/:wsId. That route must also opt
into app-session auth through withSessionAuth(..., { allowAppSessionAuth: CURRENT_USER_APP_SESSION_AUTH }) so satellite sessions do
not receive 401 Unauthorized on personal or other workspace aliases.
When a workspace-scoped satellite page already has an app-session user, shared
permission checks must resolve personal from that explicit user context rather
than falling back to cookie-bound Supabase Auth on the app host. App-session
cookies are Tuturuuu-managed, so the app host may not have a local Supabase Auth
user even though the user is authenticated.
Coordination Worker leases and alarm costs
apps/coordination/src/object.ts stores one SQLite row per hashed entity.
Authenticator acquisitions have a five-minute lease and retention deadline;
meeting acquisitions have a ten-minute lease with fingerprint retention initially
set to two hours. A busy acquisition does not extend those deadlines. A meeting
release clears its lease but preserves fingerprint retention; an authenticator
release deletes its row. Completion requires the current owner and an unexpired
lease. These are coordination/idempotency contracts, not account-spend caps.
The alarm deletes an expired row and reads its remaining expiry. Before expiry
it schedules that same deadline; after expiry or release it deletes the alarm.
It makes no downstream HTTP calls. Controlled probes of the actual object with
local SQLite counted two SQL statements and one alarm-set call per early
callback. A due callback changed one row and removed the alarm. Twenty-four
reconstructed callbacks after deletion made 72 SQL statements (including each
constructor’s schema statement), zero changed/read rows, and 24 alarm-delete
calls, without scheduling new alarms. Twenty-four busy request retries made 72
SQL statements, read 48 rows and set the same alarm 24 times. Count statements,
rows and alarm operations separately; they are not interchangeable billing units.
A failed alarm write after acquisition leaves the persisted lease intact:
24 reconstructed request retries still do not acquire it twice, but repeat alarm
write attempts. A controlled delayed-write race can leave one alarm after an
authenticator release; that callback finds no row and removes the alarm without
reviving the lease. This is not a durable authenticated stop-fence guarantee.
The probes invoke the object directly with mocked alarm methods and local SQLite;
they do not certify Worker ingress auth, hosted SQLite billing, delivered alarm
retries, clock rollback, or provider-wide emergency shutdown. Existing
apps/coordination/src/worker.test.mjs exercises the real local Worker/SQLite
request contracts. Keep hosted alarm and independent-stop evidence separate.
Repeated authenticated requests, or new acquisitions after expiry, can still
create billable work. A finite row lifetime does not bound total ingress, entity
count or account spend. Verify applicable alerts and an independent operator stop
before certifying this service’s cost controls; do not configure production
billing or resources merely to qualify an adoption candidate.