Skip to content

Commit ee04a7d

Browse files
committed
feat: scaffold control plane API with EffectTS and BetterAuth
1 parent 92ccd3d commit ee04a7d

15 files changed

Lines changed: 534 additions & 5 deletions

apps/api/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77
"build": "tsc",
88
"typecheck": "tsc --noEmit",
99
"lint": "eslint src/",
10-
"test": "echo 'no tests yet'"
10+
"test": "bun test",
11+
"dev": "bun run src/index.ts"
1112
},
1213
"dependencies": {
14+
"@effect/platform": "^0.94.5",
15+
"@effect/platform-node": "^0.104.1",
16+
"@effect/schema": "^0.75.5",
1317
"better-auth": "^1.4.18",
18+
"effect": "^3.19.18",
1419
"mysql2": "^3.17.4"
1520
},
1621
"devDependencies": {
22+
"@types/bun": "^1.3.9",
1723
"@types/node": "^25.3.0"
1824
}
1925
}

apps/api/src/context.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Context } from 'effect'
2+
3+
export interface AuthInfo {
4+
readonly userId: string
5+
readonly orgId: string
6+
}
7+
8+
export class AuthContext extends Context.Tag('AuthContext')<AuthContext, AuthInfo>() {}

apps/api/src/errors.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { Data } from 'effect'
2+
import { HttpServerResponse } from '@effect/platform'
3+
4+
export class NotFoundError extends Data.TaggedError('NotFoundError')<{
5+
readonly message: string
6+
}> {}
7+
8+
export class UnauthorizedError extends Data.TaggedError('UnauthorizedError')<{
9+
readonly message: string
10+
}> {}
11+
12+
export class ForbiddenError extends Data.TaggedError('ForbiddenError')<{
13+
readonly message: string
14+
}> {}
15+
16+
export class ValidationError extends Data.TaggedError('ValidationError')<{
17+
readonly message: string
18+
}> {}
19+
20+
export class ConflictError extends Data.TaggedError('ConflictError')<{
21+
readonly message: string
22+
}> {}
23+
24+
export class RateLimitedError extends Data.TaggedError('RateLimitedError')<{
25+
readonly message: string
26+
readonly retryAfter: number
27+
}> {}
28+
29+
export class SandboxNotRunningError extends Data.TaggedError('SandboxNotRunningError')<{
30+
readonly message: string
31+
}> {}
32+
33+
export class NotImplementedError extends Data.TaggedError('NotImplementedError')<{
34+
readonly message: string
35+
}> {}
36+
37+
export type ApiError =
38+
| NotFoundError
39+
| UnauthorizedError
40+
| ForbiddenError
41+
| ValidationError
42+
| ConflictError
43+
| RateLimitedError
44+
| SandboxNotRunningError
45+
| NotImplementedError
46+
47+
const STATUS_MAP: Record<ApiError['_tag'], number> = {
48+
NotFoundError: 404,
49+
UnauthorizedError: 401,
50+
ForbiddenError: 403,
51+
ValidationError: 400,
52+
ConflictError: 409,
53+
RateLimitedError: 429,
54+
SandboxNotRunningError: 409,
55+
NotImplementedError: 501,
56+
}
57+
58+
const CODE_MAP: Record<ApiError['_tag'], string> = {
59+
NotFoundError: 'not_found',
60+
UnauthorizedError: 'unauthorized',
61+
ForbiddenError: 'forbidden',
62+
ValidationError: 'validation_error',
63+
ConflictError: 'conflict',
64+
RateLimitedError: 'rate_limited',
65+
SandboxNotRunningError: 'sandbox_not_running',
66+
NotImplementedError: 'not_implemented',
67+
}
68+
69+
export function errorToResponse(error: ApiError, requestId: string) {
70+
return HttpServerResponse.unsafeJson(
71+
{
72+
error: CODE_MAP[error._tag],
73+
message: error.message,
74+
request_id: requestId,
75+
retry_after: error._tag === 'RateLimitedError' ? error.retryAfter : null,
76+
},
77+
{
78+
status: STATUS_MAP[error._tag],
79+
headers: { 'content-type': 'application/json' },
80+
},
81+
)
82+
}
83+
84+
/** Formats any error as an API error response. Handles both ApiError and unknown errors. */
85+
export function formatApiError(error: unknown, requestId: string = '') {
86+
const tag =
87+
typeof error === 'object' && error !== null && '_tag' in error
88+
? (error as { _tag: string })._tag
89+
: null
90+
if (tag !== null && tag in STATUS_MAP) {
91+
return errorToResponse(error as ApiError, requestId)
92+
}
93+
return HttpServerResponse.unsafeJson(
94+
{
95+
error: 'internal_error',
96+
message: 'An unexpected error occurred',
97+
request_id: requestId,
98+
retry_after: null,
99+
},
100+
{ status: 500, headers: { 'content-type': 'application/json' } },
101+
)
102+
}

