Skip to content

Commit 236b76e

Browse files
committed
refactor API routing and fix cloud auth sign-in flow
1 parent ffcfabb commit 236b76e

36 files changed

Lines changed: 471 additions & 409 deletions

File tree

apps/cloud/src/api.ts

Lines changed: 171 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,45 @@
11
// ---------------------------------------------------------------------------
2-
// Cloud API — core handlers from @executor/api + cloud-specific plugins + auth
2+
// Cloud API — protected core API + public auth endpoints
33
// ---------------------------------------------------------------------------
44

55
import {
6+
HttpApi,
67
HttpApiBuilder,
78
HttpApiSwagger,
89
HttpMiddleware,
910
HttpServer,
1011
} from "@effect/platform";
1112
import { Effect, Layer } from "effect";
13+
import { setCookie } from "@tanstack/react-start/server";
1214

1315
import { addGroup, CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api";
1416
import { createExecutionEngine } from "@executor/execution";
1517
import { OpenApiGroup, OpenApiExtensionService, OpenApiHandlers } from "@executor/plugin-openapi/api";
1618
import { McpGroup, McpExtensionService, McpHandlers } from "@executor/plugin-mcp/api";
17-
import { GoogleDiscoveryGroup, GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "@executor/plugin-google-discovery/api";
19+
import {
20+
GoogleDiscoveryGroup,
21+
GoogleDiscoveryExtensionService,
22+
GoogleDiscoveryHandlers,
23+
} from "@executor/plugin-google-discovery/api";
1824
import { GraphqlGroup, GraphqlExtensionService, GraphqlHandlers } from "@executor/plugin-graphql/api";
1925

20-
import { createTeamExecutor } from "./services/executor";
21-
import { CloudAuthApi } from "./auth/api";
22-
import { CloudAuthHandlers } from "./auth/handlers";
26+
import { CloudAuthApi, CloudAuthPublicApi } from "./auth/api";
2327
import { AuthContext, UserStoreService } from "./auth/context";
28+
import { CloudAuthHandlers, CloudAuthPublicHandlers } from "./auth/handlers";
2429
import { WorkOSAuth } from "./auth/workos";
25-
import type { DrizzleDb } from "./services/db";
26-
27-
// ---------------------------------------------------------------------------
28-
// Cloud API — core + cloud plugins + cloud auth (no onepassword)
29-
// ---------------------------------------------------------------------------
30+
import { DbService } from "./services/db";
31+
import { createTeamExecutor } from "./services/executor";
3032

31-
const CloudApi = addGroup(OpenApiGroup)
33+
const ProtectedCloudApi = addGroup(OpenApiGroup)
3234
.add(McpGroup)
3335
.add(GoogleDiscoveryGroup)
3436
.add(GraphqlGroup)
3537
.add(CloudAuthApi);
3638

37-
const CloudApiBase = HttpApiBuilder.api(CloudApi).pipe(
39+
const PublicCloudApi = HttpApi.make("cloudPublic")
40+
.add(CloudAuthPublicApi);
41+
42+
const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(
3843
Layer.provide(CoreHandlers),
3944
Layer.provide(Layer.mergeAll(
4045
OpenApiHandlers,
@@ -45,100 +50,180 @@ const CloudApiBase = HttpApiBuilder.api(CloudApi).pipe(
4550
)),
4651
);
4752

48-
// ---------------------------------------------------------------------------
49-
// Cookie parser
50-
// ---------------------------------------------------------------------------
53+
const PublicCloudApiLive = HttpApiBuilder.api(PublicCloudApi).pipe(
54+
Layer.provide(CloudAuthPublicHandlers),
55+
);
56+
57+
const DbLive = DbService.Live;
58+
const UserStoreLive = UserStoreService.Live.pipe(
59+
Layer.provide(DbLive),
60+
);
61+
62+
const SharedServices = Layer.mergeAll(
63+
DbLive,
64+
UserStoreLive,
65+
WorkOSAuth.Default,
66+
HttpServer.layerContext,
67+
);
68+
69+
const publicApiHandler = HttpApiBuilder.toWebHandler(
70+
PublicCloudApiLive.pipe(
71+
Layer.provideMerge(SharedServices),
72+
),
73+
{ middleware: HttpMiddleware.logger },
74+
);
5175

5276
const parseCookie = (cookieHeader: string | null, name: string): string | null => {
5377
if (!cookieHeader) return null;
5478
const match = cookieHeader
5579
.split(";")
56-
.map((c) => c.trim())
57-
.find((c) => c.startsWith(`${name}=`));
80+
.map((value) => value.trim())
81+
.find((value) => value.startsWith(`${name}=`));
5882
if (!match) return null;
5983
return match.slice(name.length + 1) || null;
6084
};
6185

62-
// ---------------------------------------------------------------------------
63-
// Create API handler with auth-based executor resolution
64-
// ---------------------------------------------------------------------------
86+
const isPublicPath = (pathname: string): boolean =>
87+
pathname === "/auth/login" || pathname === "/auth/callback";
6588

66-
export const createCloudApiHandler = (db: DrizzleDb, encryptionKey: string) => {
67-
return async (request: Request): Promise<Response> => {
68-
// Authenticate via WorkOS sealed session
69-
const auth = await Effect.runPromise(
70-
Effect.gen(function* () {
71-
const workos = yield* WorkOSAuth;
72-
return yield* workos.authenticateRequest(request);
73-
}).pipe(Effect.provide(WorkOSAuth.Default)),
74-
);
89+
const unauthorized = (message: string): Response =>
90+
Response.json({ error: message }, { status: 401 });
7591

76-
if (!auth) {
77-
return Response.json({ error: "Unauthorized" }, { status: 401 });
78-
}
92+
const COOKIE_OPTIONS = {
93+
path: "/",
94+
httpOnly: true,
95+
sameSite: "lax" as const,
96+
maxAge: 60 * 60 * 24 * 7,
97+
secure: process.env.NODE_ENV === "production",
98+
};
7999

80-
const teamId = parseCookie(request.headers.get("cookie"), "executor_team");
81-
if (!teamId) {
82-
return Response.json({ error: "No team selected" }, { status: 401 });
83-
}
100+
const resolveAuth = (request: Request) =>
101+
Effect.gen(function* () {
102+
const workos = yield* WorkOSAuth;
103+
return yield* workos.authenticateRequest(request);
104+
}).pipe(
105+
Effect.provide(SharedServices),
106+
Effect.runPromise,
107+
);
108+
109+
const resolveTeamId = (
110+
auth: {
111+
readonly userId: string;
112+
readonly email: string;
113+
readonly firstName: string | null | undefined;
114+
readonly lastName: string | null | undefined;
115+
readonly avatarUrl: string | null | undefined;
116+
},
117+
cookieTeamId: string | null,
118+
) =>
119+
Effect.gen(function* () {
120+
if (cookieTeamId) return cookieTeamId;
121+
122+
const users = yield* UserStoreService;
123+
const teams = yield* users.use((store) => store.getTeamsForUser(auth.userId));
124+
if (teams.length > 0) return teams[0]!.teamId;
125+
126+
const user = yield* users.use((store) =>
127+
store.upsertUser({
128+
id: auth.userId,
129+
email: auth.email,
130+
name: `${auth.firstName ?? ""} ${auth.lastName ?? ""}`.trim() || undefined,
131+
avatarUrl: auth.avatarUrl ?? undefined,
132+
}),
133+
);
84134

85-
// Resolve team name
86-
const userStore = UserStoreService.layer(db);
87-
const team = await Effect.runPromise(
88-
Effect.gen(function* () {
89-
const users = yield* UserStoreService;
90-
return yield* users.use((s) => s.getTeam(teamId));
91-
}).pipe(Effect.provide(userStore)),
135+
const team = yield* users.use((store) =>
136+
store.createTeam(`${user.name ?? user.email}'s Team`),
92137
);
138+
yield* users.use((store) => store.addMember(team.id, user.id, "owner"));
139+
return team.id;
140+
}).pipe(
141+
Effect.provide(SharedServices),
142+
Effect.runPromise,
143+
);
144+
145+
const resolveExecutor = (teamId: string) =>
146+
Effect.gen(function* () {
147+
const users = yield* UserStoreService;
148+
const team = yield* users.use((store) => store.getTeam(teamId));
93149
const teamName = team?.name ?? "Unknown Team";
150+
const encryptionKey = process.env.ENCRYPTION_KEY ?? "local-dev-encryption-key";
151+
return yield* createTeamExecutor(teamId, teamName, encryptionKey);
152+
}).pipe(
153+
Effect.provide(SharedServices),
154+
Effect.runPromise,
155+
);
156+
157+
type TeamExecutor = Awaited<ReturnType<typeof resolveExecutor>>;
158+
159+
const createProtectedHandler = (
160+
auth: {
161+
readonly userId: string;
162+
readonly email: string;
163+
readonly firstName: string | null | undefined;
164+
readonly lastName: string | null | undefined;
165+
readonly avatarUrl: string | null | undefined;
166+
},
167+
teamId: string,
168+
executor: TeamExecutor,
169+
) => {
170+
const engine = createExecutionEngine({ executor });
171+
172+
const requestServices = Layer.mergeAll(
173+
Layer.succeed(AuthContext, {
174+
userId: auth.userId,
175+
teamId,
176+
email: auth.email,
177+
name: `${auth.firstName ?? ""} ${auth.lastName ?? ""}`.trim() || null,
178+
avatarUrl: auth.avatarUrl ?? null,
179+
}),
180+
Layer.succeed(ExecutorService, executor),
181+
Layer.succeed(ExecutionEngineService, engine),
182+
Layer.succeed(OpenApiExtensionService, executor.openapi),
183+
Layer.succeed(McpExtensionService, executor.mcp),
184+
Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery),
185+
Layer.succeed(GraphqlExtensionService, executor.graphql),
186+
);
187+
188+
return HttpApiBuilder.toWebHandler(
189+
HttpApiSwagger.layer({ path: "/docs" }).pipe(
190+
Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
191+
Layer.provideMerge(ProtectedCloudApiLive),
192+
Layer.provideMerge(requestServices),
193+
Layer.provideMerge(SharedServices),
194+
),
195+
{ middleware: HttpMiddleware.logger },
196+
);
197+
};
94198

95-
const executor = await Effect.runPromise(
96-
createTeamExecutor(db, teamId, teamName, encryptionKey),
97-
);
199+
export const handleApiRequest = async (request: Request): Promise<Response> => {
200+
const pathname = new URL(request.url).pathname;
98201

99-
const pluginExtensions = Layer.mergeAll(
100-
Layer.succeed(OpenApiExtensionService, executor.openapi),
101-
Layer.succeed(McpExtensionService, executor.mcp),
102-
Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery),
103-
Layer.succeed(GraphqlExtensionService, executor.graphql),
104-
);
202+
if (isPublicPath(pathname)) {
203+
return publicApiHandler.handler(request);
204+
}
105205

106-
const engine = createExecutionEngine({ executor });
107-
108-
const handler = HttpApiBuilder.toWebHandler(
109-
HttpApiSwagger.layer().pipe(
110-
Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
111-
Layer.provideMerge(CloudApiBase),
112-
Layer.provideMerge(pluginExtensions),
113-
Layer.provideMerge(Layer.succeed(ExecutorService, executor)),
114-
Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)),
115-
Layer.provideMerge(Layer.succeed(AuthContext, {
116-
userId: auth.userId,
117-
email: auth.email,
118-
teamId,
119-
name: `${auth.firstName ?? ""} ${auth.lastName ?? ""}`.trim() || null,
120-
avatarUrl: auth.avatarUrl,
121-
})),
122-
Layer.provideMerge(UserStoreService.layer(db)),
123-
Layer.provideMerge(WorkOSAuth.Default),
124-
Layer.provideMerge(HttpServer.layerContext),
125-
),
126-
{ middleware: HttpMiddleware.logger },
127-
);
206+
const auth = await resolveAuth(request);
207+
if (!auth) return unauthorized("Unauthorized");
208+
209+
const cookieTeamId = parseCookie(request.headers.get("cookie"), "executor_team");
210+
const teamId = await resolveTeamId(auth, cookieTeamId);
128211

129-
try {
130-
const response = await handler.handler(request);
212+
const executor = await resolveExecutor(teamId);
213+
const handler = createProtectedHandler(auth, teamId, executor);
131214

132-
if (auth.refreshedCookie) {
133-
const newResponse = new Response(response.body, response);
134-
newResponse.headers.append("Set-Cookie", auth.refreshedCookie);
135-
return newResponse;
136-
}
215+
try {
216+
const response = await handler.handler(request);
137217

138-
return response;
139-
} finally {
140-
await Effect.runPromise(executor.close()).catch(() => undefined);
141-
handler.dispose();
218+
if (auth.refreshedSession) {
219+
setCookie("wos-session", auth.refreshedSession, COOKIE_OPTIONS);
220+
}
221+
if (!cookieTeamId) {
222+
setCookie("executor_team", teamId, COOKIE_OPTIONS);
142223
}
143-
};
224+
return response;
225+
} finally {
226+
await Effect.runPromise(executor.close()).catch(() => undefined);
227+
await handler.dispose().catch(() => undefined);
228+
}
144229
};

apps/cloud/src/auth/api.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform";
22
import { Schema } from "effect";
3-
import { UserStoreError, WorkOSError } from "./context";
3+
import { UserStoreError, WorkOSError } from "./errors";
44

55
const AuthUser = Schema.Struct({
66
id: Schema.String,
@@ -23,12 +23,14 @@ const AuthCallbackSearch = Schema.Struct({
2323
code: Schema.String,
2424
});
2525

26-
export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
27-
.add(
28-
HttpApiEndpoint.get("me")`/auth/me`
29-
.addSuccess(AuthMeResponse)
30-
.addError(UserStoreError),
31-
)
26+
export const AUTH_PATHS = {
27+
login: "/api/auth/login",
28+
logout: "/api/auth/logout",
29+
callback: "/api/auth/callback",
30+
} as const;
31+
32+
/** Public auth endpoints — no authentication required */
33+
export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
3234
.add(
3335
HttpApiEndpoint.get("login")`/auth/login`,
3436
)
@@ -37,7 +39,16 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
3739
.setUrlParams(AuthCallbackSearch)
3840
.addError(UserStoreError)
3941
.addError(WorkOSError),
42+
) {}
43+
44+
/** Protected auth endpoints — require authentication */
45+
export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
46+
.add(
47+
HttpApiEndpoint.get("me")`/auth/me`
48+
.addSuccess(AuthMeResponse)
49+
.addError(UserStoreError),
4050
)
4151
.add(
4252
HttpApiEndpoint.post("logout")`/auth/logout`,
43-
) {}
53+
)
54+
{}

0 commit comments

Comments
 (0)