Files
Matt Aitken e4981d1b11 feat(webapp): consolidate auth path + add comprehensive auth tests (#3499)
## Summary

Consolidates the webapp's authentication and authorization into a small
set of route helpers, replacing the ad-hoc `requireUser` /
`requireUserId` / `authenticatedEnvironmentForAuthentication` calls
scattered across routes. Same security model, but the per-request flow
(authenticate → authorize → load) now lives in one place per route
family.

Introduces a plugin seam (`@trigger.dev/plugins`) that lets the cloud
build install a richer RBAC implementation without touching webapp code.
The OSS fallback keeps the pre-RBAC permissive behaviour intact, so
self-hosted deployments work unchanged.

Adds a comprehensive end-to-end auth test suite that didn't exist before
— 193 `it()` blocks (vitest reports ~199 after `it.each` expansion)
covering API key, PAT and JWT auth across the public API surface, plus
dashboard session auth for admin pages.

## Changes

### Plugin contract — `@trigger.dev/plugins`

`RoleBaseAccessController` interface authoritative for both OSS
(fallback) and cloud (enterprise plugin):
- `authenticateBearer(request, { allowJWT? })` — API-key / public-JWT
auth, returns env + ability
- `authenticateSession(request, { userId, organizationId?, projectId?
})` — dashboard auth, caller resolves `userId` from the session cookie
and passes it in (no `helpers.getSessionUserId` callback — decouples the
plugin host from session-cookie code)
- `authenticatePat(request, { organizationId?, projectId? })` — PAT
auth, returns identity + `lastAccessedAt` so the host can throttle the
per-request update
- `authenticateAuthorize*` variants for the auth-and-check-in-one-call
cases
- `isUsingPlugin(): Promise<boolean>` — capability flag for UI /
branching where plugin-present-ness matters; replaces the
sentinel-string coupling that had `personalAccessToken.server` matching
`"RBAC plugin not installed"` literally

### Dashboard auth (started, partial rollout)

Admin and settings pages migrated to a unified `dashboardLoader` /
`dashboardAction` helper that authenticates the session, runs an
authorization check, and exposes the result to the route. Other
dashboard routes still on the old pattern; remaining migration tracked
in TRI-8730.

Migrated routes:
- `admin.*` (14 admin / back-office / feature-flags / LLM-models /
notifications / orgs / concurrency pages)
- `_app.orgs.$organizationSlug.settings.team`
- `_app.orgs.$organizationSlug.settings.roles`

### API / realtime / engine auth (complete for the migrated families)

71 routes migrated to a unified `apiBuilder` that centralizes Bearer /
PAT / Public-JWT authentication and applies the per-route authorization
check before the handler runs. Includes:
- `api.v1.*` and `api.v2.*` and `api.v3.*` — tasks, runs, batches,
queues, prompts, deployments, query, sessions, waitpoints, packets,
workers, idempotency keys
- `realtime.v1.*` — runs, batches, sessions, streams
- `engine.v1.*` — dev / worker-action protocols

29 routes still on the legacy `authenticateApiRequest*` helpers —
tracked as a post-deploy follow-up in TRI-9228.

Multi-resource auth direction is now explicit at the call site via
`anyResource(...)` (OR) and `everyResource(...)` (AND). Bare arrays no
longer typecheck — fixes a class of bug where a JWT scoped to one
resource could implicitly access others under OR semantics.

PAT auth path consolidated: was three DB queries per request (legacy
`authenticateApiRequestWithPersonalAccessToken` findFirst +
`rbac.authenticatePat` join + `lastAccessedAt` update). Now one query in
the steady state — plugin returns `lastAccessedAt`, host smart-skips the
update via JS-side throttle when fresh.

Side effect: action aliases preserved historic JWT scope semantics where
the new model is stricter (e.g. a `write:tasks` JWT now also satisfies
`trigger` / `batchTrigger` / `update` actions on the same resource —
matched at the auth boundary, not in the route handler).

### Backwards-compat fixes

The strict-match model regressed several real-world JWT shapes. Each
preserved via explicit `anyResource(...)` entries in the route's authz
block:

- **Batch retrieve routes** (`api.v1.batches.$batchId`, `api.v2.*`,
`realtime.v1.batches.*`) accept `read:runs` JWTs again (pre-RBAC
literal-match superScope behaviour)
- **Runs list routes** (`api.v1.runs`, `realtime.v1.runs`) accept
type-level `read:tasks` / `read:tags` on unfiltered queries (matched the
legacy `Object.keys` iteration semantic)
- **PAT/OAT auth shape** normalized through `toAuthenticated` so all
auth methods return the same slim `AuthenticatedEnvironment` (was:
API-key returned the slim shape but PAT/OAT returned raw Prisma
`Decimal` / no `orgMember`)
- **Scope `:` preservation** in resource ids — `read:tags:env:staging`
now correctly identifies the tag id as `env:staging`, not `env`

### Slim `AuthenticatedEnvironment`

Extracted to `@trigger.dev/core/v3/auth/environment` — a structural
shape independent of `@trigger.dev/database`. The plugin contract
returns this; webapp consumers import from there; the cloud plugin
(Drizzle) returns the same shape without Prisma's `Decimal` class
leaking into the public surface. Lets internal-packages (run-engine,
etc.) refer to `AuthenticatedEnvironment` without pulling Prisma in.

### Auth test suite (new — `*.e2e.full.test.ts`)

193 e2e tests run against a real spawned webapp + Postgres (no mocks).
Coverage matrix:

- **API key auth** — read / write / trigger / batchTrigger / deploy
actions across runs, batches, deployments, prompts, queues, query,
sessions, input-streams, waitpoints, tasks, idempotency keys; multi-key
resources (a run carries batch / tag / task identifiers — auth must
accept any matching scope)
- **Personal Access Token auth** — comprehensive matrix: scope match,
scope mismatch, missing scope, expired token, malformed token
- **Public JWT auth** — sub-vs-URL environment resolution, expired JWTs,
signature verification, scope checking, otu (one-time-use) token
semantics, branch-environment signing-key fallback
- **Dashboard session auth** — admin-only pages reject non-admins;
per-action gating
- **Cross-cutting edge cases** — revoked API key grace window, JWT
cross-environment isolation, MissingResource branch behaviour

### Hygiene cleanups

- Deleted dead `app/services/authorization.server.ts` (legacy
`checkAuthorization` + types — no live consumers post-migration) and its
orphaned test
- Dropped the never-populated `scopes` field from
`ApiAuthenticationResultSuccess`
- `scheduleEmail` moved out of `email.server.ts` into its own module —
breaks a `commonWorker → marqs/V1` import chain that was poisoning the
auth test graph
- OSS Roles page shows a deployment-aware empty state ("Roles aren't
available in this self-hosted deployment" vs the plan-upsell copy) via
`rbac.isUsingPlugin()`
- Team action handler: explicit per-intent ability gates
(`manage:billing` for purchase-seats, `manage:members` for set-role +
remove-member with self-leave carve-out)

### Cross-repo coordination

All public-package contract changes paired in `triggerdotdev/cloud#763`
(rbac-packages branch) — the enterprise plugin implements the same
`RoleBaseAccessController` interface against Drizzle.

## Test plan

- [x] `pnpm run typecheck --filter webapp` clean
- [x] `pnpm --filter webapp exec vitest run --config
vitest.e2e.full.config.ts` — 193/193 pass (requires Docker for
testcontainers)
- [x] Spot-check an authed API endpoint with a valid + invalid API key
against a local stack
- [x] Spot-check the migrated admin pages render and gate non-admins

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:16:20 +01:00

307 lines
11 KiB
JSON

{
"private": true,
"name": "webapp",
"version": "1.0.0",
"sideEffects": false,
"scripts": {
"build": "run-s build:** && pnpm run upload:sourcemaps",
"build:remix": "remix build --sourcemap",
"build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap",
"build:sentry": "esbuild --platform=node --format=cjs --outbase=. ./sentry.server.ts ./app/utils/sentryTraceContext.server.ts --outdir=build --sourcemap",
"dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"",
"dev:worker": "cross-env NODE_PATH=../../node_modules/.pnpm/node_modules node ./build/server.js",
"format": "prettier --write .",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js",
"start:local": "cross-env node --max-old-space-size=8192 ./build/server.js",
"typecheck": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsc --noEmit -p ./tsconfig.check.json",
"db:seed": "tsx seed.mts",
"db:seed:ai-spans": "tsx seed-ai-spans.mts",
"upload:sourcemaps": "bash ./upload-sourcemaps.sh",
"test": "vitest --no-file-parallelism",
"eval:dev": "evalite watch"
},
"eslintIgnore": [
"/node_modules",
"/build",
"/public/build"
],
"dependencies": {
"@ai-sdk/openai": "^1.3.23",
"@ariakit/react": "^0.4.6",
"@ariakit/react-core": "^0.4.6",
"@aws-sdk/client-ecr": "^3.931.0",
"@aws-sdk/client-s3": "^3.936.0",
"@aws-sdk/client-sqs": "^3.445.0",
"@aws-sdk/client-sts": "^3.840.0",
"@aws-sdk/credential-provider-node": "^3.936.0",
"@aws-sdk/s3-presigned-post": "^3.936.0",
"@aws-sdk/s3-request-presigner": "^3.936.0",
"@better-auth/utils": "^0.2.6",
"@codemirror/autocomplete": "6.4.0",
"@codemirror/commands": "6.1.3",
"@codemirror/lang-javascript": "6.1.2",
"@codemirror/lang-json": "6.0.1",
"@codemirror/lang-sql": "6.5.5",
"@codemirror/language": "6.3.2",
"@codemirror/lint": "6.4.2",
"@codemirror/search": "6.2.3",
"@codemirror/state": "6.2.0",
"@codemirror/view": "6.7.2",
"@conform-to/react": "0.9.2",
"@conform-to/zod": "0.9.2",
"@depot/cli": "0.0.1-cli.2.80.0",
"@depot/sdk-node": "^1.0.0",
"@electric-sql/react": "^0.3.5",
"@headlessui/react": "^1.7.8",
"@heroicons/react": "^2.0.12",
"@jsonhero/schema-infer": "^0.1.5",
"@internal/cache": "workspace:*",
"@internal/compute": "workspace:*",
"@internal/llm-model-catalog": "workspace:*",
"@internal/redis": "workspace:*",
"@internal/run-engine": "workspace:*",
"@internal/schedule-engine": "workspace:*",
"@internal/tracing": "workspace:*",
"@internal/tsql": "workspace:*",
"@internal/zod-worker": "workspace:*",
"@internationalized/date": "^3.5.1",
"@kapaai/react-sdk": "^0.1.3",
"@lezer/highlight": "^1.1.6",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/api-logs": "0.203.0",
"@opentelemetry/core": "2.0.1",
"@opentelemetry/exporter-logs-otlp-http": "0.203.0",
"@opentelemetry/exporter-metrics-otlp-proto": "0.203.0",
"@opentelemetry/exporter-trace-otlp-http": "0.203.0",
"@opentelemetry/host-metrics": "^0.37.0",
"@opentelemetry/instrumentation": "0.203.0",
"@opentelemetry/instrumentation-aws-sdk": "^0.57.0",
"@opentelemetry/instrumentation-express": "^0.52.0",
"@opentelemetry/instrumentation-http": "0.203.0",
"@opentelemetry/resource-detector-aws": "^2.3.0",
"@opentelemetry/resources": "2.0.1",
"@opentelemetry/sdk-logs": "0.203.0",
"@opentelemetry/sdk-metrics": "2.0.1",
"@opentelemetry/sdk-node": "0.203.0",
"@opentelemetry/sdk-trace-base": "2.0.1",
"@opentelemetry/sdk-trace-node": "2.0.1",
"@opentelemetry/semantic-conventions": "1.36.0",
"@popperjs/core": "^2.11.8",
"@prisma/instrumentation": "^6.14.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-label": "^2.0.1",
"@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-portal": "^1.1.9",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^1.2.1",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.5",
"@react-aria/datepicker": "^3.9.1",
"@react-stately/datepicker": "^3.9.1",
"@react-types/datepicker": "^3.7.1",
"@remix-run/express": "2.17.4",
"@remix-run/node": "2.17.4",
"@remix-run/react": "2.17.4",
"@remix-run/router": "^1.23.2",
"@remix-run/serve": "2.17.4",
"@remix-run/server-runtime": "2.17.4",
"@remix-run/v1-meta": "^0.1.3",
"@s2-dev/streamstore": "^0.22.5",
"@sentry/remix": "9.46.0",
"@slack/web-api": "7.9.1",
"@socket.io/redis-adapter": "^8.3.0",
"@tabler/icons-react": "^3.36.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.0.4",
"@team-plain/typescript-sdk": "^3.5.0",
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/rbac": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.27",
"@trigger.dev/redis-worker": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"@types/pg": "8.6.6",
"@uiw/react-codemirror": "^4.19.5",
"@unkey/cache": "^1.5.0",
"@unkey/error": "^0.2.0",
"@upstash/ratelimit": "^1.1.3",
"@vercel/sdk": "^1.19.1",
"@whatwg-node/fetch": "^0.9.14",
"@window-splitter/react": "1.1.3",
"ai": "^4.3.19",
"assert-never": "^1.2.1",
"aws4fetch": "^1.0.18",
"class-variance-authority": "^0.5.2",
"clsx": "^1.2.1",
"compression": "^1.7.4",
"cookie": "^0.6.0",
"cron-parser": "^4.9.0",
"cronstrue": "^2.21.0",
"cross-env": "^7.0.3",
"cuid": "^2.1.8",
"date-fns": "^4.1.0",
"dompurify": "^3.4.1",
"dotenv": "^16.4.5",
"effect": "^3.21.2",
"emails": "workspace:*",
"eventsource": "^4.0.0",
"evt": "^2.4.13",
"express": "4.20.0",
"framer-motion": "^10.12.11",
"graphile-worker": "0.16.6",
"humanize-duration": "^3.27.3",
"input-otp": "^1.4.2",
"intl-parse-accept-language": "^1.0.0",
"ioredis": "^5.3.2",
"isbot": "^3.6.5",
"jose": "^5.4.0",
"json-stable-stringify": "^1.3.0",
"jsonpointer": "^5.0.1",
"lodash.omit": "^4.5.0",
"lucide-react": "^0.229.0",
"marked": "^4.0.18",
"match-sorter": "^6.3.4",
"morgan": "^1.10.0",
"nanoid": "3.3.8",
"neverthrow": "^8.2.0",
"non.geist": "^1.0.2",
"octokit": "^3.2.1",
"ohash": "^1.1.3",
"openai": "^4.33.1",
"p-limit": "^6.2.0",
"p-map": "^6.0.0",
"p-retry": "^4.6.1",
"parse-duration": "^2.1.0",
"posthog-js": "^1.93.3",
"posthog-node": "4.17.1",
"prism-react-renderer": "^2.3.1",
"prismjs": "^1.30.0",
"prom-client": "^15.1.0",
"qrcode.react": "^4.2.0",
"random-words": "^2.0.0",
"react": "^18.2.0",
"react-aria": "^3.31.1",
"react-collapse": "^5.1.1",
"react-day-picker": "^9.13.0",
"react-dom": "^18.2.0",
"react-grid-layout": "^2.2.2",
"react-hotkeys-hook": "^4.4.1",
"react-markdown": "^10.1.0",
"react-popper": "^2.3.0",
"react-resizable": "^3.1.3",
"react-resizable-panels": "^2.0.9",
"react-stately": "^3.29.1",
"react-use": "17.5.1",
"recharts": "^2.15.2",
"regression": "^2.0.1",
"remix-auth": "^3.6.0",
"remix-auth-email-link": "2.0.2",
"remix-auth-github": "^1.6.0",
"remix-auth-google": "^2.0.0",
"remix-typedjson": "0.3.1",
"remix-utils": "^7.7.0",
"seedrandom": "^3.0.5",
"semver": "^7.5.0",
"simple-oauth2": "^5.0.0",
"simplur": "^3.0.1",
"slug": "^6.0.0",
"socket.io": "4.7.4",
"socket.io-adapter": "^2.5.4",
"sonner": "^1.0.3",
"sql-formatter": "^15.4.10",
"sqs-consumer": "^7.4.0",
"streamdown": "^1.4.0",
"superjson": "^2.2.1",
"tailwind-merge": "^1.12.0",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.5",
"tailwindcss-textshadow": "^2.1.3",
"tiny-invariant": "^1.2.0",
"ulid": "^2.3.0",
"ulidx": "^2.2.1",
"uuid": "^14.0.0",
"ws": "^8.11.0",
"zod": "3.25.76",
"zod-error": "1.5.0",
"zod-validation-error": "^1.5.0"
},
"devDependencies": {
"@internal/clickhouse": "workspace:*",
"@internal/replication": "workspace:*",
"@internal/testcontainers": "workspace:*",
"@remix-run/dev": "2.17.4",
"@remix-run/eslint-config": "2.17.4",
"@remix-run/testing": "^2.17.4",
"@sentry/cli": "2.50.2",
"@swc/core": "^1.3.4",
"@swc/helpers": "^0.4.11",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@total-typescript/ts-reset": "^0.4.2",
"@types/bcryptjs": "^2.4.2",
"@types/compression": "^1.7.2",
"@types/cookie": "^0.6.0",
"@types/eslint": "^8.4.6",
"@types/express": "^4.17.13",
"@types/humanize-duration": "^3.27.1",
"@types/json-query": "^2.2.3",
"@types/lodash.omit": "^4.5.7",
"@types/marked": "^4.0.3",
"@types/morgan": "^1.9.3",
"@types/node-fetch": "^2.6.2",
"@types/prismjs": "^1.26.0",
"@types/qs": "^6.9.7",
"@types/react": "18.2.69",
"@types/react-collapse": "^5.0.4",
"@types/react-dom": "18.2.7",
"@types/regression": "^2.0.6",
"@types/seedrandom": "^3.0.8",
"@types/semver": "^7.5.0",
"@types/simple-oauth2": "^5.0.4",
"@types/slug": "^5.0.3",
"@types/supertest": "^6.0.2",
"@types/tar": "^6.1.4",
"@types/ws": "^8.5.3",
"@typescript-eslint/eslint-plugin": "^5.59.6",
"@typescript-eslint/parser": "^5.59.6",
"autoevals": "^0.0.130",
"autoprefixer": "^10.4.13",
"css-loader": "^6.10.0",
"datepicker": "link:@types/@react-aria/datepicker",
"engine.io": "^6.5.4",
"esbuild": "^0.15.10",
"eslint": "^8.24.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-turbo": "^2.0.4",
"evalite": "^0.11.4",
"npm-run-all": "^4.1.5",
"postcss-import": "^16.0.1",
"postcss-loader": "^8.1.1",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"prop-types": "^15.8.1",
"rimraf": "^6.0.1",
"style-loader": "^3.3.4",
"supertest": "^7.0.0",
"tailwind-scrollbar": "^3.0.1",
"tailwindcss": "3.4.1",
"tsconfig-paths": "^3.14.1",
"tsx": "^4.20.6",
"vite-tsconfig-paths": "^4.0.5"
},
"engines": {
"node": ">=18.19.0 || >=20.6.0"
}
}