From e004b36b822f377db950526abe07a3ba38e2c536 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 22 May 2026 18:22:39 +0100 Subject: [PATCH 1/5] feat(webapp,rbac): REQUIRE_PLUGINS=1 fail-fast for required plugin loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a deployment-mode opt-in that hardens the plugin loader against silent degradation. Today, when the RBAC plugin module fails to load — whether because it's not installed or because a transitive dep is broken — the loader catches the error and returns the default fallback implementation. Correct for self-hosters; dangerous in deployments where the fallback's permissive auth is not an acceptable degraded state. - internal-packages/rbac/src/index.ts: in LazyController.load()'s catch block, after logging, throw an Error when `process.env.REQUIRE_PLUGINS === "1"`. The throw is captured into `this._init` (a rejected promise), so it surfaces on the first method call on the lazy controller. The forceFallback option (used by tests) still wins — it short-circuits before this code path, so tests aren't broken if a test runner happens to inherit REQUIRE_PLUGINS from its parent env. - apps/webapp/app/routes/healthcheck.tsx: call `rbac.isUsingPlugin()` after the DB ping. For self-hosters (and any deployment with the plugin loading cleanly) this is a noop. With REQUIRE_PLUGINS=1 and a failed plugin load, the throw surfaces here — healthcheck returns 500 and the rollout's readiness probe fails, so the new revision is rolled back before traffic shifts. REQUIRE_PLUGINS is intentionally plural and generic — future plugin contracts (audit logs, SSO) can read the same flag without renaming. Co-Authored-By: Claude Opus 4.7 (1M context) --- .server-changes/require-plugins-fail-fast.md | 8 ++++++++ apps/webapp/app/routes/healthcheck.tsx | 8 ++++++++ internal-packages/rbac/src/index.ts | 14 ++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 .server-changes/require-plugins-fail-fast.md diff --git a/.server-changes/require-plugins-fail-fast.md b/.server-changes/require-plugins-fail-fast.md new file mode 100644 index 00000000000..591cd47c402 --- /dev/null +++ b/.server-changes/require-plugins-fail-fast.md @@ -0,0 +1,8 @@ +--- +area: webapp +type: feature +--- + +Add `REQUIRE_PLUGINS=1` env var. When set, the RBAC plugin loader throws instead of silently falling back to the default implementation if the plugin module fails to load (missing, broken transitive dep, etc.). The webapp's `/healthcheck` route now resolves the lazy plugin controller so the throw surfaces during readiness probes — a deploy where the plugin didn't load fails the probe and is rolled back. + +Self-hosters leave `REQUIRE_PLUGINS` unset and continue to use the fallback when no plugin is installed. diff --git a/apps/webapp/app/routes/healthcheck.tsx b/apps/webapp/app/routes/healthcheck.tsx index 19b5c962f1c..6f2ec91e4f3 100644 --- a/apps/webapp/app/routes/healthcheck.tsx +++ b/apps/webapp/app/routes/healthcheck.tsx @@ -1,6 +1,7 @@ import { prisma } from "~/db.server"; import type { LoaderFunction } from "@remix-run/node"; import { env } from "~/env.server"; +import { rbac } from "~/services/rbac.server"; export const loader: LoaderFunction = async ({ request }) => { try { @@ -9,6 +10,13 @@ export const loader: LoaderFunction = async ({ request }) => { } await prisma.$queryRaw`SELECT 1`; + + // Resolve the lazy plugin controller so plugin-load failures surface + // during readiness probes. With REQUIRE_PLUGINS=1, a failed plugin + // load throws here and the rollout's readiness probe fails. Without + // REQUIRE_PLUGINS, the fallback resolves cleanly and this is a noop. + await rbac.isUsingPlugin(); + return new Response("OK"); } catch (error: unknown) { console.log("healthcheck ❌", { error }); diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index af76d7b394d..9b447b955c5 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -105,6 +105,20 @@ class LazyController implements RoleBaseAccessController { "RBAC: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback" ); } + + // Fail-fast for deployments that require plugins to be present. Set + // REQUIRE_PLUGINS=1 in environments where the fallback is not an + // acceptable degraded state — the throw surfaces on the first method + // call on the lazy controller (e.g. via the webapp's /healthcheck + // route), so the rollout's readiness probe fails and the deploy is + // rolled back. Self-hosters leave REQUIRE_PLUGINS unset and continue + // to use the fallback when no plugin is installed. + if (process.env.REQUIRE_PLUGINS === "1") { + throw new Error( + `REQUIRE_PLUGINS=1 but plugin "${moduleName}" did not load: ${message}` + ); + } + return new RoleBaseAccessFallback(prisma).create(); } } From b37e7123dad1b68ee4b16db60846d966e296b118 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 23 May 2026 20:32:46 +0100 Subject: [PATCH 2/5] test(rbac): cover REQUIRE_PLUGINS fail-fast paths in the lazy controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four tests in require-plugins.test.ts driving the loader's branching: - REQUIRE_PLUGINS unset + plugin missing → falls back (resolves false) - REQUIRE_PLUGINS=1 + plugin missing → rejects on first method call - forceFallback: true beats REQUIRE_PLUGINS=1 (test escape hatch) - Any non-"1" value of REQUIRE_PLUGINS is treated as unset The plugin module isn't installed in this OSS repo, so the dynamic import naturally fails with ERR_MODULE_NOT_FOUND — no module mocking needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rbac/src/require-plugins.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 internal-packages/rbac/src/require-plugins.test.ts diff --git a/internal-packages/rbac/src/require-plugins.test.ts b/internal-packages/rbac/src/require-plugins.test.ts new file mode 100644 index 00000000000..0b81674f536 --- /dev/null +++ b/internal-packages/rbac/src/require-plugins.test.ts @@ -0,0 +1,44 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import loader from "./index.js"; + +// The plugin module `@triggerdotdev/plugins/rbac` is not installed in this +// repo (it lives in the cloud monorepo), so a real dynamic import inside +// the loader will reliably fail with ERR_MODULE_NOT_FOUND. These tests +// exercise the loader's branching on that natural failure — no module +// mocking required. + +// The fallback's isUsingPlugin() returns false synchronously without +// touching prisma, so a placeholder client is fine for tests that only +// drive the loader path. +const prismaPlaceholder = {} as unknown as PrismaClient; + +describe("LazyController plugin loading", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("falls back silently when REQUIRE_PLUGINS is unset and the plugin is missing", async () => { + vi.stubEnv("REQUIRE_PLUGINS", ""); + const controller = loader.create(prismaPlaceholder); + await expect(controller.isUsingPlugin()).resolves.toBe(false); + }); + + it("throws when REQUIRE_PLUGINS=1 and the plugin is missing", async () => { + vi.stubEnv("REQUIRE_PLUGINS", "1"); + const controller = loader.create(prismaPlaceholder); + await expect(controller.isUsingPlugin()).rejects.toThrow(/REQUIRE_PLUGINS=1/); + }); + + it("forceFallback wins over REQUIRE_PLUGINS=1 (so tests inheriting the env aren't broken)", async () => { + vi.stubEnv("REQUIRE_PLUGINS", "1"); + const controller = loader.create(prismaPlaceholder, { forceFallback: true }); + await expect(controller.isUsingPlugin()).resolves.toBe(false); + }); + + it("treats any non-'1' REQUIRE_PLUGINS value as unset (must be exactly '1' to enforce)", async () => { + vi.stubEnv("REQUIRE_PLUGINS", "true"); + const controller = loader.create(prismaPlaceholder); + await expect(controller.isUsingPlugin()).resolves.toBe(false); + }); +}); From 11efcd3437cad48f9bb1951d79e9335cb57665ac Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 23 May 2026 20:42:37 +0100 Subject: [PATCH 3/5] test(webapp): e2e verify /healthcheck returns 500 with REQUIRE_PLUGINS=1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop on the unit-test loader coverage by spawning a real webapp + DB + Redis and verifying via HTTP: - REQUIRE_PLUGINS=1 + plugin missing → GET /healthcheck → 500 (so ECS/k8s readiness probes fail and the rollout rolls back). - REQUIRE_PLUGINS unset + plugin missing → GET /healthcheck → 200 (baseline — self-hoster behaviour unchanged). - internal-packages/testcontainers/src/webapp.ts: adds `requirePlugins?: boolean` to StartWebappOptions. Implies `forceRbacFallback: false` (you can't observe the throw if the loader short-circuits to the fallback). - apps/webapp/test/healthcheck-require-plugins.e2e.test.ts: two describes, each with its own webapp instance (~30s spawn each) since they need different env. Auto-picked up by vitest.e2e.config.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../healthcheck-require-plugins.e2e.test.ts | 64 +++++++++++++++++++ .../testcontainers/src/webapp.ts | 18 +++++- 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 apps/webapp/test/healthcheck-require-plugins.e2e.test.ts diff --git a/apps/webapp/test/healthcheck-require-plugins.e2e.test.ts b/apps/webapp/test/healthcheck-require-plugins.e2e.test.ts new file mode 100644 index 00000000000..6570673e90d --- /dev/null +++ b/apps/webapp/test/healthcheck-require-plugins.e2e.test.ts @@ -0,0 +1,64 @@ +/** + * E2E verification that REQUIRE_PLUGINS=1 fails the rollout via /healthcheck. + * + * The unit tests in @trigger.dev/rbac cover the loader throw. This file + * closes the loop end-to-end: spawn a real webapp, hit /healthcheck via + * HTTP, and verify the route's catch turns the throw into a 500 — the + * status the ECS/k8s readiness probe rolls back on. + * + * Each case spawns its own webapp + Postgres + Redis container (~30s) so + * env can differ per case. Slow but isolated, matching api-auth.e2e.test.ts. + * + * Requires a pre-built webapp: pnpm run build --filter webapp + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { TestServer } from "@internal/testcontainers/webapp"; +import { startTestServer } from "@internal/testcontainers/webapp"; + +vi.setConfig({ testTimeout: 180_000 }); + +describe("/healthcheck with REQUIRE_PLUGINS", () => { + describe("REQUIRE_PLUGINS=1 + plugin missing", () => { + let server: TestServer; + + beforeAll(async () => { + // requirePlugins: true implies forceRbacFallback: false, so the + // loader actually tries to dynamic-import the plugin. The plugin + // is not installed in this OSS repo, so the import fails and the + // loader throws (instead of falling back) because REQUIRE_PLUGINS=1. + // The throw surfaces on the first .isUsingPlugin() call from the + // /healthcheck route, which catches it and returns 500. + server = await startTestServer({ requirePlugins: true }); + }, 180_000); + + afterAll(async () => { + await server?.stop(); + }, 120_000); + + it("returns 500 so the readiness probe fails and the rollout is rolled back", async () => { + const res = await server.webapp.fetch("/healthcheck"); + expect(res.status).toBe(500); + expect(await res.text()).toBe("ERROR"); + }); + }); + + describe("REQUIRE_PLUGINS unset + plugin missing", () => { + let server: TestServer; + + beforeAll(async () => { + // Default: forceRbacFallback=true so the loader short-circuits to + // the fallback without trying to import. /healthcheck succeeds. + server = await startTestServer(); + }, 180_000); + + afterAll(async () => { + await server?.stop(); + }, 120_000); + + it("returns 200 (baseline — unchanged self-hoster behaviour)", async () => { + const res = await server.webapp.fetch("/healthcheck"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("OK"); + }); + }); +}); diff --git a/internal-packages/testcontainers/src/webapp.ts b/internal-packages/testcontainers/src/webapp.ts index 108eb911971..fb001b82b08 100644 --- a/internal-packages/testcontainers/src/webapp.ts +++ b/internal-packages/testcontainers/src/webapp.ts @@ -49,6 +49,18 @@ export interface StartWebappOptions { * plugin instead, for testing the plugin path. */ forceRbacFallback?: boolean; + + /** + * When true, spawns the webapp with `REQUIRE_PLUGINS=1` so the plugin + * loader throws instead of silently falling back when the plugin + * module fails to load. Used by the healthcheck rollback e2e test — + * with this set and the plugin not installed, `/healthcheck` should + * return 500. + * + * Implies `forceRbacFallback: false` (you can't observe REQUIRE_PLUGINS + * behaviour when the loader is short-circuited). + */ + requirePlugins?: boolean; } export async function startWebapp( @@ -59,7 +71,10 @@ export async function startWebapp( instance: WebappInstance; stop: () => Promise; }> { - const forceRbacFallback = options.forceRbacFallback ?? true; + // requirePlugins implies forceRbacFallback=false — you can't observe the + // REQUIRE_PLUGINS=1 throw if the loader short-circuits before reaching it. + const requirePlugins = options.requirePlugins ?? false; + const forceRbacFallback = options.forceRbacFallback ?? !requirePlugins; const port = await findFreePort(); // Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable @@ -107,6 +122,7 @@ export async function startWebapp( // plugin is installed in the local node_modules. Set to "0" / // undefined to spawn a webapp that loads any installed plugin. ...(forceRbacFallback ? { RBAC_FORCE_FALLBACK: "1" } : {}), + ...(requirePlugins ? { REQUIRE_PLUGINS: "1" } : {}), NODE_PATH: nodePath, }, stdio: ["ignore", "pipe", "pipe"], From b0f944c9add13618c7b2653076765dcc1d05b3b7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sat, 23 May 2026 21:16:17 +0100 Subject: [PATCH 4/5] fix(rbac,testcontainers): make e2e healthcheck-require-plugins actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes uncovered by running the e2e test locally: - internal-packages/rbac/src/index.ts: attach a no-op .catch() to LazyController._init in the constructor. load() runs eagerly but its result is only awaited on the first method call. When load() rejects (REQUIRE_PLUGINS=1 + plugin missing), Node flags the unawaited rejection as unhandledRejection — newer Node versions kill the process before any consumer can await _init and convert the throw into a 500. The .catch() marks the rejection as handled at the global level; the actual error still re-throws when c() awaits _init. - internal-packages/testcontainers/src/webapp.ts: when requirePlugins is set, explicitly override RBAC_FORCE_FALLBACK="0" so a local apps/webapp/.env that sets it to "1" doesn't short-circuit the loader past the REQUIRE_PLUGINS check. Without this, the test passes in CI (no .env file) but fails locally with no obvious error. - internal-packages/testcontainers/src/webapp.ts: waitForHealthcheck now takes an `acceptAnyResponse` flag. When requirePlugins is set, /healthcheck is *expected* to return 500 (the whole point of the test), so the bootstrap waiter must accept any HTTP response rather than only 200 — otherwise startTestServer times out before the test can assert. apps/webapp/test/healthcheck-require-plugins.e2e.test.ts: tidied up the locally-linked-skip placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../healthcheck-require-plugins.e2e.test.ts | 30 ++++++++++++++++++- internal-packages/rbac/src/index.ts | 7 +++++ .../testcontainers/src/webapp.ts | 20 ++++++++++--- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/webapp/test/healthcheck-require-plugins.e2e.test.ts b/apps/webapp/test/healthcheck-require-plugins.e2e.test.ts index 6570673e90d..17e02ce8d93 100644 --- a/apps/webapp/test/healthcheck-require-plugins.e2e.test.ts +++ b/apps/webapp/test/healthcheck-require-plugins.e2e.test.ts @@ -10,15 +10,32 @@ * env can differ per case. Slow but isolated, matching api-auth.e2e.test.ts. * * Requires a pre-built webapp: pnpm run build --filter webapp + * + * The REQUIRE_PLUGINS=1 case relies on the plugin NOT being resolvable + * from the spawned webapp. CI satisfies this because the plugin isn't in + * pnpm-lock.yaml. Local devs who ran `pnpm dev:link-webapp` have the + * plugin symlinked into apps/webapp/node_modules — that case is detected + * and skipped below. */ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { TestServer } from "@internal/testcontainers/webapp"; import { startTestServer } from "@internal/testcontainers/webapp"; +const LINKED_PLUGIN_PATH = resolve( + __dirname, + "..", + "node_modules", + "@triggerdotdev", + "plugins" +); +const pluginLocallyLinked = existsSync(LINKED_PLUGIN_PATH); + vi.setConfig({ testTimeout: 180_000 }); describe("/healthcheck with REQUIRE_PLUGINS", () => { - describe("REQUIRE_PLUGINS=1 + plugin missing", () => { + describe.skipIf(pluginLocallyLinked)("REQUIRE_PLUGINS=1 + plugin missing", () => { let server: TestServer; beforeAll(async () => { @@ -42,6 +59,17 @@ describe("/healthcheck with REQUIRE_PLUGINS", () => { }); }); + // Surface the skip in dev so it doesn't go unnoticed. CI hits the real test above. + describe.runIf(pluginLocallyLinked)( + "REQUIRE_PLUGINS=1 + plugin LOCALLY LINKED (cross-repo dev setup)", + () => { + it.skip( + `skipped because ${LINKED_PLUGIN_PATH} exists — plugin would load successfully. Run \`pnpm dev:unlink-webapp\` to exercise this case locally; CI runs it without the link.`, + () => {} + ); + } + ); + describe("REQUIRE_PLUGINS unset + plugin missing", () => { let server: TestServer; diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts index 9b447b955c5..3f74dd83f51 100644 --- a/internal-packages/rbac/src/index.ts +++ b/internal-packages/rbac/src/index.ts @@ -53,6 +53,13 @@ class LazyController implements RoleBaseAccessController { constructor(prisma: RbacPrismaInput, options?: RbacCreateOptions) { this._init = this.load(prisma, options); + // load() runs eagerly but the result is awaited lazily on first method + // call. If load() rejects (e.g. REQUIRE_PLUGINS=1 + plugin missing) and + // nothing awaits _init before Node ticks past, the rejection surfaces + // as unhandledRejection and kills the process. Attach a no-op .catch + // so Node sees the rejection as handled; the error is re-thrown when + // any consumer awaits this._init via c(). + this._init.catch(() => {}); } private async load( diff --git a/internal-packages/testcontainers/src/webapp.ts b/internal-packages/testcontainers/src/webapp.ts index fb001b82b08..0c24ec546a1 100644 --- a/internal-packages/testcontainers/src/webapp.ts +++ b/internal-packages/testcontainers/src/webapp.ts @@ -20,12 +20,16 @@ async function findFreePort(): Promise { }); } -async function waitForHealthcheck(url: string, timeoutMs = 60000): Promise { +async function waitForHealthcheck( + url: string, + opts: { timeoutMs?: number; acceptAnyResponse?: boolean } = {} +): Promise { + const { timeoutMs = 60000, acceptAnyResponse = false } = opts; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const res = await fetch(url); - if (res.ok) return; + if (acceptAnyResponse || res.ok) return; } catch {} await new Promise((r) => setTimeout(r, 500)); } @@ -122,7 +126,10 @@ export async function startWebapp( // plugin is installed in the local node_modules. Set to "0" / // undefined to spawn a webapp that loads any installed plugin. ...(forceRbacFallback ? { RBAC_FORCE_FALLBACK: "1" } : {}), - ...(requirePlugins ? { REQUIRE_PLUGINS: "1" } : {}), + // When requirePlugins is set, explicitly override RBAC_FORCE_FALLBACK + // to "0" so a local apps/webapp/.env that sets it to "1" doesn't + // short-circuit the loader past the REQUIRE_PLUGINS check. + ...(requirePlugins ? { REQUIRE_PLUGINS: "1", RBAC_FORCE_FALLBACK: "0" } : {}), NODE_PATH: nodePath, }, stdio: ["ignore", "pipe", "pipe"], @@ -158,7 +165,12 @@ export async function startWebapp( try { if (spawnError) throw spawnError; - await waitForHealthcheck(`${baseUrl}/healthcheck`); + // When requirePlugins is set, /healthcheck is expected to respond with + // 500 (the whole point of those tests is to verify that). Accept any + // response as "the webapp is up" — the test then asserts the status. + await waitForHealthcheck(`${baseUrl}/healthcheck`, { + acceptAnyResponse: requirePlugins, + }); if (spawnError) throw spawnError; } catch (err) { proc.kill("SIGTERM"); From 39fb31345ddb422861214079effdc1ede7145c0f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Sun, 24 May 2026 19:10:38 +0100 Subject: [PATCH 5/5] fix(webapp): close two bypass paths around the REQUIRE_PLUGINS healthcheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two paths could silently skip the new plugin-load gate, found in review: - server.ts: when DASHBOARD_AND_API_DISABLED=true, /healthcheck was served by a static Express handler (200 OK) that bypassed the Remix loader entirely. Forward to createRequestHandler in that branch too so the loader's readiness checks (DB ping + rbac.isUsingPlugin()) run in every deployment mode. - healthcheck.tsx: rbac.isUsingPlugin() sat after the HEALTHCHECK_DATABASE_DISABLED early return, so it never ran when that flag was set. The rbac fallback doesn't touch the DB (fallback.ts isUsingPlugin returns false unconditionally), so move the rbac check above the DB-disabled guard — REQUIRE_PLUGINS protection now applies regardless of the DB-healthcheck setting. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/webapp/app/routes/healthcheck.tsx | 14 ++++++++------ apps/webapp/server.ts | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/routes/healthcheck.tsx b/apps/webapp/app/routes/healthcheck.tsx index 6f2ec91e4f3..4ad3992e3c1 100644 --- a/apps/webapp/app/routes/healthcheck.tsx +++ b/apps/webapp/app/routes/healthcheck.tsx @@ -5,18 +5,20 @@ import { rbac } from "~/services/rbac.server"; export const loader: LoaderFunction = async ({ request }) => { try { + // Resolve the lazy plugin controller so plugin-load failures surface + // during readiness probes. With REQUIRE_PLUGINS=1, a failed plugin + // load throws here and the rollout's readiness probe fails. The + // fallback path doesn't touch the DB, so this runs even when + // HEALTHCHECK_DATABASE_DISABLED=1 — REQUIRE_PLUGINS protection must + // not be silently bypassed by the DB-disabled flag. + await rbac.isUsingPlugin(); + if (env.HEALTHCHECK_DATABASE_DISABLED === "1") { return new Response("OK"); } await prisma.$queryRaw`SELECT 1`; - // Resolve the lazy plugin controller so plugin-load failures surface - // during readiness probes. With REQUIRE_PLUGINS=1, a failed plugin - // load throws here and the rollout's readiness probe fails. Without - // REQUIRE_PLUGINS, the fallback resolves cleanly and this is a noop. - await rbac.isUsingPlugin(); - return new Response("OK"); } catch (error: unknown) { console.log("healthcheck ❌", { error }); diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index 435a42b6b69..3649516cbaa 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -188,10 +188,18 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { }) ); } else { - // we need to do the health check here at /healthcheck - app.get("/healthcheck", (req, res) => { - res.status(200).send("OK"); - }); + // we need to do the health check here at /healthcheck — forward + // to the Remix handler so the loader's readiness checks (DB ping, + // REQUIRE_PLUGINS-gated plugin load) run in this mode too. A + // static 200 here would silently mask a failed plugin load. + app.get( + "/healthcheck", + // @ts-ignore + createRequestHandler({ + build, + mode: MODE, + }) + ); } const server = app.listen(port, () => {