Unified · Split
| @@ -9,6 +9,11 @@ CONVEX_URL=https://your-deployment.convex.cloud · 8 unmodified lines | ||
| 9 | 9 | CONVEX_SITE_URL=https://your-deployment.convex.site |
| 10 | 10 | |
| 11 | 11 | # ── Runtime execution mode (set in Convex prod) ── |
| 12 | +# Deployment mode controls security defaults in backend logic. | |
| 13 | +# - cloud: enforce managed credential storage and other cloud-safe defaults | |
| 14 | +# - self-hosted: allow local storage primitives for single-host deployments | |
| 15 | +# - unset: default to self-hosted (set cloud/prod explicitly in hosted deploys) | |
| 16 | +# EXECUTOR_DEPLOYMENT_MODE=cloud | |
| 12 | 17 | # When enabled, local Bun execution is allowed. |
| 13 | 18 | # Leave unset in production to default to Cloudflare runtime. |
| 14 | 19 | # DANGEROUSLY_ALLOW_LOCAL_VM=1 |
| @@ -39,6 +44,8 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 24 unmodified lines | ||
| 39 | 44 | # ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800 |
| 40 | 45 | # MCP API key signing secret (optional; falls back to ANONYMOUS_AUTH_PRIVATE_KEY_PEM when unset) |
| 41 | 46 | # MCP_API_KEY_SECRET=change-me |
| 47 | +# Force managed credentials regardless of deployment-mode defaults | |
| 48 | +# EXECUTOR_ENFORCE_MANAGED_CREDENTIALS=1 | |
| 42 | 49 | |
| 43 | 50 | # ── Stripe Billing (optional — required for checkout/portal) ── |
| 44 | 51 | # STRIPE_SECRET_KEY=sk_test_... |
| @@ -14,6 +14,29 @@ import { · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | const recordSchema = z.record(z.unknown()); |
| 16 | 16 | |
| 17 | +function isTruthyEnvValue(value: string | undefined): boolean { | |
| 18 | + const normalized = value?.trim().toLowerCase(); | |
| 19 | + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; | |
| 20 | +} | |
| 21 | + | |
| 22 | +function managedCredentialMode(): "cloud" | "self-hosted" { | |
| 23 | + const explicit = (process.env.EXECUTOR_DEPLOYMENT_MODE ?? "").trim().toLowerCase(); | |
| 24 | + if (explicit === "cloud" || explicit === "hosted" || explicit === "production" || explicit === "prod") { | |
| 25 | + return "cloud"; | |
| 26 | + } | |
| 27 | + if (explicit === "self-hosted" || explicit === "self_hosted" || explicit === "selfhosted") { | |
| 28 | + return "self-hosted"; | |
| 29 | + } | |
| 30 | + if (isTruthyEnvValue(process.env.EXECUTOR_ENFORCE_MANAGED_CREDENTIALS)) { | |
| 31 | + return "cloud"; | |
| 32 | + } | |
| 33 | + return "self-hosted"; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function shouldEnforceManagedCredentials(): boolean { | |
| 37 | + return managedCredentialMode() === "cloud"; | |
| 38 | +} | |
| 39 | + | |
| 17 | 40 | function toRecordValue(value: unknown): Record<string, unknown> { |
| 18 | 41 | const parsed = recordSchema.safeParse(value); |
| 19 | 42 | return parsed.success ? parsed.data : {}; |
| @@ -156,6 +179,12 @@ export const upsertCredential = internalMutation({ · 136 unmodified lines | ||
| 156 | 179 | |
| 157 | 180 | const exemplar = linkedRows[0] ?? existing ?? null; |
| 158 | 181 | const provider = args.provider ?? exemplar?.provider ?? "local-convex"; |
| 182 | + if (shouldEnforceManagedCredentials() && provider !== "workos-vault") { | |
| 183 | + throw new Error( | |
| 184 | + "Managed credential storage is required in cloud deployments. Configure WORKOS_API_KEY and use provider 'workos-vault'.", | |
| 185 | + ); | |
| 186 | + } | |
| 187 | + | |
| 159 | 188 | const fallbackSecret = toRecordValue(exemplar?.secretJson); |
| 160 | 189 | const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; |
| 161 | 190 | if (Object.keys(finalSecret).length === 0) { |
| @@ -310,6 +339,22 @@ export const listCredentialProviders = internalQuery({ · 148 unmodified lines | ||
| 310 | 339 | args: {}, |
| 311 | 340 | handler: async () => { |
| 312 | 341 | const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim()); |
| 342 | + const enforceManaged = shouldEnforceManagedCredentials(); | |
| 343 | + | |
| 344 | + if (enforceManaged && !workosEnabled) { | |
| 345 | + return [] as const; | |
| 346 | + } | |
| 347 | + | |
| 348 | + if (enforceManaged) { | |
| 349 | + return [ | |
| 350 | + { | |
| 351 | + id: "workos-vault", | |
| 352 | + label: "Encrypted", | |
| 353 | + description: "Secrets are stored in WorkOS Vault.", | |
| 354 | + }, | |
| 355 | + ] as const; | |
| 356 | + } | |
| 357 | + | |
| 313 | 358 | return [ |
| 314 | 359 | { |
| 315 | 360 | id: workosEnabled ? "workos-vault" : "local-convex", |
| @@ -298,13 +298,6 @@ export const upsertCredential = workspaceMutation({ · 297 unmodified lines | ||
| 298 | 298 | overridesJson: v.optional(jsonObjectValidator), |
| 299 | 299 | }, |
| 300 | 300 | handler: async (ctx, args) => { |
| 301 | − if (args.scopeType === "organization") { | |
| 302 | − const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id); | |
| 303 | − if (!organizationMembership || !isAdminRole(organizationMembership.role)) { | |
| 304 | − throw new Error("Only organization admins can manage organization-level credentials"); | |
| 305 | − } | |
| 306 | − } | |
| 307 | − | |
| 308 | 301 | if (args.scopeType === "account") { |
| 309 | 302 | if (!args.accountId) { |
| 310 | 303 | throw new Error("accountId is required for account-scoped credentials"); |
| @@ -371,13 +364,6 @@ export const upsertToolSource = workspaceMutation({ · 60 unmodified lines | ||
| 371 | 364 | enabled: v.optional(v.boolean()), |
| 372 | 365 | }, |
| 373 | 366 | handler: async (ctx, args) => { |
| 374 | − if (args.scopeType === "organization") { | |
| 375 | − const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id); | |
| 376 | − if (!organizationMembership || !isAdminRole(organizationMembership.role)) { | |
| 377 | − throw new Error("Only organization admins can manage organization-level tool sources"); | |
| 378 | − } | |
| 379 | − } | |
| 380 | − | |
| 381 | 367 | return await ctx.runMutation(internal.database.upsertToolSource, { |
| 382 | 368 | ...args, |
| 383 | 369 | workspaceId: ctx.workspaceId, |
| @@ -507,23 +493,6 @@ export const deleteToolSource = workspaceMutation({ · 123 unmodified lines | ||
| 507 | 493 | sourceId: v.string(), |
| 508 | 494 | }, |
| 509 | 495 | handler: async (ctx, args) => { |
| 510 | − const sources = await ctx.runQuery(internal.database.listToolSources, { | |
| 511 | − workspaceId: ctx.workspaceId, | |
| 512 | − }); | |
| 513 | − let source: { id: string; scopeType?: "organization" | "workspace" } | undefined; | |
| 514 | − for (const entry of sources as Array<{ id: string; scopeType?: "organization" | "workspace" }>) { | |
| 515 | − if (entry.id === args.sourceId) { | |
| 516 | − source = entry; | |
| 517 | − break; | |
| 518 | − } | |
| 519 | − } | |
| 520 | − if (source?.scopeType === "organization") { | |
| 521 | − const organizationMembership = await getOrganizationMembership(ctx, ctx.workspace.organizationId, ctx.account._id); | |
| 522 | − if (!organizationMembership || !isAdminRole(organizationMembership.role)) { | |
| 523 | − throw new Error("Only organization admins can delete organization-level tool sources"); | |
| 524 | − } | |
| 525 | − } | |
| 526 | − | |
| 527 | 496 | return await ctx.runMutation(internal.database.deleteToolSource, { |
| 528 | 497 | ...args, |
| 529 | 498 | workspaceId: ctx.workspaceId, |