diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 99867ef852d..e8b1e1607be 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -26,6 +26,7 @@ "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", + "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", diff --git a/apps/realtime/src/bootstrap.ts b/apps/realtime/src/bootstrap.ts new file mode 100644 index 00000000000..fe786372052 --- /dev/null +++ b/apps/realtime/src/bootstrap.ts @@ -0,0 +1,9 @@ +/** + * Container entrypoint. Hydrates `process.env` from the runtime secret before + * loading the Socket.IO server, whose modules (`@/env`, DB preflight) read env + * at import time. See `@sim/runtime-secrets`. + */ +import { loadRuntimeSecrets } from '@sim/runtime-secrets' + +await loadRuntimeSecrets() +await import('@/index') diff --git a/apps/sim/bootstrap.ts b/apps/sim/bootstrap.ts new file mode 100644 index 00000000000..bc2e92b882c --- /dev/null +++ b/apps/sim/bootstrap.ts @@ -0,0 +1,13 @@ +/** + * Container entrypoint. Hydrates `process.env` from the runtime secret before + * loading the Next.js standalone server, so application modules that read env at + * import time see the full configuration. See `@sim/runtime-secrets`. + */ +import { loadRuntimeSecrets } from '@sim/runtime-secrets' + +await loadRuntimeSecrets() +// `server.js` is the Next standalone build artifact, a sibling of this file in +// the image; it does not exist at type-check time, so the specifier is held in a +// variable to keep it out of static module resolution. +const standaloneServer = './server.js' +await import(standaloneServer) diff --git a/apps/sim/package.json b/apps/sim/package.json index 88e9575836d..dcb9c2bc649 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -99,6 +99,7 @@ "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", + "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", diff --git a/bun.lock b/bun.lock index e9bb4f978b3..c20f6bfa669 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -67,6 +68,7 @@ "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", + "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", @@ -158,6 +160,7 @@ "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", + "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", @@ -400,6 +403,21 @@ "typescript": "^5.7.3", }, }, + "packages/runtime-secrets": { + "name": "@sim/runtime-secrets", + "version": "0.1.0", + "dependencies": { + "@aws-sdk/client-secrets-manager": "3.1032.0", + "@sim/logger": "workspace:*", + "@sim/utils": "workspace:*", + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3", + "vitest": "^4.1.0", + }, + }, "packages/security": { "name": "@sim/security", "version": "0.1.0", @@ -1469,6 +1487,8 @@ "@sim/realtime-protocol": ["@sim/realtime-protocol@workspace:packages/realtime-protocol"], + "@sim/runtime-secrets": ["@sim/runtime-secrets@workspace:packages/runtime-secrets"], + "@sim/security": ["@sim/security@workspace:packages/security"], "@sim/testing": ["@sim/testing@workspace:packages/testing"], @@ -4237,6 +4257,8 @@ "@sim/realtime/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "@sim/runtime-secrets/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], + "@sim/security/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="], @@ -4749,6 +4771,8 @@ "@sim/realtime/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "@sim/runtime-secrets/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + "@sim/security/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "@trigger.dev/core/@opentelemetry/api-logs/@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index ff0ea1ccc28..e6e7f22bb53 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -81,6 +81,13 @@ RUN --mount=type=cache,id=next-cache-${TARGETPLATFORM},target=/app/apps/sim/.nex --mount=type=cache,id=turbo-cache-${TARGETPLATFORM},target=/app/.turbo \ bun run build +# Bundle the secrets-loading bootstrap into a self-contained entrypoint. It runs +# before (and outside) the Next standalone server, so its dependencies +# (@sim/runtime-secrets, AWS SDK) are inlined here rather than resolved from the +# pruned standalone node_modules. The dynamic import of ./server.js stays a +# runtime import. +RUN bun build apps/sim/bootstrap.ts --target=bun --outfile=apps/sim/bootstrap.js + # ======================================== # Runner Stage: Run the actual app # ======================================== @@ -100,6 +107,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/public ./apps/sim/public COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/.next/static ./apps/sim/.next/static +# Self-contained secrets-loading bootstrap (bundled in the builder stage). Runs +# before the standalone server.js to hydrate process.env from the runtime secret. +COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/bootstrap.js ./apps/sim/bootstrap.js + # Copy blog/author content for runtime filesystem reads (not part of the JS bundle) COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/content ./apps/sim/content @@ -128,4 +139,4 @@ EXPOSE 3000 ENV PORT=3000 \ HOSTNAME="0.0.0.0" -CMD ["bun", "apps/sim/server.js"] +CMD ["bun", "apps/sim/bootstrap.js"] diff --git a/docker/realtime.Dockerfile b/docker/realtime.Dockerfile index 16f3cd1c32f..d403c906462 100644 --- a/docker/realtime.Dockerfile +++ b/docker/realtime.Dockerfile @@ -49,4 +49,4 @@ USER nextjs EXPOSE 3002 -CMD ["bun", "apps/realtime/src/index.ts"] +CMD ["bun", "apps/realtime/src/bootstrap.ts"] diff --git a/packages/runtime-secrets/package.json b/packages/runtime-secrets/package.json new file mode 100644 index 00000000000..ee57201f3d1 --- /dev/null +++ b/packages/runtime-secrets/package.json @@ -0,0 +1,38 @@ +{ + "name": "@sim/runtime-secrets", + "version": "0.1.0", + "private": true, + "sideEffects": false, + "type": "module", + "license": "Apache-2.0", + "engines": { + "bun": ">=1.2.13", + "node": ">=20.0.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "biome check --write --unsafe .", + "lint:check": "biome check .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@aws-sdk/client-secrets-manager": "3.1032.0", + "@sim/logger": "workspace:*", + "@sim/utils": "workspace:*" + }, + "devDependencies": { + "@sim/tsconfig": "workspace:*", + "@types/node": "24.2.1", + "typescript": "^5.7.3", + "vitest": "^4.1.0" + } +} diff --git a/packages/runtime-secrets/src/index.test.ts b/packages/runtime-secrets/src/index.test.ts new file mode 100644 index 00000000000..dfd5cec1f4b --- /dev/null +++ b/packages/runtime-secrets/src/index.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() })) + +vi.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: class SecretsManagerClient { + send = mockSend + }, + GetSecretValueCommand: class GetSecretValueCommand { + constructor(public input: unknown) {} + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})) + +vi.mock('@sim/utils/helpers', () => ({ + sleep: vi.fn().mockResolvedValue(undefined), +})) + +import { loadRuntimeSecrets } from './index' + +const TOUCHED = ['SIM_ENV_SECRET_ID', 'FOO', 'BAZ'] as const + +describe('loadRuntimeSecrets', () => { + beforeEach(() => { + vi.clearAllMocks() + for (const key of TOUCHED) delete process.env[key] + }) + + afterEach(() => { + for (const key of TOUCHED) delete process.env[key] + }) + + it('no-ops when SIM_ENV_SECRET_ID is unset', async () => { + await loadRuntimeSecrets() + expect(mockSend).not.toHaveBeenCalled() + }) + + it('hydrates process.env from the parsed secret JSON', async () => { + process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' + mockSend.mockResolvedValue({ SecretString: JSON.stringify({ FOO: 'bar', BAZ: 'qux' }) }) + + await loadRuntimeSecrets() + + expect(process.env.FOO).toBe('bar') + expect(process.env.BAZ).toBe('qux') + }) + + it('never overwrites an already-set env var', async () => { + process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' + process.env.FOO = 'existing' + mockSend.mockResolvedValue({ SecretString: JSON.stringify({ FOO: 'new', BAZ: 'qux' }) }) + + await loadRuntimeSecrets() + + expect(process.env.FOO).toBe('existing') + expect(process.env.BAZ).toBe('qux') + }) + + it('throws when the secret is not valid JSON', async () => { + process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' + mockSend.mockResolvedValue({ SecretString: 'not json' }) + + await expect(loadRuntimeSecrets()).rejects.toThrow(/not valid JSON/) + }) + + it('throws when the secret JSON is not an object', async () => { + process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' + mockSend.mockResolvedValue({ SecretString: JSON.stringify(['a', 'b']) }) + + await expect(loadRuntimeSecrets()).rejects.toThrow(/must be a JSON object/) + }) + + it('throws immediately on a binary secret (no SecretString), without retrying', async () => { + process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' + mockSend.mockResolvedValue({}) + + await expect(loadRuntimeSecrets()).rejects.toThrow(/binary secrets/) + expect(mockSend).toHaveBeenCalledTimes(1) + }) + + it('retries then throws when the fetch keeps failing', async () => { + process.env.SIM_ENV_SECRET_ID = '/test/sim/env-vars' + mockSend.mockRejectedValue(new Error('boom')) + + await expect(loadRuntimeSecrets()).rejects.toThrow(/Failed to fetch runtime secrets/) + expect(mockSend).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/runtime-secrets/src/index.ts b/packages/runtime-secrets/src/index.ts new file mode 100644 index 00000000000..86c79e7952d --- /dev/null +++ b/packages/runtime-secrets/src/index.ts @@ -0,0 +1,102 @@ +import type { GetSecretValueCommandOutput } from '@aws-sdk/client-secrets-manager' +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' + +const logger = createLogger('RuntimeSecrets') + +/** Plaintext env var (set in the ECS task definition) naming the secret to ingest. */ +const SECRET_ID_ENV = 'SIM_ENV_SECRET_ID' + +const MAX_ATTEMPTS = 3 + +/** Bounds each Secrets Manager request so a stalled response can't hang boot. */ +const REQUEST_TIMEOUT_MS = 5000 + +/** + * Fetches the combined `/{env}/sim/env-vars` secret once at container boot and + * hydrates `process.env`, so secrets no longer have to be fanned out into the + * ECS task definition (which is approaching the 64 KB rendered-document limit). + * + * Must run before any application module that reads env at import time. No-ops + * when {@link SECRET_ID_ENV} is unset (local dev / self-hosted keep using their + * own env). Existing `process.env` keys are never overwritten, so explicit + * task-definition `environment` entries win. Throws on any fetch/parse failure + * so a misconfigured container crashes instead of booting without its config. + */ +export async function loadRuntimeSecrets(): Promise { + const secretId = process.env[SECRET_ID_ENV] + if (!secretId) { + logger.info(`${SECRET_ID_ENV} not set; skipping runtime secret ingestion`) + return + } + + const client = new SecretsManagerClient( + process.env.AWS_REGION ? { region: process.env.AWS_REGION } : {} + ) + + const secretString = await fetchSecretString(client, secretId) + const entries = parseSecretJson(secretString) + + let loaded = 0 + let skipped = 0 + for (const [key, value] of Object.entries(entries)) { + if (key in process.env) { + skipped++ + continue + } + process.env[key] = typeof value === 'string' ? value : JSON.stringify(value) + loaded++ + } + + logger.info('Runtime secrets ingested', { secretId, loaded, skipped }) +} + +async function fetchSecretString(client: SecretsManagerClient, secretId: string): Promise { + const response = await sendWithRetry(client, secretId) + if (!response.SecretString) { + // Non-retriable: a binary secret will never become a string between attempts. + throw new Error('Secret has no SecretString (binary secrets are not supported)') + } + return response.SecretString +} + +async function sendWithRetry( + client: SecretsManagerClient, + secretId: string +): Promise { + let lastError: unknown + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return await client.send(new GetSecretValueCommand({ SecretId: secretId }), { + abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + } catch (error) { + lastError = error + if (attempt < MAX_ATTEMPTS) { + const delay = backoffWithJitter(attempt, null, { baseMs: 200, maxMs: 2000 }) + logger.warn( + `Failed to fetch runtime secrets (attempt ${attempt}/${MAX_ATTEMPTS}), retrying`, + { error: getErrorMessage(error) } + ) + await sleep(delay) + } + } + } + throw new Error(`Failed to fetch runtime secrets from ${secretId}: ${getErrorMessage(lastError)}`) +} + +function parseSecretJson(secretString: string): Record { + let parsed: unknown + try { + parsed = JSON.parse(secretString) + } catch (error) { + throw new Error(`Runtime secret is not valid JSON: ${getErrorMessage(error)}`) + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Runtime secret must be a JSON object of key/value pairs') + } + return parsed as Record +} diff --git a/packages/runtime-secrets/tsconfig.json b/packages/runtime-secrets/tsconfig.json new file mode 100644 index 00000000000..1ffa3d2e844 --- /dev/null +++ b/packages/runtime-secrets/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@sim/tsconfig/library.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/runtime-secrets/vitest.config.ts b/packages/runtime-secrets/vitest.config.ts new file mode 100644 index 00000000000..2b1c323fe22 --- /dev/null +++ b/packages/runtime-secrets/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + }, +})