Files
triggerdotdev--trigger.dev/apps/webapp/CLAUDE.md
T
nicktrn 976171ea16 feat(webapp): management API for orgs, projects, members, and settings (#4146)
## 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.
2026-07-15 10:20:08 +01:00

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_screenshot first to orient, then take_snapshot only 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.ts
  • app/v3/alertsWorker.server.ts
  • app/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.ts or batchTriggerV3.server.ts. Task defaults (TTL, etc.) are resolved via backgroundWorkerTask.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:
    1. Trigger time (triggerTask.server.ts): Only TTL is resolved from task defaults. Everything else uses whatever the caller provides.
    2. Dequeue time (dequeueSystem.ts): Full BackgroundWorkerTask is loaded and retry config, machine config, maxDuration, etc. are resolved against task defaults.
  • If you need to add a new task-level default, add it to the existing select clause in the backgroundWorkerTask.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 findFirst instead of findUnique. Prisma's findUnique has an implicit DataLoader that batches concurrent calls into a single IN query. 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). findFirst is never batched and avoids this entire class of issues.

Transactions

  • Always use the $transaction helper from ~/db.server, never prisma.$transaction (or $replica.$transaction) directly. The helper wraps the raw call with tracing (an OTEL span + an isolation_level attribute) 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 for Serializable when 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 like findProjectByRef / resolveOrganizationForApiUser). A PAT is user-scoped and can name any org/project by id/slug, and the OSS RBAC fallback ability is permissive — so ability.can(...) alone does NOT reject a non-member on self-hosted. The RBAC authorization gate enforces the role; the membership-scoped query is the tenant floor. Skipping it opens cross-org access on OSS.

React Patterns

  • Only use useCallback/useMemo for 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.