Skip to content

Commit bb277d3

Browse files
mantrakp04N2D4
andauthored
Backend fallback (cloud run) (#1306)
- Added support for `@opentelemetry/sdk-node` in the backend. - Updated various dependencies including AWS SDK and OpenTelemetry packages. - Implemented graceful shutdown handling for non-Vercel runtimes in `prisma-client.tsx`. - Enhanced AWS credentials retrieval to support GCP Workload Identity Federation. - Introduced a Dockerfile for Cloud Run deployment, optimizing the backend build process. - Updated `.gitignore` to include Terraform runtime files and secrets. This commit improves the backend's observability and deployment flexibility, particularly for Cloud Run environments. <!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * OpenTelemetry observability with dynamic provider selection per deployment. * Cloud Run trusted-proxy support for accurate client IP handling. * Graceful shutdown that waits for in-flight background work. * New background-task handling to improve async webhook/email delivery reliability. * AWS credential providers added (Vercel OIDC & GCP Workload Identity Federation). * Dockerized backend image for Cloud Run / self-host deployments. * **Chores** * Updated dependencies for OpenTelemetry and AWS SDK support. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
1 parent 0a48aa5 commit bb277d3

34 files changed

Lines changed: 2146 additions & 217 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# TODO: keep in sync with e2e-tests.yaml — this is a near-copy with the backend
2+
# started on the fallback port (8110) only, so the SDK exercises fallback logic.
3+
name: Runs E2E Fallback Tests
4+
5+
on:
6+
push:
7+
branches:
8+
- main
9+
- dev
10+
pull_request:
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
15+
16+
jobs:
17+
build:
18+
name: E2E Fallback Tests (Node ${{ matrix.node-version }})
19+
runs-on: ubicloud-standard-8
20+
env:
21+
NODE_ENV: test
22+
STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes
23+
STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:8128/stackframe"
24+
STACK_EXTERNAL_DB_SYNC_MAX_DURATION_MS: "20000"
25+
STACK_EXTERNAL_DB_SYNC_DIRECT: "false"
26+
# SDK reads this as the primary URL, discovers hardcoded fallback to port 8110
27+
NEXT_PUBLIC_STACK_API_URL: "http://localhost:8102"
28+
# Tells js-helpers to omit explicit baseUrl so the SDK exercises fallback logic
29+
STACK_TEST_SDK_FALLBACK: "true"
30+
31+
strategy:
32+
matrix:
33+
node-version: [22.x]
34+
35+
steps:
36+
- uses: actions/checkout@v6
37+
38+
- name: Setup Node.js ${{ matrix.node-version }}
39+
uses: actions/setup-node@v6
40+
with:
41+
node-version: ${{ matrix.node-version }}
42+
43+
- name: Setup pnpm
44+
uses: pnpm/action-setup@v4
45+
46+
- name: Start Docker Compose in background
47+
uses: JarvusInnovations/background-action@v1.0.7
48+
with:
49+
run: docker compose -f docker/dependencies/docker.compose.yaml up --pull always -d &
50+
wait-on: /dev/null
51+
tail: true
52+
wait-for: 3s
53+
log-output-if: true
54+
55+
- name: Install dependencies
56+
run: pnpm install --frozen-lockfile
57+
58+
- name: Create .env.test.local files
59+
run: |
60+
cp apps/backend/.env.development apps/backend/.env.test.local
61+
cp apps/dashboard/.env.development apps/dashboard/.env.test.local
62+
cp apps/e2e/.env.development apps/e2e/.env.test.local
63+
cp docs/.env.development docs/.env.test.local
64+
cp examples/cjs-test/.env.development examples/cjs-test/.env.test.local
65+
cp examples/demo/.env.development examples/demo/.env.test.local
66+
cp examples/docs-examples/.env.development examples/docs-examples/.env.test.local
67+
cp examples/e-commerce/.env.development examples/e-commerce/.env.test.local
68+
cp examples/middleware/.env.development examples/middleware/.env.test.local
69+
cp examples/supabase/.env.development examples/supabase/.env.test.local
70+
cp examples/convex/.env.development examples/convex/.env.test.local
71+
72+
- name: Build
73+
run: pnpm build
74+
75+
- name: Wait on Postgres
76+
run: pnpm run wait-until-postgres-is-ready:pg_isready
77+
78+
- name: Wait on Inbucket
79+
run: pnpx wait-on tcp:localhost:8129
80+
81+
- name: Wait on Svix
82+
run: pnpx wait-on tcp:localhost:8113
83+
84+
- name: Wait on QStash
85+
run: pnpx wait-on tcp:localhost:8125
86+
87+
- name: Wait on ClickHouse
88+
run: pnpx wait-on http://localhost:8136/ping
89+
90+
- name: Initialize database
91+
run: pnpm run db:init
92+
93+
# Start backend ONLY on fallback port 8110 — primary port 8102 is intentionally left down
94+
# so the SDK exercises its fallback logic for every request.
95+
- name: Start stack-backend on fallback port (8110)
96+
uses: JarvusInnovations/background-action@v1.0.7
97+
with:
98+
run: pnpm -C apps/backend run with-env:test next start --port 8110 &
99+
wait-on: |
100+
http://localhost:8110
101+
tail: true
102+
wait-for: 30s
103+
log-output-if: true
104+
105+
- name: Start stack-dashboard in background
106+
uses: JarvusInnovations/background-action@v1.0.7
107+
with:
108+
run: pnpm run start:dashboard --log-order=stream &
109+
wait-on: |
110+
http://localhost:8101
111+
tail: true
112+
wait-for: 30s
113+
log-output-if: true
114+
115+
- name: Start mock-oauth-server in background
116+
uses: JarvusInnovations/background-action@v1.0.7
117+
with:
118+
run: pnpm run start:mock-oauth-server --log-order=stream &
119+
wait-on: |
120+
http://localhost:8110
121+
tail: true
122+
wait-for: 30s
123+
log-output-if: true
124+
125+
- name: Start run-email-queue in background
126+
uses: JarvusInnovations/background-action@v1.0.7
127+
with:
128+
run: pnpm -C apps/backend run run-email-queue --log-order=stream &
129+
wait-on: |
130+
http://localhost:8110
131+
tail: true
132+
wait-for: 30s
133+
log-output-if: true
134+
135+
- name: Start run-cron-jobs in background
136+
uses: JarvusInnovations/background-action@v1.0.7
137+
with:
138+
run: pnpm -C apps/backend run run-cron-jobs:test --log-order=stream &
139+
wait-on: |
140+
http://localhost:8110
141+
tail: true
142+
wait-for: 30s
143+
log-output-if: true
144+
145+
- name: Wait 10 seconds
146+
run: sleep 10
147+
148+
- name: Verify primary port 8102 is NOT running
149+
run: |
150+
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8102/health 2>/dev/null | grep -q "200"; then
151+
echo "ERROR: Primary backend on port 8102 should NOT be running for fallback tests"
152+
exit 1
153+
fi
154+
echo "Confirmed: primary port 8102 is down, fallback tests will exercise SDK fallback logic"
155+
156+
# Only run JS SDK tests — these exercise the SDK's fallback logic.
157+
# Backend API tests use direct HTTP calls that don't go through fallback.
158+
# Exclude cross-domain-auth which hardcodes the primary URL.
159+
- name: Run SDK fallback tests
160+
run: pnpm -w run pre && cd apps/e2e && npx vitest run tests/js/ --exclude '**/{cross-domain-auth,oauth,email-template-existing-project}*'
161+
162+
- name: Print Docker Compose logs
163+
if: always()
164+
run: docker compose -f docker/dependencies/docker.compose.yaml logs

apps/backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"with-env:dev": "dotenv -c development --",
1212
"with-env:prod": "dotenv -c production --",
1313
"with-env:test": "dotenv -c test --",
14-
"dev": "concurrently -n \"dev,codegen,prisma-studio,email-queue,cron-jobs\" -k \"next dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02 ${STACK_BACKEND_DEV_EXTRA_ARGS:-}\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-email-queue\" \"pnpm run run-cron-jobs\"",
14+
"dev": "BACKEND_PORT=${STACK_DEV_FALLBACK_BACKEND:+${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}10} && BACKEND_PORT=${BACKEND_PORT:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02} && concurrently -n \"dev,codegen,prisma-studio,email-queue,cron-jobs\" -k \"next dev --port $BACKEND_PORT ${STACK_BACKEND_DEV_EXTRA_ARGS:-}\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-email-queue\" \"pnpm run run-cron-jobs\"",
1515
"dev:inspect": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev",
1616
"dev:profile": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev",
1717
"build": "pnpm run codegen && next build",

apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createAuthTokens } from "@/lib/tokens";
44
import { getRequestContextAndBotChallengeAssessment, botChallengeFlowRequestSchemaFields } from "@/lib/turnstile";
55
import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users";
66
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
7-
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
7+
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
88
import { KnownErrors } from "@stackframe/stack-shared";
99
import { getPasswordError } from "@stackframe/stack-shared/dist/helpers/password";
1010
import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, passwordSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { parseAndValidateConfig } from './route';
3+
4+
describe('parseAndValidateConfig', () => {
5+
it('should parse a single entry with probability 1', () => {
6+
const result = parseAndValidateConfig({
7+
"1": ["https://api.stack-auth.com"],
8+
});
9+
expect(result).toEqual([
10+
{ probability: 1, urls: ["https://api.stack-auth.com"] },
11+
]);
12+
});
13+
14+
it('should parse multiple entries', () => {
15+
const result = parseAndValidateConfig({
16+
"0.7": ["https://api.stack-auth.com", "https://api2.stack-auth.com"],
17+
"0.3": ["https://api2.stack-auth.com", "https://api.stack-auth.com"],
18+
});
19+
expect(result).toEqual([
20+
{ probability: 0.7, urls: ["https://api.stack-auth.com", "https://api2.stack-auth.com"] },
21+
{ probability: 0.3, urls: ["https://api2.stack-auth.com", "https://api.stack-auth.com"] },
22+
]);
23+
});
24+
25+
it('should allow probabilities summing to less than 1', () => {
26+
const result = parseAndValidateConfig({
27+
"0.5": ["https://api.stack-auth.com"],
28+
"0.3": ["https://api2.stack-auth.com"],
29+
});
30+
expect(result).toHaveLength(2);
31+
});
32+
33+
it('should reject non-object input', () => {
34+
expect(() => parseAndValidateConfig("string")).toThrow("must be a JSON object");
35+
expect(() => parseAndValidateConfig(null)).toThrow("must be a JSON object");
36+
expect(() => parseAndValidateConfig([])).toThrow("must be a JSON object");
37+
expect(() => parseAndValidateConfig(42)).toThrow("must be a JSON object");
38+
});
39+
40+
it('should reject empty object', () => {
41+
expect(() => parseAndValidateConfig({})).toThrow("at least one entry");
42+
});
43+
44+
it('should reject invalid probability keys', () => {
45+
expect(() => parseAndValidateConfig({ "abc": ["https://a.com"] })).toThrow("must be a number between 0 and 1");
46+
expect(() => parseAndValidateConfig({ "-0.1": ["https://a.com"] })).toThrow("must be a number between 0 and 1");
47+
expect(() => parseAndValidateConfig({ "1.5": ["https://a.com"] })).toThrow("must be a number between 0 and 1");
48+
});
49+
50+
it('should reject probabilities summing to more than 1', () => {
51+
expect(() => parseAndValidateConfig({
52+
"0.6": ["https://api.stack-auth.com"],
53+
"0.5": ["https://api2.stack-auth.com"],
54+
})).toThrow("exceeds 1");
55+
});
56+
57+
it('should reject invalid URL values', () => {
58+
expect(() => parseAndValidateConfig({ "1": ["not-a-url"] })).toThrow();
59+
});
60+
61+
it('should reject empty URL arrays', () => {
62+
expect(() => parseAndValidateConfig({ "1": [] })).toThrow();
63+
});
64+
65+
it('should reject non-array values', () => {
66+
expect(() => parseAndValidateConfig({ "1": "https://api.stack-auth.com" })).toThrow();
67+
});
68+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
2+
import { urlSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
3+
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
4+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
5+
import { getDefaultApiUrls } from "@stackframe/stack-shared/dist/utils/urls";
6+
7+
/**
8+
* Env var format: JSON object mapping probability (as string number) to URL arrays.
9+
* Probabilities must sum to <= 1. Remaining probability uses the last entry as fallback.
10+
*
11+
* Example:
12+
* {
13+
* "0.7": ["https://api.stack-auth.com", "https://api2.stack-auth.com"],
14+
* "0.3": ["https://api2.stack-auth.com", "https://api.stack-auth.com"]
15+
* }
16+
*/
17+
18+
const urlsArraySchema = yupArray(urlSchema.defined()).min(1).defined();
19+
20+
export function parseAndValidateConfig(raw: unknown): Array<{ probability: number, urls: string[] }> {
21+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
22+
throw new StackAssertionError("STACK_BACKEND_URLS_CONFIG must be a JSON object mapping probability strings to URL arrays");
23+
}
24+
25+
const entries = Object.entries(raw as Record<string, unknown>).map(([key, value]) => {
26+
const probability = Number(key);
27+
if (isNaN(probability) || probability < 0 || probability > 1) {
28+
throw new StackAssertionError(`Invalid probability key "${key}": must be a number between 0 and 1`);
29+
}
30+
const urls = urlsArraySchema.validateSync(value);
31+
return { probability, urls };
32+
});
33+
34+
if (entries.length === 0) {
35+
throw new StackAssertionError("STACK_BACKEND_URLS_CONFIG must have at least one entry");
36+
}
37+
38+
const sum = entries.reduce((acc, e) => acc + e.probability, 0);
39+
if (sum > 1 + 1e-9) {
40+
throw new StackAssertionError(`Probabilities sum to ${sum}, which exceeds 1`);
41+
}
42+
43+
return entries;
44+
}
45+
46+
let cachedEntries: ReturnType<typeof parseAndValidateConfig> | undefined;
47+
function getCachedConfig() {
48+
if (!cachedEntries) {
49+
const rawEnv = getEnvVariable("STACK_BACKEND_URLS_CONFIG", "");
50+
if (rawEnv) {
51+
let parsed;
52+
try {
53+
parsed = JSON.parse(rawEnv);
54+
} catch (e) {
55+
throw new StackAssertionError(`STACK_BACKEND_URLS_CONFIG is not valid JSON: ${e}`);
56+
}
57+
cachedEntries = parseAndValidateConfig(parsed);
58+
} else {
59+
cachedEntries = [{ probability: 1, urls: getDefaultApiUrls(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")) }];
60+
}
61+
}
62+
return cachedEntries;
63+
}
64+
65+
export const GET = createSmartRouteHandler({
66+
metadata: {
67+
hidden: true,
68+
summary: "Get backend URLs",
69+
description: "Returns a prioritized list of backend API URLs for client-side failover",
70+
tags: ["Internal"],
71+
},
72+
request: yupObject({
73+
method: yupString().oneOf(["GET"]).defined(),
74+
}),
75+
response: yupObject({
76+
statusCode: yupNumber().oneOf([200]).defined(),
77+
bodyType: yupString().oneOf(["json"]).defined(),
78+
body: yupObject({
79+
urls: yupArray(yupString().defined()).defined(),
80+
}).defined(),
81+
}),
82+
handler: async () => {
83+
const entries = getCachedConfig();
84+
85+
const roll = Math.random();
86+
let cumulative = 0;
87+
for (const entry of entries) {
88+
cumulative += entry.probability;
89+
if (roll < cumulative) {
90+
return {
91+
statusCode: 200,
92+
bodyType: "json",
93+
body: { urls: entry.urls },
94+
} as const;
95+
}
96+
}
97+
98+
return {
99+
statusCode: 200,
100+
bodyType: "json",
101+
body: { urls: entries[entries.length - 1].urls },
102+
} as const;
103+
},
104+
});

apps/backend/src/app/api/latest/project-permissions/crud.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ensureProjectPermissionExists, ensureUserExists } from "@/lib/request-c
33
import { sendProjectPermissionCreatedWebhook, sendProjectPermissionDeletedWebhook } from "@/lib/webhooks";
44
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
55
import { createCrudHandlers } from "@/route-handlers/crud-handler";
6-
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
6+
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
77
import { KnownErrors } from "@stackframe/stack-shared";
88
import { projectPermissionsCrud } from '@stackframe/stack-shared/dist/interface/crud/project-permissions';
99
import { permissionDefinitionIdSchema, userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

apps/backend/src/app/api/latest/team-memberships/crud.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { PrismaTransaction } from "@/lib/types";
66
import { sendTeamMembershipCreatedWebhook, sendTeamMembershipDeletedWebhook, sendTeamPermissionCreatedWebhook } from "@/lib/webhooks";
77
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
88
import { createCrudHandlers } from "@/route-handlers/crud-handler";
9-
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
9+
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
1010
import { KnownErrors } from "@stackframe/stack-shared";
1111
import { teamMembershipsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-memberships";
1212
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

apps/backend/src/app/api/latest/team-permissions/crud.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/li
33
import { sendTeamPermissionCreatedWebhook, sendTeamPermissionDeletedWebhook } from "@/lib/webhooks";
44
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
55
import { createCrudHandlers } from "@/route-handlers/crud-handler";
6-
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
6+
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
77
import { KnownErrors } from "@stackframe/stack-shared";
88
import { teamPermissionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions';
99
import { permissionDefinitionIdSchema, userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

apps/backend/src/app/api/latest/teams/crud.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sendTeamCreatedWebhook, sendTeamDeletedWebhook, sendTeamUpdatedWebhook
44
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
55
import { createCrudHandlers } from "@/route-handlers/crud-handler";
66
import { uploadAndGetUrl } from "@/s3";
7-
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
7+
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
88
import { Prisma } from "@/generated/prisma/client";
99
import { KnownErrors } from "@stackframe/stack-shared";
1010
import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";

0 commit comments

Comments
 (0)