fix: security release 2026-07-08 (#4316)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled

This commit is contained in:
Chris Arderne
2026-07-21 12:00:58 +01:00
committed by GitHub
parent cc748422d8
commit 6997aeb05e
141 changed files with 3939 additions and 324 deletions
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Prevent prototype pollution when applying run metadata operations or reconstructing nested telemetry attributes, while preserving legitimate `constructor` and `prototype` fields.
+6
View File
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---
Require explicit browser approval for CLI and MCP login, with resilient polling while approval is pending.
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Deployed task telemetry now reports the deployment identifier (e.g. `deployment_abc123`) in the `worker.id` attribute, instead of an opaque internal value. Upgrade to get the readable identifier in your own OpenTelemetry exporters.
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add helpers to mint and verify the deployment-scoped token used to authenticate run controllers to the platform.
+1
View File
@@ -2,6 +2,7 @@
SESSION_SECRET=abcdef1234
MAGIC_LINK_SECRET=abcdef1234
ENCRYPTION_KEY=ae13021afef0819c3a307ad487071c06 # Must be a random 16 byte hex string. You can generate an encryption key by running `openssl rand -hex 16` in your terminal
MANAGED_WORKER_SECRET=abcdef1234 # Must match the supervisor's MANAGED_WORKER_SECRET
LOGIN_ORIGIN=http://localhost:3030
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
# This sets the URL used for direct connections to the database and should only be needed in limited circumstances
+3 -1
View File
@@ -52,12 +52,14 @@ jobs:
- name: Lint Helm Chart
run: |
helm lint ./hosting/k8s/helm/
helm lint ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/ci/lint-values.yaml
- name: Render templates
run: |
helm template test-release ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/values.yaml \
--values ./hosting/k8s/helm/ci/lint-values.yaml \
--output-dir ./helm-output
- name: Validate manifests
+3 -1
View File
@@ -47,12 +47,14 @@ jobs:
- name: Lint Helm Chart
run: |
helm lint ./hosting/k8s/helm/
helm lint ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/ci/lint-values.yaml
- name: Render templates
run: |
helm template test-release ./hosting/k8s/helm/ \
--values ./hosting/k8s/helm/values.yaml \
--values ./hosting/k8s/helm/ci/lint-values.yaml \
--output-dir ./helm-output
- name: Validate manifests
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
Added optional request rate limiting for telemetry ingestion endpoints.
@@ -0,0 +1,6 @@
---
area: webapp
type: breaking
---
Self-hosted deployments no longer ship shared default credentials; fresh installs generate their own. If yours still uses a previously published default, set a unique value before upgrading, or set `ALLOW_INSECURE_DEFAULT_SECRETS=true` to keep booting while you migrate.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Background-worker deployment lookups are now scoped to the authenticated environment.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Updating a GitHub App installation from the callback flow is now scoped to your own organization, so an installation ID belonging to another organization can no longer be used to refresh that organization's installation record. The GitHub App installation session is also now single-use, so completing an installation callback invalidates its state and it can no longer be replayed.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Scope schedule and environment-variable writes to the caller's project and environment
@@ -0,0 +1,6 @@
---
area: supervisor
type: fix
---
Reject compute snapshot callbacks that do not match the snapshot request that created them.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Require secret-key authentication to initialize the session out (agent→client) stream, matching the append route.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Live run and trace subscriptions now validate their identifiers more strictly and only return data from your own organization.
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Window-function names in the query compiler are now validated against the allowlist, matching how other function calls are handled.
@@ -0,0 +1,6 @@
---
area: supervisor
type: fix
---
Authenticate run controllers to the platform with a signed, deployment-scoped token.
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Verify that worker actions (starting, completing, and continuing a run, and reading its snapshots) target a run belonging to the caller's environment.
+2 -2
View File
@@ -1,8 +1,8 @@
# This needs to match the token of the worker group you want to connect to
TRIGGER_WORKER_TOKEN=
# This needs to match the MANAGED_WORKER_SECRET env var on the webapp
MANAGED_WORKER_SECRET=managed-secret
# Must match the webapp's MANAGED_WORKER_SECRET. Generate with: openssl rand -hex 16
MANAGED_WORKER_SECRET=
# Point this at the webapp in prod
TRIGGER_API_URL=http://localhost:3030
+18 -1
View File
@@ -14,8 +14,17 @@ export const Env = z
// Required settings
TRIGGER_API_URL: z.string().url(),
TRIGGER_WORKER_TOKEN: z.string(), // accepts file:// path to read from a file
TRIGGER_WORKER_TOKEN: z.string().min(1), // accepts file:// path to read from a file
MANAGED_WORKER_SECRET: z.string(),
// Deployment token: sign a token into TRIGGER_DEPLOYMENT_ID at pod creation and verify it on
// inbound workload calls. "disabled" = off; "log" = mint + verify + metrics only; "enforce" =
// also reject invalid tokens.
WORKLOAD_TOKEN_SECRET: z.string().optional(),
WORKLOAD_TOKEN_ENFORCEMENT: z.enum(["disabled", "log", "enforce"]).default("disabled"),
// Absolute expiry for minted deployment tokens. Deterministic (no wall-clock issued-at) so every
// pod of a deployment carries an identical token; bump before this date. Must outlive any run.
WORKLOAD_TOKEN_EXP: z.string().datetime().default("2032-01-01T00:00:00.000Z"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(), // set on the runners
// Workload API settings (coordinator mode) - the workload API is what the run controller connects to
@@ -365,6 +374,14 @@ export const Env = z
path: ["TRIGGER_WORKLOAD_API_DOMAIN"],
});
}
if (data.WORKLOAD_TOKEN_ENFORCEMENT !== "disabled" && !data.WORKLOAD_TOKEN_SECRET) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"WORKLOAD_TOKEN_SECRET is required when WORKLOAD_TOKEN_ENFORCEMENT is not disabled",
path: ["WORKLOAD_TOKEN_SECRET"],
});
}
if (
data.TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED &&
!data.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST
+16 -1
View File
@@ -27,6 +27,7 @@ import { register } from "./metrics.js";
import { PodCleaner } from "./services/podCleaner.js";
import { FailedPodHandler } from "./services/failedPodHandler.js";
import { getWorkerToken } from "./workerToken.js";
import { mintDeploymentToken } from "./workloadToken.js";
import { OtlpTraceService } from "./services/otlpTraceService.js";
import {
WarmStartVerificationService,
@@ -96,6 +97,7 @@ class ManagedSupervisor {
COMPUTE_GATEWAY_AUTH_TOKEN,
DOCKER_REGISTRY_PASSWORD,
TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PASSWORD,
WORKLOAD_TOKEN_SECRET,
...envWithoutSecrets
} = env;
@@ -290,8 +292,10 @@ class ManagedSupervisor {
});
}
const workerToken = getWorkerToken();
this.workerSession = new SupervisorSession({
workerToken: getWorkerToken(),
workerToken,
apiUrl: env.TRIGGER_API_URL,
instanceName: env.TRIGGER_WORKER_INSTANCE_NAME,
managedWorkerSecret: env.MANAGED_WORKER_SECRET,
@@ -569,6 +573,7 @@ class ManagedSupervisor {
checkpointClient: this.checkpointClient,
computeManager: this.computeManager,
tracing: this.tracing,
snapshotCallbackSecret: workerToken,
wideEventOpts: this.wideEventOpts,
wideEventsNoisyRoutes: this.wideEventsNoisyRoutes,
});
@@ -603,6 +608,15 @@ class ManagedSupervisor {
throw new Error("Image is missing");
}
const deploymentToken = await mintDeploymentToken({
deployment: message.deployment.friendlyId,
deployment_version: message.backgroundWorker.version,
environment_id: message.environment.id,
environment_type: message.environment.type,
org_id: message.organization.id,
project_id: message.project.id,
});
await this.workloadManager.create({
dequeuedAt: message.dequeuedAt,
dequeueResponseMs: timings.dequeueResponseMs,
@@ -617,6 +631,7 @@ class ManagedSupervisor {
deploymentFriendlyId: message.deployment.friendlyId,
deploymentVersion: message.backgroundWorker.version,
runtime: message.backgroundWorker.runtime,
deploymentToken,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
@@ -20,13 +20,26 @@ function createService() {
snapshot,
} as unknown as ComputeWorkloadManager;
const submitSuspendCompletion = vi.fn(async () => ({ success: true }));
const service = new ComputeSnapshotService({
computeManager,
workerClient: {} as SupervisorHttpClient,
workerClient: { submitSuspendCompletion } as unknown as SupervisorHttpClient,
wideEventOpts: { service: "supervisor-test", env: {}, enabled: false },
snapshotCallbackSecret: "test-secret",
});
return { service, snapshot };
return { service, snapshot, submitSuspendCompletion };
}
function dispatchedMetadata(snapshot: {
mock: { calls: Array<Array<{ metadata?: Record<string, string> }>> };
}) {
const metadata = snapshot.mock.calls[0]?.[0]?.metadata;
if (!metadata) {
throw new Error("Snapshot was not dispatched");
}
return metadata;
}
function delayedSnapshot(runnerId = "runner-1") {
@@ -38,6 +51,24 @@ function delayedSnapshot(runnerId = "runner-1") {
}
describe("ComputeSnapshotService", () => {
it("refuses to construct with an empty callback secret", () => {
const computeManager = {
snapshotDelayMs: DELAY_MS,
snapshotDispatchLimit: 1,
snapshot: vi.fn(async () => true),
} as unknown as ComputeWorkloadManager;
expect(
() =>
new ComputeSnapshotService({
computeManager,
workerClient: {} as SupervisorHttpClient,
wideEventOpts: { service: "supervisor-test", env: {}, enabled: false },
snapshotCallbackSecret: "",
})
).toThrow();
});
it("dispatches a scheduled snapshot after the delay", async () => {
const { service, snapshot } = createService();
try {
@@ -46,7 +77,12 @@ describe("ComputeSnapshotService", () => {
await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 });
expect(snapshot).toHaveBeenCalledWith({
runnerId: "runner-1",
metadata: { runId: "run_1", snapshotFriendlyId: "snapshot_1" },
metadata: expect.objectContaining({
runId: "run_1",
snapshotFriendlyId: "snapshot_1",
snapshotCallbackNonce: expect.any(String),
snapshotCallbackToken: expect.any(String),
}),
});
} finally {
service.stop();
@@ -121,10 +157,86 @@ describe("ComputeSnapshotService", () => {
expect(snapshot).toHaveBeenCalledTimes(1);
expect(snapshot).toHaveBeenCalledWith({
runnerId: "runner-1",
metadata: { runId: "run_1", snapshotFriendlyId: "snapshot_2" },
metadata: expect.objectContaining({
runId: "run_1",
snapshotFriendlyId: "snapshot_2",
snapshotCallbackNonce: expect.any(String),
snapshotCallbackToken: expect.any(String),
}),
});
} finally {
service.stop();
}
});
it("accepts a snapshot callback with the dispatched token", async () => {
const { service, snapshot, submitSuspendCompletion } = createService();
try {
service.schedule("run_1", delayedSnapshot());
await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 });
const metadata = dispatchedMetadata(snapshot);
const result = await service.handleCallback({
status: "completed",
instance_id: "instance_1",
snapshot_id: "compute_snapshot_1",
metadata,
});
expect(result).toEqual({ ok: true, status: 200 });
expect(submitSuspendCompletion).toHaveBeenCalledWith({
runId: "run_1",
snapshotId: "snapshot_1",
body: {
success: true,
checkpoint: {
type: "COMPUTE",
location: "compute_snapshot_1",
},
},
});
} finally {
service.stop();
}
});
it("rejects a snapshot callback without a valid token", async () => {
const { service, submitSuspendCompletion } = createService();
try {
const result = await service.handleCallback({
status: "completed",
instance_id: "instance_1",
snapshot_id: "compute_snapshot_1",
metadata: { runId: "run_1", snapshotFriendlyId: "snapshot_1" },
});
expect(result).toEqual({ ok: false, status: 401 });
expect(submitSuspendCompletion).not.toHaveBeenCalled();
} finally {
service.stop();
}
});
it("rejects a snapshot callback whose token is for a different snapshot", async () => {
const { service, snapshot, submitSuspendCompletion } = createService();
try {
service.schedule("run_1", delayedSnapshot());
await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 });
const metadata = dispatchedMetadata(snapshot);
const result = await service.handleCallback({
status: "completed",
instance_id: "instance_1",
snapshot_id: "compute_snapshot_1",
metadata: { ...metadata, snapshotFriendlyId: "snapshot_2" },
});
expect(result).toEqual({ ok: false, status: 401 });
expect(submitSuspendCompletion).not.toHaveBeenCalled();
} finally {
service.stop();
}
});
});
@@ -1,3 +1,4 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import pLimit from "p-limit";
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
import { parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
@@ -16,6 +17,13 @@ import {
type WideEventOptions,
} from "../wideEvents/index.js";
const SNAPSHOT_CALLBACK_NONCE_METADATA_KEY = "snapshotCallbackNonce";
const SNAPSHOT_CALLBACK_TOKEN_METADATA_KEY = "snapshotCallbackToken";
// Domain-separation label so the callback-signing key is derived from, rather
// than equal to, the secret used for other protocols. Bump the suffix to rotate.
const SNAPSHOT_CALLBACK_KEY_INFO = "compute-snapshot-callback-v1";
type DelayedSnapshot = {
runnerId: string;
runFriendlyId: string;
@@ -34,6 +42,7 @@ export type ComputeSnapshotServiceOptions = {
workerClient: SupervisorHttpClient;
tracing?: OtlpTraceService;
wideEventOpts: WideEventOptions;
snapshotCallbackSecret: string;
};
export class ComputeSnapshotService {
@@ -48,6 +57,7 @@ export class ComputeSnapshotService {
private readonly workerClient: SupervisorHttpClient;
private readonly tracing?: OtlpTraceService;
private readonly wideEventOpts: WideEventOptions;
private readonly snapshotCallbackKey: Buffer;
constructor(opts: ComputeSnapshotServiceOptions) {
this.computeManager = opts.computeManager;
@@ -55,6 +65,18 @@ export class ComputeSnapshotService {
this.tracing = opts.tracing;
this.wideEventOpts = opts.wideEventOpts;
// Reject an empty secret up front: an empty HMAC key would make callback
// tokens forgeable by anyone. Guarding here (rather than only at env parse)
// also covers the case where the secret is read from an empty file.
if (!opts.snapshotCallbackSecret) {
throw new Error("snapshotCallbackSecret must not be empty");
}
// Derive a dedicated key by domain separation so the raw secret is never
// used directly as a MAC key for this protocol.
this.snapshotCallbackKey = createHmac("sha256", opts.snapshotCallbackSecret)
.update(SNAPSHOT_CALLBACK_KEY_INFO)
.digest();
this.dispatchLimit = pLimit(this.computeManager.snapshotDispatchLimit);
this.timerWheel = new TimerWheel<DelayedSnapshot>({
delayMs: this.computeManager.snapshotDelayMs,
@@ -146,15 +168,29 @@ export class ComputeSnapshotService {
instanceId: body.instance_id,
status: body.status,
error: body.status === "failed" ? body.error : undefined,
metadata: body.metadata,
runId,
snapshotFriendlyId,
durationMs: body.duration_ms,
});
if (!runId || !snapshotFriendlyId) {
this.logger.error("Snapshot callback missing metadata", { body });
this.logger.error("Snapshot callback missing metadata", {
status: body.status,
instanceId: body.instance_id,
metadataKeys: Object.keys(body.metadata ?? {}),
});
return { ok: false as const, status: 400 };
}
if (!this.#verifyCallbackToken(body.metadata, runId, snapshotFriendlyId)) {
this.logger.error("Snapshot callback failed token verification", {
runId,
snapshotFriendlyId,
instanceId: body.instance_id,
});
return { ok: false as const, status: 401 };
}
this.#emitSnapshotSpan(runId, body.duration_ms, snapshotId);
if (body.status === "completed") {
@@ -266,11 +302,18 @@ export class ComputeSnapshotService {
},
},
async () => {
const callbackNonce = randomBytes(16).toString("hex");
const result = await this.computeManager.snapshot({
runnerId: snapshot.runnerId,
metadata: {
runId: snapshot.runFriendlyId,
snapshotFriendlyId: snapshot.snapshotFriendlyId,
[SNAPSHOT_CALLBACK_NONCE_METADATA_KEY]: callbackNonce,
[SNAPSHOT_CALLBACK_TOKEN_METADATA_KEY]: this.#createCallbackToken(
callbackNonce,
snapshot.runFriendlyId,
snapshot.snapshotFriendlyId
),
},
});
@@ -281,6 +324,51 @@ export class ComputeSnapshotService {
);
}
#createCallbackToken(nonce: string, runFriendlyId: string, snapshotFriendlyId: string): string {
return createHmac("sha256", this.snapshotCallbackKey)
.update(nonce)
.update("\0")
.update(runFriendlyId)
.update("\0")
.update(snapshotFriendlyId)
.digest("hex");
}
/**
* Verify that a callback carries a token this supervisor issued for the given
* run and snapshot. The token binds only the identifiers known at dispatch
* time (nonce, run, snapshot); it intentionally does not cover result fields
* such as the snapshot location or status/error, which are produced by the
* gateway after the snapshot and so cannot be signed in advance. Verification
* is also stateless, so a token is not single-use.
*
* This closes the primary risk (a caller that can merely reach the endpoint
* cannot mint a valid token, so cannot forge a result for an arbitrary run).
* It does not defend against an attacker who can observe a genuine callback
* and then replay it or alter its unsigned result fields - that relies on the
* gateway->supervisor callback channel being authenticated and encrypted.
*/
#verifyCallbackToken(
metadata: Record<string, string> | undefined,
runFriendlyId: string,
snapshotFriendlyId: string
): boolean {
const nonce = metadata?.[SNAPSHOT_CALLBACK_NONCE_METADATA_KEY];
const token = metadata?.[SNAPSHOT_CALLBACK_TOKEN_METADATA_KEY];
if (!nonce || !token) {
return false;
}
const expected = this.#createCallbackToken(nonce, runFriendlyId, snapshotFriendlyId);
const expectedBuffer = Buffer.from(expected, "hex");
const tokenBuffer = Buffer.from(token, "hex");
return (
expectedBuffer.length === tokenBuffer.length && timingSafeEqual(expectedBuffer, tokenBuffer)
);
}
#emitSnapshotSpan(runFriendlyId: string, durationMs?: number, snapshotId?: string) {
if (!this.tracing) return;
@@ -151,7 +151,9 @@ export class ComputeWorkloadManager implements WorkloadManager {
TRIGGER_DEQUEUED_AT_MS: String(opts.dequeuedAt.getTime()),
TRIGGER_POD_SCHEDULED_AT_MS: String(Date.now()),
TRIGGER_ENV_ID: opts.envId,
TRIGGER_DEPLOYMENT_ID: opts.deploymentFriendlyId,
TRIGGER_DEPLOYMENT_ID: opts.deploymentToken ?? opts.deploymentFriendlyId,
// Plain friendlyId for telemetry (worker.id), so it isn't the opaque token in DEPLOYMENT_ID.
TRIGGER_DEPLOYMENT_FRIENDLY_ID: opts.deploymentFriendlyId,
TRIGGER_DEPLOYMENT_VERSION: opts.deploymentVersion,
TRIGGER_RUN_ID: opts.runFriendlyId,
TRIGGER_SNAPSHOT_ID: opts.snapshotFriendlyId,
@@ -72,7 +72,9 @@ export class DockerWorkloadManager implements WorkloadManager {
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
`TRIGGER_ENV_ID=${opts.envId}`,
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentToken ?? opts.deploymentFriendlyId}`,
// Plain friendlyId for telemetry (worker.id), so it isn't the opaque token in DEPLOYMENT_ID.
`TRIGGER_DEPLOYMENT_FRIENDLY_ID=${opts.deploymentFriendlyId}`,
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
@@ -158,6 +158,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
{
name: "TRIGGER_DEPLOYMENT_ID",
value: opts.deploymentToken ?? opts.deploymentFriendlyId,
},
{
// Plain friendlyId for telemetry (worker.id), not the opaque token in DEPLOYMENT_ID.
name: "TRIGGER_DEPLOYMENT_FRIENDLY_ID",
value: opts.deploymentFriendlyId,
},
{
@@ -44,6 +44,8 @@ export interface WorkloadManagerCreateOptions {
deploymentVersion: string;
// Canonical runtime identifier (e.g. "node", "node-22", "node-24")
runtime?: string;
// When set, overrides the TRIGGER_DEPLOYMENT_ID value the runner forwards as its identity header.
deploymentToken?: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
+155 -9
View File
@@ -25,6 +25,11 @@ import { type Namespace, Server, type Socket } from "socket.io";
import { z } from "zod";
import { env } from "../env.js";
import { register } from "../metrics.js";
import {
verifyDeploymentIdHeader,
workloadTokenEnforced,
workloadTokensEnabled,
} from "../workloadToken.js";
import {
ComputeSnapshotService,
type RunTraceContext,
@@ -86,6 +91,7 @@ type WorkloadServerOptions = {
checkpointClient?: CheckpointClient;
computeManager?: ComputeWorkloadManager;
tracing?: OtlpTraceService;
snapshotCallbackSecret: string;
wideEventOpts: WideEventOptions;
/** When true, high-frequency HTTP routes also emit wide events. */
wideEventsNoisyRoutes: boolean;
@@ -136,6 +142,7 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
workerClient: opts.workerClient,
tracing: opts.tracing,
wideEventOpts: this.wideEventOpts,
snapshotCallbackSecret: opts.snapshotCallbackSecret,
});
}
@@ -169,6 +176,34 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
return this.headerValueFromRequest(req, WORKLOAD_HEADERS.PROJECT_REF);
}
/**
* Verify the deployment token from the workload deployment-id header and return the verified
* environment_id to forward upstream. The env id is only forwarded in enforce mode: in log mode
* we still verify + record metrics but attach no header (so the platform never scopes). Only
* enforce fails a request, and only for a present-but-invalid token; absent and legacy ids pass.
*/
private async authorizeWorkloadRequest(
req: IncomingMessage
): Promise<{ ok: true; environmentId?: string } | { ok: false }> {
if (!workloadTokensEnabled) {
return { ok: true };
}
const result = await verifyDeploymentIdHeader(this.deploymentIdFromRequest(req), "http");
if (result.outcome === "jwt_invalid" && workloadTokenEnforced) {
return { ok: false };
}
return {
ok: true,
environmentId:
workloadTokenEnforced && result.outcome === "jwt_valid"
? result.claims.environment_id
: undefined,
};
}
/**
* Sets common route meta on the wide-event state from URL params.
*/
@@ -250,11 +285,17 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"POST",
async () => {
const { req, reply, params, body } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const startResponse = await this.workerClient.startRunAttempt(
params.runFriendlyId,
params.snapshotFriendlyId,
body,
this.runnerIdFromRequest(req)
this.runnerIdFromRequest(req),
auth.environmentId
);
if (!startResponse.success) {
@@ -286,6 +327,11 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"POST",
async () => {
const { req, reply, params, body } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const runnerId = this.runnerIdFromRequest(req);
// A completion attempt invalidates any pending delayed snapshot
@@ -304,7 +350,8 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
params.runFriendlyId,
params.snapshotFriendlyId,
body,
runnerId
runnerId,
auth.environmentId
);
if (!completeResponse.success) {
@@ -336,6 +383,11 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"POST",
async () => {
const { req, reply, params, body } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const heartbeatResponse = await this.workerClient.heartbeatRun(
params.runFriendlyId,
params.snapshotFriendlyId,
@@ -373,6 +425,11 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"GET",
async () => {
const { reply, params, req } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const runnerId = this.runnerIdFromRequest(req);
const deploymentVersion = this.deploymentVersionFromRequest(req);
const projectRef = this.projectRefFromRequest(req);
@@ -469,6 +526,11 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"GET",
async () => {
const { req, reply, params } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
this.logger.debug("Run continuation request", { params });
// Cancel any pending delayed snapshot for this run
@@ -477,7 +539,8 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const continuationResult = await this.workerClient.continueRunExecution(
params.runFriendlyId,
params.snapshotFriendlyId,
this.runnerIdFromRequest(req)
this.runnerIdFromRequest(req),
auth.environmentId
);
if (!continuationResult.success) {
@@ -511,10 +574,16 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
"GET",
async () => {
const { req, reply, params } = ctx;
const auth = await this.authorizeWorkloadRequest(req);
if (!auth.ok) {
reply.empty(401);
return;
}
const sinceSnapshotResponse = await this.workerClient.getSnapshotsSince(
params.runFriendlyId,
params.snapshotFriendlyId,
this.runnerIdFromRequest(req)
this.runnerIdFromRequest(req),
auth.environmentId
);
if (!sinceSnapshotResponse.success) {
@@ -585,9 +654,18 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const { req, reply, params, body } = ctx;
reply.empty(204);
// Redact TRIGGER_DEPLOYMENT_ID before relaying to the platform.
const sanitizedBody =
body.properties && "TRIGGER_DEPLOYMENT_ID" in body.properties
? {
...body,
properties: { ...body.properties, TRIGGER_DEPLOYMENT_ID: "[redacted]" },
}
: body;
await this.workerClient.sendDebugLog(
params.runFriendlyId,
body,
sanitizedBody,
this.runnerIdFromRequest(req)
);
},
@@ -681,7 +759,31 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
return;
}
this.logger.debug("[WS] auth success", socket.data);
if (workloadTokensEnabled) {
const result = await verifyDeploymentIdHeader(socket.data.deploymentId, "ws");
if (result.outcome === "jwt_invalid" && workloadTokenEnforced) {
this.logger.error("[WS] deployment token verification failed", {
runnerId: socket.data.runnerId,
});
socket.disconnect(true);
return;
}
// Re-source the deployment id from the verified claim; the raw header may be an opaque token.
// A legacy bare id is itself the friendlyId, so it's safe to keep.
socket.data.deploymentFriendlyId =
result.outcome === "jwt_valid"
? result.claims.deployment
: result.outcome === "legacy_bare"
? socket.data.deploymentId
: undefined;
}
this.logger.debug("[WS] handshake complete", {
runnerId: socket.data.runnerId,
deploymentFriendlyId: socket.data.deploymentFriendlyId,
});
next();
});
@@ -693,7 +795,7 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const getSocketMetadata = () => {
return {
deploymentId: socket.data.deploymentId,
deploymentId: socket.data.deploymentFriendlyId ?? socket.data.deploymentId,
runId: socket.data.runFriendlyId,
snapshotId: socket.data.snapshotId,
runnerId: socket.data.runnerId,
@@ -712,8 +814,9 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
populate: (state) => {
state.extras.event = event;
setMeta(state, "run_id", friendlyId);
if (socket.data.deploymentId) {
setMeta(state, "deployment_id", socket.data.deploymentId);
const deploymentId = socket.data.deploymentFriendlyId ?? socket.data.deploymentId;
if (deploymentId) {
setMeta(state, "deployment_id", deploymentId);
}
if (socket.data.runnerId) setMeta(state, "runner_id", socket.data.runnerId);
state.extras.socket_id = socket.id;
@@ -725,6 +828,33 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const runConnected = (friendlyId: string) => {
socketLogger.debug("runConnected", { ...getSocketMetadata() });
// Only the owning runner may (re)bind a run. A live socket from a *different*
// runner keeps its binding so an unrelated connection can't hijack the run. But
// the newest socket for the *same* runner is a legitimate reconnection/handoff and
// is allowed to take over even while the stale socket still reports connected -
// otherwise, during a reconnect race the fresh socket would silently stay unbound
// (missing continue/cancel/suspend notifications) until the dead socket times out.
const existing = this.runSockets.get(friendlyId);
if (existing && existing.id !== socket.id && existing.connected) {
const sameRunner =
!!socket.data.runnerId && existing.data.runnerId === socket.data.runnerId;
if (!sameRunner) {
socketLogger.warn("runConnected: run already bound to another socket", {
...getSocketMetadata(),
friendlyId,
existingSocketId: existing.id,
});
return;
}
socketLogger.debug("runConnected: replacing stale socket for same runner", {
...getSocketMetadata(),
friendlyId,
existingSocketId: existing.id,
});
}
// If there's already a run ID set, we should "disconnect" it from this socket
if (socket.data.runFriendlyId && socket.data.runFriendlyId !== friendlyId) {
socketLogger.debug("runConnected: disconnecting existing run", {
@@ -744,6 +874,22 @@ export class WorkloadServer extends EventEmitter<WorkloadServerEvents> {
const runDisconnected = (friendlyId: string, reason: string) => {
socketLogger.debug("runDisconnected", { ...getSocketMetadata() });
// A newer socket may have taken over this run (same-runner reconnect race). If the
// run is now bound to a different socket, this stale socket must not clear the fresh
// binding or emit a spurious disconnect - just drop its own reference and bail.
const bound = this.runSockets.get(friendlyId);
if (bound && bound.id !== socket.id) {
socketLogger.debug("runDisconnected: run rebound to another socket, skipping", {
...getSocketMetadata(),
friendlyId,
boundSocketId: bound.id,
});
if (socket.data.runFriendlyId === friendlyId) {
socket.data.runFriendlyId = undefined;
}
return;
}
// The run is gone from this runner (crash, exit, or replaced by a new
// run), so a pending delayed snapshot for it is stale. Genuine
// waitpoint suspensions keep the socket connected, so this doesn't
@@ -0,0 +1,107 @@
import { mintWorkloadDeploymentToken } from "@trigger.dev/core/v3";
import { WORKLOAD_HEADERS } from "@trigger.dev/core/v3/workers";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
// Set enforce mode + secret before env.ts parses (vi.mock is hoisted above imports, so the secret
// must be a literal here). SECRET below mirrors it for use in the test body.
vi.mock("std-env", () => ({
env: {
TRIGGER_API_URL: "http://localhost:3030",
TRIGGER_WORKER_TOKEN: "test-token",
MANAGED_WORKER_SECRET: "test-secret",
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
WORKLOAD_TOKEN_SECRET: "integration-test-secret",
WORKLOAD_TOKEN_ENFORCEMENT: "enforce",
},
}));
const SECRET = "integration-test-secret";
const EXP = Math.floor(Date.UTC(2032, 0, 1) / 1000);
const { WorkloadServer } = await import("./index.js");
const PORT = 18732;
const BASE = `http://127.0.0.1:${PORT}`;
function claims(environmentId = "env_test_123") {
return {
deployment: "deployment_test",
deployment_version: "20260710.1",
environment_id: environmentId,
environment_type: "PRODUCTION",
org_id: "org_1",
project_id: "proj_1",
};
}
// Records the args each relay method is called with so we can assert the forwarded claim.
const calls: { getSnapshotsSince: any[][] } = { getSnapshotsSince: [] };
const workerClient = {
getSnapshotsSince: vi.fn(async (...args: any[]) => {
calls.getSnapshotsSince.push(args);
return { success: true as const, data: { snapshots: [] } };
}),
} as any;
let server: InstanceType<typeof WorkloadServer>;
beforeAll(async () => {
server = new WorkloadServer({
port: PORT,
workerClient,
snapshotCallbackSecret: "snapshot-callback-secret",
wideEventOpts: { service: "supervisor", env: { nodeId: "test" }, enabled: false },
wideEventsNoisyRoutes: false,
});
await server.start();
});
afterAll(async () => {
await server.stop();
});
function snapshotsSince(deploymentIdHeader?: string) {
const headers: Record<string, string> = {
[WORKLOAD_HEADERS.RUNNER_ID]: "runner_1",
};
if (deploymentIdHeader !== undefined) {
headers[WORKLOAD_HEADERS.DEPLOYMENT_ID] = deploymentIdHeader;
}
return fetch(`${BASE}/api/v1/workload-actions/runs/run_1/snapshots/since/snap_1`, { headers });
}
describe("WorkloadServer auth (enforce mode)", () => {
it("allows a valid token and forwards the verified environment_id", async () => {
const token = await mintWorkloadDeploymentToken(claims("env_forwarded_42"), SECRET, EXP);
const res = await snapshotsSince(token);
expect(res.status).toBe(200);
const lastCall = calls.getSnapshotsSince.at(-1)!;
// getSnapshotsSince(runId, snapshotId, runnerId, environmentId)
expect(lastCall[3]).toBe("env_forwarded_42");
});
it("rejects a token signed with the wrong secret (401) and does not relay", async () => {
const before = calls.getSnapshotsSince.length;
const badToken = await mintWorkloadDeploymentToken(claims(), "wrong-secret", EXP);
const res = await snapshotsSince(badToken);
expect(res.status).toBe(401);
expect(calls.getSnapshotsSince.length).toBe(before);
});
it("allows a legacy bare friendlyId and forwards no environment_id", async () => {
const res = await snapshotsSince("deployment_legacy_bare");
expect(res.status).toBe(200);
expect(calls.getSnapshotsSince.at(-1)![3]).toBeUndefined();
});
it("allows an absent token and forwards no environment_id", async () => {
const res = await snapshotsSince(undefined);
expect(res.status).toBe(200);
expect(calls.getSnapshotsSince.at(-1)![3]).toBeUndefined();
});
});
@@ -0,0 +1,87 @@
import { mintWorkloadDeploymentToken } from "@trigger.dev/core/v3";
import { WORKLOAD_HEADERS } from "@trigger.dev/core/v3/workers";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
// Log mode: mint + verify + metrics, but the platform must NOT be scoped, so no environment_id is
// forwarded even for a valid token. (vi.mock is hoisted; secret literal here, mirrored below.)
vi.mock("std-env", () => ({
env: {
TRIGGER_API_URL: "http://localhost:3030",
TRIGGER_WORKER_TOKEN: "test-token",
MANAGED_WORKER_SECRET: "test-secret",
OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
WORKLOAD_TOKEN_SECRET: "integration-test-secret",
WORKLOAD_TOKEN_ENFORCEMENT: "log",
},
}));
const SECRET = "integration-test-secret";
const EXP = Math.floor(Date.UTC(2032, 0, 1) / 1000);
const { WorkloadServer } = await import("./index.js");
const PORT = 18733;
const BASE = `http://127.0.0.1:${PORT}`;
const claims = {
deployment: "deployment_test",
deployment_version: "20260710.1",
environment_id: "env_should_not_forward",
environment_type: "PRODUCTION",
org_id: "org_1",
project_id: "proj_1",
};
const calls: { getSnapshotsSince: any[][] } = { getSnapshotsSince: [] };
const workerClient = {
getSnapshotsSince: vi.fn(async (...args: any[]) => {
calls.getSnapshotsSince.push(args);
return { success: true as const, data: { snapshots: [] } };
}),
} as any;
let server: InstanceType<typeof WorkloadServer>;
beforeAll(async () => {
server = new WorkloadServer({
port: PORT,
workerClient,
snapshotCallbackSecret: "snapshot-callback-secret",
wideEventOpts: { service: "supervisor", env: { nodeId: "test" }, enabled: false },
wideEventsNoisyRoutes: false,
});
await server.start();
});
afterAll(async () => {
await server.stop();
});
describe("WorkloadServer auth (log mode)", () => {
it("allows a valid token but forwards no environment_id", async () => {
const token = await mintWorkloadDeploymentToken(claims, SECRET, EXP);
const res = await fetch(`${BASE}/api/v1/workload-actions/runs/run_1/snapshots/since/snap_1`, {
headers: {
[WORKLOAD_HEADERS.RUNNER_ID]: "runner_1",
[WORKLOAD_HEADERS.DEPLOYMENT_ID]: token,
},
});
expect(res.status).toBe(200);
expect(calls.getSnapshotsSince.at(-1)![3]).toBeUndefined();
});
it("does not reject an invalid token in log mode", async () => {
const badToken = await mintWorkloadDeploymentToken(claims, "wrong-secret", EXP);
const res = await fetch(`${BASE}/api/v1/workload-actions/runs/run_1/snapshots/since/snap_1`, {
headers: {
[WORKLOAD_HEADERS.RUNNER_ID]: "runner_1",
[WORKLOAD_HEADERS.DEPLOYMENT_ID]: badToken,
},
});
expect(res.status).toBe(200);
expect(calls.getSnapshotsSince.at(-1)![3]).toBeUndefined();
});
});
+85
View File
@@ -0,0 +1,85 @@
import {
classifyDeploymentIdHeader,
mintWorkloadDeploymentToken,
type WorkloadDeploymentTokenClaims,
type WorkloadDeploymentTokenInput,
} from "@trigger.dev/core/v3";
import { Counter } from "prom-client";
import { env } from "./env.js";
import { register } from "./metrics.js";
const secret = env.WORKLOAD_TOKEN_SECRET;
// Absolute expiry (epoch seconds) shared by every mint, so tokens stay byte-deterministic per
// deployment regardless of when/where a pod is created.
const tokenExpSeconds = Math.floor(new Date(env.WORKLOAD_TOKEN_EXP).getTime() / 1000);
/** Mint + verify run in "log" (dry-run) and "enforce"; the env superRefine guarantees a secret then. */
export const workloadTokensEnabled = env.WORKLOAD_TOKEN_ENFORCEMENT !== "disabled";
/** Only "enforce" rejects a present-but-invalid token; "log" observes and always allows. */
export const workloadTokenEnforced = env.WORKLOAD_TOKEN_ENFORCEMENT === "enforce";
const mintCounter = new Counter({
name: "workload_token_minted_total",
help: "Deployment tokens minted and injected into TRIGGER_DEPLOYMENT_ID at pod creation",
labelNames: ["env_type"] as const,
registers: [register],
});
export type WorkloadAuthTransport = "http" | "ws";
export type WorkloadAuthOutcome = "jwt_valid" | "jwt_invalid" | "legacy_bare" | "token_absent";
const verifyCounter = new Counter({
name: "workload_auth_verify_total",
help: "Runner-boundary token verification outcomes at the supervisor workload server",
labelNames: ["outcome", "transport", "env_type"] as const,
registers: [register],
});
export async function mintDeploymentToken(
claims: WorkloadDeploymentTokenInput
): Promise<string | undefined> {
if (!workloadTokensEnabled || !secret) {
return undefined;
}
const token = await mintWorkloadDeploymentToken(claims, secret, tokenExpSeconds);
mintCounter.inc({ env_type: claims.environment_type });
return token;
}
export type VerifiedDeploymentHeader =
| { outcome: "jwt_valid"; claims: WorkloadDeploymentTokenClaims }
| { outcome: "jwt_invalid" | "legacy_bare" | "token_absent"; claims?: undefined };
/**
* Verify the deployment-id header value and record the outcome. "jwt_valid" returns the claims so the
* caller can forward the verified environment_id upstream; other outcomes carry no trusted data.
*/
export async function verifyDeploymentIdHeader(
value: string | undefined,
transport: WorkloadAuthTransport
): Promise<VerifiedDeploymentHeader> {
const result = await classify(value);
verifyCounter.inc({
outcome: result.outcome,
transport,
env_type: result.outcome === "jwt_valid" ? result.claims.environment_type : "unknown",
});
return result;
}
async function classify(value: string | undefined): Promise<VerifiedDeploymentHeader> {
if (!value || !secret) {
return { outcome: "token_absent" };
}
const result = await classifyDeploymentIdHeader(value, secret);
if (result.outcome === "jwt_valid" && result.claims) {
return { outcome: "jwt_valid", claims: result.claims };
}
return { outcome: result.outcome === "jwt_valid" ? "jwt_invalid" : result.outcome };
}
+1
View File
@@ -300,6 +300,7 @@ singleton("SentryTenantContextProcessor", () => {
export { apiRateLimiter } from "./services/apiRateLimit.server";
export { engineRateLimiter } from "./services/engineRateLimit.server";
export { otlpRateLimiter } from "./services/otlpRateLimit.server";
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
export { tenantContextMiddleware } from "./services/tenantContextResolver.server";
export { socketIo } from "./v3/handleSocketIo.server";
+76 -4
View File
@@ -85,6 +85,29 @@ const S2EnvSchema = z.preprocess(
])
);
// Previously published secret values must never be accepted, including when
// an existing deployment or external secret manager still supplies one.
const INSECURE_SECRET_VALUES = [
"managed-secret",
"2818143646516f6fffd707b36f334bbb",
"44da78b7bbb0dfe709cf38931d25dcdd",
"f686147ab967943ebbe9ed3b496e465a",
"447c29678f9eaf289e9c4b70d3dd8a7f",
];
// Escape hatch for deployments that can't rotate a published default yet (e.g.
// ENCRYPTION_KEY protects existing data). Read raw: a refine can't see the
// sibling parsed flag.
const allowInsecureDefaultSecrets = ["true", "1"].includes(
(process.env.ALLOW_INSECURE_DEFAULT_SECRETS ?? "").toLowerCase().trim()
);
const isNotInsecureSecret = (value: string) =>
allowInsecureDefaultSecrets || !INSECURE_SECRET_VALUES.includes(value);
const INSECURE_SECRET_MESSAGE =
"must not be a known-insecure published default; set a strong, unique value. If you cannot rotate it yet (e.g. it protects existing encrypted data or active sessions), set ALLOW_INSECURE_DEFAULT_SECRETS=1 to boot while you migrate.";
const EnvironmentSchema = z
.object({
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -188,14 +211,15 @@ const EnvironmentSchema = z
// Control-plane cache relax knobs. Unset -> defaults (DEFAULT_CP_CACHE_TTL_MS / _MAX_ENTRIES).
CONTROL_PLANE_CACHE_TTL_MS: z.coerce.number().int().optional(),
CONTROL_PLANE_CACHE_MAX_ENTRIES: z.coerce.number().int().optional(),
SESSION_SECRET: z.string(),
MAGIC_LINK_SECRET: z.string(),
SESSION_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
MAGIC_LINK_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
ENCRYPTION_KEY: z
.string()
.refine(
(val) => Buffer.from(val, "utf8").length === 32,
"ENCRYPTION_KEY must be exactly 32 bytes"
),
)
.refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
WHITELISTED_EMAILS: z
.string()
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
@@ -547,6 +571,22 @@ const EnvironmentSchema = z
API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"),
API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60),
// Per-IP rate limit for the unauthenticated OTLP ingestion endpoints
// (/otel/*). Bounds unauthenticated request rates. Opt-in
// (disabled by default): because it keys on the source IP, it is only
// safe to enable when each client presents a distinct IP through a proxy
// that appends the real client IP to X-Forwarded-For. Enabling it where
// many clients share one egress IP (e.g. behind NAT or a shared proxy)
// would collapse that traffic into a single bucket and could throttle
// legitimate telemetry. Set OTLP_RATE_LIMIT_ENABLED=1 to enable, then tune
// OTLP_RATE_LIMIT_MAX / OTLP_RATE_LIMIT_WINDOW for expected volume.
OTLP_RATE_LIMIT_ENABLED: z.string().default("0"),
OTLP_RATE_LIMIT_WINDOW: z
.string()
.regex(/^\d+ ?(?:ms|s|m|h|d)$/)
.default("1m"),
OTLP_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(3000),
DEPOT_TOKEN: z.string().optional(),
DEPOT_ORG_ID: z.string().optional(),
DEPOT_REGION: z.string().default("us-east-1"),
@@ -668,7 +708,18 @@ const EnvironmentSchema = z
EVENTS_LOAD_SHEDDING_THRESHOLD: z.coerce.number().int().default(100000),
EVENTS_LOAD_SHEDDING_ENABLED: z.string().default("1"),
MANAGED_WORKER_SECRET: z.string().default("managed-secret"),
MANAGED_WORKER_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
// Allow booting with a known-insecure published default secret. Temporary
// bridge for deployments that can't rotate yet; rotate as soon as possible.
ALLOW_INSECURE_DEFAULT_SECRETS: BoolEnv.default(false),
// Tenant scoping on worker actions is header-driven (folded into the engine snapshot read) and
// needs no flag. This is only the no-header fallback: when "1", a worker action on a run created
// after WORKLOAD_TOKEN_CUTOFF without a verified env header is rejected; runs on or before the
// cutoff pass (grandfathered). Default off = no run-row read, byte-for-byte today's behavior.
WORKLOAD_CREATED_AT_GATE_ENABLED: z.string().default("0"),
WORKLOAD_TOKEN_CUTOFF: z.string().datetime().optional(),
// Development OTEL environment variables
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
@@ -2090,3 +2141,24 @@ const EnvironmentSchema = z
export type Environment = z.infer<typeof EnvironmentSchema>;
export const env = EnvironmentSchema.parse(process.env);
if (env.ALLOW_INSECURE_DEFAULT_SECRETS) {
const insecure = (
[
["SESSION_SECRET", env.SESSION_SECRET],
["MAGIC_LINK_SECRET", env.MAGIC_LINK_SECRET],
["ENCRYPTION_KEY", env.ENCRYPTION_KEY],
["MANAGED_WORKER_SECRET", env.MANAGED_WORKER_SECRET],
] as const
)
.filter(([, value]) => INSECURE_SECRET_VALUES.includes(value))
.map(([name]) => name);
if (insecure.length > 0) {
console.warn(
`⚠️ ALLOW_INSECURE_DEFAULT_SECRETS is enabled and these secrets still use a known-insecure published default: ${insecure.join(
", "
)}. This is insecure - rotate them as soon as you can.`
);
}
}
+46 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma } from "~/db.server";
import type { Prisma, PrismaClientOrTransaction, TaskSchedule } from "@trigger.dev/database";
export function scheduleUniqWhereClause(
projectId: string,
@@ -35,3 +35,48 @@ export function scheduleWhereClause(
deduplicationKey: scheduleId,
};
}
/**
* Resolve a schedule's visibility for an environment-scoped caller.
*
* - "visible": the schedule exists in the project and has at least one
* instance bound to `environmentId` (or has no instances yet).
* - "hidden": the schedule exists but none of its instances live in the
* caller's environment.
* - "missing": no schedule exists for the (project, scheduleId) pair.
*
* A schedule can be bound to several environments at once, so visibility
* mirrors the "some instance is in this environment" rule the schedule
* list uses: a schedule that is listed for a key must also be readable
* and mutable by that key. This still rejects cross-environment access to
* schedules the caller has no instance in, and `scheduleWhereClause`
* already confines the lookup to the caller's project.
*
* The tri-state lets PUT (upsert) disambiguate "hidden" (refuse) from
* "missing" (fall through to create). DELETE/GET treat hidden and
* missing the same way.
*/
export type ScheduleEnvVisibility =
| { status: "visible"; schedule: TaskSchedule }
| { status: "hidden" }
| { status: "missing" };
export async function getScheduleEnvVisibility(
prisma: PrismaClientOrTransaction,
projectId: string,
scheduleId: string,
environmentId: string
): Promise<ScheduleEnvVisibility> {
const schedule = await prisma.taskSchedule.findFirst({
where: scheduleWhereClause(projectId, scheduleId),
include: { instances: { select: { environmentId: true } } },
});
if (!schedule) return { status: "missing" };
const { instances, ...rest } = schedule;
if (instances.length === 0) return { status: "visible", schedule: rest };
const scoped = instances.some((i) => i.environmentId === environmentId);
if (!scoped) return { status: "hidden" };
return { status: "visible", schedule: rest };
}
@@ -1,6 +1,9 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
import {
destroyGitHubAppInstallSession,
validateGitHubAppInstallSession,
} from "~/services/gitHubSession.server";
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
import { logger } from "~/services/logger.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
@@ -75,6 +78,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
// The install session is single-use: once a callback consumes an
// installation_id, invalidate the state cookie so the same initiation
// cannot be replayed against other installation_ids.
const clearInstallSession = await destroyGitHubAppInstallSession(cookieHeader);
const consumingSession = (response: Response) => {
response.headers.append("Set-Cookie", clearInstallSession);
return response;
};
switch (callbackData.setup_action) {
case "install": {
const [error] = await tryCatch(
@@ -85,23 +97,33 @@ export async function loader({ request }: LoaderFunctionArgs) {
logger.error("Failed to link GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
return consumingSession(
await redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app")
);
}
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
return consumingSession(
await redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully")
);
}
case "update": {
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));
const [error] = await tryCatch(
updateGitHubAppInstallation(callbackData.installation_id, organizationId)
);
if (error) {
logger.error("Failed to update GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
return consumingSession(
await redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App")
);
}
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
return consumingSession(
await redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully")
);
}
case "request": {
@@ -55,6 +55,7 @@ import {
} from "~/utils/pathBuilder";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments";
const Variable = z.object({
key: EnvironmentVariableKey,
@@ -164,6 +165,31 @@ export const action = dashboardAction(
return json(submission.reply({ formErrors: ["Project not found"] }));
}
// The submitted `environmentIds` are user-supplied. Shared env types are
// writable by any member; a DEV env only by its owner. See
// findUnauthorizedEnvironmentId.
const submittedEnvs = await prisma.runtimeEnvironment.findMany({
where: {
projectId: project.id,
id: { in: submission.value.environmentIds },
},
select: { id: true, type: true, orgMember: { select: { userId: true } } },
});
const unauthorizedEnvironmentId = findUnauthorizedEnvironmentId(
submittedEnvs,
submission.value.environmentIds,
userId
);
if (unauthorizedEnvironmentId) {
return json(
submission.reply({
fieldErrors: {
environmentIds: ["One or more of the selected environments is not writable by you."],
},
})
);
}
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.create(project.id, {
...submission.value,
@@ -78,6 +78,7 @@ import {
v3NewEnvironmentVariablesPath,
} from "~/utils/pathBuilder";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments";
import {
DeleteEnvironmentVariableValue,
EditEnvironmentVariableValue,
@@ -267,6 +268,24 @@ export const action = dashboardAction(
return json(submission.reply({ formErrors: ["Project not found"] }));
}
// Per-env write gate for the mutating value actions: `environmentId` is a
// user-supplied hidden field and the repository only checks project
// membership. Mirrors the create route's check.
if (submission.value.action === "edit" || submission.value.action === "delete") {
const submittedEnvs = await prisma.runtimeEnvironment.findMany({
where: { projectId: project.id, id: submission.value.environmentId },
select: { id: true, type: true, orgMember: { select: { userId: true } } },
});
const unauthorizedEnvironmentId = findUnauthorizedEnvironmentId(
submittedEnvs,
[submission.value.environmentId],
userId
);
if (unauthorizedEnvironmentId) {
return json(submission.reply({ formErrors: ["This environment is not writable by you."] }));
}
}
switch (submission.value.action) {
case "edit": {
const repository = new EnvironmentVariablesRepository(prisma);
@@ -6,7 +6,6 @@ import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { LinkButton } from "~/components/primitives/Buttons";
import { ScheduleInspector } from "~/components/schedules/ScheduleInspector";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -78,11 +77,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
// `_format=json` → return JSON instead of redirecting; caller stays put.
const wantsJson = formData.get("_format") === "json";
const project = await prisma.project.findFirst({
where: {
slug: projectParam,
},
});
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
const message = `No project found with slug ${projectParam}`;
@@ -1,14 +1,19 @@
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Form } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header1 } from "~/components/primitives/Headers";
import { Icon } from "~/components/primitives/Icon";
import { Paragraph } from "~/components/primitives/Paragraph";
import { logger } from "~/services/logger.server";
import { createPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
import {
createPersonalAccessTokenFromAuthorizationCode,
isAuthorizationCodeMintable,
} from "~/services/personalAccessToken.server";
import { requireUserId } from "~/services/session.server";
const ParamsSchema = z.object({
@@ -20,49 +25,57 @@ const SearchParamsSchema = z.object({
clientName: z.string().optional(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
function parseParams(params: unknown) {
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
logger.info("Invalid params", { params });
throw new Response(undefined, {
status: 400,
statusText: "Invalid params",
});
throw new Response(undefined, { status: 400, statusText: "Invalid params" });
}
return parsedParams.data;
}
function parseSearch(request: Request) {
const url = new URL(request.url);
const searchObject = Object.fromEntries(url.searchParams.entries());
const searchParams = SearchParamsSchema.safeParse(searchObject);
const source = (searchParams.success ? searchParams.data.source : undefined) ?? "cli";
const clientName = (searchParams.success ? searchParams.data.clientName : undefined) ?? "unknown";
return { source, clientName };
}
// The loader only renders a consent screen; minting/binding a PAT happens in
// the `action`, behind an explicit "Authorize" POST.
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireUserId(request);
const { authorizationCode } = parseParams(params);
const { source, clientName } = parseSearch(request);
const mintable = await isAuthorizationCodeMintable(authorizationCode);
return typedjson({
status: mintable ? ("consent" as const) : ("invalid" as const),
source,
clientName,
});
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { authorizationCode } = parseParams(params);
const { source, clientName } = parseSearch(request);
try {
const _personalAccessToken = await createPersonalAccessTokenFromAuthorizationCode(
parsedParams.data.authorizationCode,
userId
);
return typedjson({
success: true as const,
source,
clientName,
});
await createPersonalAccessTokenFromAuthorizationCode(authorizationCode, userId);
return typedjson({ success: true as const, source, clientName });
} catch (error) {
if (error instanceof Response) {
throw error;
}
if (error instanceof Error) {
return typedjson({
success: false as const,
error: error.message,
source,
clientName,
});
return typedjson({ success: false as const, error: error.message, source, clientName });
}
logger.error(JSON.stringify(error));
@@ -74,32 +87,78 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function Page() {
const result = useTypedLoaderData<typeof loader>();
const loaderData = useTypedLoaderData<typeof loader>();
const actionData = useTypedActionData<typeof action>();
// After the consent POST: success or failure.
if (actionData) {
return (
<AuthShell>
{actionData.success ? (
<div>
<Header1 className="mb-2 flex items-center gap-1">
<Icon icon={CheckCircleIcon} className="h-6 w-6 text-emerald-500" /> Successfully
authenticated
</Header1>
<Paragraph>
{getInstructionsForSource(actionData.source, actionData.clientName)}
</Paragraph>
</div>
) : (
<div>
<Header1 className="mb-2">Authentication failed</Header1>
<Callout variant="error" className="my-2">
{actionData.error}
</Callout>
<Paragraph spacing>
There was a problem authenticating you, please try logging in with your CLI again.
</Paragraph>
</div>
)}
</AuthShell>
);
}
// Initial GET: invalid/expired code, or the consent prompt.
if (loaderData.status === "invalid") {
return (
<AuthShell>
<div>
<Header1 className="mb-2">Authentication failed</Header1>
<Callout variant="error" className="my-2">
This login link is invalid or has expired.
</Callout>
<Paragraph spacing>
Please try logging in with your CLI again to get a fresh link.
</Paragraph>
</div>
</AuthShell>
);
}
return (
<AuthShell>
<div className="flex flex-col gap-4">
<Header1>Authorize login</Header1>
<Paragraph>{getConsentPrompt(loaderData.source, loaderData.clientName)}</Paragraph>
<Form method="post">
<Button type="submit" variant="primary/medium" fullWidth>
Authorize
</Button>
</Form>
<Paragraph variant="extra-small">
Only authorize if you started this login yourself. If you didn't, close this page.
</Paragraph>
</div>
</AuthShell>
);
}
function AuthShell({ children }: { children: React.ReactNode }) {
return (
<AppContainer>
<MainCenteredContainer className="max-w-88">
<div className="flex flex-col items-center space-y-4">
{result.success ? (
<div>
<Header1 className="mb-2 flex items-center gap-1">
<Icon icon={CheckCircleIcon} className="h-6 w-6 text-emerald-500" /> Successfully
authenticated
</Header1>
<Paragraph>{getInstructionsForSource(result.source, result.clientName)}</Paragraph>
</div>
) : (
<div>
<Header1 className="mb-2">Authentication failed</Header1>
<Callout variant="error" className="my-2">
{result.error}
</Callout>
<Paragraph spacing>
There was a problem authenticating you, please try logging in with your CLI again.
</Paragraph>
</div>
)}
</div>
<div className="flex flex-col items-center space-y-4">{children}</div>
</MainCenteredContainer>
</AppContainer>
);
@@ -113,6 +172,18 @@ const prettyClientNames: Record<string, string> = {
"claude-ai": "Claude Desktop",
};
function getConsentPrompt(source: string, clientName: string) {
if (source === "mcp") {
const pretty = prettyClientNames[clientName] ?? clientName;
if (pretty && pretty !== "unknown") {
return `Authorize ${pretty} to access your Trigger.dev account?`;
}
return `Authorize this MCP client to access your Trigger.dev account?`;
}
return `Authorize the Trigger.dev CLI to access your account?`;
}
function getInstructionsForSource(source: string, clientName: string) {
if (source === "mcp") {
if (clientName) {
@@ -3,7 +3,12 @@ import { json } from "@remix-run/server-runtime";
import type { CreateAuthorizationCodeResponse } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import {
AuthorizationCodeRateLimitError,
checkAuthorizationCodeMintRateLimit,
} from "~/services/authCodeRateLimiter.server";
import { createAuthorizationCode } from "~/services/personalAccessToken.server";
import { extractClientIp } from "~/utils/extractClientIp.server";
/** Used to create an AuthorizationCode, that can then be used to obtain a Personal Access Token by logging in with the provided URL */
export async function action({ request }: ActionFunctionArgs) {
@@ -14,8 +19,24 @@ export async function action({ request }: ActionFunctionArgs) {
return { status: 405, body: "Method Not Allowed" };
}
//there is no authentication on this endpoint, anyone can create an AuthorizationCode.
//they're only used to allow a user to login, when they'll then receive a Personal Access Token
//this endpoint is unauthenticated (codes only allow a user to log in), so it's
//rate-limited per client IP. Keyed by X-Forwarded-For; if there's no trustworthy
//client IP we skip the limit rather than bucket everyone together. Self-hosters
//wanting per-IP limiting should front the app with a proxy that sets X-Forwarded-For.
const clientIp = extractClientIp(request.headers.get("x-forwarded-for"));
if (clientIp) {
try {
await checkAuthorizationCodeMintRateLimit(clientIp);
} catch (error) {
if (error instanceof AuthorizationCodeRateLimitError) {
return json(
{ error: "Too many requests, please try again later." },
{ status: 429, headers: { "Retry-After": Math.ceil(error.retryAfter / 1000).toString() } }
);
}
throw error;
}
}
try {
const authorizationCode = await createAuthorizationCode();
@@ -45,7 +45,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
const deploymentService = new DeploymentService();
return await deploymentService
.cancelDeployment(authenticatedEnv, deploymentId, {
.cancelDeployment({ id: authenticatedEnv.id }, deploymentId, {
canceledReason: body.data.reason,
})
.match(
@@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server";
import { getScheduleEnvVisibility, scheduleUniqWhereClause } from "~/models/schedules.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -34,14 +34,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
try {
const existingSchedule = await prisma.taskSchedule.findFirst({
where: scheduleWhereClause(
authenticationResult.environment.projectId,
parsedParams.data.scheduleId
),
});
if (!existingSchedule) {
// Env-scoped API keys can only toggle schedules that have an instance in
// their own environment. Without this a key scoped to one environment
// could enable/disable a schedule that only runs in another environment
// of the same project.
const visibility = await getScheduleEnvVisibility(
prisma,
authenticationResult.environment.projectId,
parsedParams.data.scheduleId,
authenticationResult.environment.id
);
if (visibility.status !== "visible") {
return json({ error: "Schedule not found" }, { status: 404 });
}
@@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server";
import { getScheduleEnvVisibility, scheduleUniqWhereClause } from "~/models/schedules.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -34,14 +34,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
try {
const existingSchedule = await prisma.taskSchedule.findFirst({
where: scheduleWhereClause(
authenticationResult.environment.projectId,
parsedParams.data.scheduleId
),
});
if (!existingSchedule) {
// Env-scoped API keys can only toggle schedules that have an instance in
// their own environment. Without this a key scoped to one environment
// could enable/disable a schedule that only runs in another environment
// of the same project.
const visibility = await getScheduleEnvVisibility(
prisma,
authenticationResult.environment.projectId,
parsedParams.data.scheduleId,
authenticationResult.environment.id
);
if (visibility.status !== "visible") {
return json({ error: "Schedule not found" }, { status: 404 });
}
@@ -5,7 +5,7 @@ import { UpdateScheduleOptions } from "@trigger.dev/core/v3";
import { z } from "zod";
import { Prisma, prisma } from "~/db.server";
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
import { scheduleUniqWhereClause } from "~/models/schedules.server";
import { getScheduleEnvVisibility, scheduleUniqWhereClause } from "~/models/schedules.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -38,6 +38,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
switch (method) {
case "DELETE": {
const visibility = await getScheduleEnvVisibility(
prisma,
authenticationResult.environment.projectId,
parsedParams.data.scheduleId,
authenticationResult.environment.id
);
if (visibility.status !== "visible") {
return json({ error: "Schedule not found" }, { status: 404 });
}
try {
const deletedSchedule = await prisma.taskSchedule.delete({
where: scheduleUniqWhereClause(
@@ -76,6 +86,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
// Env-scoped API keys can't see or mutate a schedule whose
// instances live in a different environment. "hidden" → refuse;
// "missing" → fall through to the upsert's create path.
const visibility = await getScheduleEnvVisibility(
prisma,
authenticationResult.environment.projectId,
parsedParams.data.scheduleId,
authenticationResult.environment.id
);
if (visibility.status === "hidden") {
return json({ error: "Schedule not found" }, { status: 404 });
}
const service = new UpsertTaskScheduleService();
try {
@@ -137,6 +160,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
);
}
const visibility = await getScheduleEnvVisibility(
prisma,
authenticationResult.environment.projectId,
parsedParams.data.scheduleId,
authenticationResult.environment.id
);
if (visibility.status !== "visible") {
return json({ error: "Schedule not found" }, { status: 404 });
}
const presenter = new ViewSchedulePresenter();
const result = await presenter.call({
+18
View File
@@ -4,6 +4,10 @@ import type { GetPersonalAccessTokenResponse } from "@trigger.dev/core/v3";
import { GetPersonalAccessTokenRequestSchema } from "@trigger.dev/core/v3";
import { generateErrorMessage } from "zod-error";
import { logger } from "~/services/logger.server";
import {
AuthorizationCodeRateLimitError,
checkAuthorizationCodeTokenPollRateLimit,
} from "~/services/authCodeRateLimiter.server";
import { getPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
@@ -25,6 +29,20 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ error: generateErrorMessage(body.error.issues) }, { status: 422 });
}
// Per-code rate limit (keyed by the code, not the IP, so the CLI's poll loop
// isn't broken behind a shared NAT).
try {
await checkAuthorizationCodeTokenPollRateLimit(body.data.authorizationCode);
} catch (error) {
if (error instanceof AuthorizationCodeRateLimitError) {
return json(
{ error: "Too many requests, please try again later." },
{ status: 429, headers: { "Retry-After": Math.ceil(error.retryAfter / 1000).toString() } }
);
}
throw error;
}
try {
const personalAccessToken = await getPersonalAccessTokenFromAuthorizationCode(
body.data.authorizationCode
@@ -18,6 +18,7 @@ export const action = createActionWorkerApiRoute(
body,
params,
runnerId,
environmentId,
}): Promise<TypedResponse<WorkerApiRunAttemptCompleteResponseBody>> => {
const { completion } = body;
const { runFriendlyId, snapshotFriendlyId } = params;
@@ -27,6 +28,7 @@ export const action = createActionWorkerApiRoute(
snapshotFriendlyId,
completion,
runnerId,
environmentId,
});
return json({ result: completeResult });
@@ -18,6 +18,7 @@ export const action = createActionWorkerApiRoute(
body,
params,
runnerId,
environmentId,
}): Promise<TypedResponse<WorkerApiRunAttemptStartResponseBody>> => {
const { runFriendlyId, snapshotFriendlyId } = params;
@@ -26,6 +27,7 @@ export const action = createActionWorkerApiRoute(
snapshotFriendlyId,
isWarmStart: body.isWarmStart,
runnerId,
environmentId,
});
return json(runExecutionData);
@@ -17,6 +17,7 @@ export const loader = createLoaderWorkerApiRoute(
authenticatedWorker,
params,
runnerId,
environmentId,
}): Promise<TypedResponse<WorkerApiContinueRunExecutionRequestBody>> => {
const { runFriendlyId, snapshotFriendlyId } = params;
@@ -27,10 +28,17 @@ export const loader = createLoaderWorkerApiRoute(
runFriendlyId,
snapshotFriendlyId,
runnerId,
environmentId,
});
return json(continuationResult);
} catch (error) {
// An authorization rejection is thrown as a Response; propagate it as-is rather than
// masking it as a generic 422.
if (error instanceof Response) {
throw error;
}
logger.warn("Failed to continue run execution", {
runFriendlyId,
snapshotFriendlyId,
@@ -13,11 +13,13 @@ export const loader = createLoaderWorkerApiRoute(
async ({
authenticatedWorker,
params,
environmentId,
}): Promise<TypedResponse<WorkerApiRunLatestSnapshotResponseBody>> => {
const { runFriendlyId } = params;
const executionData = await authenticatedWorker.getLatestSnapshot({
runFriendlyId,
environmentId,
});
if (!executionData) {
@@ -14,12 +14,14 @@ export const loader = createLoaderWorkerApiRoute(
async ({
authenticatedWorker,
params,
environmentId,
}): Promise<TypedResponse<WorkerApiRunSnapshotsSinceResponseBody>> => {
const { runFriendlyId, snapshotId } = params;
const snapshots = await authenticatedWorker.getSnapshotsSince({
runFriendlyId,
snapshotId,
environmentId,
});
if (!snapshots) {
+8 -1
View File
@@ -1,6 +1,8 @@
import { type ActionFunction, type LoaderFunction } from "@remix-run/node";
import { redirect, type ActionFunction, type LoaderFunction } from "@remix-run/node";
import { env } from "~/env.server";
import { authenticator } from "~/services/auth.server";
import { sanitizeRedirectPath } from "~/utils";
import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";
import { SSO_SESSION_EXPIRED_REASON } from "~/utils/ssoSession";
function logoutRedirectTo(request: Request): string {
@@ -18,5 +20,10 @@ export const action: ActionFunction = async ({ request }) => {
};
export const loader: LoaderFunction = async ({ request }) => {
// GET /logout is state-changing, so reject cross-site navigations.
if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
throw redirect("/");
}
return await authenticator.logout(request, { redirectTo: logoutRedirectTo(request) });
};
@@ -2,6 +2,7 @@ import { z } from "zod";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { resolveRealtimeStreamClient } from "~/services/realtime/resolveRealtimeStreamClient.server";
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { UNSAFE_REALTIME_TAG_CHARS } from "~/v3/electricShape.server";
const SearchParamsSchema = z.object({
tags: z
@@ -9,6 +10,20 @@ const SearchParamsSchema = z.object({
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
})
.superRefine((tags, ctx) => {
if (!tags) return;
for (const tag of tags) {
// Mirror the runtime sanitiser's reject list so the API returns 400
// instead of a 500. Single quotes are allowed — escaped downstream.
if (UNSAFE_REALTIME_TAG_CHARS.test(tag) || tag.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid tag: ${JSON.stringify(tag)}`,
});
return;
}
}
}),
createdAt: z.string().optional(),
});
@@ -34,6 +34,16 @@ const { action } = createActionApiRoute(
},
},
async ({ params, authentication }) => {
// `.out` is the agent→client channel. Only PRIVATE (secret key) auth —
// i.e. the agent run itself — may initialize it. Session-scoped JWTs carry
// `write:sessions:<key>` for `.in`; without this gate they could obtain
// credentials to forge assistant chunks on their own session's `.out`.
if (params.io === "out" && authentication.type !== "PRIVATE") {
return new Response("Initializing the out channel requires secret key authentication", {
status: 403,
});
}
// Row-optional addressing. The agent calls PUT initialize as part
// of `session.out.writer()`, by which time it has already created
// the row at bind, so a missing row here is an unusual case
@@ -75,7 +75,7 @@ export const action = dashboardAction(
prisma.workerDeployment.findUnique({
select: {
friendlyId: true,
projectId: true,
environmentId: true,
},
where: {
projectId_shortCode: {
@@ -96,10 +96,7 @@ export const action = dashboardAction(
const result = await verifyProjectMembership()
.andThen(findDeploymentFriendlyId)
.andThen((deployment) =>
deploymentService.cancelDeployment(
{ projectId: deployment.projectId },
deployment.friendlyId
)
deploymentService.cancelDeployment({ id: deployment.environmentId }, deployment.friendlyId)
);
if (result.isErr()) {
+29 -3
View File
@@ -1,15 +1,33 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { getUserId } from "~/services/session.server";
import { longPollingFetch } from "~/utils/longPollingFetch";
import {
OtelTraceIdSchema,
RESERVED_ELECTRIC_SHAPE_PARAMS,
buildElectricTraceWhereClause,
} from "~/v3/electricShape.server";
const Params = z.object({
traceId: OtelTraceIdSchema,
});
export async function loader({ params, request }: LoaderFunctionArgs) {
try {
const userId = await getUserId(request);
logger.log(`/sync/traces/${params.traceId}`, { userId });
const parsedParams = Params.safeParse(params);
if (!parsedParams.success) {
// Treat a malformed traceId as not-found rather than 400 to avoid
// signalling the validator.
return new Response("Not found", { status: 404 });
}
const { traceId } = parsedParams.data;
logger.log(`/sync/traces/${traceId}`, { userId });
if (!userId) {
return new Response("No user found in cookie", { status: 401 });
@@ -20,7 +38,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
organizationId: true,
},
where: {
traceId: params.traceId,
traceId,
},
});
@@ -41,11 +59,19 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const originUrl = new URL(`${env.ELECTRIC_ORIGIN}/v1/shape/public."TaskEvent"`);
// Strip params we set ourselves so the caller can't override them.
url.searchParams.forEach((value, key) => {
if (RESERVED_ELECTRIC_SHAPE_PARAMS.has(key)) return;
originUrl.searchParams.set(key, value);
});
originUrl.searchParams.set("where", `"traceId"='${params.traceId}'`);
originUrl.searchParams.set(
"where",
buildElectricTraceWhereClause({
traceId,
scope: { column: "organizationId", id: trace.organizationId },
})
);
const finalUrl = originUrl.toString();
@@ -5,17 +5,27 @@ import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { getUserId } from "~/services/session.server";
import { longPollingFetch } from "~/utils/longPollingFetch";
import { runStore } from "~/v3/runStore.server";
import {
OtelTraceIdSchema,
RESERVED_ELECTRIC_SHAPE_PARAMS,
buildElectricTraceWhereClause,
} from "~/v3/electricShape.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { runStore } from "~/v3/runStore.server";
const Params = z.object({
traceId: z.string(),
traceId: OtelTraceIdSchema,
});
export async function loader({ params, request }: LoaderFunctionArgs) {
try {
const userId = await getUserId(request);
const { traceId } = Params.parse(params);
const parsedParams = Params.safeParse(params);
if (!parsedParams.success) {
return new Response("Not found", { status: 404 });
}
const { traceId } = parsedParams.data;
logger.log(`/sync/runs/${traceId}`, { userId });
@@ -29,6 +39,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
},
{
select: {
projectId: true,
runtimeEnvironmentId: true,
},
},
@@ -40,7 +51,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
// primary before 404ing so a live run's realtime trace feed isn't spuriously not-found.
run = await runStore.findRunOnPrimary(
{ traceId },
{ select: { runtimeEnvironmentId: true } }
{ select: { projectId: true, runtimeEnvironmentId: true } }
);
}
@@ -67,11 +78,22 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const originUrl = new URL(`${env.ELECTRIC_ORIGIN}/v1/shape/public."TaskRun"`);
// Strip params we set ourselves so the caller can't override them.
url.searchParams.forEach((value, key) => {
if (RESERVED_ELECTRIC_SHAPE_PARAMS.has(key)) return;
originUrl.searchParams.set(key, value);
});
originUrl.searchParams.set("where", `"traceId"='${traceId}'`);
originUrl.searchParams.set(
"where",
// Scope by non-null projectId, not the nullable organizationId (legacy
// rows would vanish). Tenant-safe: membership was verified against this
// project's org and a trace's runs all live in one project.
buildElectricTraceWhereClause({
traceId,
scope: { column: "projectId", id: run.projectId },
})
);
const finalUrl = originUrl.toString();
@@ -48,6 +48,9 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
// Allow /api/v1/tasks/:id/callback/:secret
pathWhiteList: [
"/api/internal/stripe_webhooks",
// Keep allowlisted: these CLI endpoints are intentionally unauthenticated,
// so this Authorization-header-keyed limiter would 401 them. They are
// throttled separately by authCodeRateLimiter.server.ts.
"/api/v1/authorization-code",
"/api/v1/token",
"/api/v1/usage/ingest",
@@ -0,0 +1,85 @@
import { Ratelimit } from "@upstash/ratelimit";
import { createHash } from "node:crypto";
import { env } from "~/env.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
import { singleton } from "~/utils/singleton";
/**
* Rate limiting for the unauthenticated CLI auth-code endpoints
* (`/api/v1/authorization-code` mint + `/api/v1/token` poll). The global
* limiter keys on the Authorization header, which these endpoints don't carry,
* so it can't throttle them this module does.
*/
export class AuthorizationCodeRateLimitError extends Error {
public readonly retryAfter: number;
constructor(retryAfter: number) {
super("Authorization code rate limit exceeded.");
this.name = "AuthorizationCodeRateLimitError";
this.retryAfter = retryAfter;
}
}
function getRedisClient() {
return createRedisRateLimitClient({
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
});
}
// Minting is unauthenticated and a real login mints one code. Cap per IP, with
// headroom for many users behind a shared NAT.
const authorizationCodeMintIpRateLimiter = singleton(
"authorizationCodeMintIpRateLimiter",
() =>
new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:authcode:mint:ip",
limiter: Ratelimit.slidingWindow(30, "1 m"), // 30 code mints / min / IP
logSuccess: false,
logFailure: true,
})
);
// Keyed by the code, not the IP: the CLI polls this endpoint ~1/s per login, so
// IP-keying would break logins behind a shared NAT. The ~60/min cadence stays
// under the cap. The code is hashed first so it never lands in a Redis key or log.
const authorizationCodeTokenPollRateLimiter = singleton(
"authorizationCodeTokenPollRateLimiter",
() =>
new RateLimiter({
redisClient: getRedisClient(),
keyPrefix: "auth:authcode:token:code",
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 polls / min / code (CLI polls ~60/min)
logSuccess: false,
logFailure: false,
})
);
function hashCode(code: string): string {
return createHash("sha256").update(code).digest("hex").slice(0, 32);
}
export async function checkAuthorizationCodeMintRateLimit(ip: string): Promise<void> {
const result = await authorizationCodeMintIpRateLimiter.limit(ip);
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new AuthorizationCodeRateLimitError(retryAfter);
}
}
export async function checkAuthorizationCodeTokenPollRateLimit(
authorizationCode: string
): Promise<void> {
const result = await authorizationCodeTokenPollRateLimiter.limit(hashCode(authorizationCode));
if (!result.success) {
const retryAfter = new Date(result.reset).getTime() - Date.now();
throw new AuthorizationCodeRateLimitError(retryAfter);
}
}
+16 -10
View File
@@ -58,26 +58,32 @@ export async function linkGitHubAppInstallation(
}
/**
* Links a GitHub App installation to a Trigger organization
* Updates a GitHub App installation owned by the given Trigger organization
*/
export async function updateGitHubAppInstallation(installationId: number): Promise<void> {
export async function updateGitHubAppInstallation(
installationId: number,
organizationId: string
): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}
// Scope the lookup to the caller's organization so a cross-tenant
// installation_id cannot update another org's record. Resolve ownership
// before calling GitHub to avoid burning the victim's API rate limit.
const existingInstallation = await prisma.githubAppInstallation.findFirst({
where: { appInstallationId: installationId, organizationId },
});
if (!existingInstallation) {
throw new Error("GitHub App installation not found");
}
const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});
const existingInstallation = await prisma.githubAppInstallation.findFirst({
where: { appInstallationId: installationId },
});
if (!existingInstallation) {
throw new Error("GitHub App installation not found");
}
const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";
// repos are updated asynchronously via webhook events
@@ -0,0 +1,91 @@
import { Ratelimit } from "@upstash/ratelimit";
import type { NextFunction, Request, Response } from "express";
import { env } from "~/env.server";
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
import { extractClientIp } from "~/utils/extractClientIp.server";
import { singleton } from "~/utils/singleton";
import { logger } from "./logger.server";
const OTLP_PATH = /^\/otel\//i;
function getOtlpIpRateLimiter() {
return singleton(
"otlpIpRateLimiter",
() =>
new RateLimiter({
redisClient: createRedisRateLimitClient({
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
}),
keyPrefix: "otlp:ip",
limiter: Ratelimit.slidingWindow(
env.OTLP_RATE_LIMIT_MAX,
env.OTLP_RATE_LIMIT_WINDOW as Parameters<typeof Ratelimit.slidingWindow>[1]
),
logSuccess: false,
logFailure: true,
})
);
}
/**
* Per-IP rate limiter for the OTLP ingestion endpoints (`/otel/*`).
*
* These endpoints are currently unauthenticated (see SEC-98), so the source IP
* is the only identity available to key on. This bounds unauthenticated
* request rates and is NOT a substitute for authenticating the
* endpoints. It fails open (allows the request) whenever the source cannot be
* identified or the limiter backend errors, so a limiter outage never drops
* legitimate telemetry.
*
* Opt-in (disabled unless `OTLP_RATE_LIMIT_ENABLED=1`). Because it keys on the
* source IP, two preconditions must hold before enabling it, or it can drop
* legitimate telemetry:
*
* 1. Each client must present a distinct IP. Where many clients share one
* egress IP (e.g. behind NAT or a shared proxy) their traffic collapses
* into a single bucket and can be throttled together. Size
* `OTLP_RATE_LIMIT_MAX` for the aggregate volume of a shared source, not a
* single client.
* 2. The IP must be trustworthy. `extractClientIp` takes the last
* `X-Forwarded-For` hop, which is only spoof-resistant behind a proxy that
* appends the real client IP. Without such a proxy the value is
* client-controlled and the per-IP bound is bypassable.
*/
export async function otlpRateLimiter(req: Request, res: Response, next: NextFunction) {
if (env.OTLP_RATE_LIMIT_ENABLED !== "1") {
return next();
}
if (req.method.toUpperCase() === "OPTIONS" || !OTLP_PATH.test(req.path)) {
return next();
}
const xff = req.headers["x-forwarded-for"];
const ip = extractClientIp(Array.isArray(xff) ? xff.join(",") : (xff ?? null)) ?? req.ip;
if (!ip) {
// Fail open: without a source we cannot fairly rate limit.
return next();
}
try {
const { success, reset } = await getOtlpIpRateLimiter().limit(ip);
if (!success) {
const retryAfterSeconds = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
res.setHeader("Retry-After", retryAfterSeconds.toString());
res.status(429).send("Too Many Requests");
return;
}
} catch (error) {
// Fail open: a rate-limiter backend outage must not drop telemetry.
logger.warn("otlpRateLimiter: limiter error, allowing request", { error });
}
return next();
}
@@ -18,6 +18,10 @@ const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", toke
// staleness is fine.
export const PAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000;
// How long an unconsumed CLI authorization code stays valid. Shared constant so
// the mint and read paths can't drift.
export const AUTHORIZATION_CODE_TTL_MS = 10 * 60 * 1000;
type CreatePersonalAccessTokenOptions = {
name: string;
userId: string;
@@ -60,16 +64,16 @@ export type ObfuscatedPersonalAccessToken = Awaited<
/** Gets a PersonalAccessToken from an Auth Code, this only works within 10 mins of the auth code being created */
export async function getPersonalAccessTokenFromAuthorizationCode(authorizationCode: string) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
// Only allow authorization codes created within the short consent window.
const validAfter = new Date(Date.now() - AUTHORIZATION_CODE_TTL_MS);
const code = await prisma.authorizationCode.findFirst({
select: {
personalAccessToken: true,
},
where: {
code: authorizationCode,
createdAt: {
gte: tenMinutesAgo,
gte: validAfter,
},
},
});
@@ -280,6 +284,27 @@ export function isPersonalAccessToken(token: string) {
return token.startsWith(tokenPrefix);
}
/**
* Read-only check that an authorization code is still mintable: it exists, is
* unconsumed (`personalAccessTokenId: null`), and within the TTL. Lets the
* consent-screen loader show Authorize vs expired/invalid without minting a PAT.
*/
export async function isAuthorizationCodeMintable(
authorizationCode: string,
prismaClient = prisma
): Promise<boolean> {
const validAfter = new Date(Date.now() - AUTHORIZATION_CODE_TTL_MS);
const code = await prismaClient.authorizationCode.findFirst({
where: {
code: authorizationCode,
personalAccessTokenId: null,
createdAt: { gte: validAfter },
},
select: { id: true },
});
return code !== null;
}
export function createAuthorizationCode() {
return prisma.authorizationCode.create({
data: {
@@ -293,14 +318,14 @@ export async function createPersonalAccessTokenFromAuthorizationCode(
authorizationCode: string,
userId: string
) {
//only allow authorization codes that were created less than 10 mins ago
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const code = await prisma.authorizationCode.findUnique({
// Only allow authorization codes created within the short consent window.
const validAfter = new Date(Date.now() - AUTHORIZATION_CODE_TTL_MS);
const code = await prisma.authorizationCode.findFirst({
where: {
code: authorizationCode,
personalAccessTokenId: null,
createdAt: {
gte: tenMinutesAgo,
gte: validAfter,
},
},
});
@@ -15,6 +15,7 @@ import { RedisCacheStore } from "./unkey/redisCacheStore.server";
import { env } from "~/env.server";
import type { API_VERSIONS } from "~/api/versions";
import { CURRENT_API_VERSION } from "~/api/versions";
import { sanitizeRealtimeTagsForSql } from "~/v3/electricShape.server";
export interface CachedLimitProvider {
getCachedLimit: (organizationId: string, defaultValue: number) => Promise<number | undefined>;
@@ -171,7 +172,10 @@ export class RealtimeClient {
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
if (params.tags) {
whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`);
// Reject unsafe chars and escape single quotes so tag values can't
// break out of the Electric SQL string literal.
const safeTags = sanitizeRealtimeTagsForSql(params.tags);
whereClauses.push(`"runTags" @> ARRAY[${safeTags.map((t) => `'${t}'`).join(",")}]`);
}
const createdAtFilter = await this.#calculateCreatedAtFilter(url, params.createdAt);
@@ -1537,6 +1537,7 @@ type WorkerLoaderHandlerFunction<
? z.infer<THeadersSchema>
: undefined;
runnerId?: string;
environmentId?: string;
}) => Promise<Response>;
export function createLoaderWorkerApiRoute<
@@ -1601,6 +1602,8 @@ export function createLoaderWorkerApiRoute<
}
const runnerId = request.headers.get(WORKER_HEADERS.RUNNER_ID) ?? undefined;
// `|| undefined` so a blank header can't become a zero-match snapshot filter (→ false reject).
const environmentId = request.headers.get(WORKER_HEADERS.ENVIRONMENT_ID) || undefined;
const result = await handler({
params: parsedParams,
@@ -1609,6 +1612,7 @@ export function createLoaderWorkerApiRoute<
request,
headers: parsedHeaders,
runnerId,
environmentId,
});
return result;
} catch (error) {
@@ -1660,6 +1664,7 @@ type WorkerActionHandlerFunction<
? z.infer<TBodySchema>
: undefined;
runnerId?: string;
environmentId?: string;
}) => Promise<Response>;
export function createActionWorkerApiRoute<
@@ -1758,6 +1763,8 @@ export function createActionWorkerApiRoute<
}
const runnerId = request.headers.get(WORKER_HEADERS.RUNNER_ID) ?? undefined;
// `|| undefined` so a blank header can't become a zero-match snapshot filter (→ false reject).
const environmentId = request.headers.get(WORKER_HEADERS.ENVIRONMENT_ID) || undefined;
const result = await handler({
params: parsedParams,
@@ -1767,6 +1774,7 @@ export function createActionWorkerApiRoute<
body: parsedBody,
headers: parsedHeaders,
runnerId,
environmentId,
});
return result;
} catch (error) {
@@ -0,0 +1,69 @@
import { z } from "zod";
/**
* OTel trace IDs are 32 lowercase hex chars. The traceparent parser only
* checks the dash-delimited format, so crafted ids can be persisted and later
* interpolated into shape `where` clauses. Validate here to close the SQLi vector.
*/
export const OtelTraceIdSchema = z
.string()
.regex(/^[0-9a-f]{32}$/, "traceId must be 32 lowercase hex characters");
/** Params the sync routes set themselves; stripped from incoming requests. */
export const RESERVED_ELECTRIC_SHAPE_PARAMS = new Set(["where", "table", "columns"]);
const CUID_LIKE = /^[a-z][a-z0-9_]*$/i;
/**
* Tenant column a trace shape is scoped by. TaskEvent scopes by non-null
* organizationId; TaskRun scopes by non-null projectId (its organizationId is
* nullable). The column is from this fixed union, never user input, so it's
* safe to interpolate.
*/
export type TraceScope =
| { column: "organizationId"; id: string }
| { column: "projectId"; id: string };
/**
* Build the Electric Shape `where` clause for the trace sync routes. Both ids
* are re-validated as defense-in-depth so a missed call site can't bypass scope.
*/
export function buildElectricTraceWhereClause(args: {
traceId: string;
scope: TraceScope;
}): string {
const { traceId, scope } = args;
if (!OtelTraceIdSchema.safeParse(traceId).success) {
throw new Error("buildElectricTraceWhereClause: unsafe traceId");
}
if (!CUID_LIKE.test(scope.id)) {
throw new Error("buildElectricTraceWhereClause: unsafe scope id");
}
return `"traceId"='${traceId}' AND "${scope.column}"='${scope.id}'`;
}
/**
* Characters rejected in realtime tag values the single source of truth
* shared by the apiBuilder Zod refine (`realtime.v1.runs.ts`) and the runtime
* sanitiser. Rejects control chars/DEL, backslash, and double-quote. Single
* quotes are allowed and escaped (`'` `''`) in `sanitizeRealtimeTagForSql`.
*/
export const UNSAFE_REALTIME_TAG_CHARS = /[\x00-\x1f\x7f\\"]/;
/**
* Sanitise a tag value for interpolation into an Electric Shape `where` clause:
* reject unsafe chars, escape single quotes per SQL standard.
*/
export function sanitizeRealtimeTagForSql(tag: string): string {
if (typeof tag !== "string" || tag.length === 0) {
throw new Error("Invalid realtime tag: empty");
}
if (UNSAFE_REALTIME_TAG_CHARS.test(tag)) {
throw new Error(`Invalid realtime tag: ${JSON.stringify(tag)} — contains unsafe character`);
}
return tag.replace(/'/g, "''");
}
export function sanitizeRealtimeTagsForSql(tags: string[]): string[] {
return tags.map(sanitizeRealtimeTagForSql);
}
@@ -82,7 +82,10 @@ export class EnvironmentVariablesRepository implements Repository {
return { success: false as const, error: "Project not found" };
}
if (options.environmentIds.every((v) => !project.environments.some((e) => e.id === v))) {
// Reject if ANY supplied environmentId is outside the caller's project.
// `.some` (not `.every`) so one in-project id can't let a mixed array
// through.
if (options.environmentIds.some((v) => !project.environments.some((e) => e.id === v))) {
return { success: false as const, error: `Environment not found` };
}
@@ -291,7 +294,9 @@ export class EnvironmentVariablesRepository implements Repository {
return { success: false as const, error: "Project not found" };
}
if (options.values.every((v) => !project.environments.some((e) => e.id === v.environmentId))) {
// Same guard as `create()`: reject if ANY supplied environmentId is
// outside the caller's project (`.some`, not `.every`).
if (options.values.some((v) => !project.environments.some((e) => e.id === v.environmentId))) {
return { success: false as const, error: `Environment not found` };
}
+6 -1
View File
@@ -12,6 +12,7 @@ import type {
import { SeverityNumber, Span_SpanKind, Status_StatusCode } from "@trigger.dev/otlp-importer";
import type { MetricsV1Input } from "@internal/clickhouse";
import { generateSpanId } from "./eventRepository/common.server";
import { unwrapWorkerIdInMetadata } from "./workerIdUnwrap.server";
import type {
CreatableEventKind,
CreatableEventStatus,
@@ -468,7 +469,11 @@ function resolveDataPointContext(
function extractEventProperties(attributes: KeyValue[], prefix?: string) {
return {
metadata: convertSelectedKeyValueItemsToMap(attributes, [SemanticInternalAttributes.METADATA]),
// Decode a deployment-token worker.id back to its friendlyId so the credential never lands in
// stored span/log metadata (the metrics path unwraps its own worker_id separately).
metadata: unwrapWorkerIdInMetadata(
convertSelectedKeyValueItemsToMap(attributes, [SemanticInternalAttributes.METADATA])
),
environmentId: extractStringAttribute(attributes, [
prefix,
SemanticInternalAttributes.ENVIRONMENT_ID,
@@ -1,6 +1,7 @@
import { ZodError } from "zod";
import { CronPattern } from "../schedules";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { resolveProjectScopedEnvironments } from "./resolveProjectScopedEnvironments";
import { getLimit } from "~/services/platform.v3.server";
import { getTimezones } from "~/utils/timezones.server";
import { env } from "~/env.server";
@@ -82,7 +83,19 @@ export class CheckScheduleService extends BaseService {
throw new ServiceValidationError("Project not found");
}
const environments = project.environments.filter((env) => environmentIds.includes(env.id));
// Reject (don't silently drop) any environmentId that doesn't belong to the
// authorized project.
const scopedEnvironments = resolveProjectScopedEnvironments(
environmentIds,
project.environments
);
if (scopedEnvironments.kind === "foreign") {
throw new ServiceValidationError(
`Environment ${scopedEnvironments.foreignEnvironmentId} does not belong to this project.`
);
}
const environments = scopedEnvironments.environments;
if (environments.some((env) => env.archivedAt)) {
throw new ServiceValidationError("Can't add or edit a schedule for an archived branch");
}
@@ -51,6 +51,7 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
const deployment = await this._prisma.workerDeployment.findFirst({
where: {
friendlyId: deploymentId,
environmentId: environment.id,
},
});
@@ -1,3 +1,4 @@
import { scheduleWhereClause } from "~/models/schedules.server";
import { BaseService } from "./baseService.server";
type Options = {
@@ -28,9 +29,7 @@ export class DeleteTaskScheduleService extends BaseService {
try {
const schedule = await this._prisma.taskSchedule.findFirst({
where: {
friendlyId,
},
where: scheduleWhereClause(projectId, friendlyId),
});
if (!schedule) {
@@ -169,7 +169,7 @@ export class DeploymentService extends BaseService {
})
);
return this.getDeployment(authenticatedEnv.projectId, friendlyId)
return this.getDeployment(authenticatedEnv.id, friendlyId)
.andThen(validateDeployment)
.andThen((deployment) => {
if (deployment.status === "PENDING") {
@@ -191,7 +191,7 @@ export class DeploymentService extends BaseService {
* @param data Cancelation reason.
*/
public cancelDeployment(
authenticatedEnv: Pick<AuthenticatedEnvironment, "projectId">,
authenticatedEnv: Pick<AuthenticatedEnvironment, "id">,
friendlyId: string,
data?: Partial<Pick<WorkerDeployment, "canceledReason">>
) {
@@ -246,7 +246,7 @@ export class DeploymentService extends BaseService {
cause: error,
}));
return this.getDeployment(authenticatedEnv.projectId, friendlyId)
return this.getDeployment(authenticatedEnv.id, friendlyId)
.andThen(validateDeployment)
.andThen(cancelDeployment)
.andThen(({ deployment }) =>
@@ -278,7 +278,7 @@ export class DeploymentService extends BaseService {
* @param friendlyId The friendly deployment ID.
*/
public generateRegistryCredentials(
authenticatedEnv: Pick<AuthenticatedEnvironment, "projectId">,
authenticatedEnv: Pick<AuthenticatedEnvironment, "id" | "projectId">,
friendlyId: string
) {
const validateDeployment = (
@@ -326,7 +326,7 @@ export class DeploymentService extends BaseService {
});
});
return this.getDeployment(authenticatedEnv.projectId, friendlyId)
return this.getDeployment(authenticatedEnv.id, friendlyId)
.andThen(validateDeployment)
.andThen(getDeploymentRegion)
.andThen(generateCredentials);
@@ -472,12 +472,12 @@ export class DeploymentService extends BaseService {
);
}
private getDeployment(projectId: string, friendlyId: string) {
private getDeployment(environmentId: string, friendlyId: string) {
return fromPromise(
this._prisma.workerDeployment.findFirst({
where: {
friendlyId,
projectId,
environmentId,
},
select: {
status: true,
@@ -286,7 +286,7 @@ export class InitializeDeploymentService extends BaseService {
});
return deploymentService
.cancelDeployment(environment, deployment.friendlyId, {
.cancelDeployment({ id: environment.id }, deployment.friendlyId, {
canceledReason: "Failed to enqueue build, please try again shortly.",
})
.orTee((cancelError) =>
@@ -0,0 +1,27 @@
// Resolve a caller-supplied list of environment ids against the environments
// that belong to the authorized project. Any id not in the project is reported
// as `foreign` so the caller can reject the request rather than silently drop
// it. Returns a discriminated result instead of throwing so it stays
// dependency-free and unit-testable.
export type ProjectScopedEnvironmentsResult<E> =
| { kind: "foreign"; foreignEnvironmentId: string }
| { kind: "ok"; environments: E[] };
export function resolveProjectScopedEnvironments<E extends { id: string }>(
environmentIds: string[],
projectEnvironments: ReadonlyArray<E>
): ProjectScopedEnvironmentsResult<E> {
const byId = new Map(projectEnvironments.map((e) => [e.id, e]));
const foreignEnvironmentId = environmentIds.find((id) => !byId.has(id));
// Explicit undefined check: an empty-string id is a foreign id that must be
// rejected, but it is falsy, so a truthiness test would silently drop it
// instead.
if (foreignEnvironmentId !== undefined) {
return { kind: "foreign", foreignEnvironmentId };
}
const environments = environmentIds.map((id) => byId.get(id)).filter((e): e is E => Boolean(e));
return { kind: "ok", environments };
}
@@ -1,3 +1,4 @@
import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server";
import { BaseService } from "./baseService.server";
type Options = {
@@ -29,9 +30,7 @@ export class SetActiveOnTaskScheduleService extends BaseService {
try {
const schedule = await this._prisma.taskSchedule.findFirst({
where: {
friendlyId,
},
where: scheduleWhereClause(projectId, friendlyId),
});
if (!schedule) {
@@ -43,9 +42,7 @@ export class SetActiveOnTaskScheduleService extends BaseService {
}
await this._prisma.taskSchedule.update({
where: {
friendlyId,
},
where: scheduleUniqWhereClause(projectId, friendlyId),
data: {
active,
},
@@ -18,11 +18,14 @@ import { fromFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import { WORKER_HEADERS, type WorkerQueueClass } from "@trigger.dev/core/v3/workers";
import type { RuntimeEnvironment, WorkerInstanceGroup } from "@trigger.dev/database";
import { Prisma, WorkerInstanceGroupType } from "@trigger.dev/database";
import { SENSITIVE_WORKER_HEADERS, sanitizeWorkerHeaders } from "./sanitizeWorkerHeaders";
import { json } from "@remix-run/server-runtime";
import { createHash, timingSafeEqual } from "crypto";
import { customAlphabet } from "nanoid";
import { Counter } from "prom-client";
import { z } from "zod";
import { env } from "~/env.server";
import { metricsRegister } from "~/metrics.server";
import { evaluateCreatedAtGate } from "./workloadTokenAuthorization.server";
import {
isWorkerQueueDequeueDisabled,
recordBlockedDequeue,
@@ -42,6 +45,30 @@ const authenticatedWorkerInstanceCache = singleton(
createAuthenticatedWorkerInstanceCache
);
// Opt-in suppression of untokened worker actions on runs created after the cutoff. Only the
// no-header path ever reads a run row, and only when this is on - default off means feature-off is
// byte-for-byte today's behavior (no extra reads). Tenant scoping itself is header-driven (folded
// into the engine snapshot read) and needs no platform flag.
const workloadCreatedAtGateEnabled = env.WORKLOAD_CREATED_AT_GATE_ENABLED === "1";
const workloadTokenCutoff = env.WORKLOAD_TOKEN_CUTOFF
? new Date(env.WORKLOAD_TOKEN_CUTOFF)
: undefined;
if (workloadCreatedAtGateEnabled && !workloadTokenCutoff) {
logger.warn(
"WORKLOAD_CREATED_AT_GATE_ENABLED is set but WORKLOAD_TOKEN_CUTOFF is missing; the created-at gate stays off until a cutoff is configured"
);
}
type WorkloadGateAction = "start" | "complete" | "continue" | "snapshots_since";
const workloadAuthGateCounter = new Counter({
name: "workload_auth_gate_total",
help: "Deployment token authorization outcomes on worker actions",
labelNames: ["outcome", "action"] as const,
registers: [metricsRegister],
});
function createAuthenticatedWorkerInstanceCache() {
return createCache({
authenticatedWorkerInstance: new Namespace<AuthenticatedWorkerInstance>(
@@ -188,6 +215,7 @@ export class WorkerGroupTokenService extends WithRunEngine {
if (a.byteLength !== b.byteLength) {
logger.error("[WorkerGroupTokenService] Managed secret length mismatch", {
managedWorkerSecret,
headers: this.sanitizeHeaders(request),
});
return;
@@ -195,6 +223,7 @@ export class WorkerGroupTokenService extends WithRunEngine {
if (!timingSafeEqual(a, b)) {
logger.error("[WorkerGroupTokenService] Managed secret mismatch", {
managedWorkerSecret,
headers: this.sanitizeHeaders(request),
});
return;
@@ -316,10 +345,16 @@ export class WorkerGroupTokenService extends WithRunEngine {
}
}
// Strip sensitive headers before logging request headers — see
// `sanitizeWorkerHeaders`.
private sanitizeHeaders(request: Request, denylist = SENSITIVE_WORKER_HEADERS) {
return sanitizeWorkerHeaders(request.headers, denylist);
private sanitizeHeaders(request: Request, skipHeaders = ["authorization"]) {
const sanitizedHeaders: Partial<Record<string, string>> = {};
for (const [key, value] of request.headers.entries()) {
if (!skipHeaders.includes(key.toLowerCase())) {
sanitizedHeaders[key] = value;
}
}
return sanitizedHeaders;
}
}
@@ -398,6 +433,55 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
});
}
/**
* The no-header fallback. When the env header is present the engine scopes the snapshot read by it
* (nothing to do here). When it's absent, this optionally suppresses runs created after the cutoff -
* a run that new enough should have carried a token, so a missing one is treated as out-of-scope.
* Only runs when the gate is enabled AND a cutoff is set; that's the ONLY path that reads a run row.
*/
private async assertCreatedAtGate({
runId,
environmentId,
action,
}: {
runId: string;
environmentId?: string;
action: WorkloadGateAction;
}): Promise<void> {
if (environmentId) {
// Scoping is delegated to the engine snapshot read; no run-row read here. Recorded so the
// platform can see how much traffic is env-scoped as enforcement rolls out.
workloadAuthGateCounter.inc({ outcome: "env_scoped", action });
return;
}
if (!workloadCreatedAtGateEnabled || !workloadTokenCutoff) {
return;
}
const run = await this._engine.runStore.findRun({ id: runId }, { select: { createdAt: true } });
if (!run) {
// Let the engine method surface the canonical not-found error.
return;
}
const { allow, outcome } = evaluateCreatedAtGate({
runCreatedAt: run.createdAt,
cutoff: workloadTokenCutoff,
});
workloadAuthGateCounter.inc({ outcome, action });
if (!allow) {
logger.warn("[workload-auth] rejecting untokened worker action created after cutoff", {
action,
runId,
});
throw json({ error: "Run does not belong to this worker" }, { status: 403 });
}
}
async heartbeatRun({
runFriendlyId,
snapshotFriendlyId,
@@ -420,22 +504,31 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
snapshotFriendlyId,
isWarmStart,
runnerId,
environmentId,
}: {
runFriendlyId: string;
snapshotFriendlyId: string;
isWarmStart?: boolean;
runnerId?: string;
environmentId?: string;
}): Promise<
StartRunAttemptResult & {
envVars: Record<string, string>;
}
> {
await this.assertCreatedAtGate({
runId: fromFriendlyId(runFriendlyId),
environmentId,
action: "start",
});
const engineResult = await this._engine.startRunAttempt({
runId: fromFriendlyId(runFriendlyId),
snapshotId: fromFriendlyId(snapshotFriendlyId),
isWarmStart,
workerId: this.workerInstanceId,
runnerId,
environmentId,
});
const defaultMachinePreset = machinePresetFromName(defaultMachine);
@@ -470,24 +563,42 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
snapshotFriendlyId,
completion,
runnerId,
environmentId,
}: {
runFriendlyId: string;
snapshotFriendlyId: string;
completion: TaskRunExecutionResult;
runnerId?: string;
environmentId?: string;
}): Promise<CompleteRunAttemptResult> {
await this.assertCreatedAtGate({
runId: fromFriendlyId(runFriendlyId),
environmentId,
action: "complete",
});
return await this._engine.completeRunAttempt({
runId: fromFriendlyId(runFriendlyId),
snapshotId: fromFriendlyId(snapshotFriendlyId),
completion,
workerId: this.workerInstanceId,
runnerId,
environmentId,
});
}
async getLatestSnapshot({ runFriendlyId }: { runFriendlyId: string }) {
async getLatestSnapshot({
runFriendlyId,
environmentId,
}: {
runFriendlyId: string;
environmentId?: string;
}) {
// No created-at gate: the only untokened caller is an internal warm-start poll that legitimately
// has no token, so an absent header must not reject. When a header is present the engine scopes.
return await this._engine.getRunExecutionData({
runId: fromFriendlyId(runFriendlyId),
environmentId,
});
}
@@ -515,29 +626,47 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
runFriendlyId,
snapshotFriendlyId,
runnerId,
environmentId,
}: {
runFriendlyId: string;
snapshotFriendlyId: string;
runnerId?: string;
environmentId?: string;
}) {
await this.assertCreatedAtGate({
runId: fromFriendlyId(runFriendlyId),
environmentId,
action: "continue",
});
return await this._engine.continueRunExecution({
runId: fromFriendlyId(runFriendlyId),
snapshotId: fromFriendlyId(snapshotFriendlyId),
workerId: this.workerInstanceId,
runnerId,
environmentId,
});
}
async getSnapshotsSince({
runFriendlyId,
snapshotId,
environmentId,
}: {
runFriendlyId: string;
snapshotId: string;
environmentId?: string;
}) {
await this.assertCreatedAtGate({
runId: fromFriendlyId(runFriendlyId),
environmentId,
action: "snapshots_since",
});
return await this._engine.getSnapshotsSince({
runId: fromFriendlyId(runFriendlyId),
snapshotId: fromFriendlyId(snapshotId),
environmentId,
});
}
@@ -0,0 +1,27 @@
/**
* The no-header created-at gate. Tenant scoping is handled by the env-scoped snapshot read in the
* engine; this only covers the fallback where no verified env header was forwarded (caller predates
* tokens, or the supervisor isn't enforcing):
*
* run created on/before cutoff -> allow (grandfather legacy untokened runs)
* run created after cutoff -> reject (a new-enough run should have carried a token)
*
* Pure and env-import-free so it stays trivially testable.
*/
export type CreatedAtGateOutcome = "grandfathered" | "suppressed";
export type CreatedAtGateEvaluation = {
outcome: CreatedAtGateOutcome;
allow: boolean;
};
export function evaluateCreatedAtGate(params: {
runCreatedAt: Date;
cutoff: Date;
}): CreatedAtGateEvaluation {
const createdAfterCutoff = params.runCreatedAt.getTime() > params.cutoff.getTime();
return createdAfterCutoff
? { outcome: "suppressed", allow: false }
: { outcome: "grandfathered", allow: true };
}
@@ -0,0 +1,75 @@
// Decodes a `worker_id` that may be a deployment token back to its friendlyId, so the stored/queried
// value stays a short, stable id. Runs on the OTEL ingest hot path: base64url + JSON decode only (no
// verify), LRU-memoized. Fails safe — a non-token value passes through unchanged.
import { LRUCache } from "lru-cache";
import { SemanticInternalAttributes } from "@trigger.dev/core/v3";
// The runner flattens `worker.id` (the raw TRIGGER_DEPLOYMENT_ID) into span/log metadata under this
// key, mirroring how the runner composes it, so it can't drift.
const WORKER_ID_METADATA_KEY = `${SemanticInternalAttributes.METADATA}.${SemanticInternalAttributes.WORKER_ID}`;
/**
* Unwrap, in place, the `worker.id` a runner stamps into span/log metadata: a deployment-token value
* becomes its friendlyId, everything else is left untouched. Keeps the credential out of stored
* telemetry and the value a stable deployment id (the metrics path unwraps its own worker_id).
*/
export function unwrapWorkerIdInMetadata<
T extends Record<string, string | number | boolean | undefined>,
>(metadata: T | undefined): T | undefined {
if (metadata) {
const value = metadata[WORKER_ID_METADATA_KEY];
if (typeof value === "string") {
(metadata as Record<string, string | number | boolean | undefined>)[WORKER_ID_METADATA_KEY] =
unwrapWorkerId(value);
}
}
return metadata;
}
// LRU, not FIFO: the active-deployment working set is unbounded, so an insertion-order memo thrashes
// once it exceeds the cap. Raw lru-cache (not @internal/cache's async wrapper) since this is sync.
const memo = new LRUCache<string, string>({ max: 32_768 });
export function unwrapWorkerId(value: string | undefined): string | undefined {
if (!value) {
return value;
}
// Fast path: a minted token is a JWT, whose base64url header always begins "eyJ". Anything else —
// a bare deployment friendlyId, "unmanaged", a dev id — returns immediately with no decode and no
// memo entry, so the non-token case costs nothing (and stays free once telemetry no longer carries
// tokens at all).
if (!value.startsWith("eyJ")) {
return value;
}
const cached = memo.get(value);
if (cached !== undefined) {
return cached;
}
const unwrapped = decodeDeploymentFriendlyId(value) ?? value;
memo.set(value, unwrapped);
return unwrapped;
}
function decodeDeploymentFriendlyId(value: string): string | undefined {
const parts = value.split(".");
// Not a JWT (three non-empty segments) → a legacy bare friendlyId; leave it as-is.
if (parts.length !== 3 || !parts[1]) {
return undefined;
}
try {
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as {
deployment?: unknown;
};
return typeof payload.deployment === "string" && payload.deployment.length > 0
? payload.deployment
: undefined;
} catch {
return undefined;
}
}
@@ -0,0 +1,33 @@
// Which environments a user may write env vars to. Shared env types
// (preview/staging/production) are writable by any project member; DEVELOPMENT
// environments are per-user and only writable by their owner.
export type WriteCheckEnvironment = {
id: string;
type: string;
orgMember: { userId: string } | null;
};
const SHARED_ENV_TYPES = new Set(["PREVIEW", "STAGING", "PRODUCTION"]);
/**
* Return the first submitted id the user may NOT write to either it isn't one
* of the project's environments or it's a DEV env owned by someone else.
* Returns null when every submitted id is writable by `userId`.
*/
export function findUnauthorizedEnvironmentId(
projectEnvironments: ReadonlyArray<WriteCheckEnvironment>,
submittedIds: ReadonlyArray<string>,
userId: string
): string | null {
const byId = new Map(projectEnvironments.map((e) => [e.id, e]));
for (const id of submittedIds) {
const env = byId.get(id);
if (!env) return id;
const writable =
SHARED_ENV_TYPES.has(env.type) ||
(env.type === "DEVELOPMENT" && env.orgMember?.userId === userId);
if (!writable) return id;
}
return null;
}
+3
View File
@@ -154,6 +154,9 @@
"isbot": "^3.6.5",
"jose": "^5.4.0",
"json-stable-stringify": "^1.3.0",
"jsonpointer": "^5.0.1",
"lodash.omit": "^4.5.0",
"lru-cache": "^11.2.4",
"lucide-react": "^0.229.0",
"marked": "^4.0.18",
"match-sorter": "^6.3.4",
+2
View File
@@ -147,6 +147,7 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
const wss: WebSocketServer | undefined = build.entry.module.wss;
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
const engineRateLimiter: RateLimitMiddleware = build.entry.module.engineRateLimiter;
const otlpRateLimiter: RequestHandler = build.entry.module.otlpRateLimiter;
const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext;
const tenantContextMiddleware: RequestHandler = build.entry.module.tenantContextMiddleware;
@@ -198,6 +199,7 @@ if (ENABLE_CLUSTER && cluster.isPrimary) {
app.use(apiRateLimiter);
app.use(engineRateLimiter);
app.use(otlpRateLimiter);
app.use(tenantContextMiddleware);
@@ -0,0 +1,58 @@
import { containerTest } from "@internal/testcontainers";
import { describe, expect, vi } from "vitest";
import {
AUTHORIZATION_CODE_TTL_MS,
isAuthorizationCodeMintable,
} from "~/services/personalAccessToken.server";
vi.setConfig({ testTimeout: 30_000 });
function randomCode() {
return `code_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
}
// Lock the read-only + TTL properties `isAuthorizationCodeMintable` relies on:
// the loader must not bind a PAT, and expired codes must not be mintable.
describe("authorization code consent gate", () => {
containerTest(
"a fresh, unconsumed code is mintable and checking it does NOT bind a PAT",
async ({ prisma }) => {
const created = await prisma.authorizationCode.create({ data: { code: randomCode() } });
expect(await isAuthorizationCodeMintable(created.code, prisma)).toBe(true);
// The check is read-only — it must not bind a Personal Access Token.
const after = await prisma.authorizationCode.findFirst({ where: { id: created.id } });
expect(after?.personalAccessTokenId).toBeNull();
}
);
containerTest("a code older than the TTL is not mintable", async ({ prisma }) => {
const created = await prisma.authorizationCode.create({ data: { code: randomCode() } });
await prisma.authorizationCode.update({
where: { id: created.id },
data: { createdAt: new Date(Date.now() - AUTHORIZATION_CODE_TTL_MS - 1_000) },
});
expect(await isAuthorizationCodeMintable(created.code, prisma)).toBe(false);
});
containerTest(
"a code created just inside the TTL is still mintable (CLI flow not broken)",
async ({ prisma }) => {
const created = await prisma.authorizationCode.create({ data: { code: randomCode() } });
await prisma.authorizationCode.update({
where: { id: created.id },
data: { createdAt: new Date(Date.now() - (AUTHORIZATION_CODE_TTL_MS - 30_000)) },
});
expect(await isAuthorizationCodeMintable(created.code, prisma)).toBe(true);
}
);
containerTest("an unknown code is not mintable", async ({ prisma }) => {
expect(await isAuthorizationCodeMintable(randomCode(), prisma)).toBe(false);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { containerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { resolveProjectScopedEnvironments } from "~/v3/services/resolveProjectScopedEnvironments";
vi.setConfig({ testTimeout: 60_000 });
// Exercises the environment-scoping primitive CheckScheduleService relies on
// (`resolveProjectScopedEnvironments`) with real RuntimeEnvironment rows,
// imported directly to avoid `~/db.server` and its eager global-prisma connect.
async function seedProjectWithEnv(prisma: PrismaClient, slugBase: string) {
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;
const organization = await prisma.organization.create({ data: { title: slug, slug } });
const project = await prisma.project.create({
data: { name: slug, slug, organizationId: organization.id, externalRef: slug },
});
const environment = await prisma.runtimeEnvironment.create({
data: {
slug: `${slug}-prod`,
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: slug.slice(0, 6),
},
});
return { organization, project, environment };
}
function projectEnvironments(prisma: PrismaClient, projectId: string) {
return prisma.runtimeEnvironment.findMany({ where: { projectId }, select: { id: true } });
}
describe("resolveProjectScopedEnvironments (schedule env scoping)", () => {
containerTest("rejects an environment id that belongs to another project", async ({ prisma }) => {
const a = await seedProjectWithEnv(prisma, "orga");
const b = await seedProjectWithEnv(prisma, "orgb");
const result = resolveProjectScopedEnvironments(
[a.environment.id, b.environment.id],
await projectEnvironments(prisma, a.project.id)
);
expect(result.kind).toBe("foreign");
expect(result).toMatchObject({ foreignEnvironmentId: b.environment.id });
});
containerTest("accepts environment ids that belong to the project", async ({ prisma }) => {
const a = await seedProjectWithEnv(prisma, "orga");
const result = resolveProjectScopedEnvironments(
[a.environment.id],
await projectEnvironments(prisma, a.project.id)
);
expect(result.kind).toBe("ok");
});
});
@@ -0,0 +1,75 @@
import { containerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { scheduleWhereClause } from "~/models/schedules.server";
vi.setConfig({ testTimeout: 60_000 });
// Exercises the project-scoping primitive DeleteTaskScheduleService relies on
// (`scheduleWhereClause`) directly against a real database, to avoid importing
// `~/db.server` and its eager global-prisma connect.
async function seedProject(prisma: PrismaClient, slugBase: string) {
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;
const organization = await prisma.organization.create({ data: { title: slug, slug } });
const project = await prisma.project.create({
data: { name: slug, slug, organizationId: organization.id, externalRef: slug },
});
return { organization, project };
}
function seedSchedule(prisma: PrismaClient, projectId: string, friendlyId: string) {
return prisma.taskSchedule.create({
data: {
friendlyId,
taskIdentifier: "my-task",
projectId,
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
type: "IMPERATIVE",
},
});
}
describe("scheduleWhereClause (delete lookup scoping)", () => {
containerTest(
"a schedule from another project is not found when scoped to the caller's project",
async ({ prisma }) => {
const a = await seedProject(prisma, "orga");
const b = await seedProject(prisma, "orgb");
const victim = await seedSchedule(
prisma,
b.project.id,
`sched_${Math.random().toString(36).slice(2, 10)}`
);
// Scoped to A's project: the cross-tenant schedule is invisible (the
// `projectId` in the where is what prevents a cross-project delete).
const fromA = await prisma.taskSchedule.findFirst({
where: scheduleWhereClause(a.project.id, victim.friendlyId),
});
expect(fromA).toBeNull();
// Scoped to its own project: found.
const fromB = await prisma.taskSchedule.findFirst({
where: scheduleWhereClause(b.project.id, victim.friendlyId),
});
expect(fromB?.id).toBe(victim.id);
}
);
containerTest("the where pins projectId for both id shapes", async ({ prisma }) => {
const a = await seedProject(prisma, "orga");
// friendlyId shape
expect(scheduleWhereClause(a.project.id, "sched_abc")).toMatchObject({
friendlyId: "sched_abc",
projectId: a.project.id,
});
// deduplicationKey shape
expect(scheduleWhereClause(a.project.id, "my-dedup-key")).toMatchObject({
projectId: a.project.id,
deduplicationKey: "my-dedup-key",
});
});
});
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const originalEnv = process.env;
const requiredEnv = {
NODE_ENV: "test",
DATABASE_URL: "postgresql://test:test@localhost:5432/test",
DIRECT_URL: "postgresql://test:test@localhost:5432/test",
SESSION_SECRET: "test-session-secret",
MAGIC_LINK_SECRET: "test-magic-link-secret",
ENCRYPTION_KEY: "test-encryption-keeeeey-32-bytes",
CLICKHOUSE_URL: "http://localhost:8123",
DEPLOY_REGISTRY_HOST: "registry.example.com",
MANAGED_WORKER_SECRET: "test-managed-worker-secret",
};
describe("webapp environment secrets", () => {
afterEach(() => {
process.env = originalEnv;
vi.resetModules();
});
it.each(["SESSION_SECRET", "MAGIC_LINK_SECRET", "ENCRYPTION_KEY", "MANAGED_WORKER_SECRET"])(
"requires %s to be explicitly set",
async (key) => {
process.env = { ...requiredEnv };
delete process.env[key];
await expect(import("../app/env.server")).rejects.toThrow(key);
}
);
it.each(["SESSION_SECRET", "MAGIC_LINK_SECRET", "ENCRYPTION_KEY", "MANAGED_WORKER_SECRET"])(
"rejects an empty %s",
async (key) => {
process.env = { ...requiredEnv, [key]: "" };
await expect(import("../app/env.server")).rejects.toThrow(key);
}
);
it.each([
["SESSION_SECRET", "2818143646516f6fffd707b36f334bbb"],
["MAGIC_LINK_SECRET", "44da78b7bbb0dfe709cf38931d25dcdd"],
["ENCRYPTION_KEY", "f686147ab967943ebbe9ed3b496e465a"],
["MANAGED_WORKER_SECRET", "managed-secret"],
["MANAGED_WORKER_SECRET", "447c29678f9eaf289e9c4b70d3dd8a7f"],
])("rejects the known-insecure default value for %s", async (key, insecureValue) => {
process.env = { ...requiredEnv, [key]: insecureValue };
await expect(import("../app/env.server")).rejects.toThrow(key);
});
it("accepts explicitly configured secrets", async () => {
process.env = { ...requiredEnv };
const { env } = await import("../app/env.server");
expect(env.SESSION_SECRET).toBe(requiredEnv.SESSION_SECRET);
expect(env.MAGIC_LINK_SECRET).toBe(requiredEnv.MAGIC_LINK_SECRET);
expect(env.ENCRYPTION_KEY).toBe(requiredEnv.ENCRYPTION_KEY);
expect(env.MANAGED_WORKER_SECRET).toBe(requiredEnv.MANAGED_WORKER_SECRET);
});
it("allows a known-insecure default when ALLOW_INSECURE_DEFAULT_SECRETS is set", async () => {
process.env = {
...requiredEnv,
ALLOW_INSECURE_DEFAULT_SECRETS: "1",
ENCRYPTION_KEY: "f686147ab967943ebbe9ed3b496e465a",
MANAGED_WORKER_SECRET: "managed-secret",
};
const { env } = await import("../app/env.server");
expect(env.ENCRYPTION_KEY).toBe("f686147ab967943ebbe9ed3b496e465a");
expect(env.MANAGED_WORKER_SECRET).toBe("managed-secret");
});
it("still rejects an empty secret even with ALLOW_INSECURE_DEFAULT_SECRETS", async () => {
process.env = { ...requiredEnv, ALLOW_INSECURE_DEFAULT_SECRETS: "1", SESSION_SECRET: "" };
await expect(import("../app/env.server")).rejects.toThrow("SESSION_SECRET");
});
});
@@ -180,4 +180,80 @@ describe("EnvironmentVariablesRepository.getVariableValuesForKeys", () => {
expect(crossProjectRequest.size).toBe(0);
});
postgresTest(
"create() rejects a mix of in-project and foreign environmentIds without writing foreign values",
async ({ prisma }) => {
const {
user,
organization,
project: projectA,
} = await createTestOrgProjectWithMember(prisma);
const projectB = await prisma.project.create({
data: {
name: "Project B",
slug: `proj-b-${Date.now()}`,
organizationId: organization.id,
externalRef: `ext-b-${Date.now()}`,
},
});
const envA = await createRuntimeEnvironment(prisma, {
projectId: projectA.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const envB = await createRuntimeEnvironment(prisma, {
projectId: projectB.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const repository = new EnvironmentVariablesRepository(prisma, prisma);
// Caller scoped to projectA supplies a mixed array: an in-project env
// (envA) plus a foreign one (envB). The whole request must be refused.
const result = await repository.create(projectA.id, {
override: true,
environmentIds: [envA.id, envB.id],
variables: [{ key: "CROSS_TENANT", value: "x" }],
isSecret: false,
lastUpdatedBy: { type: "user", userId: user.id },
});
expect(result.success).toBe(false);
// No value row may have been written against the foreign environment.
const foreignValues = await prisma.environmentVariableValue.findMany({
where: { environmentId: envB.id },
});
expect(foreignValues).toHaveLength(0);
}
);
postgresTest(
"create() still succeeds for an all-in-project environmentIds array",
async ({ prisma }) => {
const { user, organization, project } = await createTestOrgProjectWithMember(prisma);
const environment = await createRuntimeEnvironment(prisma, {
projectId: project.id,
organizationId: organization.id,
type: "PRODUCTION",
});
const repository = new EnvironmentVariablesRepository(prisma, prisma);
const result = await repository.create(project.id, {
override: true,
environmentIds: [environment.id],
variables: [{ key: "OK_KEY", value: "v" }],
isSecret: false,
lastUpdatedBy: { type: "user", userId: user.id },
});
expect(result.success).toBe(true);
}
);
});
+3
View File
@@ -10,6 +10,9 @@ describe("getRegistryConfig", () => {
MAGIC_LINK_SECRET: "test-magic-link-secret",
ENCRYPTION_KEY: "test-encryption-keeeeey-32-bytes",
CLICKHOUSE_URL: "http://localhost:8123",
PROVIDER_SECRET: "test-provider-secret",
COORDINATOR_SECRET: "test-coordinator-secret",
MANAGED_WORKER_SECRET: "test-managed-worker-secret",
};
beforeEach(() => {
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { resolveProjectScopedEnvironments } from "../app/v3/services/resolveProjectScopedEnvironments.js";
const projectEnvs = [{ id: "env_prod" }, { id: "env_staging" }, { id: "env_dev" }];
// A submitted environment id that doesn't belong to the project must be
// rejected, not silently dropped.
describe("resolveProjectScopedEnvironments", () => {
it("resolves ids that all belong to the project", () => {
const r = resolveProjectScopedEnvironments(["env_prod", "env_dev"], projectEnvs);
expect(r.kind).toBe("ok");
if (r.kind === "ok") expect(r.environments.map((e) => e.id)).toEqual(["env_prod", "env_dev"]);
});
it("rejects a foreign environment id (the cross-tenant vector)", () => {
const r = resolveProjectScopedEnvironments(["env_someone_elses"], projectEnvs);
expect(r.kind).toBe("foreign");
if (r.kind === "foreign") expect(r.foreignEnvironmentId).toBe("env_someone_elses");
});
it("rejects when a foreign id is mixed in with valid ones (not silently dropped)", () => {
const r = resolveProjectScopedEnvironments(["env_prod", "env_foreign"], projectEnvs);
expect(r.kind).toBe("foreign");
if (r.kind === "foreign") expect(r.foreignEnvironmentId).toBe("env_foreign");
});
it("returns an empty set for no ids", () => {
const r = resolveProjectScopedEnvironments([], projectEnvs);
expect(r.kind).toBe("ok");
if (r.kind === "ok") expect(r.environments).toEqual([]);
});
it("rejects an empty-string id rather than dropping it (falsy edge case)", () => {
const r = resolveProjectScopedEnvironments([""], projectEnvs);
expect(r.kind).toBe("foreign");
if (r.kind === "foreign") expect(r.foreignEnvironmentId).toBe("");
});
it("rejects an empty-string id mixed in with valid ids", () => {
const r = resolveProjectScopedEnvironments(["env_prod", ""], projectEnvs);
expect(r.kind).toBe("foreign");
if (r.kind === "foreign") expect(r.foreignEnvironmentId).toBe("");
});
});
@@ -0,0 +1,261 @@
import { containerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { getScheduleEnvVisibility } from "~/models/schedules.server";
vi.setConfig({ testTimeout: 60_000 });
async function seedProjectWithEnvs(prisma: PrismaClient, slugBase: string) {
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;
const organization = await prisma.organization.create({
data: { title: slug, slug },
});
const project = await prisma.project.create({
data: {
name: slug,
slug,
organizationId: organization.id,
externalRef: slug,
},
});
const prodEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "prod",
type: "PRODUCTION",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_prod_${slug}`,
pkApiKey: `pk_prod_${slug}`,
shortcode: `p${slug.slice(0, 4)}`,
},
});
const stagingEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "staging",
type: "STAGING",
projectId: project.id,
organizationId: organization.id,
apiKey: `tr_staging_${slug}`,
pkApiKey: `pk_staging_${slug}`,
shortcode: `s${slug.slice(0, 4)}`,
},
});
return { organization, project, prodEnv, stagingEnv };
}
async function seedScheduleWithInstance(
prisma: PrismaClient,
projectId: string,
environmentId: string,
opts: { friendlyId?: string; deduplicationKey?: string } = {}
) {
const schedule = await prisma.taskSchedule.create({
data: {
friendlyId: opts.friendlyId ?? `sched_${Math.random().toString(36).slice(2, 10)}`,
taskIdentifier: "my-task",
projectId,
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
type: "IMPERATIVE",
...(opts.deduplicationKey
? { deduplicationKey: opts.deduplicationKey, userProvidedDeduplicationKey: true }
: {}),
},
});
await prisma.taskScheduleInstance.create({
data: {
taskScheduleId: schedule.id,
environmentId,
projectId,
},
});
return schedule;
}
describe("getScheduleEnvVisibility", () => {
containerTest(
"returns 'hidden' when an instance lives in a different env (by friendlyId)",
async ({ prisma }) => {
const env = await seedProjectWithEnvs(prisma, "orga");
const schedule = await seedScheduleWithInstance(prisma, env.project.id, env.prodEnv.id);
const visibility = await getScheduleEnvVisibility(
prisma,
env.project.id,
schedule.friendlyId,
env.stagingEnv.id
);
expect(visibility.status).toBe("hidden");
}
);
containerTest("returns 'visible' when every instance is in caller env", async ({ prisma }) => {
const env = await seedProjectWithEnvs(prisma, "orga");
const schedule = await seedScheduleWithInstance(prisma, env.project.id, env.prodEnv.id);
const visibility = await getScheduleEnvVisibility(
prisma,
env.project.id,
schedule.friendlyId,
env.prodEnv.id
);
expect(visibility.status).toBe("visible");
if (visibility.status === "visible") {
expect(visibility.schedule.id).toBe(schedule.id);
}
});
containerTest("returns 'missing' when no schedule exists", async ({ prisma }) => {
const env = await seedProjectWithEnvs(prisma, "orga");
const visibility = await getScheduleEnvVisibility(
prisma,
env.project.id,
"sched_does_not_exist",
env.prodEnv.id
);
expect(visibility.status).toBe("missing");
});
containerTest("returns 'visible' when no instances exist yet", async ({ prisma }) => {
const env = await seedProjectWithEnvs(prisma, "orga");
const schedule = await prisma.taskSchedule.create({
data: {
friendlyId: `sched_${Math.random().toString(36).slice(2, 10)}`,
taskIdentifier: "my-task",
projectId: env.project.id,
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
type: "IMPERATIVE",
},
});
const visibility = await getScheduleEnvVisibility(
prisma,
env.project.id,
schedule.friendlyId,
env.prodEnv.id
);
expect(visibility.status).toBe("visible");
});
containerTest(
"returns 'visible' from every environment a multi-env schedule spans",
async ({ prisma }) => {
const env = await seedProjectWithEnvs(prisma, "orga");
const schedule = await prisma.taskSchedule.create({
data: {
friendlyId: `sched_${Math.random().toString(36).slice(2, 10)}`,
taskIdentifier: "my-task",
projectId: env.project.id,
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
type: "IMPERATIVE",
},
});
await prisma.taskScheduleInstance.createMany({
data: [
{ taskScheduleId: schedule.id, environmentId: env.prodEnv.id, projectId: env.project.id },
{
taskScheduleId: schedule.id,
environmentId: env.stagingEnv.id,
projectId: env.project.id,
},
],
});
// The schedule list surfaces a schedule for any environment it has an
// instance in, so per-schedule reads/mutations must resolve the same
// way. A multi-env schedule is visible from each environment it spans.
const fromProd = await getScheduleEnvVisibility(
prisma,
env.project.id,
schedule.friendlyId,
env.prodEnv.id
);
expect(fromProd.status).toBe("visible");
const fromStaging = await getScheduleEnvVisibility(
prisma,
env.project.id,
schedule.friendlyId,
env.stagingEnv.id
);
expect(fromStaging.status).toBe("visible");
}
);
containerTest(
"returns 'hidden' from an environment the schedule has no instance in",
async ({ prisma }) => {
const env = await seedProjectWithEnvs(prisma, "orga");
// A third environment with no instance of the schedule.
const devEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "dev",
type: "DEVELOPMENT",
projectId: env.project.id,
organizationId: env.organization.id,
apiKey: `tr_dev_${env.project.slug}`,
pkApiKey: `pk_dev_${env.project.slug}`,
shortcode: `d${env.project.slug.slice(0, 4)}`,
},
});
const schedule = await prisma.taskSchedule.create({
data: {
friendlyId: `sched_${Math.random().toString(36).slice(2, 10)}`,
taskIdentifier: "my-task",
projectId: env.project.id,
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
type: "IMPERATIVE",
},
});
await prisma.taskScheduleInstance.createMany({
data: [
{ taskScheduleId: schedule.id, environmentId: env.prodEnv.id, projectId: env.project.id },
{
taskScheduleId: schedule.id,
environmentId: env.stagingEnv.id,
projectId: env.project.id,
},
],
});
const fromDev = await getScheduleEnvVisibility(
prisma,
env.project.id,
schedule.friendlyId,
devEnv.id
);
expect(fromDev.status).toBe("hidden");
}
);
containerTest(
"resolves by user-provided deduplicationKey (the non-sched_ prefix branch)",
async ({ prisma }) => {
const env = await seedProjectWithEnvs(prisma, "orga");
const dedupKey = `my-daily-cleanup-${Math.random().toString(36).slice(2, 8)}`;
await seedScheduleWithInstance(prisma, env.project.id, env.prodEnv.id, {
deduplicationKey: dedupKey,
});
const hiddenFromStaging = await getScheduleEnvVisibility(
prisma,
env.project.id,
dedupKey,
env.stagingEnv.id
);
expect(hiddenFromStaging.status).toBe("hidden");
const visibleFromProd = await getScheduleEnvVisibility(
prisma,
env.project.id,
dedupKey,
env.prodEnv.id
);
expect(visibleFromProd.status).toBe("visible");
}
);
});
@@ -0,0 +1,149 @@
import { containerTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import {
getScheduleEnvVisibility,
scheduleUniqWhereClause,
scheduleWhereClause,
} from "~/models/schedules.server";
vi.setConfig({ testTimeout: 60_000 });
// Exercises the project-scoping primitives SetActiveOnTaskScheduleService relies
// on (`scheduleWhereClause` + `scheduleUniqWhereClause`) directly against a real
// database, to avoid importing `~/db.server` and its eager global-prisma connect.
async function seedProject(prisma: PrismaClient, slugBase: string) {
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;
const organization = await prisma.organization.create({ data: { title: slug, slug } });
const project = await prisma.project.create({
data: { name: slug, slug, organizationId: organization.id, externalRef: slug },
});
return { organization, project };
}
function seedSchedule(
prisma: PrismaClient,
projectId: string,
friendlyId: string,
active: boolean = true
) {
return prisma.taskSchedule.create({
data: {
friendlyId,
taskIdentifier: "my-task",
projectId,
generatorExpression: "0 * * * *",
generatorDescription: "every hour",
type: "IMPERATIVE",
active,
},
});
}
describe("schedule scoping (enable/disable)", () => {
containerTest(
"toggling scoped to another project does not touch the victim schedule",
async ({ prisma }) => {
const a = await seedProject(prisma, "orga");
const b = await seedProject(prisma, "orgb");
const victim = await seedSchedule(
prisma,
b.project.id,
`sched_${Math.random().toString(36).slice(2, 10)}`,
true
);
// Project A cannot toggle B's schedule: the where pins projectId, so the
// update matches zero rows.
const result = await prisma.taskSchedule.updateMany({
where: scheduleWhereClause(a.project.id, victim.friendlyId),
data: { active: false },
});
expect(result.count).toBe(0);
const unchanged = await prisma.taskSchedule.findUnique({ where: { id: victim.id } });
expect(unchanged?.active).toBe(true);
// The owning project can toggle it.
const owned = await prisma.taskSchedule.updateMany({
where: scheduleWhereClause(b.project.id, victim.friendlyId),
data: { active: false },
});
expect(owned.count).toBe(1);
}
);
containerTest("the unique-where pins projectId", async ({ prisma }) => {
const a = await seedProject(prisma, "orga");
expect(scheduleUniqWhereClause(a.project.id, "sched_abc")).toMatchObject({
friendlyId: "sched_abc",
projectId: a.project.id,
});
});
// The public activate/deactivate endpoints gate on getScheduleEnvVisibility
// before toggling `active`. A key scoped to one environment must not be able
// to enable/disable a schedule that only runs in another environment of the
// same project.
containerTest(
"an env-scoped caller cannot toggle a schedule that only runs in another env",
async ({ prisma }) => {
const a = await seedProject(prisma, "orga");
const prodEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "prod",
type: "PRODUCTION",
projectId: a.project.id,
organizationId: a.organization.id,
apiKey: `tr_prod_${a.project.slug}`,
pkApiKey: `pk_prod_${a.project.slug}`,
shortcode: `p${a.project.slug.slice(0, 4)}`,
},
});
const stagingEnv = await prisma.runtimeEnvironment.create({
data: {
slug: "staging",
type: "STAGING",
projectId: a.project.id,
organizationId: a.organization.id,
apiKey: `tr_staging_${a.project.slug}`,
pkApiKey: `pk_staging_${a.project.slug}`,
shortcode: `s${a.project.slug.slice(0, 4)}`,
},
});
const schedule = await seedSchedule(
prisma,
a.project.id,
`sched_${Math.random().toString(36).slice(2, 10)}`,
true
);
// The schedule only runs in staging.
await prisma.taskScheduleInstance.create({
data: {
taskScheduleId: schedule.id,
environmentId: stagingEnv.id,
projectId: a.project.id,
},
});
// A prod-scoped key is refused (the route returns 404 and never updates).
const fromProd = await getScheduleEnvVisibility(
prisma,
a.project.id,
schedule.friendlyId,
prodEnv.id
);
expect(fromProd.status).toBe("hidden");
// The staging-scoped key that owns an instance can toggle it.
const fromStaging = await getScheduleEnvVisibility(
prisma,
a.project.id,
schedule.friendlyId,
stagingEnv.id
);
expect(fromStaging.status).toBe("visible");
}
);
});
+3
View File
@@ -13,6 +13,9 @@ config({ path: path.resolve(__dirname, "../.env") });
// the pair — the ioredis mock below forces lazyConnect, so nothing ever dials.
process.env.REDIS_HOST ??= "localhost";
process.env.REDIS_PORT ??= "6379";
process.env.PROVIDER_SECRET ??= "test-provider-secret";
process.env.COORDINATOR_SECRET ??= "test-coordinator-secret";
process.env.MANAGED_WORKER_SECRET ??= "test-managed-worker-secret";
// Worker singletons construct a RedisWorker at import time whose ioredis client
// connects eagerly, so any test importing the service graph opens real Redis
@@ -475,7 +475,7 @@ describe("run-trace/span-detail route loaders under a lagging replica", () => {
const seed = await seedTenant(prisma14, suffix);
const runId = `run_${CUID_25}`;
const friendlyId = `run_${suffix}`;
const traceId = `trace_${suffix}`;
const traceId = "a".repeat(32);
const userId = `user_${suffix}`;
// The dashboard user, joined to the org so the route's real orgMember check passes.
@@ -538,7 +538,8 @@ describe("run-trace/span-detail route loaders under a lagging replica", () => {
holder.resolvedEnv = { organizationId: seed.organization.id };
holder.replicaMarker = { orgMember: prisma14.orgMember };
const res = (await syncTraceRunsLoader(syncRequest("trace_does_not_exist"))) as Response;
const res = (await syncTraceRunsLoader(syncRequest("b".repeat(32)))) as Response;
expect(lagged.legacyReplica.wasHit("taskRun")).toBe(true);
expect(res.status).toBe(404);
}
);
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { mintWorkloadDeploymentToken, SemanticInternalAttributes } from "@trigger.dev/core/v3";
import { unwrapWorkerId, unwrapWorkerIdInMetadata } from "~/v3/workerIdUnwrap.server";
const EXP = Math.floor(Date.UTC(2032, 0, 1) / 1000);
const WORKER_ID_KEY = `${SemanticInternalAttributes.METADATA}.${SemanticInternalAttributes.WORKER_ID}`;
function mint(deployment: string) {
return mintWorkloadDeploymentToken(
{
deployment,
deployment_version: "20260709.1",
environment_id: "env_1",
environment_type: "PRODUCTION",
org_id: "org_1",
project_id: "proj_1",
},
"any-secret",
EXP
);
}
describe("unwrapWorkerId", () => {
it("unwraps a real minted token to its deployment friendlyId", async () => {
// Decode is signature-independent (display-only), so the secret is irrelevant here.
expect(unwrapWorkerId(await mint("deployment_abc123"))).toBe("deployment_abc123");
});
it("passes a legacy bare friendlyId through unchanged", () => {
expect(unwrapWorkerId("deployment_legacy")).toBe("deployment_legacy");
});
it("passes undefined and garbage through unchanged (fail-safe)", () => {
expect(unwrapWorkerId(undefined)).toBeUndefined();
expect(unwrapWorkerId("")).toBe("");
expect(unwrapWorkerId("a.b.c")).toBe("a.b.c");
expect(unwrapWorkerId("not-a-token")).toBe("not-a-token");
});
it("is stable across repeated calls (memoized)", async () => {
const token = await mint("deployment_memo");
expect(unwrapWorkerId(token)).toBe("deployment_memo");
expect(unwrapWorkerId(token)).toBe("deployment_memo");
});
});
describe("unwrapWorkerIdInMetadata", () => {
it("unwraps a token worker.id in the metadata bag, leaving other keys untouched", async () => {
const metadata = {
[WORKER_ID_KEY]: await mint("deployment_span"),
"$metadata.custom": "keep-me",
"$metadata.worker.version": "20260709.1",
};
const result = unwrapWorkerIdInMetadata(metadata);
expect(result?.[WORKER_ID_KEY]).toBe("deployment_span");
expect(result?.["$metadata.custom"]).toBe("keep-me");
expect(result?.["$metadata.worker.version"]).toBe("20260709.1");
});
it("leaves a legacy bare friendlyId worker.id unchanged", () => {
const metadata = { [WORKER_ID_KEY]: "deployment_legacy" };
expect(unwrapWorkerIdInMetadata(metadata)?.[WORKER_ID_KEY]).toBe("deployment_legacy");
});
it("handles absent worker.id and undefined metadata", () => {
expect(unwrapWorkerIdInMetadata(undefined)).toBeUndefined();
expect(unwrapWorkerIdInMetadata({ "$metadata.other": "x" })?.["$metadata.other"]).toBe("x");
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { evaluateCreatedAtGate } from "~/v3/services/worker/workloadTokenAuthorization.server";
const cutoff = new Date("2026-07-09T00:00:00.000Z");
const before = new Date("2026-07-01T00:00:00.000Z");
const after = new Date("2026-07-10T00:00:00.000Z");
describe("evaluateCreatedAtGate", () => {
it("grandfathers a run created before the cutoff", () => {
const result = evaluateCreatedAtGate({ runCreatedAt: before, cutoff });
expect(result.outcome).toBe("grandfathered");
expect(result.allow).toBe(true);
});
it("suppresses a run created after the cutoff", () => {
const result = evaluateCreatedAtGate({ runCreatedAt: after, cutoff });
expect(result.outcome).toBe("suppressed");
expect(result.allow).toBe(false);
});
it("treats a run created exactly at the cutoff as grandfathered (not after)", () => {
const result = evaluateCreatedAtGate({ runCreatedAt: cutoff, cutoff });
expect(result.outcome).toBe("grandfathered");
expect(result.allow).toBe(true);
});
});
@@ -0,0 +1,88 @@
import { postgresTest } from "@internal/testcontainers";
import type { PrismaClient } from "@trigger.dev/database";
import { describe, expect } from "vitest";
// The env-scoped snapshot read the platform now performs for worker actions. Mirrors the where clause
// in getLatestExecutionSnapshot so this exercises the real select against the schema.
async function readLatestSnapshot(prisma: PrismaClient, runId: string, environmentId?: string) {
return prisma.taskRunExecutionSnapshot.findFirst({
where: { runId, isValid: true, ...(environmentId ? { environmentId } : {}) },
orderBy: { createdAt: "desc" },
});
}
async function seed(prisma: PrismaClient) {
const org = await prisma.organization.create({
data: { title: "Org", slug: `org-${Date.now()}` },
});
const project = await prisma.project.create({
data: {
name: "Project",
slug: `proj-${Date.now()}`,
externalRef: `proj_${Date.now()}`,
organizationId: org.id,
},
});
const env = await prisma.runtimeEnvironment.create({
data: {
type: "PRODUCTION",
slug: "prod",
projectId: project.id,
organizationId: org.id,
apiKey: "api_key",
pkApiKey: "pk_api_key",
shortcode: "short",
},
});
const run = await prisma.taskRun.create({
data: {
friendlyId: `run_${Date.now()}`,
taskIdentifier: "test-task",
payload: "{}",
payloadType: "application/json",
traceId: "trace_1",
spanId: "span_1",
queue: "task/test-task",
runtimeEnvironmentId: env.id,
projectId: project.id,
organizationId: org.id,
},
});
await prisma.taskRunExecutionSnapshot.create({
data: {
engine: "V2",
executionStatus: "RUN_CREATED",
description: "seed",
runId: run.id,
runStatus: "PENDING",
environmentId: env.id,
environmentType: "PRODUCTION",
projectId: project.id,
organizationId: org.id,
},
});
return { env, run };
}
describe("env-scoped snapshot read against a real DB row", () => {
postgresTest(
"returns the snapshot for the matching env and nothing for another",
async ({ prisma }) => {
const { env, run } = await seed(prisma as PrismaClient);
// No env scoping -> found (internal callers)
expect(await readLatestSnapshot(prisma as PrismaClient, run.id)).not.toBeNull();
// Matching env -> found
expect(await readLatestSnapshot(prisma as PrismaClient, run.id, env.id)).not.toBeNull();
// Different env -> not found (rejected as a tenant boundary)
expect(
await readLatestSnapshot(prisma as PrismaClient, run.id, "clenvdoesnotexist000000000")
).toBeNull();
}
);
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
findUnauthorizedEnvironmentId,
type WriteCheckEnvironment,
} from "../app/v3/writableEnvironments.js";
const prod: WriteCheckEnvironment = { id: "env_prod", type: "PRODUCTION", orgMember: null };
const staging: WriteCheckEnvironment = { id: "env_staging", type: "STAGING", orgMember: null };
const myDev: WriteCheckEnvironment = {
id: "env_dev_me",
type: "DEVELOPMENT",
orgMember: { userId: "user_me" },
};
const otherDev: WriteCheckEnvironment = {
id: "env_dev_other",
type: "DEVELOPMENT",
orgMember: { userId: "user_other" },
};
const projectEnvs = [prod, staging, myDev, otherDev];
// Shared env types are writable by any project member; a DEVELOPMENT env only by
// its owner; an id not in the project is never writable.
describe("findUnauthorizedEnvironmentId", () => {
it("allows shared env types for any member", () => {
expect(
findUnauthorizedEnvironmentId(projectEnvs, ["env_prod", "env_staging"], "user_me")
).toBeNull();
});
it("allows a caller's own DEV env", () => {
expect(findUnauthorizedEnvironmentId(projectEnvs, ["env_dev_me"], "user_me")).toBeNull();
});
it("rejects another user's DEV env (the cross-user injection vector)", () => {
expect(findUnauthorizedEnvironmentId(projectEnvs, ["env_dev_other"], "user_me")).toBe(
"env_dev_other"
);
});
it("rejects a foreign DEV env even when mixed with allowed ones", () => {
expect(
findUnauthorizedEnvironmentId(
projectEnvs,
["env_prod", "env_dev_me", "env_dev_other"],
"user_me"
)
).toBe("env_dev_other");
});
it("rejects an id that isn't one of the project's environments", () => {
expect(findUnauthorizedEnvironmentId(projectEnvs, ["env_not_in_project"], "user_me")).toBe(
"env_not_in_project"
);
});
});
+19 -1
View File
@@ -75,12 +75,23 @@ git clone --depth=1 https://github.com/triggerdotdev/trigger.dev
cd trigger.dev/hosting/docker
```
2. Create a `.env` file
2. Create a `.env` file and generate secrets
```bash
cp .env.example .env
# Fills the required secrets in .env with strong, unique values.
# Safe to re-run - it never overwrites a secret you've already set.
./generate-secrets.sh
```
<Note>
The stack ships no working default credentials. `generate-secrets.sh` fills the
application secrets and the bundled datastore passwords with strong, unique values.
Keep them safe - rotating the encryption key or session secret later will invalidate
existing sessions and encrypted data.
</Note>
3. Start the webapp
```bash
@@ -130,6 +141,13 @@ docker compose up -d
4. Configure the supervisor using the [environment variables](/self-hosting/env/supervisor) in your `.env` file, including the [worker token](#worker-token).
<Warning>
For a split webapp/worker setup, set `MANAGED_WORKER_SECRET` on the worker to
the **same** value as the webapp's `MANAGED_WORKER_SECRET`. Don't run
`generate-secrets.sh` on the worker host - it would create a mismatched value
and the worker would fail to authenticate.
</Warning>
5. Apply the changes:
```bash
+2 -1
View File
@@ -11,7 +11,8 @@ mode: "wide"
| `SESSION_SECRET` | Yes | — | Session encryption secret. Run: `openssl rand -hex 16` |
| `MAGIC_LINK_SECRET` | Yes | — | Magic link encryption secret. Run: `openssl rand -hex 16` |
| `ENCRYPTION_KEY` | Yes | — | Secret store encryption key. Run: `openssl rand -hex 16` |
| `MANAGED_WORKER_SECRET` | No | managed-secret | Managed worker secret. Should be changed and match supervisor. |
| `MANAGED_WORKER_SECRET` | Yes | — | Managed worker secret. Must be set and match supervisor. Run: `openssl rand -hex 32` |
| `ALLOW_INSECURE_DEFAULT_SECRETS` | No | false | Boot even if a secret is still a known-insecure published default. Temporary escape hatch for values you can't safely rotate yet (see [Secret generation and rotation](/self-hosting/kubernetes#secret-generation-and-rotation)). |
| **Domains & ports** | | | |
| `REMIX_APP_PORT` | No | 3030 | Remix app port. |
| `APP_ORIGIN` | Yes | http://localhost:3030 | App origin URL. |
+24 -2
View File
@@ -121,8 +121,10 @@ The default values are insecure and are only suitable for testing. You will need
Create a `values-custom.yaml` file to override the defaults. For example:
```yaml
# Generate new secrets with `openssl rand -hex 16`
# WARNING: You should probably use an existingSecret instead
# Leave these unset to have the chart auto-generate strong values on first
# install (retained across upgrades). Set them explicitly only if you need to
# control the value - e.g. sharing MANAGED_WORKER_SECRET with an external
# supervisor - or use an existingSecret.
secrets:
enabled: true
sessionSecret: "your-32-char-hex-secret-1"
@@ -133,6 +135,8 @@ secrets:
# - SESSION_SECRET
# - MAGIC_LINK_SECRET
# - ENCRYPTION_KEY
# - PROVIDER_SECRET
# - COORDINATOR_SECRET
# - MANAGED_WORKER_SECRET
# - OBJECT_STORE_ACCESS_KEY_ID
# - OBJECT_STORE_SECRET_ACCESS_KEY
@@ -176,6 +180,24 @@ helm upgrade -n trigger --install trigger \
-f values-custom.yaml
```
### Secret generation and rotation
Application, control-plane, and bundled-datastore secrets left unset are generated on
first install and **retained across `helm upgrade`** - they are never rotated
automatically, so sessions, encrypted data, and datastore volumes survive upgrades.
<Warning>
GitOps tools that render with `helm template` (e.g. Argo CD) cannot read the existing
secret, so they regenerate these values on every sync - which rotates them. If you
deploy via GitOps, always supply your own `secrets.existingSecret` (and datastore
credentials) so nothing is generated in-cluster.
</Warning>
There is no clean migration for a compromised `ENCRYPTION_KEY`: changing it makes
existing encrypted data unreadable. If a deployment is still running a previously
published default and cannot rotate yet, set `ALLOW_INSECURE_DEFAULT_SECRETS=true` on
the webapp to keep booting while you plan a migration.
### Extra env
You can set extra environment variables on all services. For example:
+32 -24
View File
@@ -3,13 +3,16 @@
# - You should change them to suit your needs, especially the secrets
# - See the docs for more information: https://trigger.dev/docs/self-hosting/overview
# Secrets
# - Do NOT use these defaults in production
# - Generate your own by running `openssl rand -hex 16` for each secret
SESSION_SECRET=2818143646516f6fffd707b36f334bbb
MAGIC_LINK_SECRET=44da78b7bbb0dfe709cf38931d25dcdd
ENCRYPTION_KEY=f686147ab967943ebbe9ed3b496e465a
MANAGED_WORKER_SECRET=447c29678f9eaf289e9c4b70d3dd8a7f
# Secrets — REQUIRED, no defaults. The stack will not boot until each is set to a unique value.
# Generate each with: openssl rand -hex 16
SESSION_SECRET=
MAGIC_LINK_SECRET=
ENCRYPTION_KEY=
# These authenticate the internal control-plane connections. Generate each with: openssl rand -hex 16
# COORDINATOR_SECRET must match the coordinator's PLATFORM_SECRET; MANAGED_WORKER_SECRET the supervisor's.
PROVIDER_SECRET=
COORDINATOR_SECRET=
MANAGED_WORKER_SECRET=
# Worker token
# - This is the token for the worker to connect to the webapp
@@ -24,13 +27,14 @@ MANAGED_WORKER_SECRET=447c29678f9eaf289e9c4b70d3dd8a7f
# OTEL_EXPORTER_OTLP_ENDPOINT=https://trigger.example.com/otel
# Postgres
# - Do NOT use these defaults in production
# - Especially if you decide to expose the database to the internet
# - Password is REQUIRED, no default. Run ./generate-secrets.sh to fill it (or openssl rand -hex 16).
# - DATABASE_URL / DIRECT_URL are derived from POSTGRES_PASSWORD automatically - only set them
# below to point at an external Postgres (POSTGRES_PASSWORD is then unused).
# POSTGRES_USER=postgres
POSTGRES_PASSWORD=unsafe-postgres-pw
POSTGRES_PASSWORD=
# POSTGRES_DB=postgres
DATABASE_URL=postgresql://postgres:unsafe-postgres-pw@postgres:5432/main?schema=public&sslmode=disable
DIRECT_URL=postgresql://postgres:unsafe-postgres-pw@postgres:5432/main?schema=public&sslmode=disable
# DATABASE_URL=postgresql://user:password@host:5432/main?schema=public&sslmode=disable
# DIRECT_URL=postgresql://user:password@host:5432/main?schema=public&sslmode=disable
# Trigger image tag
# - This is the version of the webapp and worker images to use, they should be locked to a specific version in production
@@ -59,19 +63,21 @@ DEV_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:8030/otel
# NODE_MAX_OLD_SPACE_SIZE=8192
# ClickHouse
# - Do NOT use these defaults in production
# - Password is REQUIRED, no default. Run ./generate-secrets.sh to fill it.
# - CLICKHOUSE_URL / RUN_REPLICATION_CLICKHOUSE_URL are derived from CLICKHOUSE_PASSWORD
# automatically - only set them below to point at an external ClickHouse.
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=password
CLICKHOUSE_URL=http://default:password@clickhouse:8123?secure=false
RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@clickhouse:8123
CLICKHOUSE_PASSWORD=
# CLICKHOUSE_URL=http://user:password@host:8123?secure=false
# RUN_REPLICATION_CLICKHOUSE_URL=http://user:password@host:8123
# Docker Registry
# - When testing locally, the default values should be fine
# - When deploying to production, you will have to change these, especially the password and URL
# - Password is REQUIRED, no default. Run ./generate-secrets.sh to fill it - it also writes
# the matching registry/auth.htpasswd (bcrypt) the bundled registry authenticates against.
# - See the docs for more information: https://trigger.dev/docs/self-hosting/docker#registry-setup
DOCKER_REGISTRY_URL=localhost:5000
DOCKER_REGISTRY_USERNAME=registry-user
DOCKER_REGISTRY_PASSWORD=very-secure-indeed
DOCKER_REGISTRY_PASSWORD=
# When using an external registry you will have to change this
# On Docker Hub it should generally be the same as your username
DOCKER_REGISTRY_NAMESPACE=trigger
@@ -80,8 +86,10 @@ DOCKER_REGISTRY_NAMESPACE=trigger
# - You need to log into the Minio dashboard and create a bucket called "packets"
# - See the docs for more information: https://trigger.dev/docs/self-hosting/docker#object-storage
# Default provider (backward compatible - no protocol prefix)
# - Secret access key is REQUIRED, no default. Run ./generate-secrets.sh to fill it.
# - For the bundled MinIO, these ARE its root credentials (MINIO_ROOT_USER/PASSWORD derive from them).
OBJECT_STORE_ACCESS_KEY_ID=admin
OBJECT_STORE_SECRET_ACCESS_KEY=very-safe-password
OBJECT_STORE_SECRET_ACCESS_KEY=
# You will have to uncomment and configure this for production
# OBJECT_STORE_BASE_URL=http://localhost:9000
# OBJECT_STORE_REGION=auto
@@ -100,11 +108,11 @@ OBJECT_STORE_SECRET_ACCESS_KEY=very-safe-password
# OBJECT_STORE_R2_SECRET_ACCESS_KEY=
# OBJECT_STORE_R2_REGION=auto
# OBJECT_STORE_R2_SERVICE=s3
# Credentials to access the Minio dashboard at http://localhost:9001
# - You should change these credentials and not use them for the `OBJECT_STORE_` env vars above
# - Instead, setup a non-root user with access the "packets" bucket
# Minio dashboard at http://localhost:9001
# - The bundled Minio's root credentials default to OBJECT_STORE_ACCESS_KEY_ID / OBJECT_STORE_SECRET_ACCESS_KEY.
# - For production, set a separate root user here and create a non-root user scoped to the "packets" bucket for OBJECT_STORE_*.
# MINIO_ROOT_USER=admin
# MINIO_ROOT_PASSWORD=very-safe-password
# MINIO_ROOT_PASSWORD=
# Realtime streams
# - Realtime streams power AI-agent token streaming and run streams
+110
View File
@@ -0,0 +1,110 @@
#!/bin/sh
# Generate strong, unique secrets and datastore passwords into the self-hosting .env.
#
# Safe to re-run: only fills values that are missing or empty, and never overwrites
# one that is already set. Rotating a live secret would orphan encrypted data, log
# everyone out, or break an already-initialised datastore volume - so rotation is
# opt-in only, via --force (which WILL break existing data/sessions).
set -eu
FORCE=0
for arg in "$@"; do
case "$arg" in
-f | --force) FORCE=1 ;;
-h | --help)
echo "Usage: $0 [--force] [env-file]"
echo " --force Regenerate every secret, overwriting existing values."
echo " WARNING: rotates live secrets - breaks encrypted data, sessions,"
echo " and already-initialised datastore volumes."
exit 0
;;
*) env_file="$arg" ;;
esac
done
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
env_file="${env_file:-$script_dir/.env}"
env_example="$script_dir/.env.example"
htpasswd_file="$script_dir/registry/auth.htpasswd"
# openssl rand -hex 16 -> 32 hex chars: URL-safe (no @ : / etc. to break connection
# strings) and satisfies the webapp's exact-32-byte ENCRYPTION_KEY check.
gen() { openssl rand -hex 16; }
if [ ! -f "$env_file" ]; then
cp "$env_example" "$env_file"
echo "Created $(basename "$env_file") from $(basename "$env_example")"
fi
sed_inplace() {
if [ "$(uname)" = "Darwin" ]; then
sed -i '' "$@"
else
sed -i "$@"
fi
}
# Value of KEY= in the env file, trailing inline comment/whitespace stripped, so
# "KEY=" and "KEY= # todo" both read as unset.
current_value() {
grep -E "^$1=" "$env_file" | head -n1 | cut -d= -f2- \
| sed -e 's/[[:space:]]*#.*$//' -e 's/[[:space:]]*$//'
}
set_var() {
key="$1"
value="$2"
if grep -qE "^$key=" "$env_file"; then
sed_inplace -e "s|^$key=.*|$key=$value|" "$env_file"
else
printf '%s=%s\n' "$key" "$value" >>"$env_file"
fi
}
# Fill KEY with a fresh secret unless it already has a value (respecting --force).
# Returns 0 if it wrote, 1 if it skipped.
fill() {
key="$1"
if [ "$FORCE" -eq 0 ] && [ -n "$(current_value "$key")" ]; then
return 1
fi
set_var "$key" "$(gen)"
echo "Generated $key"
return 0
}
generated=0
# App + control-plane secrets, and the bundled-datastore passwords. All plain
# values consumed directly (or, for datastores, woven into the connection URLs by
# docker-compose interpolation - see .env.example).
for key in \
SESSION_SECRET MAGIC_LINK_SECRET ENCRYPTION_KEY \
PROVIDER_SECRET COORDINATOR_SECRET MANAGED_WORKER_SECRET \
POSTGRES_PASSWORD CLICKHOUSE_PASSWORD OBJECT_STORE_SECRET_ACCESS_KEY; do
if fill "$key"; then generated=$((generated + 1)); fi
done
# Registry is special: the bundled registry authenticates against a bcrypt htpasswd
# file, so when we set its password we must regenerate that file to match. bcrypt is
# the only hash the registry accepts, and openssl can't produce it - use httpd's
# htpasswd via docker (already required for this stack).
if [ "$FORCE" -eq 1 ] || [ -z "$(current_value DOCKER_REGISTRY_PASSWORD)" ]; then
registry_user=$(current_value DOCKER_REGISTRY_USERNAME)
registry_user=${registry_user:-registry-user}
registry_pass=$(gen)
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: docker is required to hash the registry password (bcrypt). Install docker and re-run." >&2
exit 1
fi
docker run --rm httpd:2 htpasswd -Bbn "$registry_user" "$registry_pass" >"$htpasswd_file"
set_var DOCKER_REGISTRY_PASSWORD "$registry_pass"
echo "Generated DOCKER_REGISTRY_PASSWORD (and wrote $(basename "$htpasswd_file"))"
generated=$((generated + 1))
fi
if [ "$generated" -eq 0 ]; then
echo "All secrets already set in $(basename "$env_file"); nothing to do. Use --force to rotate."
else
echo "Wrote $generated secret(s) to $(basename "$env_file")."
fi
-1
View File
@@ -1 +0,0 @@
registry-user:$2y$05$6ingYqw0.3j13dxHY4w3neMSvKhF3pvRmc0AFifScWsVA9JpuLwNK

Some files were not shown because too many files have changed in this diff Show More