Launch Architecture
Everything flows through a single RPC endpoint — the web app and any external client (Magnus) all hit the same door. Auth runs first, then services. No shortcuts, no alternate paths to the data.
RPCiRPC (Remote Procedure Call) enables calling functions on remote servers as if they were local. It handles the underlying complexity of data serialization and network communication, letting developers focus on business logic rather than protocol details.
Every data operation goes through one endpoint: /api/rpc. There are no REST routes and no direct database access from the frontend. The web app and external API clients share the same entry point.
Each procedure declares what it accepts, what it returns, and what errors it can produce — all strongly typed. Handlers are deliberately thin: they receive the request, call an application service, and return the result. All business logic and permission enforcement lives in the service layer behind the handler.
// defining a procedure
Rpc.make("CreateProject", {
success: Project,
error: Schema.Union(ProjectSlugTakenError, ForbiddenError),
payload: {
orgId: Schema.String,
name: Schema.String.pipe(Schema.minLength(1)),
},
}).middleware(AuthMiddleware)
// handler — just calls the service
CreateProject: ({ orgId, name, slug, description }) =>
createProject(new CreateProjectDto({ orgId, name, slug, description }))Two access patterns, both type-safe end-to-end:
// web app — TanStack Query hooks, session cookie auth
api.ListOrgs.useQuery()
api.CreateProject.useMutation()
// external clients (Magnus) — same endpoint, Bearer JWT
fetch("/api/rpc", { headers: { Authorization: `Bearer ${jwt}` } })On the web app side, RPC integrates with React Query so procedure types flow straight into hooks. You get typed queries, mutations, automatic cache keys, and built-in invalidation — no hand-written API layer needed.
Auth
Launch serves both B&G employees and external stakeholders (clients, partners), so authentication needs to handle both cleanly. B&G emails sign in through Microsoft Entra SSO. Everyone else uses email + password or a third-party provider (Google, etc.), all handled by Better Auth.
The distinction matters because B&G users get additional baseline access automatically (more on that in Permissions). A database hook marks them as isInternal: true at signup so the rest of the system can make that distinction without checking email domains at runtime.
Launch is also an OAuth 2.1 provider. External systems like Magnus go through Authorization Code + PKCE, receive a JWT, and call the same RPC endpoint with a Bearer token. This is forward-looking — not critical today, but designed so the platform is ready for AI-connected clients when the time comes.
Regardless of how someone authenticates, the auth middleware normalizes every request into one shape: CurrentUser. Session cookie? Look up the session. Bearer token? Verify the JWT. The rest of the system never needs to know which path was used:
CurrentUser {
userId: string
isInternal: boolean // B&G employee
isSystemAdmin: boolean // super-admin
impersonation?: { // if admin is impersonating
realUserId: string
realUserIsInternal: boolean
}
}Impersonation lets a system admin see Launch exactly as another user sees it — useful for debugging access issues and support requests.
When active, the middleware swaps userId to the target user but keeps the real admin's identity in impersonation.realUserId. The impersonated session is always forced to isSystemAdmin: false, so there is no path to escalate privileges through impersonation.
Every impersonation start event is logged with both the admin and target user IDs, so audit trails always reflect who actually initiated the action.
Permissions
Launch serves B&G employees, external clients, and API integrations through the same endpoint. A single person might be an admin on one org, a read-only client on a project in another org, and have a one-off override somewhere else. The permission system needs to answer “what can this user do, right here, right now?” on every request — and get it right every time.
Access model (high-level)
1) Define capabilities as resource:action permissions
2) Use roles as starting templates for memberships
3) Resolve effective permissions per scope (org or project)
4) Enforce on every service call
Permission vocabulary (single source of truth)
organization: view | edit | delete | manage
project: view | create | edit | delete | manage
member: view | invite | edit | remove
configuration: view | manage
benchmark: viewRoles like admin, viewer, lead, and client are templates, not identity checks. They provide a default set for a membership, then membership-level overrides refine it (revoke/remove, grant/add). Grants win, so targeted exceptions are explicit and easy to reason about.
Everything is scoped. When someone tries to edit a project, Launch checks their project membership first. No project membership? It falls back to their org membership. No org membership either? If they are a B&G employee, internal defaults give them baseline read access. Otherwise, access is denied. Resolution happens fresh on every request, so changes to roles or overrides take effect immediately.
Example: a client stakeholder is added to an org as viewer (read-only). Later, the team wants them to have more access on one specific project — so they assign them a project-level role like editor on that project. The viewer role stays the same at the org level; the elevated access is scoped to that one project. Individual grant/revoke overrides are also available for edge cases, but in practice you'd reach for a role change first.
Decision path for "can this user edit this project?"
1) Resolve effective permissions for this scope:
- role defaults
- minus revokes
- plus grants
- plus internal defaults (if applicable)
2) Check for project:edit in this scope
3) Allow or deny before business logic runsConfidence comes from observability: internal debug RPCs like TracePermission and GetEffectivePermissions expose the exact resolution path (which membership, which overrides, which fallback) so access decisions are explainable, not mysterious.
The composable policy pattern was inspired by Building a Composable Policy System in TypeScript with Effect by Lucas Barake.
Request Lifecycle
Every request — whether it comes from the web app or an external client — follows the exact same path. This consistency is what makes the system predictable to work in and reason about:
web app / external client
↓
POST /api/rpc
↓
AuthMiddleware → produces CurrentUser
↓
RPC Handler (thin adapter, no logic)
↓
Application Service
├── permission check (runs first, denies early)
├── business logic
└── repository calls → DB
↓
typed response → JSON → clientCore Technologies
These are the core choices behind Launch: fast iteration, strong typing, and predictable operations in production.
Next.js powers the web app and server runtime with strong defaults for routing, caching, and rendering.
Effect-TS is the backbone for application services: typed errors, composable workflows, and clear dependency boundaries.
React Query (TanStack Query) with Effect Query integration powers the RPC client experience: typed API helpers, cache management, and predictable invalidation behavior on the web app.
Drizzle ORM gives a type-safe schema and SQL layer so DB changes stay explicit and reviewable.
Better Auth handles auth flows (SSO, email/password, social providers, OAuth) while keeping identity normalized into one runtime user context.
Vercel keeps deployments fast and repeatable with preview environments, straightforward rollouts, and secure environment variable management — secrets are stored and scoped per deployment (production, preview, development) so credentials never live in the repo.