Unified · Split
| @@ -9,6 +9,11 @@ CONVEX_URL=https://your-deployment.convex.cloud · 8 unmodified lines | |||
| 9 | CONVEX_SITE_URL=https://your-deployment.convex.site | 9 | CONVEX_SITE_URL=https://your-deployment.convex.site |
| 10 | 10 | ||
| 11 | # ── Runtime execution mode (set in Convex prod) ── | 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 | # When enabled, local Bun execution is allowed. | 17 | # When enabled, local Bun execution is allowed. |
| 13 | # Leave unset in production to default to Cloudflare runtime. | 18 | # Leave unset in production to default to Cloudflare runtime. |
| 14 | # DANGEROUSLY_ALLOW_LOCAL_VM=1 | 19 | # DANGEROUSLY_ALLOW_LOCAL_VM=1 |
| @@ -39,6 +44,8 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 24 unmodified lines | |||
| 39 | # ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800 | 44 | # ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800 |
| 40 | # MCP API key signing secret (optional; falls back to ANONYMOUS_AUTH_PRIVATE_KEY_PEM when unset) | 45 | # MCP API key signing secret (optional; falls back to ANONYMOUS_AUTH_PRIVATE_KEY_PEM when unset) |
| 41 | # MCP_API_KEY_SECRET=change-me | 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 | # ── Stripe Billing (optional — required for checkout/portal) ── | 50 | # ── Stripe Billing (optional — required for checkout/portal) ── |
| 44 | # STRIPE_SECRET_KEY=sk_test_... | 51 | # STRIPE_SECRET_KEY=sk_test_... |
| @@ -14,6 +14,29 @@ import { · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | const recordSchema = z.record(z.unknown()); | 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 | function toRecordValue(value: unknown): Record<string, unknown> { | 40 | function toRecordValue(value: unknown): Record<string, unknown> { |
| 18 | const parsed = recordSchema.safeParse(value); | 41 | const parsed = recordSchema.safeParse(value); |
| 19 | return parsed.success ? parsed.data : {}; | 42 | return parsed.success ? parsed.data : {}; |
| @@ -156,6 +179,12 @@ export const upsertCredential = internalMutation({ · 136 unmodified lines | |||
| 156 | 179 | ||
| 157 | const exemplar = linkedRows[0] ?? existing ?? null; | 180 | const exemplar = linkedRows[0] ?? existing ?? null; |
| 158 | const provider = args.provider ?? exemplar?.provider ?? "local-convex"; | 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 | const fallbackSecret = toRecordValue(exemplar?.secretJson); | 188 | const fallbackSecret = toRecordValue(exemplar?.secretJson); |
| 160 | const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; | 189 | const finalSecret = hasSubmittedSecret ? submittedSecret : fallbackSecret; |
| 161 | if (Object.keys(finalSecret).length === 0) { | 190 | if (Object.keys(finalSecret).length === 0) { |
| @@ -310,6 +339,22 @@ export const listCredentialProviders = internalQuery({ · 148 unmodified lines | |||
| 310 | args: {}, | 339 | args: {}, |
| 311 | handler: async () => { | 340 | handler: async () => { |
| 312 | const workosEnabled = Boolean(process.env.WORKOS_API_KEY?.trim()); | 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 | return [ | 358 | return [ |
| 314 | { | 359 | { |
| 315 | id: workosEnabled ? "workos-vault" : "local-convex", | 360 | id: workosEnabled ? "workos-vault" : "local-convex", |
| @@ -298,13 +298,6 @@ export const upsertCredential = workspaceMutation({ · 297 unmodified lines | |||
| 298 | overridesJson: v.optional(jsonObjectValidator), | 298 | overridesJson: v.optional(jsonObjectValidator), |
| 299 | }, | 299 | }, |
| 300 | handler: async (ctx, args) => { | 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 | if (args.scopeType === "account") { | 301 | if (args.scopeType === "account") { |
| 309 | if (!args.accountId) { | 302 | if (!args.accountId) { |
| 310 | throw new Error("accountId is required for account-scoped credentials"); | 303 | throw new Error("accountId is required for account-scoped credentials"); |
| @@ -371,13 +364,6 @@ export const upsertToolSource = workspaceMutation({ · 60 unmodified lines | |||
| 371 | enabled: v.optional(v.boolean()), | 364 | enabled: v.optional(v.boolean()), |
| 372 | }, | 365 | }, |
| 373 | handler: async (ctx, args) => { | 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 | return await ctx.runMutation(internal.database.upsertToolSource, { | 367 | return await ctx.runMutation(internal.database.upsertToolSource, { |
| 382 | ...args, | 368 | ...args, |
| 383 | workspaceId: ctx.workspaceId, | 369 | workspaceId: ctx.workspaceId, |
| @@ -507,23 +493,6 @@ export const deleteToolSource = workspaceMutation({ · 123 unmodified lines | |||
| 507 | sourceId: v.string(), | 493 | sourceId: v.string(), |
| 508 | }, | 494 | }, |
| 509 | handler: async (ctx, args) => { | 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 | return await ctx.runMutation(internal.database.deleteToolSource, { | 496 | return await ctx.runMutation(internal.database.deleteToolSource, { |
| 528 | ...args, | 497 | ...args, |
| 529 | workspaceId: ctx.workspaceId, | 498 | workspaceId: ctx.workspaceId, |