## Summary
Adds a set of PAT-authenticated management API endpoints so orgs,
projects, members/invites, environment variables, and a few
project/environment settings can be managed programmatically (scripting,
automation) rather than only through the dashboard. Each route is a thin
wrapper over the **existing** service the dashboard already uses, with
the same authorization applied at the route layer - no new business
logic.
## Endpoints
**Organizations**
- `POST /api/v1/orgs` - create an org (`createOrganization`)
- `PATCH /api/v1/orgs/:orgParam` - rename (title)
- `DELETE /api/v1/orgs/:orgParam` - soft-delete
(`DeleteOrganizationService`; keeps the active-subscription guard)
**Members & invites**
- `GET /api/v1/orgs/:orgParam/members` - list members + pending invites
- `DELETE /api/v1/orgs/:orgParam/members/:memberId` - remove a member
(last-member guarded)
- `POST /api/v1/orgs/:orgParam/invites` - invite by email
(`inviteMembers`, sends the invite email)
- `DELETE /api/v1/orgs/:orgParam/invites/:inviteId` - revoke an invite
**Projects**
- `PATCH /api/v1/projects/:projectRef` - rename
(`ProjectSettingsService`)
- `DELETE /api/v1/projects/:projectRef` - soft-delete
(`DeleteProjectService`)
- `PUT /api/v1/projects/:projectRef/default-region` - set the default
region by worker-group name (`SetDefaultRegionService`)
- project GET/list now return `defaultRegion` (worker-group name, or
null when unset)
**Environments**
- `POST /api/v1/projects/:projectRef/:env/pause` and `/resume`
(`PauseEnvironmentService`)
- `POST /api/v1/projects/:projectRef/:env/regenerate-api-key` - rotate
the env secret key (`regenerateApiKey`, RBAC `write:apiKeys`)
- env var create now accepts an optional `isSecret` flag
## Auth & authorization
- All routes authenticate with a **Personal Access Token**
(`Authorization: Bearer tr_pat_...`).
- Org/project routes are built on the PAT route builders in
`apiBuilder.server.ts`: `createLoaderPATApiRoute` (already existed) and
**`createActionPATApiRoute`** (added here - the loader builder had no
mutation counterpart). The builder runs auth, resolves the org/project
role-floor via `context`, and enforces a declarative `authorization`
block using the same RBAC actions the dashboard applies
(`manage:organization` / `read:members` / `manage:members` /
`manage:project`). Handlers keep a membership-scoped query as the floor,
so a non-member gets a 404. This also gives these routes `tenantContext`
user attribution (Sentry) and `ServiceValidationError`-to-status mapping
for free.
- **Membership floor (important).** The OSS RBAC fallback grants a
permissive ability, so `ability.can(...)` can't reject a non-member on
self-hosted. Every handler therefore resolves the target scoped to the
caller's membership (`members: { some: { userId } }`) → 404 for
non-members. `authorization` is the *role* gate; this is the *tenant*
gate. `resolveOrganizationForApiUser`
(`organizationApiAccess.server.ts`) is the org-tier version of the
existing `findProjectByRef` - org-addressed PAT routes are new, so no
such helper existed before.
- Env-tier routes reuse the existing `authorizePatEnvironmentAccess`
(`write:apiKeys`).
### What `createActionPATApiRoute` gives you
A route is pure declaration - the builder handles auth, RBAC,
validation, tracing, and error mapping:
```ts
export const action = createActionPATApiRoute(
{
method: "PUT", // one verb, or ["PATCH", "DELETE"] for multi-verb routes
params: ParamsSchema,
body: SetDefaultRegionRequestBody, // zod-validated
context: async ({ projectRef }) => { // resolve the org for the RBAC role-floor
const project = await prisma.project.findFirst({
where: { externalRef: projectRef, deletedAt: null },
select: { organizationId: true },
});
return project ? { organizationId: project.organizationId } : {};
},
authorization: { action: "manage", resource: () => ({ type: "project" }) },
},
async ({ params, body, authentication, ability }) => {
// auth + authz already enforced. Just do the work.
// `throw new ServiceValidationError("Region not found", 400)` → mapped to that status.
return json({ ok: true });
}
);
```
Handled for you, so handlers stay thin:
- **Method allowlist** - `method` accepts a verb or an array; any other
verb → `405` with an `Allow` header, *before* auth runs:
```ts
const allowedMethods = method ? (Array.isArray(method) ? method :
[method]) : undefined;
if (allowedMethods && !(allowedMethods as
string[]).includes(request.method.toUpperCase())) {
return json({ error: "Method not allowed" }, { status: 405, headers: {
Allow: allowedMethods.join(", ") } });
}
```
- **PAT / user-actor auth** in a single roundtrip → `401` on
missing/invalid/revoked token.
- **RBAC** - `context` computes the caller's role-floor for the target
org/project; `authorization` gates it → `403` with a structured error
body.
- **Sentry attribution** - `tenantContext.enrich({ userId })` so events
from the handler carry the acting user.
- **Typed errors** - a thrown `ServiceValidationError` is mapped to its
`.status` (default 400); anything else → `500`, and expected boundary
errors are logged as `warn` (kept out of Sentry).
- **Validation** - params / query / headers / body are all zod-checked →
`400` with details.
## Notes for reviewers
- Everything wraps an existing service; the intent is API parity for
things that are currently dashboard-only, not new behaviour.
- `createActionPATApiRoute` is new shared infra (the PAT + RBAC mutation
builder that didn't exist). It's self-contained - the loader builder and
existing routes are untouched.
- `@trigger.dev/core` gets one additive field (`defaultRegion` on the
project response, optional/nullable for client-server version skew) -
changeset included, patch.
- `removeTeamMember`'s last-member guard is now atomic (Serializable
transaction via the `$transaction` helper, with retry), so the dashboard
and API both get it server-side. Added a `## Transactions` rule to
`apps/webapp/CLAUDE.md` (always use the `$transaction` helper);
migrating the remaining direct usages is tracked in TRI-11698.
## Open questions
- ~~Is PAT the right auth (vs OAT for automation)?~~ **Resolved: PAT.**
Organization Access Tokens are currently internal-only (used by the
image builder) and not user-accessible, so they can't back this yet.
- Should any of these be gated behind a flag or scope?
- Naming/shape of the routes.
8.5 KiB
Webapp
Remix 2.17.4 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (server.ts).
Verifying Changes
Never run pnpm run build --filter webapp to verify changes. Building proves almost nothing about correctness. The webapp is an app, not a public package — use typecheck from the repo root:
pnpm run typecheck --filter webapp # ~1-2 minutes
Only run typecheck after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues.
Note: Public packages (packages/*) use build instead. See the root CLAUDE.md for details.
Testing Dashboard Changes with Chrome DevTools MCP
Use the chrome-devtools MCP server to visually verify local dashboard changes. The webapp must be running (pnpm run dev --filter webapp from repo root).
Login
1. mcp__chrome-devtools__new_page(url: "http://localhost:3030")
→ Redirects to /login
2. mcp__chrome-devtools__click the "Continue with Email" link
3. mcp__chrome-devtools__fill the email field with "local@trigger.dev"
4. mcp__chrome-devtools__click "Send a magic link"
→ Auto-logs in and redirects to the dashboard (no email verification needed locally)
Navigating and Verifying
- take_snapshot: Get an a11y tree of the page (text content, element UIDs for interaction). Prefer this over screenshots for understanding page structure.
- take_screenshot: Capture what the page looks like visually. Use to verify styling, layout, and visual changes.
- navigate_page: Go to specific URLs, e.g.
http://localhost:3030/orgs/references-bc08/projects/hello-world-SiWs/env/dev/runs - click / fill: Interact with elements using UIDs from
take_snapshot. - evaluate_script: Run JS in the browser console for debugging.
- list_console_messages: Check for console errors after navigating.
Tips
- Snapshots can be very large on complex pages (200K+ chars). Use
take_screenshotfirst to orient, thentake_snapshotonly when you need element UIDs to interact. - The local seeded user email is
local@trigger.dev. - Dashboard URL pattern:
http://localhost:3030/orgs/{orgSlug}/projects/{projectSlug}/env/{envSlug}/{section}
Key File Locations
- Trigger API:
app/routes/api.v1.tasks.$taskId.trigger.ts - Batch trigger:
app/routes/api.v1.tasks.batch.ts - OTEL endpoints:
app/routes/otel.v1.logs.ts,app/routes/otel.v1.traces.ts - Prisma setup:
app/db.server.ts - Run engine config:
app/v3/runEngine.server.ts - Services:
app/v3/services/**/*.server.ts - Presenters:
app/v3/presenters/**/*.server.ts
Route Convention
Routes use Remix flat-file convention with dot-separated segments:
api.v1.tasks.$taskId.trigger.ts -> /api/v1/tasks/:taskId/trigger
Abort Signals
Never use request.signal for detecting client disconnects. It is broken due to a Node.js bug (nodejs/node#55428) where the AbortSignal chain is severed when Remix internally clones the Request object. Instead, use getRequestAbortSignal() from app/services/httpAsyncStorage.server.ts, which is wired directly to Express res.on("close") and fires reliably.
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
// In route handlers, SSE streams, or any server-side code:
const signal = getRequestAbortSignal();
Environment Variables
Access via env export from app/env.server.ts. Never use process.env directly.
For testable code, never import env.server.ts in test files. Pass configuration as options instead:
realtime/nativeRealtimeClient.server.ts(testable service, takes config as constructor arg)realtime/nativeRealtimeClientInstance.server.ts(creates singleton with env config)
Run Engine 2.0
The webapp integrates @internal/run-engine via app/v3/runEngine.server.ts. This is the singleton engine instance. Services in app/v3/services/ call engine methods for all run lifecycle operations (triggering, completing, cancelling, etc.).
The engineVersion.server.ts file determines V1 vs V2 for a given environment. New code should always target V2.
Background Workers
Background job workers use @trigger.dev/redis-worker:
app/v3/commonWorker.server.tsapp/v3/alertsWorker.server.tsapp/v3/batchTriggerWorker.server.ts
Real-time
- Socket.io:
app/v3/handleSocketIo.server.ts,app/v3/handleWebsockets.server.ts - Electric SQL: Powers real-time data sync for the dashboard
v3 (engine V1) removed
v3 (engine V1: MarQS + Graphile worker) is end-of-life and its execution code is gone. The app/v3/ directory name is historical; everything under it now serves V2. There is no V1 execution path: a RunEngineVersion V1 branch (e.g. in triggerTask.server.ts, cancelTaskRun.server.ts) only rejects/finalizes gracefully so v3 clients get a clean 4xx, never a 5xx. Do not reintroduce V1. See .claude/rules/legacy-v3-code.md for the deprecation boundary.
Performance: Trigger Hot Path
The triggerTask.server.ts service is the highest-throughput code path in the system. Every API trigger call goes through it. Keep it fast:
- Do NOT add database queries to
triggerTask.server.tsorbatchTriggerV3.server.ts. Task defaults (TTL, etc.) are resolved viabackgroundWorkerTask.findFirst()in the queue concern (queues.server.ts) - one query per request, in mutually exclusive branches depending on locked/non-locked path. Piggyback on the existing query instead of adding new ones. - Two-stage resolution pattern: Task metadata is resolved in two stages by design:
- Trigger time (
triggerTask.server.ts): Only TTL is resolved from task defaults. Everything else uses whatever the caller provides. - Dequeue time (
dequeueSystem.ts): FullBackgroundWorkerTaskis loaded and retry config, machine config, maxDuration, etc. are resolved against task defaults.
- Trigger time (
- If you need to add a new task-level default, add it to the existing
selectclause in thebackgroundWorkerTask.findFirst()query — do NOT add a second query. If the default doesn't need to be known at trigger time, resolve it at dequeue time instead. - Batch triggers (
batchTriggerV3.server.ts) follow the same pattern — keep batch paths equally fast.
Prisma Query Patterns
- Always use
findFirstinstead offindUnique. Prisma'sfindUniquehas an implicit DataLoader that batches concurrent calls into a singleINquery. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021).findFirstis never batched and avoids this entire class of issues.
Transactions
- Always use the
$transactionhelper from~/db.server, neverprisma.$transaction(or$replica.$transaction) directly. The helper wraps the raw call with tracing (an OTEL span + anisolation_levelattribute) and boundary logging for infrastructure errors (e.g.PrismaClientInitializationError) that the raw client swallows. Signature:$transaction(prisma, name?, async (tx) => { ... }, options?). - Pass the isolation level via options as a string:
{ isolationLevel: "Serializable" }. Reach forSerializablewhen a read-then-write must be atomic against concurrent transactions (e.g. a count-then-delete invariant); the loser of a race fails and can retry, which is the right trade for rare, correctness-critical paths. - The helper returns
R | undefined— guard the result (if (!result) throw ...) when callers need a definite value.
PAT-authenticated API routes
- A PAT route must resolve its target org/project scoped to the caller's membership (
members: { some: { userId } }, or a helper likefindProjectByRef/resolveOrganizationForApiUser). A PAT is user-scoped and can name any org/project by id/slug, and the OSS RBAC fallback ability is permissive — soability.can(...)alone does NOT reject a non-member on self-hosted. The RBACauthorizationgate enforces the role; the membership-scoped query is the tenant floor. Skipping it opens cross-org access on OSS.
React Patterns
- Only use
useCallback/useMemofor context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations. - Use named constants for sentinel/placeholder values (e.g.
const UNSET_VALUE = "__unset__") instead of raw string literals scattered across comparisons.