Skip to content

Commit a47bbf7

Browse files
committed
Add gRPC insecure mode and node timeouts
1 parent c396452 commit a47bbf7

11 files changed

Lines changed: 463 additions & 63 deletions

File tree

apps/api/src/auth.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ function getResend(resendApiKey: string) {
1313

1414
function createAuth() {
1515
const env = loadEnv()
16+
const authBaseUrl = new URL(env.BETTER_AUTH_BASE_URL)
17+
const isLocalAuth =
18+
authBaseUrl.hostname === 'localhost' || authBaseUrl.hostname === '127.0.0.1'
19+
1620
return betterAuth({
1721
baseURL: env.BETTER_AUTH_BASE_URL,
1822
secret: env.BETTER_AUTH_SECRET,
@@ -28,13 +32,17 @@ function createAuth() {
2832
},
2933
},
3034
advanced: {
31-
crossSubDomainCookies: {
32-
enabled: true,
33-
domain: env.BETTER_AUTH_COOKIE_DOMAIN ?? '.sandchest.com',
34-
},
35+
...(isLocalAuth
36+
? {}
37+
: {
38+
crossSubDomainCookies: {
39+
enabled: true,
40+
domain: env.BETTER_AUTH_COOKIE_DOMAIN ?? '.sandchest.com',
41+
},
42+
}),
3543
defaultCookieAttributes: {
36-
sameSite: 'none',
37-
secure: true,
44+
sameSite: isLocalAuth ? 'lax' : 'none',
45+
secure: isLocalAuth ? false : true,
3846
},
3947
},
4048
database: createPool({

apps/api/src/env.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ describe('loadEnv', () => {
6666
expect(env.REDIS_URL).toBe('redis://localhost:6379')
6767
})
6868

69+
test('NODE_GRPC_INSECURE defaults to false when unset', () => {
70+
const env = loadEnv()
71+
expect(env.NODE_GRPC_INSECURE).toBe(false)
72+
})
73+
74+
test('reads NODE_GRPC_INSECURE from env when set to 1', () => {
75+
process.env.NODE_GRPC_INSECURE = '1'
76+
const env = loadEnv()
77+
expect(env.NODE_GRPC_INSECURE).toBe(true)
78+
})
79+
6980
test('returns undefined for ARTIFACT_BUCKET_NAME when unset', () => {
7081
const env = loadEnv()
7182
expect(env.ARTIFACT_BUCKET_NAME).toBeUndefined()

apps/api/src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export function loadEnv() {
5050
NODE_GRPC_CERT_PATH: process.env.NODE_GRPC_CERT_PATH as string | undefined,
5151
NODE_GRPC_KEY_PATH: process.env.NODE_GRPC_KEY_PATH as string | undefined,
5252
NODE_GRPC_CA_PATH: process.env.NODE_GRPC_CA_PATH as string | undefined,
53+
NODE_GRPC_INSECURE: process.env.NODE_GRPC_INSECURE === '1',
5354
// mTLS via PEM content (Fly.io secrets — preferred in production)
5455
MTLS_CA_PEM: process.env.MTLS_CA_PEM as string | undefined,
5556
MTLS_CLIENT_CERT_PEM: process.env.MTLS_CLIENT_CERT_PEM as string | undefined,

apps/api/src/index.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,16 @@ const isProduction = env.NODE_ENV === 'production'
6767

6868
const RedisLive = REDIS_URL ? createRedisLayer(REDIS_URL, { family: REDIS_FAMILY }) : RedisMemory
6969

70-
const { NODE_GRPC_ADDR, NODE_GRPC_NODE_ID, NODE_GRPC_CERT_PATH, NODE_GRPC_KEY_PATH, NODE_GRPC_CA_PATH, MTLS_CA_PEM, MTLS_CLIENT_CERT_PEM, MTLS_CLIENT_KEY_PEM } = env
70+
const { NODE_GRPC_ADDR, NODE_GRPC_NODE_ID, NODE_GRPC_CERT_PATH, NODE_GRPC_KEY_PATH, NODE_GRPC_CA_PATH, NODE_GRPC_INSECURE, MTLS_CA_PEM, MTLS_CLIENT_CERT_PEM, MTLS_CLIENT_KEY_PEM } = env
7171
const hasPemContent = MTLS_CA_PEM && MTLS_CLIENT_CERT_PEM && MTLS_CLIENT_KEY_PEM
7272
const hasFilePaths = NODE_GRPC_CERT_PATH && NODE_GRPC_KEY_PATH && NODE_GRPC_CA_PATH
73+
const hasNodeGrpcConfig = NODE_GRPC_ADDR && NODE_GRPC_NODE_ID && (NODE_GRPC_INSECURE || hasPemContent || hasFilePaths)
7374
const NodeClientLive =
74-
NODE_GRPC_ADDR && NODE_GRPC_NODE_ID && (hasPemContent || hasFilePaths)
75+
hasNodeGrpcConfig
7576
? createNodeClientLayer({
76-
address: NODE_GRPC_ADDR,
77-
nodeId: NODE_GRPC_NODE_ID,
77+
address: NODE_GRPC_ADDR!,
78+
nodeId: NODE_GRPC_NODE_ID!,
79+
insecure: NODE_GRPC_INSECURE,
7880
// Prefer PEM content (Fly.io secrets) over file paths (local dev)
7981
caPem: MTLS_CA_PEM,
8082
certPem: MTLS_CLIENT_CERT_PEM,
@@ -156,10 +158,10 @@ const ProductionFallbackWarnings = Layer.scopedDiscard(
156158
)
157159
}
158160

159-
const hasNodeClient = NODE_GRPC_ADDR && NODE_GRPC_NODE_ID && (hasPemContent || hasFilePaths)
161+
const hasNodeClient = hasNodeGrpcConfig
160162
if (!hasNodeClient) {
161163
yield* Effect.logWarning(
162-
'Node gRPC client is not configured — using in-memory stub. Sandbox creation, exec, and session operations will return mock data. Fix: set NODE_GRPC_ADDR, NODE_GRPC_NODE_ID, and mTLS credentials',
164+
'Node gRPC client is not configured — using in-memory stub. Sandbox creation, exec, and session operations will return mock data. Fix: set NODE_GRPC_ADDR, NODE_GRPC_NODE_ID, and either NODE_GRPC_INSECURE=1 for localhost or mTLS credentials',
163165
)
164166
}
165167
}),

apps/api/src/routes/files.test.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,16 @@ import { createInMemoryMetricsRepo } from '../services/metrics-repo.memory.js'
2626
import { ShutdownControllerLive } from '../shutdown.js'
2727
import { idToBytes } from '@sandchest/contract'
2828
import { RUN_API_INTEGRATION_TESTS } from '../test-support.js'
29+
import type { NodeClientApi } from '../services/node-client.js'
2930

3031
const TEST_ORG = 'org_test_123'
3132
const TEST_USER = 'user_test_456'
3233

33-
function createTestEnv() {
34+
function createTestEnv(overrides?: { nodeClient?: NodeClientApi }) {
3435
const sandboxRepo = createInMemorySandboxRepo()
3536
const execRepo = createInMemoryExecRepo()
3637
const sessionRepo = createInMemorySessionRepo()
37-
const nodeClient = createInMemoryNodeClient()
38+
const nodeClient = overrides?.nodeClient ?? createInMemoryNodeClient()
3839
const redis = createInMemoryRedisApi()
3940
const artifactRepo = createInMemoryArtifactRepo()
4041

@@ -231,6 +232,48 @@ describe.skipIf(!RUN_API_INTEGRATION_TESTS)('PUT /v1/sandboxes/:id/files — upl
231232

232233
expect(result.status).toBe(409)
233234
})
235+
236+
test('returns 500 when node putFile times out', async () => {
237+
const env = createTestEnv({
238+
nodeClient: {
239+
...createInMemoryNodeClient(),
240+
putFile: () => Effect.never,
241+
},
242+
})
243+
const sandboxId = await createRunningSandbox(env)
244+
const originalTimeout = process.env.NODE_FILE_TIMEOUT_MS
245+
process.env.NODE_FILE_TIMEOUT_MS = '10'
246+
247+
try {
248+
const result = await env.runTest(
249+
Effect.gen(function* () {
250+
const client = yield* HttpClient.HttpClient
251+
const response = yield* client.execute(
252+
HttpClientRequest.put(
253+
`/v1/sandboxes/${sandboxId}/files?path=/work/test.txt`,
254+
).pipe(
255+
HttpClientRequest.bodyUint8Array(
256+
new TextEncoder().encode('data'),
257+
'application/octet-stream',
258+
),
259+
),
260+
)
261+
const body = yield* response.json
262+
return { status: response.status, body: body as { error: string; message: string } }
263+
}),
264+
)
265+
266+
expect(result.status).toBe(500)
267+
expect(result.body.error).toBe('internal_error')
268+
expect(result.body.message).toContain('Node putFile timed out after 10ms')
269+
} finally {
270+
if (originalTimeout === undefined) {
271+
delete process.env.NODE_FILE_TIMEOUT_MS
272+
} else {
273+
process.env.NODE_FILE_TIMEOUT_MS = originalTimeout
274+
}
275+
}
276+
})
234277
})
235278

236279
// ---------------------------------------------------------------------------

apps/api/src/routes/files.ts

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { HttpRouter, HttpServerRequest, HttpServerResponse } from '@effect/platform'
2-
import { Effect } from 'effect'
2+
import { Cause, Effect } from 'effect'
33
import { idToBytes } from '@sandchest/contract'
44
import type { FileEntry, ListFilesResponse } from '@sandchest/contract'
55
import {
6+
InternalError,
67
NotFoundError,
78
SandboxNotRunningError,
89
ValidationError,
@@ -15,6 +16,7 @@ import { NodeClient } from '../services/node-client.js'
1516
const MAX_SINGLE_FILE = 5 * 1024 * 1024 * 1024 // 5 GB
1617
const MAX_BATCH_FILE = 10 * 1024 * 1024 * 1024 // 10 GB
1718
const DEFAULT_LIST_LIMIT = 200
19+
const DEFAULT_NODE_FILE_TIMEOUT_MS = 30_000
1820

1921
function parseSandboxId(idStr: string | undefined) {
2022
if (!idStr) {
@@ -27,6 +29,40 @@ function parseSandboxId(idStr: string | undefined) {
2729
}
2830
}
2931

32+
function resolveNodeFileTimeoutMs(): number {
33+
const raw = process.env['NODE_FILE_TIMEOUT_MS']
34+
if (!raw) {
35+
return DEFAULT_NODE_FILE_TIMEOUT_MS
36+
}
37+
38+
const parsed = Number.parseInt(raw, 10)
39+
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_NODE_FILE_TIMEOUT_MS
40+
}
41+
42+
function withNodeFileTimeout<A>(
43+
operation: string,
44+
effect: Effect.Effect<A, never, never>,
45+
): Effect.Effect<A, InternalError, never> {
46+
const timeoutMs = resolveNodeFileTimeoutMs()
47+
48+
return effect.pipe(
49+
Effect.timeoutFail({
50+
duration: `${timeoutMs} millis`,
51+
onTimeout: () =>
52+
new InternalError({
53+
message: `Node ${operation} timed out after ${timeoutMs}ms`,
54+
}),
55+
}),
56+
Effect.catchAllCause((cause) =>
57+
Effect.fail(
58+
new InternalError({
59+
message: `Node ${operation} failed: ${Cause.pretty(cause)}`,
60+
}),
61+
),
62+
),
63+
)
64+
}
65+
3066
// -- Upload file -------------------------------------------------------------
3167

3268
const uploadFile = Effect.gen(function* () {
@@ -77,11 +113,14 @@ const uploadFile = Effect.gen(function* () {
77113
)
78114
}
79115

80-
const result = yield* nodeClient.putFile({
81-
sandboxId: sandboxIdBytes,
82-
path,
83-
data,
84-
})
116+
const result = yield* withNodeFileTimeout(
117+
'putFile',
118+
nodeClient.putFile({
119+
sandboxId: sandboxIdBytes,
120+
path,
121+
data,
122+
}),
123+
)
85124

86125
return HttpServerResponse.unsafeJson({
87126
path,
@@ -136,10 +175,13 @@ const downloadOrListFiles = Effect.gen(function* () {
136175
)
137176
}
138177

139-
const entries = yield* nodeClient.listFiles({
140-
sandboxId: sandboxIdBytes,
141-
path,
142-
})
178+
const entries = yield* withNodeFileTimeout(
179+
'listFiles',
180+
nodeClient.listFiles({
181+
sandboxId: sandboxIdBytes,
182+
path,
183+
}),
184+
)
143185

144186
// Apply cursor-based pagination
145187
let startIdx = 0
@@ -167,10 +209,13 @@ const downloadOrListFiles = Effect.gen(function* () {
167209
}
168210

169211
// File download
170-
const data = yield* nodeClient.getFile({
171-
sandboxId: sandboxIdBytes,
172-
path,
173-
})
212+
const data = yield* withNodeFileTimeout(
213+
'getFile',
214+
nodeClient.getFile({
215+
sandboxId: sandboxIdBytes,
216+
path,
217+
}),
218+
)
174219

175220
return HttpServerResponse.uint8Array(data, {
176221
contentType: 'application/octet-stream',
@@ -216,10 +261,13 @@ const deleteFile = Effect.gen(function* () {
216261
return yield* Effect.fail(new ValidationError({ message: 'path query parameter is required' }))
217262
}
218263

219-
yield* nodeClient.deleteFile({
220-
sandboxId: sandboxIdBytes,
221-
path,
222-
})
264+
yield* withNodeFileTimeout(
265+
'deleteFile',
266+
nodeClient.deleteFile({
267+
sandboxId: sandboxIdBytes,
268+
path,
269+
}),
270+
)
223271

224272
return HttpServerResponse.unsafeJson({ ok: true })
225273
})

0 commit comments

Comments
 (0)