apps/api/src/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,12 @@
1-
export { auth } from './auth.js'
2-
export { authClient } from './auth-client.js'
1+
import { NodeHttpServer, NodeRuntime } from '@effect/platform-node'
2+
import { Layer } from 'effect'
3+
import { createServer } from 'node:http'
4+
import { AppLive } from './server.js'
5+
6+
const PORT = Number(process.env.PORT ?? 3000)
7+
8+
const ServerLive = AppLive.pipe(
9+
Layer.provide(NodeHttpServer.layer(() => createServer(), { port: PORT })),
10+
)
11+
12+
NodeRuntime.runMain(Layer.launch(ServerLive))

apps/api/src/middleware.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { HttpMiddleware, HttpServerRequest, HttpServerResponse } from '@effect/platform'
2+
import { Effect } from 'effect'
3+
import { UnauthorizedError } from './errors.js'
4+
import { AuthContext } from './context.js'
5+
import { auth } from './auth.js'
6+
7+
/**
8+
* Generates a request ID (or propagates from X-Request-Id header)
9+
* and attaches it to the response.
10+
*/
11+
export const withRequestId = HttpMiddleware.make((app) =>
12+
Effect.gen(function* () {
13+
const request = yield* HttpServerRequest.HttpServerRequest
14+
const incoming = request.headers['x-request-id']
15+
const requestId = incoming ?? crypto.randomUUID()
16+
const response = yield* app
17+
return response.pipe(HttpServerResponse.setHeader('x-request-id', requestId))
18+
}),
19+
)
20+
21+
/**
22+
* Validates API key or session cookie and provides AuthContext.
23+
* Skips auth for /health and /api/auth/* routes.
24+
*/
25+
export const withAuth = HttpMiddleware.make((app) =>
26+
Effect.gen(function* () {
27+
const request = yield* HttpServerRequest.HttpServerRequest
28+
29+
if (request.url.startsWith('/health') || request.url.startsWith('/api/auth')) {
30+
return yield* Effect.provideService(app, AuthContext, { userId: '', orgId: '' })
31+
}
32+
33+
const authHeader = request.headers['authorization']
34+
if (authHeader?.startsWith('Bearer ')) {
35+
const key = authHeader.slice(7)
36+
const result = yield* Effect.tryPromise({
37+
try: () => auth.api.verifyApiKey({ body: { key } }),
38+
catch: () => new UnauthorizedError({ message: 'Invalid API key' }),
39+
})
40+
41+
if (!result?.valid) {
42+
return yield* Effect.fail(new UnauthorizedError({ message: 'Invalid API key' }))
43+
}
44+
45+
const metadata = (result as { metadata?: { orgId?: string } }).metadata
46+
return yield* Effect.provideService(app, AuthContext, {
47+
userId: (result as { userId?: string }).userId ?? '',
48+
orgId: metadata?.orgId ?? '',
49+
})
50+
}
51+
52+
const sessionResult = yield* Effect.tryPromise({
53+
try: () =>
54+
auth.api.getSession({
55+
headers: new Headers(request.headers as Record<string, string>),
56+
}),
57+
catch: () => new UnauthorizedError({ message: 'Invalid session' }),
58+
})
59+
60+
if (!sessionResult?.session) {
61+
return yield* Effect.fail(new UnauthorizedError({ message: 'Authentication required' }))
62+
}
63+
64+
return yield* Effect.provideService(app, AuthContext, {
65+
userId: sessionResult.session.userId,
66+
orgId:
67+
(sessionResult.session as { activeOrganizationId?: string }).activeOrganizationId ?? '',
68+
})
69+
}),
70+
)

