Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/encrypted-provider-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@elmohq/cli": patch
---

Added an `ELMO_ENCRYPTION_KEY` to new and upgraded deployments, which lets Elmo store provider credentials encrypted in the database instead of only in `.env`.
8 changes: 6 additions & 2 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ function assertNotCancelled<T>(value: T | symbol): asserts value is T {
}
}

function generateSecret(bytes = 32): string {
return crypto.randomBytes(bytes).toString("base64url");
function generateSecret(bytes = 32, encoding: BufferEncoding = "base64url"): string {
return crypto.randomBytes(bytes).toString(encoding);
}

function link(text: string, url: string): string {
Expand Down Expand Up @@ -244,6 +244,10 @@ async function runInit(options: InitOptions, version: string): Promise<void> {
env.VITE_DEPLOYMENT_MODE = "local";
env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
env.BETTER_AUTH_SECRET = generateSecret();
// Standard base64 (not base64url): the app decodes this with Buffer.from(key,
// "base64") and requires exactly 32 bytes. Enables storing provider
// credentials encrypted in the database via the in-app Providers UI.
env.ELMO_ENCRYPTION_KEY = generateSecret(32, "base64");
env.APP_NAME = DEFAULT_APP_NAME;
env.APP_ICON = DEFAULT_APP_ICON;
env.VITE_APP_NAME = DEFAULT_APP_NAME;
Expand Down
47 changes: 47 additions & 0 deletions apps/cli/src/migrations/encryption-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { MIGRATIONS } from "./index.js";
import type { Migration, MigrationContext } from "./types.js";

function inMemoryContext(initial: Record<string, string> = {}): MigrationContext & {
env: () => Record<string, string>;
} {
let env: Record<string, string> = { ...initial };
return {
configDir: "/fake",
log: { info: () => {}, warn: () => {}, step: () => {} },
readEnv: async () => ({ ...env }),
writeEnv: async (next) => {
env = { ...next };
},
env: () => ({ ...env }),
};
}

const encryptionKeyMigration = MIGRATIONS.find((m) => m.from === "0.2.17") as Migration;

describe("ELMO_ENCRYPTION_KEY migration", () => {
it("is registered as the 0.2.17 → 0.2.18 entry", () => {
expect(encryptionKeyMigration).toBeDefined();
expect(encryptionKeyMigration.to).toBe("0.2.18");
});

it("adds a 32-byte base64 key when absent, leaving other vars untouched", async () => {
const ctx = inMemoryContext({ DATABASE_URL: "postgres://x", OTHER: "keep" });
await encryptionKeyMigration.run(ctx);
const { ELMO_ENCRYPTION_KEY, ...rest } = ctx.env();
expect(rest).toEqual({ DATABASE_URL: "postgres://x", OTHER: "keep" });
expect(Buffer.from(ELMO_ENCRYPTION_KEY, "base64").length).toBe(32);
});

it("is a no-op when the key already exists (never clobbers an operator's value)", async () => {
const ctx = inMemoryContext({ ELMO_ENCRYPTION_KEY: "existing", OTHER: "keep" });
await encryptionKeyMigration.run(ctx);
expect(ctx.env()).toEqual({ ELMO_ENCRYPTION_KEY: "existing", OTHER: "keep" });
});

it("leaves an explicitly-emptied key alone", async () => {
const ctx = inMemoryContext({ ELMO_ENCRYPTION_KEY: "" });
await encryptionKeyMigration.run(ctx);
expect(ctx.env()).toEqual({ ELMO_ENCRYPTION_KEY: "" });
});
});
22 changes: 21 additions & 1 deletion apps/cli/src/migrations/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import type { Migration } from "./types.js";

export * from "./planner.js";
Expand All @@ -23,4 +24,23 @@ export * from "./types.js";
// }
// },
// }
export const MIGRATIONS: readonly Migration[] = [];
export const MIGRATIONS: readonly Migration[] = [
{
// Keyed to the last published version without the key (0.2.17) so every
// existing install picks it up on its next upgrade; the planner fires a
// `from` entry when the deployment's version is at or below it.
from: "0.2.17",
to: "0.2.18",
description: "add ELMO_ENCRYPTION_KEY for encrypted in-app provider credentials",
async run(ctx) {
const env = await ctx.readEnv();
if (env.ELMO_ENCRYPTION_KEY !== undefined) return;
// Standard base64 (not base64url): decoded with Buffer.from(key,
// "base64") to exactly 32 bytes. Absent key = storage UI disabled and
// env-provided credentials keep working, so this is purely additive.
env.ELMO_ENCRYPTION_KEY = randomBytes(32).toString("base64");
await ctx.writeEnv(env);
ctx.log.info("Added ELMO_ENCRYPTION_KEY — lets you store provider credentials encrypted in the app.");
},
},
];
4 changes: 4 additions & 0 deletions apps/web/scripts/smoke-deployment-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ const MINIMAL_ENV: Record<SmokeMode, Record<string, string>> = {
RESEND_FROM_EMAIL: "Smoke <smoke@example.com>",
GOOGLE_CLIENT_ID: "smoke-google-client-id",
GOOGLE_CLIENT_SECRET: "smoke-google-client-secret",
INFISICAL_CLIENT_ID: "smoke-client-id",
INFISICAL_CLIENT_SECRET: "smoke-client-secret",
INFISICAL_PROJECT_ID: "smoke-project-id",
INFISICAL_ENVIRONMENT: "prod",
},
};

Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ declare global {
readonly OXYLABS_USERNAME?: string;
readonly OXYLABS_PASSWORD?: string;
readonly JINA_API_KEY?: string;
readonly ELMO_ENCRYPTION_KEY?: string;
readonly INFISICAL_CLIENT_ID?: string;
readonly INFISICAL_CLIENT_SECRET?: string;
readonly INFISICAL_PROJECT_ID?: string;
readonly INFISICAL_ENVIRONMENT?: string;
readonly DATAFORSEO_LOGIN: string;
readonly DATAFORSEO_PASSWORD: string;
readonly BETTER_AUTH_SECRET?: string;
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import "../instrument.server.mjs";
import { wrapFetchWithSentry } from "@sentry/tanstackstart-react";
import handler, { createServerEntry } from "@tanstack/react-start/server-entry";
import { startBackgroundCredentialRefresh } from "@workspace/deployment/credentials";

startBackgroundCredentialRefresh();

// HSTS asserts HTTPS-only for the host that served the response. Whitelabel
// deployments run on customer-controlled custom domains, where `includeSubDomains`
Expand Down
3 changes: 3 additions & 0 deletions apps/worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Sentry from "@sentry/node";
import { getDeployment } from "@workspace/deployment";
import { startWorkerCredentialRefresh } from "@workspace/deployment/credentials-worker";
import { getProvider, parseScrapeTargets, validateScrapeTargets } from "@workspace/lib/providers";
import boss from "./boss";
import { registerHandlers } from "./handlers";
Expand All @@ -16,6 +17,8 @@ if (process.env.SENTRY_DSN) {
async function main() {
console.log("Starting pg-boss worker...");

await startWorkerCredentialRefresh();

// Fail fast on misconfigured SCRAPE_TARGETS — surfaces unknown providers,
// missing API keys, and per-provider target errors before any job runs.
validateScrapeTargets(parseScrapeTargets(process.env.SCRAPE_TARGETS), getProvider);
Expand Down
28 changes: 28 additions & 0 deletions packages/cloud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ The auth and email features in this package need:
| `RESEND_FROM_EMAIL` | Sender address, e.g. `Elmo <notifications@updates.example.com>` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID for social sign-in |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `INFISICAL_CLIENT_ID` | Infisical machine identity client ID |
| `INFISICAL_CLIENT_SECRET` | Infisical machine identity client secret |
| `INFISICAL_PROJECT_ID` | Project containing provider credentials |
| `INFISICAL_ENVIRONMENT` | Environment slug containing provider credentials |

Only the worker reads the Infisical variables at runtime — see the Infisical section — but they are required across cloud so a deployment cannot go out with a worker that has no way to reach Infisical.

There is nothing else to configure: credentials are read from the root of the given environment, on Infisical's US cloud.

The canonical list of every cloud-required variable (Stripe, database, etc.) lives in `packages/config/src/env-registry.ts`; env validation fails cloud startup when any of them is missing.

Expand All @@ -23,6 +31,26 @@ The canonical list of every cloud-required variable (Stripe, database, etc.) liv

Email templates are code — `packages/cloud/src/email-templates.ts` — not Resend-hosted templates; there is nothing to configure template-side in Resend.

## Infisical setup

Provider credentials live at the **root of the `INFISICAL_ENVIRONMENT` environment**, named exactly as the canonical environment variables (`OPENAI_API_KEY`, `OXYLABS_USERNAME`, `OXYLABS_PASSWORD`, …). The path is not configurable, and the read is not recursive, so secrets in subfolders are neither fetched nor used. The two runtimes read that folder differently.

**Worker** — a long-lived VM, so it authenticates once with the SDK and refreshes every minute. Rotating a credential in Infisical reaches it without a deploy.

1. Create a machine identity with Universal Auth and read access to that environment's root.
2. Set the Infisical variables above on the worker.

A refresh that fails, or that returns no provider credentials at all, keeps the last known-good values and logs an error; the worker refuses to start if the very first load fails.

**Web app** — runs on Vercel serverless. Use Infisical's [Vercel secret sync](https://infisical.com/docs/integrations/secret-syncs/vercel) to push the same folder into the project's environment variables, and the app reads them from `process.env`.

1. Add a Vercel app connection in Infisical, then a secret sync from that same environment root to the Vercel project.
2. Enable auto-sync, and redeploy after a rotation — Vercel bakes environment variables in at deploy time.

Reading Infisical at runtime instead would put an authentication round trip on every cold start and Infisical's availability in front of the whole site, to serve credentials the request path barely uses.

Infisical is only used by `DEPLOYMENT_MODE=cloud`. Self-hosted deployments use encrypted database credentials or environment variables.

## Google OAuth setup

1. In the Google Cloud console, create an OAuth client of type **Web application**.
Expand Down
4 changes: 3 additions & 1 deletion packages/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./auth-hooks": "./src/auth-hooks.ts"
"./auth-hooks": "./src/auth-hooks.ts",
"./infisical-credentials": "./src/infisical-credentials.ts"
},
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@infisical/sdk": "5.0.2",
"@workspace/config": "workspace:*",
"@workspace/lib": "workspace:*",
"better-auth": "^1.6.23",
Expand Down
91 changes: 91 additions & 0 deletions packages/cloud/src/infisical-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import { createInfisicalCredentialLoader } from "./infisical-credentials";

const ENV = {
INFISICAL_CLIENT_ID: "client-id",
INFISICAL_CLIENT_SECRET: "client-secret",
INFISICAL_PROJECT_ID: "project-id",
INFISICAL_ENVIRONMENT: "prod",
};

function fakeClient(secrets: Array<{ secretKey: string; secretValue: string }>) {
const listSecretsWithImports = vi.fn(async () => secrets);
const client = {
auth: () => ({
universalAuth: {
login: vi.fn(async () => client),
},
}),
secrets: () => ({ listSecretsWithImports }),
};
return { client, listSecretsWithImports };
}

describe("createInfisicalCredentialLoader", () => {
it("authenticates and returns only canonical provider credentials", async () => {
const { client, listSecretsWithImports } = fakeClient([
{ secretKey: "OPENAI_API_KEY", secretValue: "sk-cloud" },
{ secretKey: "OXYLABS_USERNAME", secretValue: "user" },
{ secretKey: "NOT_AN_ELMO_CREDENTIAL", secretValue: "ignored" },
]);
const clientFactory = vi.fn(() => client);
const load = createInfisicalCredentialLoader({ env: ENV, clientFactory });

const credentials = await load();

expect(credentials).toEqual(
new Map([
["OPENAI_API_KEY", "sk-cloud"],
["OXYLABS_USERNAME", "user"],
]),
);
// Root of the environment, and not recursive: the whole folder contract.
expect(listSecretsWithImports).toHaveBeenCalledWith({
environment: "prod",
projectId: "project-id",
secretPath: "/",
expandSecretReferences: true,
viewSecretValue: true,
});
});

// Cloud has no environment fallback, so an empty read has to fail loudly —
// otherwise a revoked grant or a stale path silently unconfigures every
// provider and the fleet stops scraping with no error anywhere.
it("throws rather than reporting zero credentials", async () => {
const { client } = fakeClient([{ secretKey: "NOT_AN_ELMO_CREDENTIAL", secretValue: "ignored" }]);
const load = createInfisicalCredentialLoader({ env: ENV, clientFactory: () => client });

await expect(load()).rejects.toThrow(/no provider credentials/);
});

it("reuses the authenticated client across successful refreshes", async () => {
const { client } = fakeClient([{ secretKey: "OPENAI_API_KEY", secretValue: "sk-cloud" }]);
const clientFactory = vi.fn(() => client);
const load = createInfisicalCredentialLoader({ env: ENV, clientFactory });

await load();
await load();

expect(clientFactory).toHaveBeenCalledOnce();
});

it("re-authenticates once after an expired token", async () => {
const first = fakeClient([]);
first.listSecretsWithImports.mockRejectedValueOnce(new Error("unauthorized"));
const second = fakeClient([{ secretKey: "OPENAI_API_KEY", secretValue: "fresh" }]);
const clientFactory = vi.fn().mockReturnValueOnce(first.client).mockReturnValueOnce(second.client);
const load = createInfisicalCredentialLoader({ env: ENV, clientFactory });

await expect(load()).resolves.toEqual(new Map([["OPENAI_API_KEY", "fresh"]]));
expect(clientFactory).toHaveBeenCalledTimes(2);
});

it("fails fast when cloud authentication configuration is incomplete", () => {
expect(() =>
createInfisicalCredentialLoader({
env: { ...ENV, INFISICAL_CLIENT_SECRET: "" },
}),
).toThrow("INFISICAL_CLIENT_SECRET");
});
});
Loading
Loading