From 41a486ea7e2335c1199564dbbfe8eb6b7efdd88e Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 12 May 2026 11:33:00 +0100
Subject: [PATCH 1/4] chore: release v4.4.6 (#3501)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
1 improvement, 1 bug fix.
## Improvements
- Fail attempts on uncaught exceptions instead of hanging to
`MAX_DURATION_EXCEEDED`. A Node `EventEmitter` (e.g. `node-redis`)
emitting `"error"` with no `.on("error", ...)` listener escalates to
`uncaughtException`, which the worker previously reported but did not
act on — runs drifted to maxDuration with empty attempts. They now fail
fast with the original error and status `FAILED`, and respect the task's
normal retry policy. You should still attach `.on("error", ...)`
listeners to long-lived clients to handle errors gracefully.
([#3529](https://github.com/triggerdotdev/trigger.dev/pull/3529))
## Bug fixes
- Fix dev workers spinning at 100% CPU after the parent CLI disconnects.
Orphaned `trigger-dev-run-worker` (and indexer) processes were caught in
an `uncaughtException` feedback loop: a periodic IPC send via
`process.send` would throw `ERR_IPC_CHANNEL_CLOSED` once the parent
closed the channel, which re-entered the same handler that itself called
`process.send`, scheduled via `setImmediate` and amplified by
source-map-support's `prepareStackTrace`. Fixed by (1) silently dropping
packets in `ZodIpcConnection` when the channel is disconnected, (2)
adding a `process.on("disconnect", ...)` handler in dev workers so they
exit cleanly when the CLI closes the IPC channel, and (3) wrapping all
`uncaughtException`-path `process.send` calls in a `safeSend` guard that
checks `process.connected` and swallows synchronous throws.
([#3491](https://github.com/triggerdotdev/trigger.dev/pull/3491))
Raw changeset output
# Releases
## @trigger.dev/build@4.4.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
## trigger.dev@4.4.6
### Patch Changes
- Fix dev workers spinning at 100% CPU after the parent CLI disconnects.
Orphaned `trigger-dev-run-worker` (and indexer) processes were caught in
an `uncaughtException` feedback loop: a periodic IPC send via
`process.send` would throw `ERR_IPC_CHANNEL_CLOSED` once the parent
closed the channel, which re-entered the same handler that itself called
`process.send`, scheduled via `setImmediate` and amplified by
source-map-support's `prepareStackTrace`. Fixed by (1) silently dropping
packets in `ZodIpcConnection` when the channel is disconnected, (2)
adding a `process.on("disconnect", ...)` handler in dev workers so they
exit cleanly when the CLI closes the IPC channel, and (3) wrapping all
`uncaughtException`-path `process.send` calls in a `safeSend` guard that
checks `process.connected` and swallows synchronous throws.
([#3491](https://github.com/triggerdotdev/trigger.dev/pull/3491))
- Fail attempts on uncaught exceptions instead of hanging to
`MAX_DURATION_EXCEEDED`. A Node `EventEmitter` (e.g. `node-redis`)
emitting `"error"` with no `.on("error", ...)` listener escalates to
`uncaughtException`, which the worker previously reported but did not
act on — runs drifted to maxDuration with empty attempts. They now fail
fast with the original error and status `FAILED`, and respect the task's
normal retry policy. You should still attach `.on("error", ...)`
listeners to long-lived clients to handle errors gracefully.
([#3529](https://github.com/triggerdotdev/trigger.dev/pull/3529))
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
- `@trigger.dev/build@4.4.6`
- `@trigger.dev/schema-to-json@4.4.6`
## @trigger.dev/core@4.4.6
### Patch Changes
- Fix dev workers spinning at 100% CPU after the parent CLI disconnects.
Orphaned `trigger-dev-run-worker` (and indexer) processes were caught in
an `uncaughtException` feedback loop: a periodic IPC send via
`process.send` would throw `ERR_IPC_CHANNEL_CLOSED` once the parent
closed the channel, which re-entered the same handler that itself called
`process.send`, scheduled via `setImmediate` and amplified by
source-map-support's `prepareStackTrace`. Fixed by (1) silently dropping
packets in `ZodIpcConnection` when the channel is disconnected, (2)
adding a `process.on("disconnect", ...)` handler in dev workers so they
exit cleanly when the CLI closes the IPC channel, and (3) wrapping all
`uncaughtException`-path `process.send` calls in a `safeSend` guard that
checks `process.connected` and swallows synchronous throws.
([#3491](https://github.com/triggerdotdev/trigger.dev/pull/3491))
- Fail attempts on uncaught exceptions instead of hanging to
`MAX_DURATION_EXCEEDED`. A Node `EventEmitter` (e.g. `node-redis`)
emitting `"error"` with no `.on("error", ...)` listener escalates to
`uncaughtException`, which the worker previously reported but did not
act on — runs drifted to maxDuration with empty attempts. They now fail
fast with the original error and status `FAILED`, and respect the task's
normal retry policy. You should still attach `.on("error", ...)`
listeners to long-lived clients to handle errors gracefully.
([#3529](https://github.com/triggerdotdev/trigger.dev/pull/3529))
## @trigger.dev/python@4.4.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
- `@trigger.dev/build@4.4.6`
- `@trigger.dev/sdk@4.4.6`
## @trigger.dev/react-hooks@4.4.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
## @trigger.dev/redis-worker@4.4.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
## @trigger.dev/rsc@4.4.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
## @trigger.dev/schema-to-json@4.4.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
## @trigger.dev/sdk@4.4.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.4.6`
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.changeset/dev-worker-disconnect-loop.md | 6 -----
.changeset/uncaught-exception-fail-attempt.md | 6 -----
.../admin-back-office-batch-rate-limit.md | 6 -----
.../admin-back-office-max-projects.md | 6 -----
.server-changes/app-auto-session-logout.md | 6 -----
.server-changes/fix-resizable-panel-stuck.md | 6 -----
.server-changes/fix-rollback-schedule-sync.md | 6 -----
.../llm-pricing-registry-reload-channel.md | 6 -----
.server-changes/per-org-stream-basins.md | 6 -----
.../redis-reconnect-on-readonly-loading.md | 6 -----
.../redis-reconnect-on-unblocked.md | 6 -----
.server-changes/run-engine-single-ttl-path.md | 6 -----
.server-changes/sanitize-api-500-errors.md | 6 -----
.server-changes/sentry-trace-id-context.md | 6 -----
.server-changes/sentry-wrapper-bypass-fix.md | 10 --------
.../strip-background-worker-metadata.md | 6 -----
.../uncaught-exception-status-mapping.md | 12 ----------
hosting/k8s/helm/Chart.yaml | 4 ++--
packages/build/CHANGELOG.md | 7 ++++++
packages/build/package.json | 4 ++--
packages/cli-v3/CHANGELOG.md | 11 +++++++++
packages/cli-v3/package.json | 8 +++----
packages/core/CHANGELOG.md | 7 ++++++
packages/core/package.json | 2 +-
packages/python/CHANGELOG.md | 9 +++++++
packages/python/package.json | 12 +++++-----
packages/react-hooks/CHANGELOG.md | 7 ++++++
packages/react-hooks/package.json | 4 ++--
packages/redis-worker/CHANGELOG.md | 7 ++++++
packages/redis-worker/package.json | 4 ++--
packages/rsc/CHANGELOG.md | 7 ++++++
packages/rsc/package.json | 6 ++---
packages/schema-to-json/CHANGELOG.md | 7 ++++++
packages/schema-to-json/package.json | 2 +-
packages/trigger-sdk/CHANGELOG.md | 7 ++++++
packages/trigger-sdk/package.json | 4 ++--
pnpm-lock.yaml | 24 +++++++++----------
37 files changed, 106 insertions(+), 149 deletions(-)
delete mode 100644 .changeset/dev-worker-disconnect-loop.md
delete mode 100644 .changeset/uncaught-exception-fail-attempt.md
delete mode 100644 .server-changes/admin-back-office-batch-rate-limit.md
delete mode 100644 .server-changes/admin-back-office-max-projects.md
delete mode 100644 .server-changes/app-auto-session-logout.md
delete mode 100644 .server-changes/fix-resizable-panel-stuck.md
delete mode 100644 .server-changes/fix-rollback-schedule-sync.md
delete mode 100644 .server-changes/llm-pricing-registry-reload-channel.md
delete mode 100644 .server-changes/per-org-stream-basins.md
delete mode 100644 .server-changes/redis-reconnect-on-readonly-loading.md
delete mode 100644 .server-changes/redis-reconnect-on-unblocked.md
delete mode 100644 .server-changes/run-engine-single-ttl-path.md
delete mode 100644 .server-changes/sanitize-api-500-errors.md
delete mode 100644 .server-changes/sentry-trace-id-context.md
delete mode 100644 .server-changes/sentry-wrapper-bypass-fix.md
delete mode 100644 .server-changes/strip-background-worker-metadata.md
delete mode 100644 .server-changes/uncaught-exception-status-mapping.md
diff --git a/.changeset/dev-worker-disconnect-loop.md b/.changeset/dev-worker-disconnect-loop.md
deleted file mode 100644
index cf5afbb2135..00000000000
--- a/.changeset/dev-worker-disconnect-loop.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"@trigger.dev/core": patch
-"trigger.dev": patch
----
-
-Fix dev workers spinning at 100% CPU after the parent CLI disconnects. Orphaned `trigger-dev-run-worker` (and indexer) processes were caught in an `uncaughtException` feedback loop: a periodic IPC send via `process.send` would throw `ERR_IPC_CHANNEL_CLOSED` once the parent closed the channel, which re-entered the same handler that itself called `process.send`, scheduled via `setImmediate` and amplified by source-map-support's `prepareStackTrace`. Fixed by (1) silently dropping packets in `ZodIpcConnection` when the channel is disconnected, (2) adding a `process.on("disconnect", ...)` handler in dev workers so they exit cleanly when the CLI closes the IPC channel, and (3) wrapping all `uncaughtException`-path `process.send` calls in a `safeSend` guard that checks `process.connected` and swallows synchronous throws.
diff --git a/.changeset/uncaught-exception-fail-attempt.md b/.changeset/uncaught-exception-fail-attempt.md
deleted file mode 100644
index d80c09c825e..00000000000
--- a/.changeset/uncaught-exception-fail-attempt.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"trigger.dev": patch
-"@trigger.dev/core": patch
----
-
-Fail attempts on uncaught exceptions instead of hanging to `MAX_DURATION_EXCEEDED`. A Node `EventEmitter` (e.g. `node-redis`) emitting `"error"` with no `.on("error", ...)` listener escalates to `uncaughtException`, which the worker previously reported but did not act on — runs drifted to maxDuration with empty attempts. They now fail fast with the original error and status `FAILED`, and respect the task's normal retry policy. You should still attach `.on("error", ...)` listeners to long-lived clients to handle errors gracefully.
diff --git a/.server-changes/admin-back-office-batch-rate-limit.md b/.server-changes/admin-back-office-batch-rate-limit.md
deleted file mode 100644
index 8802845f928..00000000000
--- a/.server-changes/admin-back-office-batch-rate-limit.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: feature
----
-
-Admin back office: edit an organization's batch rate limit (`batchRateLimitConfig`) from the org page, alongside the existing API rate limit editor. The rate-limit form UI is now shared between the API and batch sections.
diff --git a/.server-changes/admin-back-office-max-projects.md b/.server-changes/admin-back-office-max-projects.md
deleted file mode 100644
index 698fdfe12ff..00000000000
--- a/.server-changes/admin-back-office-max-projects.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: feature
----
-
-Admin back office: edit an organization's `maximumProjectCount` from the org page, beneath the API rate limit editor.
diff --git a/.server-changes/app-auto-session-logout.md b/.server-changes/app-auto-session-logout.md
deleted file mode 100644
index b729fbe21ae..00000000000
--- a/.server-changes/app-auto-session-logout.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: feature
----
-
-App auto session logout. Users can configure their own session duration; org admins can set a `maxSessionDuration` cap that takes the tightest value across an account's orgs. Sessions exceeding their effective duration are redirected to `/logout` with a HIPAA audit trail emitted to CloudWatch (`event: session.auto_logout`). Enforcement reads `User.nextSessionEnd` — written at login and bulk-updated when admins change the cap — so the auth path adds no per-request DB queries.
diff --git a/.server-changes/fix-resizable-panel-stuck.md b/.server-changes/fix-resizable-panel-stuck.md
deleted file mode 100644
index 1a36f0c71e6..00000000000
--- a/.server-changes/fix-resizable-panel-stuck.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: fix
----
-
-Fix the run-view inspector panel glitching out and locking up in Firefox. Disabled the underlying resizable library's collapse animation on Firefox (where its `requestAnimationFrame`-driven actor caused visual glitches and intermittent state-machine errors) while keeping it intact for Chromium and Safari, and bumped the inspector minimum from 50px to 250px so dragging can't shrink the panel into a near-useless width.
diff --git a/.server-changes/fix-rollback-schedule-sync.md b/.server-changes/fix-rollback-schedule-sync.md
deleted file mode 100644
index c9f3d14f59b..00000000000
--- a/.server-changes/fix-rollback-schedule-sync.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: fix
----
-
-Sync declarative schedules when rolling back or promoting deployments
diff --git a/.server-changes/llm-pricing-registry-reload-channel.md b/.server-changes/llm-pricing-registry-reload-channel.md
deleted file mode 100644
index ec1daad0a31..00000000000
--- a/.server-changes/llm-pricing-registry-reload-channel.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: improvement
----
-
-The LLM pricing registry now reloads from the database whenever a publish lands on `LLM_PRICING_RELOAD_CHANNEL` on the worker Redis, instead of waiting for the next 5-minute interval. LLM model and pricing changes reflect in cost enrichment within seconds.
diff --git a/.server-changes/per-org-stream-basins.md b/.server-changes/per-org-stream-basins.md
deleted file mode 100644
index 4e45129849c..00000000000
--- a/.server-changes/per-org-stream-basins.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: feature
----
-
-Per-org S2 stream basins with retention tied to the org's billing plan, gated by `REALTIME_STREAMS_PER_ORG_BASINS_ENABLED`. Stops basin retention from deleting streams out from under live chat sessions and unlocks per-org cost attribution.
diff --git a/.server-changes/redis-reconnect-on-readonly-loading.md b/.server-changes/redis-reconnect-on-readonly-loading.md
deleted file mode 100644
index 3173d99860b..00000000000
--- a/.server-changes/redis-reconnect-on-readonly-loading.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: improvement
----
-
-Add `reconnectOnError` to the shared ioredis client config so READONLY / LOADING reply errors during ElastiCache node-type changes trigger a disconnect-reconnect-retry cycle instead of surfacing to caller code.
diff --git a/.server-changes/redis-reconnect-on-unblocked.md b/.server-changes/redis-reconnect-on-unblocked.md
deleted file mode 100644
index 10129f2b854..00000000000
--- a/.server-changes/redis-reconnect-on-unblocked.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: improvement
----
-
-Extend the shared ioredis `reconnectOnError` hook (PR #3548) to also match `UNBLOCKED` reply errors so blocking commands like BLPOP transparently reconnect-and-retry when the ElastiCache primary forces them to unblock during a node role change.
diff --git a/.server-changes/run-engine-single-ttl-path.md b/.server-changes/run-engine-single-ttl-path.md
deleted file mode 100644
index cac5e3a3cb1..00000000000
--- a/.server-changes/run-engine-single-ttl-path.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: improvement
----
-
-Route TTL expiration through the batch TTL path only. Removes the redundant per-run `expireRun` worker job, leaving the batch consumer as the single mechanism that flips runs to `EXPIRED` when their TTL elapses while still queued.
diff --git a/.server-changes/sanitize-api-500-errors.md b/.server-changes/sanitize-api-500-errors.md
deleted file mode 100644
index 1621e15a16f..00000000000
--- a/.server-changes/sanitize-api-500-errors.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: fix
----
-
-Stop leaking raw exception messages on 500 responses across webapp API routes; return a generic error string and log the full error server-side instead.
diff --git a/.server-changes/sentry-trace-id-context.md b/.server-changes/sentry-trace-id-context.md
deleted file mode 100644
index eaf2c333aa3..00000000000
--- a/.server-changes/sentry-trace-id-context.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: improvement
----
-
-Stamp the active OpenTelemetry trace_id and span_id onto every Sentry event so issues can be cross-referenced with traces in any OTel backend.
diff --git a/.server-changes/sentry-wrapper-bypass-fix.md b/.server-changes/sentry-wrapper-bypass-fix.md
deleted file mode 100644
index b19d90d84c5..00000000000
--- a/.server-changes/sentry-wrapper-bypass-fix.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-area: webapp
-type: fix
----
-
-Stop nine catch sites in the webapp from escalating expected user-input
-failures (`ServiceValidationError`, `OutOfEntitlementError`,
-`CreateDeclarativeScheduleError`, `QueryError`) as `error`-level events.
-Type-discriminate before logging; downgrade the user-facing branches to
-`warn` while keeping unknown-error fall-throughs at `error`.
diff --git a/.server-changes/strip-background-worker-metadata.md b/.server-changes/strip-background-worker-metadata.md
deleted file mode 100644
index c92ee62b1d6..00000000000
--- a/.server-changes/strip-background-worker-metadata.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-area: webapp
-type: improvement
----
-
-Strip BackgroundWorker.metadata to the schedule slice read at deploy promotion, removing a 5+ second event-loop block in Prisma's client serializer when creating workers for projects with many tasks or source files.
diff --git a/.server-changes/uncaught-exception-status-mapping.md b/.server-changes/uncaught-exception-status-mapping.md
deleted file mode 100644
index 941342359fb..00000000000
--- a/.server-changes/uncaught-exception-status-mapping.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-area: run-engine
-type: fix
----
-
-Map the new `TASK_RUN_UNCAUGHT_EXCEPTION` internal-error code to
-`COMPLETED_WITH_ERRORS` (Failed) status in `runStatusFromError`. cli-v3
-now emits this code when the worker process surfaces an uncaught
-exception (e.g. a Node EventEmitter emitting `"error"` with no listener),
-so the run renders as a regular task failure in the dashboard rather
-than a system failure, while still routing through the engine's
-`lockedRetryConfig` lookup so the user's retry policy is honoured.
diff --git a/hosting/k8s/helm/Chart.yaml b/hosting/k8s/helm/Chart.yaml
index 83734b4ddb1..529daddce08 100644
--- a/hosting/k8s/helm/Chart.yaml
+++ b/hosting/k8s/helm/Chart.yaml
@@ -2,8 +2,8 @@ apiVersion: v2
name: trigger
description: The official Trigger.dev Helm chart
type: application
-version: 4.4.5
-appVersion: v4.4.5
+version: 4.4.6
+appVersion: v4.4.6
home: https://trigger.dev
sources:
- https://github.com/triggerdotdev/trigger.dev
diff --git a/packages/build/CHANGELOG.md b/packages/build/CHANGELOG.md
index 0be938f72f8..742c66c83ef 100644
--- a/packages/build/CHANGELOG.md
+++ b/packages/build/CHANGELOG.md
@@ -1,5 +1,12 @@
# @trigger.dev/build
+## 4.4.6
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@trigger.dev/core@4.4.6`
+
## 4.4.5
### Patch Changes
diff --git a/packages/build/package.json b/packages/build/package.json
index 49a310e46e7..206a80b89da 100644
--- a/packages/build/package.json
+++ b/packages/build/package.json
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/build",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "trigger.dev build extensions",
"license": "MIT",
"publishConfig": {
@@ -78,7 +78,7 @@
},
"dependencies": {
"@prisma/config": "^6.10.0",
- "@trigger.dev/core": "workspace:4.4.5",
+ "@trigger.dev/core": "workspace:4.4.6",
"mlly": "^1.7.1",
"pkg-types": "^1.1.3",
"resolve": "^1.22.8",
diff --git a/packages/cli-v3/CHANGELOG.md b/packages/cli-v3/CHANGELOG.md
index 4f9b456832a..c0a0c29fd18 100644
--- a/packages/cli-v3/CHANGELOG.md
+++ b/packages/cli-v3/CHANGELOG.md
@@ -1,5 +1,16 @@
# trigger.dev
+## 4.4.6
+
+### Patch Changes
+
+- Fix dev workers spinning at 100% CPU after the parent CLI disconnects. Orphaned `trigger-dev-run-worker` (and indexer) processes were caught in an `uncaughtException` feedback loop: a periodic IPC send via `process.send` would throw `ERR_IPC_CHANNEL_CLOSED` once the parent closed the channel, which re-entered the same handler that itself called `process.send`, scheduled via `setImmediate` and amplified by source-map-support's `prepareStackTrace`. Fixed by (1) silently dropping packets in `ZodIpcConnection` when the channel is disconnected, (2) adding a `process.on("disconnect", ...)` handler in dev workers so they exit cleanly when the CLI closes the IPC channel, and (3) wrapping all `uncaughtException`-path `process.send` calls in a `safeSend` guard that checks `process.connected` and swallows synchronous throws. ([#3491](https://github.com/triggerdotdev/trigger.dev/pull/3491))
+- Fail attempts on uncaught exceptions instead of hanging to `MAX_DURATION_EXCEEDED`. A Node `EventEmitter` (e.g. `node-redis`) emitting `"error"` with no `.on("error", ...)` listener escalates to `uncaughtException`, which the worker previously reported but did not act on — runs drifted to maxDuration with empty attempts. They now fail fast with the original error and status `FAILED`, and respect the task's normal retry policy. You should still attach `.on("error", ...)` listeners to long-lived clients to handle errors gracefully. ([#3529](https://github.com/triggerdotdev/trigger.dev/pull/3529))
+- Updated dependencies:
+ - `@trigger.dev/core@4.4.6`
+ - `@trigger.dev/build@4.4.6`
+ - `@trigger.dev/schema-to-json@4.4.6`
+
## 4.4.5
### Patch Changes
diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json
index 7d75fa8c3d9..326104a624d 100644
--- a/packages/cli-v3/package.json
+++ b/packages/cli-v3/package.json
@@ -1,6 +1,6 @@
{
"name": "trigger.dev",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "A Command-Line Interface for Trigger.dev projects",
"type": "module",
"license": "MIT",
@@ -95,9 +95,9 @@
"@opentelemetry/sdk-trace-node": "2.0.1",
"@opentelemetry/semantic-conventions": "1.36.0",
"@s2-dev/streamstore": "^0.22.5",
- "@trigger.dev/build": "workspace:4.4.5",
- "@trigger.dev/core": "workspace:4.4.5",
- "@trigger.dev/schema-to-json": "workspace:4.4.5",
+ "@trigger.dev/build": "workspace:4.4.6",
+ "@trigger.dev/core": "workspace:4.4.6",
+ "@trigger.dev/schema-to-json": "workspace:4.4.6",
"ansi-escapes": "^7.0.0",
"braces": "^3.0.3",
"c12": "^1.11.1",
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 3ca55e930f6..56b6d757ed7 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,12 @@
# internal-platform
+## 4.4.6
+
+### Patch Changes
+
+- Fix dev workers spinning at 100% CPU after the parent CLI disconnects. Orphaned `trigger-dev-run-worker` (and indexer) processes were caught in an `uncaughtException` feedback loop: a periodic IPC send via `process.send` would throw `ERR_IPC_CHANNEL_CLOSED` once the parent closed the channel, which re-entered the same handler that itself called `process.send`, scheduled via `setImmediate` and amplified by source-map-support's `prepareStackTrace`. Fixed by (1) silently dropping packets in `ZodIpcConnection` when the channel is disconnected, (2) adding a `process.on("disconnect", ...)` handler in dev workers so they exit cleanly when the CLI closes the IPC channel, and (3) wrapping all `uncaughtException`-path `process.send` calls in a `safeSend` guard that checks `process.connected` and swallows synchronous throws. ([#3491](https://github.com/triggerdotdev/trigger.dev/pull/3491))
+- Fail attempts on uncaught exceptions instead of hanging to `MAX_DURATION_EXCEEDED`. A Node `EventEmitter` (e.g. `node-redis`) emitting `"error"` with no `.on("error", ...)` listener escalates to `uncaughtException`, which the worker previously reported but did not act on — runs drifted to maxDuration with empty attempts. They now fail fast with the original error and status `FAILED`, and respect the task's normal retry policy. You should still attach `.on("error", ...)` listeners to long-lived clients to handle errors gracefully. ([#3529](https://github.com/triggerdotdev/trigger.dev/pull/3529))
+
## 4.4.5
### Patch Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index f58708dff92..cd62bc97d6f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/core",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "Core code used across the Trigger.dev SDK and platform",
"license": "MIT",
"publishConfig": {
diff --git a/packages/python/CHANGELOG.md b/packages/python/CHANGELOG.md
index 04207148062..357e7dc1cd3 100644
--- a/packages/python/CHANGELOG.md
+++ b/packages/python/CHANGELOG.md
@@ -1,5 +1,14 @@
# @trigger.dev/python
+## 4.4.6
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@trigger.dev/core@4.4.6`
+ - `@trigger.dev/build@4.4.6`
+ - `@trigger.dev/sdk@4.4.6`
+
## 4.4.5
### Patch Changes
diff --git a/packages/python/package.json b/packages/python/package.json
index 969c5c3d693..be93677702d 100644
--- a/packages/python/package.json
+++ b/packages/python/package.json
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/python",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "Python runtime and build extension for Trigger.dev",
"license": "MIT",
"publishConfig": {
@@ -45,7 +45,7 @@
"check-exports": "attw --pack ."
},
"dependencies": {
- "@trigger.dev/core": "workspace:4.4.5",
+ "@trigger.dev/core": "workspace:4.4.6",
"tinyexec": "^0.3.2"
},
"devDependencies": {
@@ -56,12 +56,12 @@
"tsx": "4.17.0",
"esbuild": "^0.23.0",
"@arethetypeswrong/cli": "^0.15.4",
- "@trigger.dev/build": "workspace:4.4.5",
- "@trigger.dev/sdk": "workspace:4.4.5"
+ "@trigger.dev/build": "workspace:4.4.6",
+ "@trigger.dev/sdk": "workspace:4.4.6"
},
"peerDependencies": {
- "@trigger.dev/sdk": "workspace:^4.4.5",
- "@trigger.dev/build": "workspace:^4.4.5"
+ "@trigger.dev/sdk": "workspace:^4.4.6",
+ "@trigger.dev/build": "workspace:^4.4.6"
},
"engines": {
"node": ">=18.20.0"
diff --git a/packages/react-hooks/CHANGELOG.md b/packages/react-hooks/CHANGELOG.md
index 1a33e35726d..fcbc0bb7be6 100644
--- a/packages/react-hooks/CHANGELOG.md
+++ b/packages/react-hooks/CHANGELOG.md
@@ -1,5 +1,12 @@
# @trigger.dev/react-hooks
+## 4.4.6
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@trigger.dev/core@4.4.6`
+
## 4.4.5
### Patch Changes
diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json
index bb32dcb4a1a..96a4a90ed8a 100644
--- a/packages/react-hooks/package.json
+++ b/packages/react-hooks/package.json
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/react-hooks",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "trigger.dev react hooks",
"license": "MIT",
"publishConfig": {
@@ -37,7 +37,7 @@
"check-exports": "attw --pack ."
},
"dependencies": {
- "@trigger.dev/core": "workspace:^4.4.5",
+ "@trigger.dev/core": "workspace:^4.4.6",
"swr": "^2.2.5"
},
"devDependencies": {
diff --git a/packages/redis-worker/CHANGELOG.md b/packages/redis-worker/CHANGELOG.md
index 80cb92c8f3a..5bad65ed478 100644
--- a/packages/redis-worker/CHANGELOG.md
+++ b/packages/redis-worker/CHANGELOG.md
@@ -1,5 +1,12 @@
# @trigger.dev/redis-worker
+## 4.4.6
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@trigger.dev/core@4.4.6`
+
## 4.4.5
### Patch Changes
diff --git a/packages/redis-worker/package.json b/packages/redis-worker/package.json
index 4e436c2908a..df8bd8a6b40 100644
--- a/packages/redis-worker/package.json
+++ b/packages/redis-worker/package.json
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/redis-worker",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "Redis worker for trigger.dev",
"license": "MIT",
"publishConfig": {
@@ -23,7 +23,7 @@
"test": "vitest --sequence.concurrent=false --no-file-parallelism"
},
"dependencies": {
- "@trigger.dev/core": "workspace:4.4.5",
+ "@trigger.dev/core": "workspace:4.4.6",
"lodash.omit": "^4.5.0",
"nanoid": "^5.0.7",
"p-limit": "^6.2.0",
diff --git a/packages/rsc/CHANGELOG.md b/packages/rsc/CHANGELOG.md
index 755fbeaedc8..9304f8caaec 100644
--- a/packages/rsc/CHANGELOG.md
+++ b/packages/rsc/CHANGELOG.md
@@ -1,5 +1,12 @@
# @trigger.dev/rsc
+## 4.4.6
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@trigger.dev/core@4.4.6`
+
## 4.4.5
### Patch Changes
diff --git a/packages/rsc/package.json b/packages/rsc/package.json
index 3ed9a6c3e5c..e41126cd7ba 100644
--- a/packages/rsc/package.json
+++ b/packages/rsc/package.json
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/rsc",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "trigger.dev rsc",
"license": "MIT",
"publishConfig": {
@@ -37,14 +37,14 @@
"check-exports": "attw --pack ."
},
"dependencies": {
- "@trigger.dev/core": "workspace:^4.4.5",
+ "@trigger.dev/core": "workspace:^4.4.6",
"mlly": "^1.7.1",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.15.4",
- "@trigger.dev/build": "workspace:^4.4.5",
+ "@trigger.dev/build": "workspace:^4.4.6",
"@types/node": "^20.14.14",
"@types/react": "*",
"@types/react-dom": "*",
diff --git a/packages/schema-to-json/CHANGELOG.md b/packages/schema-to-json/CHANGELOG.md
index 995a2c4d293..707367feff5 100644
--- a/packages/schema-to-json/CHANGELOG.md
+++ b/packages/schema-to-json/CHANGELOG.md
@@ -1,5 +1,12 @@
# @trigger.dev/schema-to-json
+## 4.4.6
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@trigger.dev/core@4.4.6`
+
## 4.4.5
### Patch Changes
diff --git a/packages/schema-to-json/package.json b/packages/schema-to-json/package.json
index c16c335aed0..0721caaa5c8 100644
--- a/packages/schema-to-json/package.json
+++ b/packages/schema-to-json/package.json
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/schema-to-json",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "Convert various schema validation libraries to JSON Schema",
"license": "MIT",
"publishConfig": {
diff --git a/packages/trigger-sdk/CHANGELOG.md b/packages/trigger-sdk/CHANGELOG.md
index 45fa51d07de..6254af0aad9 100644
--- a/packages/trigger-sdk/CHANGELOG.md
+++ b/packages/trigger-sdk/CHANGELOG.md
@@ -1,5 +1,12 @@
# @trigger.dev/sdk
+## 4.4.6
+
+### Patch Changes
+
+- Updated dependencies:
+ - `@trigger.dev/core@4.4.6`
+
## 4.4.5
### Patch Changes
diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json
index 9a1b90b059e..eac075466f0 100644
--- a/packages/trigger-sdk/package.json
+++ b/packages/trigger-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@trigger.dev/sdk",
- "version": "4.4.5",
+ "version": "4.4.6",
"description": "trigger.dev Node.JS SDK",
"license": "MIT",
"publishConfig": {
@@ -52,7 +52,7 @@
"dependencies": {
"@opentelemetry/api": "1.9.0",
"@opentelemetry/semantic-conventions": "1.36.0",
- "@trigger.dev/core": "workspace:4.4.5",
+ "@trigger.dev/core": "workspace:4.4.6",
"chalk": "^5.2.0",
"cronstrue": "^2.21.0",
"debug": "^4.3.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a67f5ba9a72..82e8a7ecb11 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1441,7 +1441,7 @@ importers:
specifier: ^6.10.0
version: 6.19.0(magicast@0.3.5)
'@trigger.dev/core':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../core
mlly:
specifier: ^1.7.1
@@ -1517,13 +1517,13 @@ importers:
specifier: ^0.22.5
version: 0.22.5(supports-color@10.0.0)
'@trigger.dev/build':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../build
'@trigger.dev/core':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../core
'@trigger.dev/schema-to-json':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../schema-to-json
ansi-escapes:
specifier: ^7.0.0
@@ -1891,7 +1891,7 @@ importers:
packages/python:
dependencies:
'@trigger.dev/core':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../core
tinyexec:
specifier: ^0.3.2
@@ -1901,10 +1901,10 @@ importers:
specifier: ^0.15.4
version: 0.15.4
'@trigger.dev/build':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../build
'@trigger.dev/sdk':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../trigger-sdk
'@types/node':
specifier: 20.14.14
@@ -1928,7 +1928,7 @@ importers:
packages/react-hooks:
dependencies:
'@trigger.dev/core':
- specifier: workspace:^4.4.5
+ specifier: workspace:^4.4.6
version: link:../core
react:
specifier: ^18.0 || ^19.0 || ^19.0.0-rc
@@ -1962,7 +1962,7 @@ importers:
packages/redis-worker:
dependencies:
'@trigger.dev/core':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../core
cron-parser:
specifier: ^4.9.0
@@ -2011,7 +2011,7 @@ importers:
packages/rsc:
dependencies:
'@trigger.dev/core':
- specifier: workspace:^4.4.5
+ specifier: workspace:^4.4.6
version: link:../core
mlly:
specifier: ^1.7.1
@@ -2027,7 +2027,7 @@ importers:
specifier: ^0.15.4
version: 0.15.4
'@trigger.dev/build':
- specifier: workspace:^4.4.5
+ specifier: workspace:^4.4.6
version: link:../build
'@types/node':
specifier: 20.14.14
@@ -2103,7 +2103,7 @@ importers:
specifier: 1.36.0
version: 1.36.0
'@trigger.dev/core':
- specifier: workspace:4.4.5
+ specifier: workspace:4.4.6
version: link:../core
chalk:
specifier: ^5.2.0
From 8e675a4e935d25457c234c55ded049542a3ec0f2 Mon Sep 17 00:00:00 2001
From: Matt Aitken
Date: Tue, 12 May 2026 15:05:01 +0100
Subject: [PATCH 2/4] fix(core): retry TASK_PROCESS_SIGSEGV under the user's
retry policy (#3552)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes
[TRI-9234](https://linear.app/triggerdotdev/issue/TRI-9234/retry-task-process-sigsegv-errors-respecting-user-retry-config)
## What this changes
SIGSEGV crashes (`TASK_PROCESS_SIGSEGV`) will now be **retried when an
attempt fails**, in line with the task's configured retry settings
(`retry.maxAttempts` etc.) — the same path SIGTERM and uncaught
exceptions already use. Previously SIGSEGV was hard-classified as
non-retriable and failed the run on the first segfault, ignoring the
user's retry policy.
Tasks without a retry policy still fail fast on the first SIGSEGV.
Behaviour is unchanged for OOM kills (separate machine-bump retry path)
and SIGKILL_TIMEOUT.
## Deploy
**Only the webapp needs to ship.** The retry decision lives entirely in
the webapp:
- V2 path: `internal-packages/run-engine` (bundled into the webapp)
- V1 path: `apps/webapp/app/v3/services/completeAttempt.server.ts`
No supervisor, CLI, SDK, or customer-task-image changes required.
Customers do not need to redeploy. The `@trigger.dev/core` changeset is
just keeping the public package in sync — the published npm version
isn't what makes the fix work.
## Why retry
SIGSEGV in Node tasks is frequently non-deterministic across processes:
- **Native addon races** (`sharp`, `canvas`, `better-sqlite3`,
`node-rdkafka`, `bcrypt`, …) — libuv thread-pool work stepping on V8
handles. Different heap layout / thread schedule on a fresh process →
retry often succeeds.
- **JIT / GC interaction** — V8 turbofan deopt or GC during a native
callback. Timing-dependent.
- **Near-OOM in native code** — when RSS approaches the cgroup limit,
native allocations fail and poorly-written addons dereference NULL →
SIGSEGV instead of clean OOM-kill.
- **Host / hardware issues** — bit flips, kernel quirks. Retry lands on
a different host.
The genuinely deterministic case (a user-code bug always tripping the
same addon) is real, but a subset — and `maxAttempts` bounds the damage.
## Pre-existing inconsistency this resolves
- `shouldRetryError` returned `false` for `TASK_PROCESS_SIGSEGV` →
`fail_run`.
- `shouldLookupRetrySettings` already listed `TASK_PROCESS_SIGSEGV` as
retry-config-aware — but that branch was unreachable because
`shouldRetryError` short-circuited first in `retrying.ts:86-90`.
- We already retry `TASK_RUN_UNCAUGHT_EXCEPTION` (clearly a user-code
bug) under the user's retry policy; refusing to retry SIGSEGV was the
odd one out.
## Test plan
- [x] `pnpm exec vitest run test/errors.test.ts` in `packages/core` —
26/26 pass (4 new)
- [x] `pnpm run build --filter @trigger.dev/core`
- [ ] CI green on PR
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context)
---
.changeset/retry-sigsegv.md | 5 +++++
packages/core/src/v3/errors.ts | 2 +-
packages/core/test/errors.test.ts | 36 ++++++++++++++++++++++++++++++-
3 files changed, 41 insertions(+), 2 deletions(-)
create mode 100644 .changeset/retry-sigsegv.md
diff --git a/.changeset/retry-sigsegv.md b/.changeset/retry-sigsegv.md
new file mode 100644
index 00000000000..5a53c351efe
--- /dev/null
+++ b/.changeset/retry-sigsegv.md
@@ -0,0 +1,5 @@
+---
+"@trigger.dev/core": patch
+---
+
+Retry `TASK_PROCESS_SIGSEGV` task crashes under the user's retry policy instead of failing the run on the first segfault. SIGSEGV in Node tasks is frequently non-deterministic (native addon races, JIT/GC interaction, near-OOM in native code, host issues), so retrying on a fresh process often succeeds. The retry is gated by the task's existing `retry` config + `maxAttempts` — same path `TASK_PROCESS_SIGTERM` and uncaught exceptions already use — so tasks without a retry policy still fail fast.
diff --git a/packages/core/src/v3/errors.ts b/packages/core/src/v3/errors.ts
index a538ca9357b..90650bbd18f 100644
--- a/packages/core/src/v3/errors.ts
+++ b/packages/core/src/v3/errors.ts
@@ -361,7 +361,6 @@ export function shouldRetryError(error: TaskRunError): boolean {
case "CONFIGURED_INCORRECTLY":
case "TASK_ALREADY_RUNNING":
case "TASK_PROCESS_SIGKILL_TIMEOUT":
- case "TASK_PROCESS_SIGSEGV":
case "TASK_PROCESS_OOM_KILLED":
case "TASK_PROCESS_MAYBE_OOM_KILLED":
case "TASK_RUN_CANCELLED":
@@ -398,6 +397,7 @@ export function shouldRetryError(error: TaskRunError): boolean {
case "TASK_RUN_UNCAUGHT_EXCEPTION":
case "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE":
case "TASK_PROCESS_SIGTERM":
+ case "TASK_PROCESS_SIGSEGV":
return true;
default:
diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts
index dee6509d3a2..9a94366d845 100644
--- a/packages/core/test/errors.test.ts
+++ b/packages/core/test/errors.test.ts
@@ -1,5 +1,13 @@
import { describe, it, expect } from "vitest";
-import { truncateStack, truncateMessage, parseError, sanitizeError } from "../src/v3/errors.js";
+import {
+ truncateStack,
+ truncateMessage,
+ parseError,
+ sanitizeError,
+ shouldRetryError,
+ shouldLookupRetrySettings,
+} from "../src/v3/errors.js";
+import type { TaskRunError } from "../src/v3/schemas/common.js";
// Helper: build a fake stack with N frames
function buildStack(messageLines: string[], frameCount: number): string {
@@ -238,3 +246,29 @@ describe("truncateStack message line bounding", () => {
expect(result).toContain("...[truncated]");
});
});
+
+describe("shouldRetryError + shouldLookupRetrySettings", () => {
+ const internal = (code: string): TaskRunError =>
+ ({ type: "INTERNAL_ERROR", code } as TaskRunError);
+
+ it("retries SIGSEGV (changed from non-retriable) and looks up retry settings", () => {
+ const err = internal("TASK_PROCESS_SIGSEGV");
+ expect(shouldRetryError(err)).toBe(true);
+ expect(shouldLookupRetrySettings(err)).toBe(true);
+ });
+
+ it("retries SIGTERM via the same path", () => {
+ const err = internal("TASK_PROCESS_SIGTERM");
+ expect(shouldRetryError(err)).toBe(true);
+ expect(shouldLookupRetrySettings(err)).toBe(true);
+ });
+
+ it("still does not retry SIGKILL timeout", () => {
+ expect(shouldRetryError(internal("TASK_PROCESS_SIGKILL_TIMEOUT"))).toBe(false);
+ });
+
+ it("still does not retry OOM kills (handled by the separate machine-bump path)", () => {
+ expect(shouldRetryError(internal("TASK_PROCESS_OOM_KILLED"))).toBe(false);
+ expect(shouldRetryError(internal("TASK_PROCESS_MAYBE_OOM_KILLED"))).toBe(false);
+ });
+});
From 3cbe9f2307022bcdcfe2cc6ae909f3a5dee8c610 Mon Sep 17 00:00:00 2001
From: Eric Allam
Date: Tue, 12 May 2026 16:21:30 +0100
Subject: [PATCH 3/4] chore: add .claude/REVIEW.md with CI drift check (#3561)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds `.claude/REVIEW.md` — a repo-specific source of truth for what AI /
agent code reviewers should treat as critical in this codebase
(rolling-deploy safety, hot-table indexes, recovery-path queries,
testcontainers usage, etc.). Pairs with a Claude-based PR audit that
flags drift between REVIEW.md and the code as it evolves.
## How the audit works
Mirrors the existing `.github/workflows/claude-md-audit.yml` pattern. On
non-draft, non-fork PRs that touch code, `anthropics/claude-code-action`
reads REVIEW.md, samples the PR diff, and posts a sticky comment with up
to 3 of:
- `[stale]` — rule cites a path / function / table that's been removed
or renamed
- `[contradiction]` — code in the PR violates a current rule
- `[missing]` — PR introduces a new pattern future reviewers should know
about
- `[obsolete]` — rule asserts a constraint the repo has moved past
If nothing's off, posts `✅ REVIEW.md looks current for this PR.`
## Test plan
- [ ] Convert this PR to ready-for-review, confirm the audit runs and
posts a sticky comment
- [ ] Verify the audit doesn't run on fork PRs (gated by
`head.repo.full_name == github.repository`)
- [ ] Verify suggestions are actionable on at least one follow-up PR
---
.claude/REVIEW.md | 49 ++++++++++++++++
.github/workflows/check-review-md.yml | 83 +++++++++++++++++++++++++++
2 files changed, 132 insertions(+)
create mode 100644 .claude/REVIEW.md
create mode 100644 .github/workflows/check-review-md.yml
diff --git a/.claude/REVIEW.md b/.claude/REVIEW.md
new file mode 100644
index 00000000000..67f7a9f15cb
--- /dev/null
+++ b/.claude/REVIEW.md
@@ -0,0 +1,49 @@
+# REVIEW.md — Trigger.dev OSS
+
+Repo-specific signal for anyone (human or agent) reviewing a PR in this codebase. Calibrates what counts as critical, what to always check, and what to skip.
+
+## What makes a 🔴 Important finding here
+
+Reserve 🔴 for things that would page someone or block a rollback. In this codebase, that means:
+
+- **Rolling-deploy breakage.** Old and new versions of the webapp/supervisor run side-by-side during deploys. A change is broken if:
+ - A Lua script's behavior changes for a given key set without versioning (rename the script with a behavior-descriptive suffix like `Tracked` rather than `V2` — both versions must coexist safely).
+ - A Redis data shape used by both versions changes in place. New shapes need a new key namespace.
+ - A migration is not backward-compatible with the prior image.
+- **Schema / migration safety.** Prisma migrations must be backward-compatible with the prior deploy. Adding NOT NULL without a default, dropping a column an old image still reads, renaming a column — all 🔴.
+- **Queue / concurrency correctness.** RunQueue, MarQS (V1, legacy), redis-worker — any change to enqueue / dequeue / locking semantics. Re-derive the invariant on paper before flagging or accepting.
+- **Missing index on a hot table.** New Prisma queries against `TaskRun`, `TaskRunExecutionSnapshot`, `JobRun`, `Project`, etc. must use an existing index. Check `internal-packages/database/prisma/schema.prisma` for the relevant `@@index` lines — don't guess and don't propose `EXPLAIN`.
+- **Recovery-path queries.** Any `TaskRun.findFirst` / `findMany` added to a schedule, run-recovery, or restart loop. Recovery fan-outs (Redis crash, restart storms) turn "rare indexed query" into a DB incident. 🔴 even if indexed.
+- **Aggregations on hot tables.** No `COUNT` / `GROUP BY` on `TaskRun` or other multi-million-row tables. Use Redis or ClickHouse for counts.
+- **Prod Redis blast-radius.** New code paths that `SCAN` with broad patterns (`*foo*`) on prod-shaped Redis, or `EVAL` Lua with `SCAN` loops inside. Both are 🔴.
+- **`@trigger.dev/core` direct import** from anywhere outside the SDK package. Always import from `@trigger.dev/sdk`. Core direct imports are 🔴 — they break the public API contract.
+- **Heavy execute-deps imported into request-handler bundles.** Specifically `chat.handover` and similar split-bundle entry points must not transitively import the agent task's execute path. Watch for new imports added at module top-level of route files.
+- **V1 engine code modified in a "V2 only" PR.** The `apps/webapp/app/v3/` directory contains both. If the PR description says V2-only but it touches `triggerTaskV1`, `cancelTaskRunV1`, `MarQS`, etc. — 🔴.
+
+## Always check
+
+- **Tests use testcontainers, not mocks.** Vitest with `redisTest` / `postgresTest` / `containerTest` from `@internal/testcontainers`. Any new `vi.mock(...)` on Redis, Postgres, BullMQ, or other infra is wrong here — 🔴 if added in production-path tests, 🟡 if isolated unit test.
+- **Public-package changes have a changeset.** `pnpm run changeset:add` produces `.changeset/*.md`. Required for any edit under `packages/*`. Missing → 🟡; missing on a breaking change → 🔴.
+- **Server-only changes have `.server-changes/*.md`.** Required for `apps/webapp/`, `apps/supervisor/` edits with no public-package change. Body should be 1-2 sentences (it has to fit as one bullet in a future changelog). Missing → 🟡.
+- **Lua script naming.** Coexisting scripts use behavior-descriptive suffixes (`Tracked`), never `V2`. Old name must keep working until the next deploy clears it.
+- **RunQueue payload shape.** V2 run-queue payload's `projectId` is consumed by `workerQueueResolver` for override matching. If a PR drops it from the payload, 🔴.
+- **`safeSend` scope.** Defensive IPC wrappers belong on loop / interval / handler contexts, not one-shot terminal sends. If the PR adds `safeSend` to a single terminal call for consistency, 🟡 with a "remove this" suggestion.
+- **Zod version.** Pinned to `3.25.76` monorepo-wide. New package adding zod with a different version or range — 🔴.
+
+## Skip (do NOT flag)
+
+- Anything Prettier / ESLint catches. CI runs both.
+- TypeScript style preferences (`type` vs `interface`) — already covered by repo standards.
+- Test coverage exhortations as a generic suggestion. Only flag missing tests when a specific code path is genuinely untested and the path has prior incidents.
+- `agentcrumbs` markers (`// @crumbs`, `// #region @crumbs`) and `agentcrumbs` imports — these are temporary debug instrumentation stripped before merge.
+- `// removed comments for removed code`, renamed `_unused` vars, re-exported types as "backwards compatibility shims" — also covered by repo standards.
+- Suggestions to "add error handling" without naming a specific scenario that breaks.
+- Documentation prose nitpicks in `docs/*` MDX files unless factually wrong.
+
+## Things V1/legacy that should NOT block a PR
+
+The `apps/webapp/app/v3/` directory name is misleading — most code there is V2. Only specific files are V1-only legacy: `MarQS` queue, `triggerTaskV1`, `cancelTaskRunV1`, and a handful of others (see `apps/webapp/CLAUDE.md` for the exact list). Don't flag "you should refactor this to use V2" on those — they're frozen.
+
+## Confidence calibration for this repo
+
+The most common false-positive pattern: speculating about race conditions in code paths the agent doesn't have runtime visibility into. If the only evidence is "this *could* race", drop it. If you can point to a specific interleaving with file:line for each step, surface it.
diff --git a/.github/workflows/check-review-md.yml b/.github/workflows/check-review-md.yml
new file mode 100644
index 00000000000..ecf44a47b27
--- /dev/null
+++ b/.github/workflows/check-review-md.yml
@@ -0,0 +1,83 @@
+name: 🔎 REVIEW.md Drift Audit
+
+on:
+ pull_request:
+ types: [opened, ready_for_review, synchronize]
+ paths-ignore:
+ - "docs/**"
+ - ".changeset/**"
+ - ".server-changes/**"
+ - "references/**"
+
+concurrency:
+ group: review-md-drift-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ audit:
+ if: >-
+ github.event.pull_request.draft == false &&
+ github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ id-token: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111
+ with:
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
+ use_sticky_comment: true
+ allowed_bots: "devin-ai-integration[bot]"
+
+ claude_args: |
+ --max-turns 15
+ --allowedTools "Read,Glob,Grep,Bash(git diff:*)"
+
+ prompt: |
+ You are auditing this PR for drift against `.claude/REVIEW.md`.
+
+ ## Context
+
+ `.claude/REVIEW.md` is the repo's source of truth for what AI / agent code reviewers should treat as critical findings (rolling-deploy safety, hot-table indexes, recovery-path queries, testcontainers usage, Lua versioning, etc.). It is consumed by review agents to calibrate severity. If REVIEW.md goes stale, every future agent review degrades.
+
+ ## Your task
+
+ 1. Read `.claude/REVIEW.md` in full.
+ 2. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR.
+ 3. Sample the diff itself for any of these four signals:
+ - **Stale references** — does any rule cite a file, directory, function, table, Prisma model, or package name that has been removed or renamed in this PR or already gone from `main`?
+ - **Contradictions** — does code in this PR violate a current REVIEW.md rule? (Only flag if one side is clearly wrong — do not re-review the PR.)
+ - **Missing rules** — does this PR introduce a new pattern future reviewers should know about? Examples: a new hot table, a new Lua-script versioning convention, a new safety wrapper, a new "must always check" invariant.
+ - **Obsolete rules** — has the repo moved past a constraint REVIEW.md still asserts (e.g. a deprecated path is gone, a pattern is now linted, V1 code is deleted)?
+
+ ## Response format
+
+ If nothing needs changing:
+
+ ✅ REVIEW.md looks current for this PR.
+
+ Otherwise:
+
+ 📝 **REVIEW.md updates suggested:**
+
+ - **[stale]** `` —
+ - **[contradiction]** `` —
+ - **[missing]** under `## ` —
+ - **[obsolete]** `` —
+
+ ## Rules
+
+ - Keep it tight. Maximum 3 suggestions per audit. Pick the highest-signal ones.
+ - Only flag things that would actually mislead a future reviewer. Style nits and wording preferences do not count.
+ - Do NOT review the PR itself. Do NOT propose rules outside REVIEW.md's existing sections.
+ - Do NOT propose adding rules for one-off PR specifics that don't generalize to future PRs.
+ - If REVIEW.md does not exist in the repo, respond with `(skip)` and stop.
From e4981d1b1116510b3d4563488697abe6b01b5b65 Mon Sep 17 00:00:00 2001
From: Matt Aitken
Date: Tue, 12 May 2026 17:16:20 +0100
Subject: [PATCH 4/4] feat(webapp): consolidate auth path + add comprehensive
auth tests (#3499)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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` — 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)
---
.changeset/plugin-auth-path.md | 5 +
.github/workflows/e2e-webapp-auth-full.yml | 120 +
.server-changes/plugin-auth-path.md | 6 +
.../OrganizationSettingsSideMenu.tsx | 14 +
.../app/components/primitives/Select.tsx | 10 +-
apps/webapp/app/env.server.ts | 3 +
apps/webapp/app/models/member.server.ts | 40 +-
apps/webapp/app/models/project.server.ts | 2 +-
.../app/models/runtimeEnvironment.server.ts | 172 +-
.../app/presenters/TeamPresenter.server.ts | 33 +-
.../v3/ApiRunListPresenter.server.ts | 4 +-
.../v3/EnvironmentQueuePresenter.server.ts | 5 +-
.../route.tsx | 183 +-
.../route.tsx | 396 +++
.../route.tsx | 464 ++-
.../route.tsx | 10 +-
.../app/routes/account.tokens/route.tsx | 150 +-
apps/webapp/app/routes/admin._index.tsx | 38 +-
.../app/routes/admin.back-office._index.tsx | 16 +-
.../routes/admin.back-office.orgs.$orgId.tsx | 94 +-
apps/webapp/app/routes/admin.back-office.tsx | 16 +-
apps/webapp/app/routes/admin.concurrency.tsx | 22 +-
.../webapp/app/routes/admin.feature-flags.tsx | 96 +-
.../app/routes/admin.llm-models.$modelId.tsx | 227 +-
.../app/routes/admin.llm-models._index.tsx | 210 +-
.../admin.llm-models.missing.$model.tsx | 45 +-
.../admin.llm-models.missing._index.tsx | 34 +-
.../app/routes/admin.llm-models.new.tsx | 150 +-
.../webapp/app/routes/admin.notifications.tsx | 84 +-
apps/webapp/app/routes/admin.orgs.tsx | 27 +-
apps/webapp/app/routes/admin.tsx | 17 +-
.../app/routes/api.v1.batches.$batchId.ts | 17 +-
apps/webapp/app/routes/api.v1.deployments.ts | 3 +-
.../api.v1.idempotencyKeys.$key.reset.ts | 3 +-
.../api.v1.projects.$projectRef.runs.ts | 15 +
...pi.v1.prompts.$slug.override.reactivate.ts | 3 +-
.../routes/api.v1.prompts.$slug.override.ts | 3 +-
.../routes/api.v1.prompts.$slug.promote.ts | 3 +-
.../webapp/app/routes/api.v1.prompts.$slug.ts | 6 +-
.../routes/api.v1.prompts.$slug.versions.ts | 3 +-
.../app/routes/api.v1.prompts._index.ts | 3 +-
.../routes/api.v1.query.dashboards._index.ts | 3 +-
apps/webapp/app/routes/api.v1.query.schema.ts | 3 +-
apps/webapp/app/routes/api.v1.query.ts | 14 +-
.../app/routes/api.v1.runs.$runId.events.ts | 23 +-
.../api.v1.runs.$runId.spans.$spanId.ts | 23 +-
.../app/routes/api.v1.runs.$runId.trace.ts | 23 +-
apps/webapp/app/routes/api.v1.runs.ts | 25 +-
.../routes/api.v1.sessions.$session.close.ts | 3 +-
...i.v1.sessions.$session.end-and-continue.ts | 12 +-
.../app/routes/api.v1.sessions.$session.ts | 17 +-
apps/webapp/app/routes/api.v1.sessions.ts | 43 +-
.../routes/api.v1.tasks.$taskId.trigger.ts | 3 +-
apps/webapp/app/routes/api.v1.tasks.batch.ts | 20 +-
...ens.$waitpointFriendlyId.callback.$hash.ts | 1 +
...ts.tokens.$waitpointFriendlyId.complete.ts | 3 +-
.../app/routes/api.v2.batches.$batchId.ts | 11 +-
.../routes/api.v2.runs.$runParam.cancel.ts | 3 +-
apps/webapp/app/routes/api.v2.tasks.batch.ts | 20 +-
apps/webapp/app/routes/api.v3.batches.ts | 9 +-
apps/webapp/app/routes/api.v3.runs.$runId.ts | 23 +-
...hots.$snapshotFriendlyId.attempts.start.ts | 4 +-
apps/webapp/app/routes/invite-resend.tsx | 2 +-
.../routes/realtime.v1.batches.$batchId.ts | 11 +-
.../app/routes/realtime.v1.runs.$runId.ts | 23 +-
apps/webapp/app/routes/realtime.v1.runs.ts | 19 +-
...ealtime.v1.sessions.$session.$io.append.ts | 10 +-
.../realtime.v1.sessions.$session.$io.ts | 11 +-
.../realtime.v1.streams.$runId.$streamId.ts | 31 +-
...ltime.v1.streams.$runId.input.$streamId.ts | 22 +-
.../runEngine/concerns/batchLimits.server.ts | 13 +-
apps/webapp/app/services/apiAuth.server.ts | 60 +-
.../app/services/authorization.server.ts | 113 -
apps/webapp/app/services/email.server.ts | 10 -
.../mfa/multiFactorAuthentication.server.ts | 2 +-
.../services/personalAccessToken.server.ts | 103 +-
.../webapp/app/services/platform.v3.server.ts | 32 +-
.../app/services/projectCreated.server.ts | 35 +
apps/webapp/app/services/rbac.server.ts | 29 +
.../app/services/realtime/jwtAuth.server.ts | 4 +-
.../routeBuilders/apiBuilder.server.ts | 363 +-
.../routeBuilders/dashboardBuilder.server.ts | 117 +
.../routeBuilders/dashboardBuilder.ts | 141 +
.../app/services/scheduleEmail.server.ts | 16 +
.../app/services/upsertBranch.server.ts | 2 +-
apps/webapp/app/utils/pathBuilder.ts | 4 +
.../environmentVariablesRepository.server.ts | 27 +-
.../app/v3/remoteImageBuilder.server.ts | 14 +-
apps/webapp/package.json | 1 +
apps/webapp/test/README.md | 65 +
apps/webapp/test/api-auth.e2e.test.ts | 306 ++
apps/webapp/test/auth-api.e2e.full.test.ts | 2977 +++++++++++++++++
.../test/auth-cross-cutting.e2e.full.test.ts | 216 ++
.../test/auth-dashboard.e2e.full.test.ts | 122 +
apps/webapp/test/authorization.test.ts | 423 ---
.../webapp/test/helpers/seedTestApiSession.ts | 47 +
apps/webapp/test/helpers/seedTestPAT.ts | 59 +
apps/webapp/test/helpers/seedTestRun.ts | 61 +
apps/webapp/test/helpers/seedTestSession.ts | 58 +
.../test/helpers/seedTestUserProject.ts | 67 +
apps/webapp/test/helpers/seedTestWaitpoint.ts | 29 +
apps/webapp/test/helpers/sharedTestServer.ts | 53 +
.../test/setup/global-e2e-full-setup.ts | 28 +
apps/webapp/test/utils/tracing.ts | 30 +-
.../webapp/test/validateGitBranchName.test.ts | 2 +-
apps/webapp/vitest.config.ts | 5 +-
apps/webapp/vitest.e2e.full.config.ts | 20 +
.../migration.sql | 5 +
.../database/prisma/schema.prisma | 10 +
internal-packages/rbac/package.json | 24 +
internal-packages/rbac/src/ability.test.ts | 164 +
internal-packages/rbac/src/ability.ts | 63 +
internal-packages/rbac/src/fallback.ts | 439 +++
internal-packages/rbac/src/index.ts | 258 ++
internal-packages/rbac/src/loader.test.ts | 69 +
internal-packages/rbac/tsconfig.json | 17 +
internal-packages/rbac/vitest.config.ts | 10 +
.../src/engine/systems/runAttemptSystem.ts | 12 +-
.../run-engine/src/shared/index.ts | 29 +-
internal-packages/testcontainers/src/utils.ts | 19 +-
.../testcontainers/src/webapp.ts | 47 +-
packages/core/package.json | 30 +
packages/core/src/v3/auth/environment.ts | 108 +
.../core/src/v3/utils}/gitBranch.ts | 15 +-
packages/plugins/CHANGELOG.md | 7 +
packages/plugins/package.json | 46 +
packages/plugins/src/index.ts | 23 +
packages/plugins/src/rbac.ts | 277 ++
packages/plugins/tsconfig.json | 7 +
packages/plugins/tsup.config.ts | 11 +
pnpm-lock.yaml | 103 +-
131 files changed, 8901 insertions(+), 1713 deletions(-)
create mode 100644 .changeset/plugin-auth-path.md
create mode 100644 .github/workflows/e2e-webapp-auth-full.yml
create mode 100644 .server-changes/plugin-auth-path.md
create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
delete mode 100644 apps/webapp/app/services/authorization.server.ts
create mode 100644 apps/webapp/app/services/projectCreated.server.ts
create mode 100644 apps/webapp/app/services/rbac.server.ts
create mode 100644 apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts
create mode 100644 apps/webapp/app/services/routeBuilders/dashboardBuilder.ts
create mode 100644 apps/webapp/app/services/scheduleEmail.server.ts
create mode 100644 apps/webapp/test/README.md
create mode 100644 apps/webapp/test/auth-api.e2e.full.test.ts
create mode 100644 apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
create mode 100644 apps/webapp/test/auth-dashboard.e2e.full.test.ts
delete mode 100644 apps/webapp/test/authorization.test.ts
create mode 100644 apps/webapp/test/helpers/seedTestApiSession.ts
create mode 100644 apps/webapp/test/helpers/seedTestPAT.ts
create mode 100644 apps/webapp/test/helpers/seedTestRun.ts
create mode 100644 apps/webapp/test/helpers/seedTestSession.ts
create mode 100644 apps/webapp/test/helpers/seedTestUserProject.ts
create mode 100644 apps/webapp/test/helpers/seedTestWaitpoint.ts
create mode 100644 apps/webapp/test/helpers/sharedTestServer.ts
create mode 100644 apps/webapp/test/setup/global-e2e-full-setup.ts
create mode 100644 apps/webapp/vitest.e2e.full.config.ts
create mode 100644 internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql
create mode 100644 internal-packages/rbac/package.json
create mode 100644 internal-packages/rbac/src/ability.test.ts
create mode 100644 internal-packages/rbac/src/ability.ts
create mode 100644 internal-packages/rbac/src/fallback.ts
create mode 100644 internal-packages/rbac/src/index.ts
create mode 100644 internal-packages/rbac/src/loader.test.ts
create mode 100644 internal-packages/rbac/tsconfig.json
create mode 100644 internal-packages/rbac/vitest.config.ts
create mode 100644 packages/core/src/v3/auth/environment.ts
rename {apps/webapp/app/v3 => packages/core/src/v3/utils}/gitBranch.ts (73%)
create mode 100644 packages/plugins/CHANGELOG.md
create mode 100644 packages/plugins/package.json
create mode 100644 packages/plugins/src/index.ts
create mode 100644 packages/plugins/src/rbac.ts
create mode 100644 packages/plugins/tsconfig.json
create mode 100644 packages/plugins/tsup.config.ts
diff --git a/.changeset/plugin-auth-path.md b/.changeset/plugin-auth-path.md
new file mode 100644
index 00000000000..7ce08b71a33
--- /dev/null
+++ b/.changeset/plugin-auth-path.md
@@ -0,0 +1,5 @@
+---
+"@trigger.dev/plugins": patch
+---
+
+The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces.
diff --git a/.github/workflows/e2e-webapp-auth-full.yml b/.github/workflows/e2e-webapp-auth-full.yml
new file mode 100644
index 00000000000..a00ca7a4195
--- /dev/null
+++ b/.github/workflows/e2e-webapp-auth-full.yml
@@ -0,0 +1,120 @@
+name: "🛡️ E2E Tests: Webapp Auth (full)"
+
+# Comprehensive RBAC auth test suite — see TRI-8731. Runs separately from
+# the smoke e2e-webapp.yml because it covers every route family with a
+# pass/fail matrix and would otherwise dominate per-PR CI time.
+#
+# Triggered:
+# - Manually via workflow_dispatch.
+# - Nightly via schedule.
+# - On pull requests touching auth-relevant files only (paths filter).
+
+permissions:
+ contents: read
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "0 4 * * *" # 04:00 UTC daily
+ pull_request:
+ paths:
+ - "apps/webapp/app/services/routeBuilders/**"
+ - "apps/webapp/app/services/rbac.server.ts"
+ - "apps/webapp/app/services/apiAuth.server.ts"
+ - "apps/webapp/app/services/personalAccessToken.server.ts"
+ - "apps/webapp/app/services/sessionStorage.server.ts"
+ - "apps/webapp/app/routes/api.v*.**"
+ - "apps/webapp/app/routes/realtime.v*.**"
+ - "apps/webapp/test/**/*.e2e.full.test.ts"
+ - "apps/webapp/test/setup/global-e2e-full-setup.ts"
+ - "apps/webapp/test/helpers/sharedTestServer.ts"
+ - "apps/webapp/test/helpers/seedTestSession.ts"
+ - "apps/webapp/vitest.e2e.full.config.ts"
+ - "internal-packages/rbac/**"
+ - "packages/plugins/**"
+ - ".github/workflows/e2e-webapp-auth-full.yml"
+
+jobs:
+ e2eAuthFull:
+ name: "🛡️ E2E Auth Tests (full)"
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ steps:
+ - name: 🔧 Disable IPv6
+ run: |
+ sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
+ sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
+ sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
+
+ - name: 🔧 Configure docker address pool
+ run: |
+ CONFIG='{
+ "default-address-pools" : [
+ {
+ "base" : "172.17.0.0/12",
+ "size" : 20
+ },
+ {
+ "base" : "192.168.0.0/16",
+ "size" : 24
+ }
+ ]
+ }'
+ mkdir -p /etc/docker
+ echo "$CONFIG" | sudo tee /etc/docker/daemon.json
+
+ - name: 🔧 Restart docker daemon
+ run: sudo systemctl restart docker
+
+ - name: ⬇️ Checkout repo
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+ # Don't leave the GITHUB_TOKEN in .git/config — this job
+ # doesn't need to push and the persisted creds would be
+ # readable from any subsequent step (zizmor/artipacked).
+ persist-credentials: false
+
+ - name: ⎔ Setup pnpm
+ uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
+ with:
+ version: 10.33.2
+
+ - name: ⎔ Setup node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: 20.20.0
+ cache: "pnpm"
+
+ - name: 🐳 Login to DockerHub
+ if: ${{ env.DOCKERHUB_USERNAME }}
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: 🐳 Skipping DockerHub login (no secrets available)
+ if: ${{ !env.DOCKERHUB_USERNAME }}
+ run: echo "DockerHub login skipped because secrets are not available."
+
+ - name: 🐳 Pre-pull testcontainer images
+ if: ${{ env.DOCKERHUB_USERNAME }}
+ run: |
+ docker pull postgres:14
+ docker pull redis:7.2
+ docker pull testcontainers/ryuk:0.11.0
+
+ - name: 📥 Download deps
+ run: pnpm install --frozen-lockfile
+
+ - name: 📀 Generate Prisma Client
+ run: pnpm run generate
+
+ - name: 🏗️ Build Webapp
+ run: pnpm run build --filter webapp
+
+ - name: 🛡️ Run Webapp Full Auth E2E Tests
+ run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.full.config.ts --reporter=default
+ env:
+ WEBAPP_TEST_VERBOSE: "1"
diff --git a/.server-changes/plugin-auth-path.md b/.server-changes/plugin-auth-path.md
new file mode 100644
index 00000000000..c8269125ffc
--- /dev/null
+++ b/.server-changes/plugin-auth-path.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: improvement
+---
+
+Webapp now supports a plugin system. Initially consolidates authentication and authorization paths.
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
index c8cd131d962..3c17ff482ba 100644
--- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
+++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx
@@ -4,6 +4,7 @@ import {
Cog8ToothIcon,
CreditCardIcon,
LockClosedIcon,
+ ShieldCheckIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
@@ -14,6 +15,7 @@ import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
+ organizationRolesPath,
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationTeamPath,
@@ -45,9 +47,11 @@ export type BuildInfo = {
export function OrganizationSettingsSideMenu({
organization,
buildInfo,
+ isUsingPlugin,
}: {
organization: MatchedOrganization;
buildInfo: BuildInfo;
+ isUsingPlugin: boolean;
}) {
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
@@ -128,6 +132,16 @@ export function OrganizationSettingsSideMenu({
to={organizationTeamPath(organization)}
data-action="team"
/>
+ {isUsingPlugin && (
+
+ )}
: undefined;
+ // In a Combobox context we wrap the caller's render in ComboboxItem
+ // so combobox keyboard nav still works. Outside a Combobox we pass
+ // the render through verbatim — without this, callers like
+ // SelectLinkItem (which uses render to swap in a ) get their
+ // render prop silently dropped, which is why those rows looked
+ // clickable but didn't navigate.
+ const render = combobox
+ ?
+ : props.render;
const ref = React.useRef(null);
const select = Ariakit.useSelectContext();
const selectValue = select?.useState("value");
diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts
index e530182bff8..97cccbc1710 100644
--- a/apps/webapp/app/env.server.ts
+++ b/apps/webapp/app/env.server.ts
@@ -1542,6 +1542,9 @@ const EnvironmentSchema = z
// Private connections
PRIVATE_CONNECTIONS_ENABLED: z.string().optional(),
PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(),
+
+ // Force RBAC to not use the plugin
+ RBAC_FORCE_FALLBACK: BoolEnv.default(false),
})
.and(GithubAppEnvSchema)
.and(S2EnvSchema)
diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts
index 04c1df1b41f..b88fc7e11c0 100644
--- a/apps/webapp/app/models/member.server.ts
+++ b/apps/webapp/app/models/member.server.ts
@@ -1,6 +1,8 @@
import { type Prisma, prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
import { customAlphabet } from "nanoid";
+import { logger } from "~/services/logger.server";
+import { rbac } from "~/services/rbac.server";
const tokenValueLength = 40;
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
@@ -86,10 +88,19 @@ export async function inviteMembers({
slug,
emails,
userId,
+ rbacRoleId,
}: {
slug: string;
emails: string[];
userId: string;
+ /**
+ * Optional RBAC role to attach to the invite. When set, accepted
+ * invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember
+ * is created.
+ *
+ * `OrgMemberInvite.role` is still set if the plugin isn't installed.
+ */
+ rbacRoleId?: string | null;
}) {
const org = await prisma.organization.findFirst({
where: { slug, members: { some: { userId } } },
@@ -107,6 +118,7 @@ export async function inviteMembers({
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
+ rbacRoleId: rbacRoleId ?? null,
} satisfies Prisma.OrgMemberInviteCreateManyInput)
);
@@ -163,7 +175,7 @@ export async function acceptInvite({
user: { id: string; email: string };
inviteId: string;
}) {
- return await prisma.$transaction(async (tx) => {
+ const result = await prisma.$transaction(async (tx) => {
// 1. Delete the invite and get the invite details
const invite = await tx.orgMemberInvite.delete({
where: {
@@ -207,8 +219,32 @@ export async function acceptInvite({
},
});
- return { remainingInvites, organization: invite.organization };
+ return {
+ remainingInvites,
+ organization: invite.organization,
+ inviteRole: invite.role,
+ rbacRoleId: invite.rbacRoleId,
+ };
});
+
+ // If the invite carried an explicit RBAC role. Errors are logged, not fatal.
+ if (result.rbacRoleId) {
+ const roleResult = await rbac.setUserRole({
+ userId: user.id,
+ organizationId: result.organization.id,
+ roleId: result.rbacRoleId,
+ });
+ if (!roleResult.ok) {
+ logger.error("acceptInvite: skipped RBAC role assignment", {
+ organizationId: result.organization.id,
+ userId: user.id,
+ rbacRoleId: result.rbacRoleId,
+ reason: roleResult.error,
+ });
+ }
+ }
+
+ return { remainingInvites: result.remainingInvites, organization: result.organization };
}
export async function declineInvite({
diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts
index 0dc634b5ab7..d084bec8add 100644
--- a/apps/webapp/app/models/project.server.ts
+++ b/apps/webapp/app/models/project.server.ts
@@ -4,7 +4,7 @@ import { $replica, prisma } from "~/db.server";
import type { Prisma, Project } from "@trigger.dev/database";
import { type Organization, createEnvironment } from "./organization.server";
import { env } from "~/env.server";
-import { projectCreated } from "~/services/platform.v3.server";
+import { projectCreated } from "~/services/projectCreated.server";
export type { Project } from "@trigger.dev/database";
const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20);
diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts
index 9db3bb3133b..64b1da3be49 100644
--- a/apps/webapp/app/models/runtimeEnvironment.server.ts
+++ b/apps/webapp/app/models/runtimeEnvironment.server.ts
@@ -3,18 +3,100 @@ import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@tri
import { $replica, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { getUsername } from "~/utils/username";
-import { sanitizeBranchName } from "~/v3/gitBranch";
+import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
export type { RuntimeEnvironment };
+// Prisma include shape that maps cleanly to the slim AuthenticatedEnvironment.
+// Use this everywhere we fetch an env that flows to handlers — keeps the
+// returned shape consistent (and the Decimal coercion in toAuthenticated()
+// strips Prisma's Decimal class from the public surface).
+export const authIncludeBase = {
+ project: true,
+ organization: true,
+ orgMember: {
+ select: {
+ userId: true,
+ user: { select: { id: true, displayName: true, name: true } },
+ },
+ },
+} satisfies Prisma.RuntimeEnvironmentInclude;
+
+export const authIncludeWithParent = {
+ ...authIncludeBase,
+ parentEnvironment: { select: { id: true, apiKey: true } },
+} satisfies Prisma.RuntimeEnvironmentInclude;
+
+type PrismaEnvWithAuth = Prisma.RuntimeEnvironmentGetPayload<{ include: typeof authIncludeBase }>;
+type PrismaEnvWithAuthAndParent = Prisma.RuntimeEnvironmentGetPayload<{
+ include: typeof authIncludeWithParent;
+}>;
+
+// Coerce a Prisma RuntimeEnvironment payload to the slim
+// AuthenticatedEnvironment shape. Drops the columns handlers don't read
+// and converts `concurrencyLimitBurstFactor` from Prisma's Decimal to a
+// plain number (lossless at this scale). The optional union accepts both
+// query shapes — with parentEnvironment loaded, or without it.
+export function toAuthenticated(
+ env: PrismaEnvWithAuth | PrismaEnvWithAuthAndParent,
+): AuthenticatedEnvironment {
+ return {
+ id: env.id,
+ slug: env.slug,
+ type: env.type,
+ apiKey: env.apiKey,
+ organizationId: env.organizationId,
+ projectId: env.projectId,
+ orgMemberId: env.orgMemberId,
+ parentEnvironmentId: env.parentEnvironmentId,
+ branchName: env.branchName,
+ archivedAt: env.archivedAt,
+ paused: env.paused,
+ shortcode: env.shortcode,
+ maximumConcurrencyLimit: env.maximumConcurrencyLimit,
+ // Coerce Prisma's Decimal to a plain number — the slim type accepts
+ // both, but downstream consumers shouldn't have to narrow before
+ // doing arithmetic. Lossless at this scale (Decimal(4,2)).
+ concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor.toNumber(),
+ builtInEnvironmentVariableOverrides: env.builtInEnvironmentVariableOverrides,
+ createdAt: env.createdAt,
+ updatedAt: env.updatedAt,
+ project: {
+ id: env.project.id,
+ slug: env.project.slug,
+ name: env.project.name,
+ externalRef: env.project.externalRef,
+ engine: env.project.engine,
+ deletedAt: env.project.deletedAt,
+ defaultWorkerGroupId: env.project.defaultWorkerGroupId,
+ organizationId: env.project.organizationId,
+ builderProjectId: env.project.builderProjectId,
+ },
+ organization: {
+ id: env.organization.id,
+ slug: env.organization.slug,
+ title: env.organization.title,
+ streamBasinName: env.organization.streamBasinName,
+ maximumConcurrencyLimit: env.organization.maximumConcurrencyLimit,
+ runsEnabled: env.organization.runsEnabled,
+ maximumDevQueueSize: env.organization.maximumDevQueueSize,
+ maximumDeployedQueueSize: env.organization.maximumDeployedQueueSize,
+ featureFlags: env.organization.featureFlags,
+ apiRateLimiterConfig: env.organization.apiRateLimiterConfig,
+ batchRateLimitConfig: env.organization.batchRateLimitConfig,
+ batchQueueConcurrencyConfig: env.organization.batchQueueConcurrencyConfig,
+ },
+ orgMember: env.orgMember,
+ parentEnvironment: "parentEnvironment" in env ? env.parentEnvironment : null,
+ };
+}
+
export async function findEnvironmentByApiKey(
apiKey: string,
branchName: string | undefined
): Promise {
const include = {
- project: true,
- organization: true,
- orgMember: true,
+ ...authIncludeBase,
childEnvironments: branchName
? {
where: {
@@ -67,23 +149,33 @@ export async function findEnvironmentByApiKey(
const childEnvironment = environment.childEnvironments.at(0);
if (childEnvironment) {
- return {
+ return toAuthenticated({
...childEnvironment,
apiKey: environment.apiKey,
orgMember: environment.orgMember,
organization: environment.organization,
project: environment.project,
- };
+ });
}
//A branch was specified but no child environment was found
return null;
}
- return environment;
+ return toAuthenticated(environment);
}
-/** @deprecated We don't use public api keys anymore */
+/**
+ * @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
+ * access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).
+ *
+ * Still exported because a handful of pre-RBAC routes that haven't been
+ * migrated to the apiBuilder still wire this lookup into their
+ * `authenticateApiKey` / `authenticateApiKeyWithFailure` flow. The new RBAC
+ * fallback (`internal-packages/rbac/src/fallback.ts`) intentionally does NOT
+ * call this — any pk_*-authenticated request that hits an apiBuilder route
+ * returns 401. That's a deliberate cutover, not an oversight.
+ */
export async function findEnvironmentByPublicApiKey(
apiKey: string,
branchName: string | undefined
@@ -92,50 +184,29 @@ export async function findEnvironmentByPublicApiKey(
where: {
pkApiKey: apiKey,
},
- include: {
- project: true,
- organization: true,
- orgMember: true,
- },
+ include: authIncludeBase,
});
- //don't return deleted projects
- if (environment?.project.deletedAt !== null) {
+ if (!environment || environment.project.deletedAt !== null) {
return null;
}
- return environment;
+ return toAuthenticated(environment);
}
-export async function findEnvironmentById(
- id: string
-): Promise<
- | (AuthenticatedEnvironment & { parentEnvironment: { id: string; apiKey: string } | null })
- | null
-> {
+export async function findEnvironmentById(id: string): Promise {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id,
},
- include: {
- project: true,
- organization: true,
- orgMember: true,
- parentEnvironment: {
- select: {
- id: true,
- apiKey: true,
- },
- },
- },
+ include: authIncludeWithParent,
});
- //don't return deleted projects
- if (environment?.project.deletedAt !== null) {
+ if (!environment || environment.project.deletedAt !== null) {
return null;
}
- return environment;
+ return toAuthenticated(environment);
}
export async function findEnvironmentBySlug(
@@ -143,7 +214,7 @@ export async function findEnvironmentBySlug(
envSlug: string,
userId: string
): Promise {
- return $replica.runtimeEnvironment.findFirst({
+ const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: projectId,
slug: envSlug,
@@ -161,12 +232,9 @@ export async function findEnvironmentBySlug(
},
],
},
- include: {
- project: true,
- organization: true,
- orgMember: true,
- },
+ include: authIncludeBase,
});
+ return environment ? toAuthenticated(environment) : null;
}
export async function findEnvironmentFromRun(
@@ -178,24 +246,16 @@ export async function findEnvironmentFromRun(
id: runId,
},
include: {
- runtimeEnvironment: {
- include: {
- project: true,
- organization: true,
- orgMember: true,
- },
- },
+ runtimeEnvironment: { include: authIncludeBase },
},
});
-
- if (!taskRun) {
- return null;
- }
-
- return taskRun?.runtimeEnvironment;
+ return taskRun?.runtimeEnvironment ? toAuthenticated(taskRun.runtimeEnvironment) : null;
}
-export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) {
+export async function createNewSession(
+ environment: Pick,
+ ipAddress: string
+) {
const session = await prisma.runtimeEnvironmentSession.create({
data: {
environmentId: environment.id,
diff --git a/apps/webapp/app/presenters/TeamPresenter.server.ts b/apps/webapp/app/presenters/TeamPresenter.server.ts
index 8b84a65a67c..f2e5da61a87 100644
--- a/apps/webapp/app/presenters/TeamPresenter.server.ts
+++ b/apps/webapp/app/presenters/TeamPresenter.server.ts
@@ -1,4 +1,5 @@
import { getTeamMembersAndInvites } from "~/models/member.server";
+import { rbac } from "~/services/rbac.server";
import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server";
import { BasePresenter } from "./v3/basePresenter.server";
@@ -13,11 +14,30 @@ export class TeamPresenter extends BasePresenter {
return;
}
- const [baseLimit, currentPlan, plans] = await Promise.all([
- getLimit(organizationId, "teamMembers", 100_000_000),
- getCurrentPlan(organizationId),
- getPlans(),
- ]);
+ const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoleMap] =
+ await Promise.all([
+ getLimit(organizationId, "teamMembers", 100_000_000),
+ getCurrentPlan(organizationId),
+ getPlans(),
+ // RBAC role catalogue (system roles + any org-defined custom
+ // roles). The default fallback returns []; an installed plugin
+ // may return the seeded system roles plus any custom roles.
+ rbac.allRoles(organizationId),
+ // Plan-gated subset — the Teams page disables dropdown options not
+ // in this set. Server-side enforcement is independent (setUserRole
+ // rejects a plan-gated assignment regardless of UI state).
+ rbac.getAssignableRoleIds(organizationId),
+ // Per-member current role in a single round-trip.
+ rbac.getUserRoles(
+ result.members.map((m) => m.user.id),
+ organizationId
+ ),
+ ]);
+
+ const memberRoles = result.members.map((m) => ({
+ userId: m.user.id,
+ role: memberRoleMap.get(m.user.id) ?? null,
+ }));
const canPurchaseSeats =
currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true;
@@ -38,6 +58,9 @@ export class TeamPresenter extends BasePresenter {
seatPricing,
maxSeatQuota,
planSeatLimit,
+ roles,
+ assignableRoleIds,
+ memberRoles,
};
}
}
diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts
index 254ec18d1c0..aa6e15e0fa5 100644
--- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts
@@ -149,10 +149,10 @@ type ApiRunListSearchParams = z.infer;
export class ApiRunListPresenter extends BasePresenter {
public async call(
- project: Project,
+ project: Pick,
searchParams: ApiRunListSearchParams,
apiVersion: API_VERSIONS,
- environment?: RuntimeEnvironment
+ environment?: Pick
) {
return this.trace("call", async (span) => {
const options: RunListOptions = {
diff --git a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts
index 10201094376..5bcdee6b0a9 100644
--- a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts
@@ -47,7 +47,10 @@ export class EnvironmentQueuePresenter extends BasePresenter {
running,
queued,
concurrencyLimit: environment.maximumConcurrencyLimit,
- burstFactor: environment.concurrencyLimitBurstFactor.toNumber(),
+ burstFactor:
+ typeof environment.concurrencyLimitBurstFactor === "number"
+ ? environment.concurrencyLimitBurstFactor
+ : environment.concurrencyLimitBurstFactor.toNumber(),
runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled,
queueSizeLimit,
};
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
index 44990abaa6e..f77c19ffbdd 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx
@@ -25,13 +25,15 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
+import { Select, SelectItem } from "~/components/primitives/Select";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { inviteMembers } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
-import { scheduleEmail } from "~/services/email.server";
+import { scheduleEmail } from "~/services/scheduleEmail.server";
+import { rbac } from "~/services/rbac.server";
import { requireUserId } from "~/services/session.server";
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
@@ -63,9 +65,77 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Not Found", { status: 404 });
}
- return typedjson(result);
+ // Inviter's own role drives the "below their level" filter on the
+ // dropdown. Plus assignable role IDs already encode the org's plan
+ // tier — the intersection is what we offer.
+ const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
+ rbac.getUserRole({ userId, organizationId: organization.id }),
+ rbac.getAssignableRoleIds(organization.id),
+ rbac.systemRoles(organization.id),
+ ]);
+
+ // Build the dropdown's offerable set server-side: roles that are
+ // (a) assignable on the current plan AND (b) at or below the
+ // inviter's own level. The client just renders these — it doesn't
+ // need to know about the system-role catalogue or the ladder.
+ const assignableSet = new Set(assignableRoleIds);
+ const offerableRoleIds = systemRoles
+ ? result.roles
+ .filter(
+ (r) =>
+ assignableSet.has(r.id) &&
+ isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
+ )
+ .map((r) => r.id)
+ : [];
+
+ return typedjson({ ...result, offerableRoleIds });
};
+// Sentinel for "no RBAC role attached to invite" — the runtime
+// fallback will derive a role from the legacy OrgMember.role write at
+// accept time. Used when the org has no RBAC plugin installed (the
+// dropdown is hidden) or as a defensive default.
+const NO_RBAC_ROLE = "__no_rbac_role__";
+
+// An inviter can only assign a role at or below their own. The
+// plugin's systemRoles array is in canonical order (highest authority
+// first), so array index drives the ladder — earlier index = higher
+// rank. Plan-tier filtering happens separately via assignableRoleIds;
+// the ladder is the absolute hierarchy. Custom roles aren't in the
+// table and are refused (TRI-8747's follow-up will handle them).
+type LadderRole = { id: string };
+
+function buildRoleLevel(roles: ReadonlyArray): Record {
+ const level: Record = {};
+ roles.forEach((r, i) => {
+ // Top of the array = highest level. Subtract from length so larger
+ // numbers always mean "more authority" — no off-by-one when a role
+ // is added or removed.
+ level[r.id] = roles.length - i;
+ });
+ return level;
+}
+
+function isAtOrBelow(
+ roles: ReadonlyArray,
+ inviterRoleId: string | null,
+ invitedRoleId: string
+): boolean {
+ // No RBAC role on inviter (e.g. the runtime fallback couldn't derive
+ // one) → fall back to the legacy OrgMember.role check the calling
+ // code already enforces. Allow the invite to proceed; the action
+ // would have already failed earlier if the inviter wasn't allowed
+ // to invite at all.
+ if (!inviterRoleId) return true;
+ const level = buildRoleLevel(roles);
+ const inviter = level[inviterRoleId];
+ const invited = level[invitedRoleId];
+ // Custom roles aren't in the level table — refuse.
+ if (inviter === undefined || invited === undefined) return false;
+ return invited <= inviter;
+}
+
const schema = z.object({
emails: z.preprocess((i) => {
if (typeof i === "string") return [i];
@@ -80,6 +150,7 @@ const schema = z.object({
return [""];
}, z.string().email().array().nonempty("At least one email is required")),
+ rbacRoleId: z.string().optional(),
});
export const action: ActionFunction = async ({ request, params }) => {
@@ -94,11 +165,62 @@ export const action: ActionFunction = async ({ request, params }) => {
return json(submission);
}
+ // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
+ // role → don't pass one through; the runtime fallback handles it.
+ // Validation: the chosen role must be in the org's assignable set
+ // (plan-tier) and at or below the inviter's own level.
+ let resolvedRbacRoleId: string | null = null;
+ const submittedRbacRoleId = submission.value.rbacRoleId;
+ if (
+ submittedRbacRoleId &&
+ submittedRbacRoleId !== NO_RBAC_ROLE
+ ) {
+ const org = await $replica.organization.findFirst({
+ where: { slug: organizationSlug },
+ select: { id: true },
+ });
+ if (!org) {
+ return json({ errors: { body: "Organization not found" } }, { status: 404 });
+ }
+ const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
+ rbac.getUserRole({ userId, organizationId: org.id }),
+ rbac.getAssignableRoleIds(org.id),
+ rbac.systemRoles(org.id),
+ ]);
+ if (!systemRoles) {
+ // No plugin installed but the form somehow submitted a role id —
+ // ignore it (fall through to legacy behaviour rather than 400).
+ resolvedRbacRoleId = null;
+ } else {
+ const assignable = new Set(assignableRoleIds);
+ if (!assignable.has(submittedRbacRoleId)) {
+ return json(
+ { errors: { body: "You can't invite someone with this role on your current plan" } },
+ { status: 400 }
+ );
+ }
+ if (
+ !isAtOrBelow(
+ systemRoles,
+ inviterRole?.id ?? null,
+ submittedRbacRoleId
+ )
+ ) {
+ return json(
+ { errors: { body: "You can only invite members at or below your own role" } },
+ { status: 403 }
+ );
+ }
+ resolvedRbacRoleId = submittedRbacRoleId;
+ }
+ }
+
try {
const invites = await inviteMembers({
slug: organizationSlug,
emails: submission.value.emails,
userId,
+ rbacRoleId: resolvedRbacRoleId,
});
for (const invite of invites) {
@@ -128,12 +250,35 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
- const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } =
- useTypedLoaderData();
+ const {
+ limits,
+ canPurchaseSeats,
+ seatPricing,
+ extraSeats,
+ maxSeatQuota,
+ planSeatLimit,
+ roles,
+ offerableRoleIds,
+ } = useTypedLoaderData();
const [total, setTotal] = useState(limits.used);
const organization = useOrganization();
const lastSubmission = useActionData();
+ // The loader filtered the catalogue to roles this inviter can
+ // actually assign (plan tier × strict-below-my-level). With no plugin
+ // installed, offerableRoleIds is [] and the picker hides entirely.
+ const offerableSet = new Set(offerableRoleIds);
+ const offerable = roles.filter((r) => offerableSet.has(r.id));
+ const showRolePicker = offerable.length > 0;
+
+ // Default to the lowest-tier offered role (the loader returns roles
+ // in its allRoles order, which the plugin emits Owner→Member; the
+ // last entry is the most restrictive).
+ const defaultRoleId = showRolePicker
+ ? offerable[offerable.length - 1].id
+ : NO_RBAC_ROLE;
+ const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
+
const [form, { emails }] = useForm({
id: "invite-members",
// TODO: type this
@@ -232,6 +377,36 @@ export default function Page() {
))}
+ {showRolePicker ? (
+
+
+
+
+
+ Invitees join with this role. They can be promoted later
+ from the Team page.
+
+
+ ) : null}
limits.limit}>
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
new file mode 100644
index 00000000000..79f2356250a
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx
@@ -0,0 +1,396 @@
+import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import { type MetaFunction } from "@remix-run/react";
+import { useState } from "react";
+import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
+import { z } from "zod";
+import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { Badge } from "~/components/primitives/Badge";
+import { Button } from "~/components/primitives/Buttons";
+import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
+import { Header3 } from "~/components/primitives/Headers";
+import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import {
+ Table,
+ TableBlankRow,
+ TableBody,
+ TableCell,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+} from "~/components/primitives/Table";
+import { cn } from "~/utils/cn";
+import { $replica } from "~/db.server";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { rbac } from "~/services/rbac.server";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
+import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
+import { TextLink } from "~/components/primitives/TextLink";
+
+export const meta: MetaFunction = () => {
+ return [
+ {
+ title: `Roles | Trigger.dev`,
+ },
+ ];
+};
+
+const Params = z.object({
+ organizationSlug: z.string(),
+});
+
+async function resolveOrgIdFromSlug(slug: string): Promise {
+ const org = await $replica.organization.findFirst({
+ where: { slug },
+ select: { id: true },
+ });
+ return org?.id ?? null;
+}
+
+export const loader = dashboardLoader(
+ {
+ params: Params,
+ context: async (params) => {
+ const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
+ return orgId ? { organizationId: orgId } : {};
+ },
+ authorization: { action: "read", resource: { type: "members" } },
+ },
+ async ({ context }) => {
+ const orgId = context.organizationId;
+ if (!orgId) {
+ throw new Response("Not Found", { status: 404 });
+ }
+
+ const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin] =
+ await Promise.all([
+ rbac.allRoles(orgId),
+ rbac.getAssignableRoleIds(orgId),
+ rbac.allPermissions(orgId),
+ rbac.systemRoles(orgId),
+ // OSS self-host: no enterprise plugin → no role infrastructure to
+ // show. Render a "roles aren't available" layout in that case
+ // rather than the plan-upsell empty state (which assumes a cloud
+ // plan and would be misleading).
+ rbac.isUsingPlugin(),
+ ]);
+
+ return typedjson({
+ roles,
+ assignableRoleIds,
+ allPermissions,
+ systemRoles,
+ isUsingPlugin,
+ });
+ }
+);
+
+type LoaderData = UseDataFunctionReturn;
+type LoaderRole = LoaderData["roles"][number];
+type LoaderPermission = LoaderData["allPermissions"][number];
+type RolePermission = LoaderRole["permissions"][number];
+
+// Permissions are bucketed by `permission.group` from the plugin.
+// Section order = first-seen order in `allPermissions()`. Permissions
+// without a group fall into "Other" at the bottom.
+const FALLBACK_GROUP = "Other";
+
+export default function Page() {
+ const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } =
+ useTypedLoaderData();
+ const organization = useOrganization();
+ const plan = useCurrentPlan();
+ const planCode = plan?.v3Subscription?.plan?.code;
+ const isEnterprise = planCode === "enterprise";
+
+ // Map role-id → role for fast cell lookup. Each role's permissions are
+ // already the expanded `effectivePermissions` output (system roles
+ // populated server-side; custom roles too) so cells just filter that
+ // list by permission name.
+ const rolesById = new Map(roles.map((r) => [r.id, r]));
+ const assignable = new Set(assignableRoleIds);
+
+ // Column ordering follows the plugin's canonical systemRoles order
+ // (highest authority first), then any custom roles in the order
+ // rbac.allRoles returned them. systemRoles is null when no plugin is
+ // installed; fall through to whatever order rbac.allRoles returns.
+ // Each entry's `available` flag reflects plan-tier eligibility — we
+ // render unavailable system roles too, but PlanBadge tags them so
+ // customers see the comparison and know what an upgrade unlocks.
+ const systemRoleOrder = systemRoles ?? [];
+ const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id));
+ const systemColumns = systemRoleOrder.flatMap((meta) => {
+ const role = rolesById.get(meta.id);
+ return role ? [{ role, fallbackName: meta.name }] : [];
+ });
+ const customColumns = roles
+ .filter((r) => !systemRoleIdSet.has(r.id))
+ .map((role) => ({ role, fallbackName: role.name }));
+ const columns = [...systemColumns, ...customColumns];
+
+ const grouped = groupPermissions(allPermissions);
+
+ return (
+
+
+
+ {/* Suppress the Enterprise-upsell button on OSS — there's no
+ plan to upgrade to in a self-hosted deployment, and the
+ dialog copy ("Available on the Enterprise plan") doesn't
+ apply. The not-supported empty state below makes the
+ absence of role infrastructure clear instead. */}
+ {isUsingPlugin && !isEnterprise ? : null}
+
+
+
+
+
+ Roles control what each team member can do in {organization.title}.
+ Compare what each role grants below; assign a role to a team member from the{" "}
+ Team page.
+
+
+
+
+ );
+}
+
+function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) {
+ // Two distinct empty states:
+ //
+ // 1. Plugin loaded, but rbac.allRoles returned nothing the org can
+ // use under its plan tier. The plan-upsell copy is correct —
+ // upgrade unlocks the role infrastructure.
+ // 2. No plugin loaded (OSS self-host). There's no "plan" to upgrade
+ // to. RBAC simply isn't part of this deployment; we use a
+ // permissive ability for every authenticated user and rely on
+ // org-membership for access control. Surface that honestly
+ // instead of dangling a fake upgrade carrot.
+ if (!isUsingPlugin) {
+ return (
+
+ Roles aren't available in this self-hosted deployment.
+
+ All members have full access. Role-Based Access Controls are available in Trigger.dev
+ Cloud or with an enterprise self-hosted license.
+
+
+ );
+ }
+ return (
+
+ No roles available on this plan.
+
+ Upgrade to Pro to unlock RBAC.
+
+
+ );
+}
+
+function PlanBadge({
+ roleId,
+ assignable,
+ systemRoleIdSet,
+}: {
+ roleId: string;
+ assignable: ReadonlySet;
+ systemRoleIdSet: ReadonlySet;
+}) {
+ // Roles the org's plan doesn't permit get a small upgrade-tier hint
+ // in the column header. The cell rendering is identical regardless
+ // — the comparison value is still useful even on Free/Hobby.
+ if (assignable.has(roleId)) return null;
+ // System roles render as "Pro" (the gating tier where they unlock —
+ // Free/Hobby see Owner+Admin only, Pro adds the rest). Custom roles
+ // render as "Enterprise" — only Enterprise plans can create or assign
+ // them.
+ if (systemRoleIdSet.has(roleId)) {
+ return Pro;
+ }
+ return Enterprise;
+}
+
+// Render a single (role × permission) cell. Filters the role's
+// effectivePermissions list to entries matching this permission name
+// and emits an icon + optional condition badge based on the rules.
+function RoleCell({
+ permissionName,
+ rolePermissions,
+}: {
+ permissionName: string;
+ rolePermissions: RolePermission[];
+}) {
+ const matching = rolePermissions.filter((p) => p.name === permissionName);
+
+ if (matching.length === 0) {
+ // No rule matches — the role denies this permission by omission.
+ return (
+
+
+
+ );
+ }
+
+ const allowed = matching.filter((p) => !p.inverted);
+ const denied = matching.filter((p) => p.inverted);
+
+ // Only inverted rules apply — the role explicitly denies this
+ // permission. Render as ✗ in error colour.
+ if (allowed.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ // At least one allow rule applies. If there's a conditional cannot
+ // rule, replace the ✓ with just the condition label so the user sees
+ // the restriction without a misleading tick. Plain unconditional
+ // allow keeps the ✓.
+ const conditionalDeny = denied.find((p) => p.conditions);
+ if (conditionalDeny?.conditions) {
+ return (
+ {conditionLabel(conditionalDeny.conditions)}
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+// Render a CASL conditions object into a tier badge label. Only
+// `envType` is recognised today (the catalogue's only allowed condition);
+// extending this requires adding a new branch when ALLOWED_CONDITIONS
+// grows.
+function conditionLabel(conditions: Record): string {
+ if (typeof conditions.envType === "string") {
+ if (conditions.envType === "PRODUCTION") return "Non-prod only";
+ return `Non-${conditions.envType.toLowerCase()} only`;
+ }
+ return JSON.stringify(conditions);
+}
+
+function groupPermissions(
+ permissions: LoaderPermission[]
+): { group: string; permissions: LoaderPermission[] }[] {
+ // Insertion-ordered map: groups appear in the order their first
+ // permission was seen. Plugins that want a specific section order
+ // just emit permissions in that order from `allPermissions()`.
+ const buckets = new Map();
+ for (const permission of permissions) {
+ const group = permission.group ?? FALLBACK_GROUP;
+ const list = buckets.get(group) ?? [];
+ list.push(permission);
+ buckets.set(group, list);
+ }
+ return Array.from(buckets, ([group, permissions]) => ({ group, permissions }));
+}
+
+function CreateRoleUpsell() {
+ const [open, setOpen] = useState(false);
+ return (
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
index dc71bc5585f..c95ca471f85 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx
@@ -9,7 +9,7 @@ import {
useFetcher,
useNavigation,
} from "@remix-run/react";
-import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
+import { json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { useEffect, useRef, useState } from "react";
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -41,24 +41,27 @@ import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
+import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
-import { cn } from "~/utils/cn";
import { $replica } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useUser } from "~/hooks/useUser";
import { removeTeamMember } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
-import { requireUserId } from "~/services/session.server";
+import { rbac } from "~/services/rbac.server";
+import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
+import { cn } from "~/utils/cn";
+import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import {
inviteTeamMemberPath,
+ organizationRolesPath,
organizationTeamPath,
resendInvitePath,
revokeInvitePath,
v3BillingPath,
} from "~/utils/pathBuilder";
-import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
@@ -74,31 +77,51 @@ const Params = z.object({
organizationSlug: z.string(),
});
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const { organizationSlug } = Params.parse(params);
-
- const organization = await $replica.organization.findFirst({
- where: { slug: organizationSlug },
+// Resolve slug → orgId in the dashboardLoader's context callback so the
+// rbac.authenticateSession call gets a real organizationId. The result
+// is cached for the duration of the request and reused by the handler
+// below (we re-find by slug there to get a typed value — the context
+// only sees the loosely typed return type).
+async function resolveOrgIdFromSlug(slug: string): Promise {
+ const org = await $replica.organization.findFirst({
+ where: { slug },
select: { id: true },
});
+ return org?.id ?? null;
+}
- if (!organization) {
- throw new Response("Not Found", { status: 404 });
- }
+export const loader = dashboardLoader(
+ {
+ params: Params,
+ context: async (params) => {
+ const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
+ return orgId ? { organizationId: orgId } : {};
+ },
+ authorization: { action: "read", resource: { type: "members" } },
+ },
+ async ({ user, ability, context }) => {
+ const orgId = context.organizationId;
+ if (!orgId) {
+ throw new Response("Not Found", { status: 404 });
+ }
- const presenter = new TeamPresenter();
- const result = await presenter.call({
- userId,
- organizationId: organization.id,
- });
+ const presenter = new TeamPresenter();
+ const result = await presenter.call({
+ userId: user.id,
+ organizationId: orgId,
+ });
- if (!result) {
- throw new Response("Not Found", { status: 404 });
- }
+ if (!result) {
+ throw new Response("Not Found", { status: 404 });
+ }
- return typedjson(result);
-};
+ // Pre-compute manage authority server-side so the UI gating matches
+ // the action gating (the action enforces it independently).
+ const canManageMembers = ability.can("manage", { type: "members" });
+
+ return typedjson({ ...result, canManageMembers });
+ }
+);
const schema = z.object({
memberId: z.string(),
@@ -111,89 +134,157 @@ const PurchaseSchema = z.discriminatedUnion("action", [
}),
z.object({
action: z.literal("quota-increase"),
- amount: z.coerce
- .number()
- .int("Must be a whole number")
- .min(1, "Amount must be greater than 0"),
+ amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"),
}),
]);
-export const action = async ({ request, params }: ActionFunctionArgs) => {
- const userId = await requireUserId(request);
- const { organizationSlug } = params;
- invariant(organizationSlug, "organizationSlug not found");
+const SetRoleSchema = z.object({
+ userId: z.string(),
+ roleId: z.string(),
+});
- const formData = await request.formData();
- const formType = formData.get("_formType");
+export const action = dashboardAction(
+ {
+ params: Params,
+ context: async (params) => {
+ const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
+ return orgId ? { organizationId: orgId } : {};
+ },
+ // No top-level authorization — different intents have different
+ // requirements. Each branch inside checks the right ability:
+ // set-role → manage:members
+ // purchase-seats → manage:billing
+ // remove-member → manage:members (skipped for self-leave)
+ // Don't rely on the model-layer (removeTeamMember /
+ // SetSeatsAddOnService) for enforcement — those are defense in
+ // depth; the route layer is where the ability gate belongs.
+ },
+ async ({ user, ability, request, params, context }) => {
+ const userId = user.id;
+ const { organizationSlug } = params;
+ invariant(organizationSlug, "organizationSlug not found");
- if (formType === "purchase-seats") {
- const org = await $replica.organization.findFirst({
- where: { slug: organizationSlug },
- select: { id: true },
- });
+ const formData = await request.formData();
+ const formType = formData.get("_formType");
- if (!org) {
- return json({ ok: false, error: "Organization not found" } as const);
+ if (formType === "set-role") {
+ if (!ability.can("manage", { type: "members" })) {
+ return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
+ }
+ const orgId = context.organizationId;
+ if (!orgId) {
+ return json({ ok: false, error: "Organization not found" } as const, { status: 404 });
+ }
+ const submission = parse(formData, { schema: SetRoleSchema });
+ if (!submission.value || submission.intent !== "submit") {
+ return json(submission);
+ }
+ const result = await rbac.setUserRole({
+ userId: submission.value.userId,
+ organizationId: orgId,
+ roleId: submission.value.roleId,
+ });
+ if (!result.ok) {
+ return json({ ok: false, error: result.error } as const, { status: 400 });
+ }
+ return json({ ok: true } as const);
}
- const submission = parse(formData, { schema: PurchaseSchema });
+ if (formType === "purchase-seats") {
+ // Adjusting seat count is a billing operation. Pre-RBAC the team
+ // page's loader gated the entire route on Owner/Admin, so reaching
+ // this action implied authority. Post-RBAC the loader requires
+ // `read:members` (broader audience), so gate the seat purchase
+ // explicitly here against the right ability rather than relying
+ // on the SetSeatsAddOnService for enforcement at the model layer.
+ if (!ability.can("manage", { type: "billing" })) {
+ return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
+ }
+ // Reuse the orgId the dashboardBuilder already resolved in the
+ // context callback (single slug → orgId lookup per request,
+ // regardless of whether the OSS fallback or cloud plugin
+ // services the auth — the plugin takes `organizationId` as
+ // input and doesn't re-resolve from a slug).
+ const orgId = context.organizationId;
+ if (!orgId) {
+ return json({ ok: false, error: "Organization not found" } as const);
+ }
- if (!submission.value || submission.intent !== "submit") {
- return json(submission);
- }
+ const submission = parse(formData, { schema: PurchaseSchema });
- const service = new SetSeatsAddOnService();
- const [error, result] = await tryCatch(
- service.call({
- userId,
- organizationId: org.id,
- action: submission.value.action,
- amount: submission.value.amount,
- })
- );
+ if (!submission.value || submission.intent !== "submit") {
+ return json(submission);
+ }
- if (error) {
- submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
- return json(submission);
+ const service = new SetSeatsAddOnService();
+ const [error, result] = await tryCatch(
+ service.call({
+ userId,
+ organizationId: orgId,
+ action: submission.value.action,
+ amount: submission.value.amount,
+ })
+ );
+
+ if (error) {
+ submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
+ return json(submission);
+ }
+
+ if (!result.success) {
+ submission.error.amount = [result.error];
+ return json(submission);
+ }
+
+ return json({ ok: true } as const);
}
- if (!result.success) {
- submission.error.amount = [result.error];
+ const submission = parse(formData, { schema });
+
+ if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
- return json({ ok: true } as const);
- }
-
- const submission = parse(formData, { schema });
+ // Default intent: remove a member or leave the org. Self-leave (the
+ // actor removing their own membership) is always allowed. Removing
+ // another member requires `manage:members` — pre-RBAC the
+ // `removeTeamMember` model fn only verified the actor was a member
+ // of the target org, so any org member could remove any other
+ // member by id; this gate fixes that latent permissions hole.
+ const targetMember = await $replica.orgMember.findFirst({
+ where: { id: submission.value.memberId },
+ select: { userId: true },
+ });
+ const isSelfLeave = targetMember?.userId === userId;
+ if (!isSelfLeave && !ability.can("manage", { type: "members" })) {
+ return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
+ }
- if (!submission.value || submission.intent !== "submit") {
- return json(submission);
- }
+ try {
+ const deletedMember = await removeTeamMember({
+ userId,
+ memberId: submission.value.memberId,
+ slug: organizationSlug,
+ });
- try {
- const deletedMember = await removeTeamMember({
- userId,
- memberId: submission.value.memberId,
- slug: organizationSlug,
- });
+ if (deletedMember.userId === userId) {
+ return redirectWithSuccessMessage("/", request, `You left the organization`);
+ }
- if (deletedMember.userId === userId) {
- return redirectWithSuccessMessage("/", request, `You left the organization`);
+ return redirectWithSuccessMessage(
+ organizationTeamPath(deletedMember.organization),
+ request,
+ `Removed ${deletedMember.user.name ?? "member"} from team`
+ );
+ } catch (error: any) {
+ return json({ errors: { body: error.message } }, { status: 400 });
}
-
- return redirectWithSuccessMessage(
- organizationTeamPath(deletedMember.organization),
- request,
- `Removed ${deletedMember.user.name ?? "member"} from team`
- );
- } catch (error: any) {
- return json({ errors: { body: error.message } }, { status: 400 });
}
-};
+);
type Member = UseDataFunctionReturn["members"][number];
type Invite = UseDataFunctionReturn["invites"][number];
+type Role = UseDataFunctionReturn["roles"][number];
export default function Page() {
const {
@@ -205,7 +296,16 @@ export default function Page() {
seatPricing,
maxSeatQuota,
planSeatLimit,
+ roles,
+ assignableRoleIds,
+ memberRoles,
+ canManageMembers,
} = useTypedLoaderData();
+ // Build a userId → roleId map so the dropdown's defaultValue matches
+ // each member's current assignment without re-querying.
+ const memberRoleByUserId = new Map(
+ memberRoles.flatMap((m) => (m.role ? [[m.userId, m.role.id]] : []))
+ );
const user = useUser();
const organization = useOrganization();
@@ -242,10 +342,31 @@ export default function Page() {
))}
- {requiresUpgrade ? (
+ {!canManageMembers ? (
+ // Gate the invite affordance on manage:members. The action
+ // route enforces this independently — hiding it here just
+ // avoids dead UI for non-managers.
+
+ Invite a team member
+
+ }
+ content="You don't have permission to invite team members"
+ disableHoverableContent
+ />
+ ) : requiresUpgrade ? (
+
Invite a team member
}
@@ -291,34 +412,57 @@ export default function Page() {
>
)}
- Active team members
-
-
+
diff --git a/apps/webapp/app/routes/account.tokens/route.tsx b/apps/webapp/app/routes/account.tokens/route.tsx
index 4ad62d7edc8..d38841cb8b7 100644
--- a/apps/webapp/app/routes/account.tokens/route.tsx
+++ b/apps/webapp/app/routes/account.tokens/route.tsx
@@ -5,6 +5,7 @@ import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
+import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
@@ -22,6 +23,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
+import { Select, SelectItem } from "~/components/primitives/Select";
import {
Table,
TableBlankRow,
@@ -34,6 +36,8 @@ import {
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { redirectWithSuccessMessage } from "~/models/message.server";
+import { prisma } from "~/db.server";
+import { rbac } from "~/services/rbac.server";
import {
type CreatedPersonalAccessToken,
type ObfuscatedPersonalAccessToken,
@@ -52,14 +56,82 @@ export const meta: MetaFunction = () => {
];
};
+// PATs aren't org-scoped, but the RBAC plugin's allRoles is org-keyed
+// (a plugin may also expose org-defined custom roles alongside the
+// global system roles). The picker shows the assignable system role
+// catalogue for the user's primary org — joining `allRoles` (for the
+// full Role with permissions) against `systemRoles` (for the per-org
+// `available` flag, which gates roles by plan tier). This is a UI-only
+// convenience — the chosen role becomes a global TokenRole that
+// applies wherever the PAT is used. Custom (org-defined) roles are
+// out of scope for v1: their org-binding semantics for a multi-org
+// user's PAT need a separate design pass.
+async function loadSystemRolesForUser(userId: string) {
+ const orgMember = await prisma.orgMember.findFirst({
+ where: { userId },
+ select: { organizationId: true },
+ orderBy: { createdAt: "asc" },
+ });
+ if (!orgMember) {
+ return {
+ roles: [],
+ userRoleId: null as string | null,
+ orgId: null as string | null,
+ };
+ }
+
+ const [allRoles, systemRoles, userRole] = await Promise.all([
+ rbac.allRoles(orgMember.organizationId),
+ rbac.systemRoles(orgMember.organizationId),
+ rbac.getUserRole({ userId, organizationId: orgMember.organizationId }),
+ ]);
+
+ // Restrict the picker to system roles the plan permits assigning —
+ // anything else would be a noisy create-time failure (or, with a
+ // permissive fallback, a token bound to a role this org isn't
+ // allowed to issue).
+ const availableIds = new Set(
+ (systemRoles ?? []).filter((r) => r.available).map((r) => r.id)
+ );
+ const roles = allRoles.filter((r) => r.isSystem && availableIds.has(r.id));
+
+ return {
+ roles,
+ userRoleId: userRole?.id ?? null,
+ orgId: orgMember.organizationId,
+ };
+}
+
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
try {
- const personalAccessTokens = await getValidPersonalAccessTokens(userId);
+ const [personalAccessTokens, { roles, userRoleId, orgId }] = await Promise.all([
+ getValidPersonalAccessTokens(userId),
+ loadSystemRolesForUser(userId),
+ ]);
+
+ // Default the role picker to the user's own role in their primary
+ // org so a freshly-created PAT isn't more privileged than the
+ // person creating it. Falls back to the most-restrictive role
+ // available on the org's plan if they don't have one. When the
+ // user isn't a member of any org or no RBAC plugin is installed,
+ // the picker is hidden anyway, so defaultRoleId is just a
+ // placeholder.
+ // Clamp to roles the picker actually renders (`roles` already
+ // joins systemRoles ∩ assignableRoleIds). If userRoleId points at
+ // a custom or plan-blocked role, the hidden form value would
+ // otherwise post a roleId the action's revalidation rejects with
+ // 400. Fall through to the most-restrictive assignable role.
+ const assignableIds = new Set(roles.map((r) => r.id));
+ const lowestAssignable = roles.at(-1)?.id ?? "";
+ const defaultRoleId =
+ userRoleId && assignableIds.has(userRoleId) ? userRoleId : lowestAssignable;
return typedjson({
personalAccessTokens,
+ roles,
+ defaultRoleId,
});
} catch (error) {
if (error instanceof Response) {
@@ -81,6 +153,10 @@ const CreateTokenSchema = z.discriminatedUnion("action", [
.string({ required_error: "You must enter a name" })
.min(2, "Your name must be at least 2 characters long")
.max(50),
+ // Optional — when no RBAC plugin is installed the UI hides the
+ // dropdown and submits no roleId; the action passes that through
+ // and createPersonalAccessToken just doesn't write a TokenRole.
+ roleId: z.string().optional(),
}),
z.object({
action: z.literal("revoke"),
@@ -100,9 +176,27 @@ export const action: ActionFunction = async ({ request }) => {
switch (submission.value.action) {
case "create": {
try {
+ // Revalidate the submitted roleId against the plan-allowed set
+ // — the loader filters the picker, but a hand-crafted POST can
+ // still submit any string. Empty / undefined is fine: that
+ // means "no role" and createPersonalAccessToken just doesn't
+ // write a TokenRole.
+ const submittedRoleId = submission.value.roleId;
+ if (submittedRoleId) {
+ const { roles } = await loadSystemRolesForUser(userId);
+ const allowed = new Set(roles.map((r) => r.id));
+ if (!allowed.has(submittedRoleId)) {
+ return json(
+ { errors: { body: "Selected role isn't available on this plan" } },
+ { status: 400 }
+ );
+ }
+ }
+
const tokenResult = await createPersonalAccessToken({
name: submission.value.tokenName,
userId,
+ roleId: submittedRoleId,
});
return json({ ...submission, payload: { token: tokenResult } });
@@ -131,7 +225,7 @@ export const action: ActionFunction = async ({ request }) => {
};
export default function Page() {
- const { personalAccessTokens } = useTypedLoaderData();
+ const { personalAccessTokens, roles, defaultRoleId } = useTypedLoaderData();
return (
@@ -151,7 +245,7 @@ export default function Page() {
Create a Personal Access Token
-
+
@@ -211,7 +305,15 @@ export default function Page() {
);
}
-function CreatePersonalAccessToken() {
+type SystemRole = { id: string; name: string; description: string };
+
+function CreatePersonalAccessToken({
+ roles,
+ defaultRoleId,
+}: {
+ roles: SystemRole[];
+ defaultRoleId: string;
+}) {
const fetcher = useFetcher();
const lastSubmission = fetcher.data as any;
@@ -228,6 +330,14 @@ function CreatePersonalAccessToken() {
? (lastSubmission?.payload?.token as CreatedPersonalAccessToken)
: undefined;
+ // With no RBAC plugin installed, rbac.allRoles returns []; hide the
+ // dropdown entirely rather than showing an empty Select.
+ // createPersonalAccessToken's roleId is optional, so omitting it
+ // produces a working PAT with no explicit role attached (matches
+ // pre-RBAC behaviour).
+ const showRolePicker = roles.length > 0;
+ const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
+
return (