apps/api/src/routes/artifacts.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { HttpRouter } from '@effect/platform'
2+
import { Effect } from 'effect'
3+
import { NotImplementedError } from '../errors.js'
4+
5+
const stub = (name: string) => Effect.fail(new NotImplementedError({ message: `${name} not yet implemented` }))
6+
7+
export const ArtifactRouter = HttpRouter.empty.pipe(
8+
HttpRouter.post('/v1/sandboxes/:id/artifacts', stub('Register artifacts')),
9+
HttpRouter.get('/v1/sandboxes/:id/artifacts', stub('List artifacts')),
10+
)

apps/api/src/routes/execs.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { HttpRouter } from '@effect/platform'
2+
import { Effect } from 'effect'
3+
import { NotImplementedError } from '../errors.js'
4+
5+
const stub = (name: string) => Effect.fail(new NotImplementedError({ message: `${name} not yet implemented` }))
6+
7+
export const ExecRouter = HttpRouter.empty.pipe(
8+
HttpRouter.post('/v1/sandboxes/:id/exec', stub('Execute command')),
9+
HttpRouter.get('/v1/sandboxes/:id/exec/:execId', stub('Get exec')),
10+
HttpRouter.get('/v1/sandboxes/:id/execs', stub('List execs')),
11+
HttpRouter.get('/v1/sandboxes/:id/exec/:execId/stream', stub('Stream exec output')),
12+
)

apps/api/src/routes/files.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { HttpRouter } from '@effect/platform'
2+
import { Effect } from 'effect'
3+
import { NotImplementedError } from '../errors.js'
4+
5+
const stub = (name: string) => Effect.fail(new NotImplementedError({ message: `${name} not yet implemented` }))
6+
7+
export const FileRouter = HttpRouter.empty.pipe(
8+
HttpRouter.put('/v1/sandboxes/:id/files', stub('Upload file')),
9+
HttpRouter.get('/v1/sandboxes/:id/files', stub('Download or list files')),
10+
HttpRouter.del('/v1/sandboxes/:id/files', stub('Delete file')),
11+
)

apps/api/src/routes/health.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { HttpRouter, HttpServerResponse } from '@effect/platform'
2+
import { Effect } from 'effect'
3+
4+
export const HealthRouter = HttpRouter.empty.pipe(
5+
HttpRouter.get(
6+
'/health',
7+
Effect.succeed(HttpServerResponse.unsafeJson({ status: 'ok' })),
8+
),
9+
)

apps/api/src/routes/sandboxes.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { HttpRouter } from '@effect/platform'
2+
import { Effect } from 'effect'
3+
import { NotImplementedError } from '../errors.js'
4+
5+
const stub = (name: string) => Effect.fail(new NotImplementedError({ message: `${name} not yet implemented` }))
6+
7+
export const SandboxRouter = HttpRouter.empty.pipe(
8+
HttpRouter.post('/v1/sandboxes', stub('Create sandbox')),
9+
HttpRouter.get('/v1/sandboxes', stub('List sandboxes')),
10+
HttpRouter.get('/v1/sandboxes/:id', stub('Get sandbox')),
11+
HttpRouter.post('/v1/sandboxes/:id/fork', stub('Fork sandbox')),
12+
HttpRouter.get('/v1/sandboxes/:id/forks', stub('Get fork tree')),
13+
HttpRouter.post('/v1/sandboxes/:id/stop', stub('Stop sandbox')),
14+
HttpRouter.del('/v1/sandboxes/:id', stub('Delete sandbox')),
15+
HttpRouter.get('/v1/sandboxes/:id/replay', stub('Get replay')),
16+
)

0 commit comments

Comments
 (0)