History

refactor API routing and fix cloud auth sign-in flow

Rhys Sullivan committed 7 months ago

Commit 236b76e1 parent ffcfabb36 files · +471 −409Browse files

Unified · Split

36 files
  1. apps/cloud/src/api.ts +171 −86
  2. apps/cloud/src/auth/api.ts +19 −8
  3. apps/cloud/src/auth/context.ts +9 −22
  4. apps/cloud/src/auth/errors.ts +11 −0
  5. apps/cloud/src/auth/handlers.ts +61 −45
  6. apps/cloud/src/auth/workos.ts +3 −22
  7. apps/cloud/src/routeTree.gen.ts +17 −38
  8. apps/cloud/src/routes/api.$.ts +18 −0
  9. apps/cloud/src/routes/v1.$.ts +0 −14
  10. apps/cloud/src/server/api-handler.ts +8 −8
  11. apps/cloud/src/services/db.ts +36 −14
  12. apps/cloud/src/services/executor.ts +2 −2
  13. apps/cloud/src/services/schema.ts +40 −0
  14. apps/cloud/src/services/user-store.ts +1 −1
  15. apps/cloud/src/web/auth.tsx +14 −2
  16. apps/cloud/src/web/pages/login.tsx +2 −1
  17. apps/cloud/src/web/shell.tsx +2 −1
  18. apps/local/src/routeTree.gen.ts +21 −21
  19. apps/local/src/routes/api.$.ts +18 −0
  20. apps/local/src/routes/v1.$.ts +0 −14
  21. apps/local/src/server/api-handler.ts +5 −1
  22. apps/local/src/server/main.ts +1 −1
  23. packages/core/api/src/executions/api.ts +1 −1
  24. packages/core/api/src/scope/api.ts +1 −1
  25. packages/core/api/src/secrets/api.ts +1 −1
  26. packages/core/api/src/sources/api.ts +1 −1
  27. packages/core/api/src/tools/api.ts +1 −1
  28. packages/core/storage-postgres/src/index.test.ts +0 −55
  29. packages/core/storage-postgres/src/index.ts +0 −2
  30. packages/core/storage-postgres/src/schema.ts +0 −39
  31. packages/plugins/google-discovery/src/api/group.ts +1 −1
  32. packages/plugins/graphql/src/api/group.ts +1 −1
  33. packages/plugins/mcp/src/api/group.ts +1 −1
  34. packages/plugins/onepassword/src/api/group.ts +1 −1
  35. packages/plugins/openapi/src/api/group.ts +1 −1
  36. packages/react/src/api/base-url.tsx +2 −2
apps/cloud/src/api.ts+171−86
@@ -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( · 4 unmodified lines
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+19−8
@@ -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({ · 16 unmodified lines
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") · 2 unmodified lines
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+{}
apps/cloud/src/auth/context.ts+9−22
@@ -1,20 +1,7 @@
1−import { Context, Effect, Layer, Schema } from "effect";
2−import { makeUserStore } from "@executor/storage-postgres";
3−import type { DrizzleDb } from "../services/db";
4−
5−// ---------------------------------------------------------------------------
6−// Errors
7−// ---------------------------------------------------------------------------
8−
9−export class UserStoreError extends Schema.TaggedError<UserStoreError>()(
10− "UserStoreError",
11− { cause: Schema.Unknown },
12−) {}
13−
14−export class WorkOSError extends Schema.TaggedError<WorkOSError>()(
15− "WorkOSError",
16− { cause: Schema.Unknown },
17−) {}
1+import { Context, Effect, Layer } from "effect";
2+import { makeUserStore } from "../services/user-store";
3+import { DbService } from "../services/db";
4+import { UserStoreError } from "./errors";
185
196 // ---------------------------------------------------------------------------
207 // AuthContext — resolved per-request from sealed session
@@ -37,9 +24,7 @@ export class AuthContext extends Context.Tag("@executor/cloud/AuthContext")< · 16 unmodified lines
3724
3825 type RawStore = ReturnType<typeof makeUserStore>;
3926
40−const makeService = (db: DrizzleDb) => {
41− const store = makeUserStore(db);
42−
27+const makeService = (store: RawStore) => {
4328 const use = <A>(fn: (s: RawStore) => Promise<A>) =>
4429 Effect.tryPromise({
4530 try: () => fn(store),
@@ -55,6 +40,8 @@ export class UserStoreService extends Context.Tag("@executor/cloud/UserStoreServ · 9 unmodified lines
5540 UserStoreService,
5641 UserStoreServiceType
5742 >() {
58− static layer = (db: DrizzleDb) =>
59− Layer.succeed(this, makeService(db));
43+ static Live = Layer.effect(
44+ this,
45+ Effect.map(DbService, (db) => makeService(makeUserStore(db))),
46+ );
6047 }
apps/cloud/src/auth/errors.ts+11−0
@@ -0,0 +1,11 @@
1+import { Schema } from "effect";
2+
3+export class UserStoreError extends Schema.TaggedError<UserStoreError>()(
4+ "UserStoreError",
5+ { cause: Schema.Unknown },
6+) {}
7+
8+export class WorkOSError extends Schema.TaggedError<WorkOSError>()(
9+ "WorkOSError",
10+ { cause: Schema.Unknown },
11+) {}
apps/cloud/src/auth/handlers.ts+61−45
@@ -1,40 +1,38 @@
1−import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
1+import { HttpApi, HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform";
22 import { Effect } from "effect";
3+import { deleteCookie, setCookie } from "@tanstack/react-start/server";
34
45 import { addGroup } from "@executor/api";
5−import { CloudAuthApi } from "./api";
6+import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
67 import { AuthContext, UserStoreService } from "./context";
7−import { WorkOSAuth, makeSessionCookie, clearSessionCookie } from "./workos";
8+import { WorkOSAuth } from "./workos";
89
9−const ApiWithCloudAuth = addGroup(CloudAuthApi);
10+const COOKIE_OPTIONS = {
11+ path: "/",
12+ httpOnly: true,
13+ sameSite: "lax" as const,
14+ maxAge: 60 * 60 * 24 * 7,
15+ secure: process.env.NODE_ENV === "production",
16+};
1017
11−export const CloudAuthHandlers = HttpApiBuilder.group(
12− ApiWithCloudAuth,
13− "cloudAuth",
18+// ---------------------------------------------------------------------------
19+// Public auth handlers (no authentication required)
20+// ---------------------------------------------------------------------------
21+
22+const PublicAuthApi = HttpApi.make("cloudPublic")
23+ .add(CloudAuthPublicApi);
24+
25+export const CloudAuthPublicHandlers = HttpApiBuilder.group(
26+ PublicAuthApi,
27+ "cloudAuthPublic",
1428 (handlers) =>
1529 handlers
16− .handle("me", () =>
17− Effect.gen(function* () {
18− const auth = yield* AuthContext;
19− const users = yield* UserStoreService;
20− const team = yield* users.use((s) => s.getTeam(auth.teamId));
21−
22− return {
23− user: {
24− id: auth.userId,
25− email: auth.email,
26− name: auth.name,
27− avatarUrl: auth.avatarUrl,
28− },
29− team: team ? { id: team.id, name: team.name } : null,
30− };
31− }),
32− )
3330 .handleRaw("login", () =>
3431 Effect.gen(function* () {
3532 const workos = yield* WorkOSAuth;
36− const baseUrl = process.env.APP_URL ?? `http://localhost:${process.env.PORT ?? "3000"}`;
37− const url = workos.getAuthorizationUrl(`${baseUrl}/auth/callback`);
33+ const req = yield* HttpServerRequest.HttpServerRequest;
34+ const origin = new URL(req.url, `http://${req.headers["host"]}`).origin;
35+ const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`);
3836 return HttpServerResponse.redirect(url, { status: 302 });
3937 }),
4038 )
@@ -81,28 +79,46 @@ export const CloudAuthHandlers = HttpApiBuilder.group( · 40 unmodified lines
8179 return HttpServerResponse.text("Failed to create session", { status: 500 });
8280 }
8381
84− return HttpServerResponse.redirect("/", {
85− status: 302,
86− headers: {
87− "Set-Cookie": [
88− makeSessionCookie(sealedSession),
89− `executor_team=${teamId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800`,
90− ].join(", "),
82+ setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
83+ setCookie("executor_team", teamId, COOKIE_OPTIONS);
84+ return HttpServerResponse.redirect("/", { status: 302 });
85+ }),
86+ ),
87+);
88+
89+// ---------------------------------------------------------------------------
90+// Protected auth handlers (require authentication via middleware)
91+// ---------------------------------------------------------------------------
92+
93+const ApiWithCloudAuth = addGroup(CloudAuthApi);
94+
95+export const CloudAuthHandlers = HttpApiBuilder.group(
96+ ApiWithCloudAuth,
97+ "cloudAuth",
98+ (handlers) =>
99+ handlers
100+ .handle("me", () =>
101+ Effect.gen(function* () {
102+ const auth = yield* AuthContext;
103+ const users = yield* UserStoreService;
104+ const team = yield* users.use((s) => s.getTeam(auth.teamId));
105+
106+ return {
107+ user: {
108+ id: auth.userId,
109+ email: auth.email,
110+ name: auth.name,
111+ avatarUrl: auth.avatarUrl,
91112 },
92− });
113+ team: team ? { id: team.id, name: team.name } : null,
114+ };
93115 }),
94116 )
95117 .handleRaw("logout", () =>
96− Effect.succeed(
97− HttpServerResponse.redirect("/", {
98− status: 302,
99− headers: {
100− "Set-Cookie": [
101− clearSessionCookie(),
102− "executor_team=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
103− ].join(", "),
104− },
105− }),
106− ),
118+ Effect.sync(() => {
119+ deleteCookie("wos-session", { path: "/" });
120+ deleteCookie("executor_team", { path: "/" });
121+ return HttpServerResponse.redirect("/", { status: 302 });
122+ }),
107123 ),
108124 );
apps/cloud/src/auth/workos.ts+3−22
@@ -4,7 +4,7 @@ · 3 unmodified lines
44
55 import { Context, Effect, Layer } from "effect";
66 import { WorkOS } from "@workos-inc/node";
7−import { WorkOSError } from "./context";
7+import { WorkOSError } from "./errors";
88
99 const COOKIE_NAME = "wos-session";
1010
@@ -67,7 +67,7 @@ const make = Effect.gen(function* () { · 56 unmodified lines
6767 lastName: result.user.lastName,
6868 avatarUrl: result.user.profilePictureUrl,
6969 sessionId: result.sessionId,
70− refreshedCookie: undefined as string | undefined,
70+ refreshedSession: undefined as string | undefined,
7171 };
7272 }
7373
@@ -87,7 +87,7 @@ const make = Effect.gen(function* () { · 13 unmodified lines
8787 lastName: refreshed.user.lastName,
8888 avatarUrl: refreshed.user.profilePictureUrl,
8989 sessionId: refreshed.sessionId,
90− refreshedCookie: makeSessionCookie(refreshed.sealedSession),
90+ refreshedSession: refreshed.sealedSession,
9191 };
9292 }),
9393 };
@@ -104,25 +104,6 @@ export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")< · 10 unmodified lines
104104 );
105105 }
106106
107−// ---------------------------------------------------------------------------
108−// Cookie helpers
109−// ---------------------------------------------------------------------------
110−
111−export const makeSessionCookie = (sealedSession: string): string => {
112− const parts = [
113− `${COOKIE_NAME}=${sealedSession}`,
114− "Path=/",
115− "HttpOnly",
116− "SameSite=Lax",
117− "Max-Age=604800",
118− ];
119− if (process.env.NODE_ENV === "production") parts.push("Secure");
120− return parts.join("; ");
121−};
122−
123−export const clearSessionCookie = (): string =>
124− `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
125−
126107 const parseCookie = (cookieHeader: string | null, name: string): string | null => {
127108 if (!cookieHeader) return null;
128109 const match = cookieHeader
apps/cloud/src/routeTree.gen.ts+17−38
@@ -12,9 +12,8 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
1212 import { Route as ToolsRouteImport } from './routes/tools'
1313 import { Route as SecretsRouteImport } from './routes/secrets'
1414 import { Route as IndexRouteImport } from './routes/index'
15−import { Route as V1SplatRouteImport } from './routes/v1.$'
1615 import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'
17−import { Route as AuthSplatRouteImport } from './routes/auth.$'
16+import { Route as ApiSplatRouteImport } from './routes/api.$'
1817 import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey'
1918
2019 const ToolsRoute = ToolsRouteImport.update({
@@ -32,19 +31,14 @@ const IndexRoute = IndexRouteImport.update({ · 11 unmodified lines
3231 path: '/',
3332 getParentRoute: () => rootRouteImport,
3433 } as any)
35−const V1SplatRoute = V1SplatRouteImport.update({
36− id: '/v1/$',
37− path: '/v1/$',
38− getParentRoute: () => rootRouteImport,
39−} as any)
4034 const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({
4135 id: '/sources/$namespace',
4236 path: '/sources/$namespace',
4337 getParentRoute: () => rootRouteImport,
4438 } as any)
45−const AuthSplatRoute = AuthSplatRouteImport.update({
46− id: '/auth/$',
47− path: '/auth/$',
39+const ApiSplatRoute = ApiSplatRouteImport.update({
40+ id: '/api/$',
41+ path: '/api/$',
4842 getParentRoute: () => rootRouteImport,
4943 } as any)
5044 const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({
@@ -57,18 +51,16 @@ export interface FileRoutesByFullPath { · 6 unmodified lines
5751 '/': typeof IndexRoute
5852 '/secrets': typeof SecretsRoute
5953 '/tools': typeof ToolsRoute
60− '/auth/$': typeof AuthSplatRoute
54+ '/api/$': typeof ApiSplatRoute
6155 '/sources/$namespace': typeof SourcesNamespaceRoute
62− '/v1/$': typeof V1SplatRoute
6356 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
6457 }
6558 export interface FileRoutesByTo {
6659 '/': typeof IndexRoute
6760 '/secrets': typeof SecretsRoute
6861 '/tools': typeof ToolsRoute
69− '/auth/$': typeof AuthSplatRoute
62+ '/api/$': typeof ApiSplatRoute
7063 '/sources/$namespace': typeof SourcesNamespaceRoute
71− '/v1/$': typeof V1SplatRoute
7264 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
7365 }
7466 export interface FileRoutesById {
@@ -76,9 +68,8 @@ export interface FileRoutesById { · 1 unmodified line
7668 '/': typeof IndexRoute
7769 '/secrets': typeof SecretsRoute
7870 '/tools': typeof ToolsRoute
79− '/auth/$': typeof AuthSplatRoute
71+ '/api/$': typeof ApiSplatRoute
8072 '/sources/$namespace': typeof SourcesNamespaceRoute
81− '/v1/$': typeof V1SplatRoute
8273 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
8374 }
8475 export interface FileRouteTypes {
@@ -87,27 +78,24 @@ export interface FileRouteTypes { · 2 unmodified lines
8778 | '/'
8879 | '/secrets'
8980 | '/tools'
90− | '/auth/$'
81+ | '/api/$'
9182 | '/sources/$namespace'
92− | '/v1/$'
9383 | '/sources/add/$pluginKey'
9484 fileRoutesByTo: FileRoutesByTo
9585 to:
9686 | '/'
9787 | '/secrets'
9888 | '/tools'
99− | '/auth/$'
89+ | '/api/$'
10090 | '/sources/$namespace'
101− | '/v1/$'
10291 | '/sources/add/$pluginKey'
10392 id:
10493 | '__root__'
10594 | '/'
10695 | '/secrets'
10796 | '/tools'
108− | '/auth/$'
97+ | '/api/$'
10998 | '/sources/$namespace'
110− | '/v1/$'
11199 | '/sources/add/$pluginKey'
112100 fileRoutesById: FileRoutesById
113101 }
@@ -115,9 +103,8 @@ export interface RootRouteChildren { · 1 unmodified line
115103 IndexRoute: typeof IndexRoute
116104 SecretsRoute: typeof SecretsRoute
117105 ToolsRoute: typeof ToolsRoute
118− AuthSplatRoute: typeof AuthSplatRoute
106+ ApiSplatRoute: typeof ApiSplatRoute
119107 SourcesNamespaceRoute: typeof SourcesNamespaceRoute
120− V1SplatRoute: typeof V1SplatRoute
121108 SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute
122109 }
123110
@@ -144,13 +131,6 @@ declare module '@tanstack/react-router' { · 20 unmodified lines
144131 preLoaderRoute: typeof IndexRouteImport
145132 parentRoute: typeof rootRouteImport
146133 }
147− '/v1/$': {
148− id: '/v1/$'
149− path: '/v1/$'
150− fullPath: '/v1/$'
151− preLoaderRoute: typeof V1SplatRouteImport
152− parentRoute: typeof rootRouteImport
153− }
154134 '/sources/$namespace': {
155135 id: '/sources/$namespace'
156136 path: '/sources/$namespace'
@@ -158,11 +138,11 @@ declare module '@tanstack/react-router' { · 1 unmodified line
158138 preLoaderRoute: typeof SourcesNamespaceRouteImport
159139 parentRoute: typeof rootRouteImport
160140 }
161− '/auth/$': {
162− id: '/auth/$'
163− path: '/auth/$'
164− fullPath: '/auth/$'
165− preLoaderRoute: typeof AuthSplatRouteImport
141+ '/api/$': {
142+ id: '/api/$'
143+ path: '/api/$'
144+ fullPath: '/api/$'
145+ preLoaderRoute: typeof ApiSplatRouteImport
166146 parentRoute: typeof rootRouteImport
167147 }
168148 '/sources/add/$pluginKey': {
@@ -179,9 +159,8 @@ const rootRouteChildren: RootRouteChildren = { · 10 unmodified lines
179159 IndexRoute: IndexRoute,
180160 SecretsRoute: SecretsRoute,
181161 ToolsRoute: ToolsRoute,
182− AuthSplatRoute: AuthSplatRoute,
162+ ApiSplatRoute: ApiSplatRoute,
183163 SourcesNamespaceRoute: SourcesNamespaceRoute,
184− V1SplatRoute: V1SplatRoute,
185164 SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute,
186165 }
187166 export const routeTree = rootRouteImport
apps/cloud/src/routes/api.$.ts+18−0
@@ -0,0 +1,18 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+const handle = async (request: Request) => {
4+ const { handleApiRequest } = await import("../server/api-handler");
5+ return handleApiRequest(request);
6+};
7+
8+export const Route = createFileRoute("/api/$")({
9+ server: {
10+ handlers: {
11+ GET: ({ request }) => handle(request),
12+ POST: ({ request }) => handle(request),
13+ PUT: ({ request }) => handle(request),
14+ DELETE: ({ request }) => handle(request),
15+ PATCH: ({ request }) => handle(request),
16+ },
17+ },
18+});
apps/cloud/src/routes/v1.$.ts+0−14
@@ -1,14 +0,0 @@
1−import { createFileRoute } from "@tanstack/react-router";
2−import { handleApiRequest } from "../server/api-handler";
3−
4−export const Route = createFileRoute("/v1/$")({
5− server: {
6− handlers: {
7− GET: ({ request }) => handleApiRequest(request),
8− POST: ({ request }) => handleApiRequest(request),
9− PUT: ({ request }) => handleApiRequest(request),
10− DELETE: ({ request }) => handleApiRequest(request),
11− PATCH: ({ request }) => handleApiRequest(request),
12− },
13− },
14−});
apps/cloud/src/server/api-handler.ts+8−8
@@ -1,9 +1,9 @@
1−import { createCloudApiHandler } from "../api";
2−import { getDb } from "../services/db";
1+import { handleApiRequest as _handleApiRequest } from "../api";
32
4−const handlerPromise = getDb().then((db) =>
5− createCloudApiHandler(db, process.env.ENCRYPTION_KEY ?? "local-dev-encryption-key"),
6−);
7−
8−export const handleApiRequest = async (request: Request) =>
9− (await handlerPromise)(request);
3+export const handleApiRequest = (request: Request) => {
4+ // Strip /api prefix — route catch-all (api.$.ts) matches /api/*
5+ // but Effect endpoints are defined without the prefix
6+ const url = new URL(request.url);
7+ url.pathname = url.pathname.replace(/^\/api/, "");
8+ return _handleApiRequest(new Request(url, request));
9+};
apps/cloud/src/services/db.ts+36−14
@@ -2,10 +2,14 @@ · 1 unmodified line
22 // Database service — PGlite for dev, node-postgres for prod
33 // ---------------------------------------------------------------------------
44
5+import { Context, Effect, Layer } from "effect";
56 import { resolve } from "node:path";
6−import * as schema from "@executor/storage-postgres";
7+import * as sharedSchema from "@executor/storage-postgres";
8+import * as cloudSchema from "./schema";
79 import type { DrizzleDb } from "@executor/storage-postgres";
810
11+const schema = { ...sharedSchema, ...cloudSchema };
12+
913 export type { DrizzleDb };
1014
1115 const MIGRATIONS_DIR = resolve(
@@ -13,27 +17,45 @@ const MIGRATIONS_DIR = resolve( · 1 unmodified line
1317 "../../../../packages/core/storage-postgres/drizzle",
1418 );
1519
16−let db: DrizzleDb | null = null;
17−
18−export const getDb = async (): Promise<DrizzleDb> => {
19− if (db) return db;
20+let dbPromise: Promise<DrizzleDb> | undefined;
2021
22+const createDb = async (): Promise<DrizzleDb> => {
2123 if (process.env.DATABASE_URL) {
2224 const { drizzle } = await import("drizzle-orm/node-postgres");
2325 const { migrate } = await import("drizzle-orm/node-postgres/migrator");
2426 const { Pool } = await import("pg");
2527 const pool = new Pool({ connectionString: process.env.DATABASE_URL });
26− db = drizzle(pool, { schema }) as DrizzleDb;
27− await migrate(db as any, { migrationsFolder: MIGRATIONS_DIR });
28− } else {
29− const { PGlite } = await import("@electric-sql/pglite");
30− const { drizzle } = await import("drizzle-orm/pglite");
31− const { migrate } = await import("drizzle-orm/pglite/migrator");
32− const dataDir = process.env.PGLITE_DATA_DIR ?? ".pglite";
33− const client = new PGlite(dataDir);
34− db = drizzle(client, { schema }) as DrizzleDb;
28+ const db = drizzle(pool, { schema }) as DrizzleDb;
3529 await migrate(db as any, { migrationsFolder: MIGRATIONS_DIR });
30+ return db;
3631 }
3732
33+ const { PGlite } = await import("@electric-sql/pglite");
34+ const { drizzle } = await import("drizzle-orm/pglite");
35+ const { migrate } = await import("drizzle-orm/pglite/migrator");
36+ const dataDir = process.env.PGLITE_DATA_DIR ?? ".pglite";
37+ const client = new PGlite(dataDir);
38+ const db = drizzle(client, { schema }) as DrizzleDb;
39+ await migrate(db as any, { migrationsFolder: MIGRATIONS_DIR });
3840 return db;
3941 };
42+
43+const getOrCreateDb = async (): Promise<DrizzleDb> => {
44+ if (!dbPromise) {
45+ dbPromise = createDb().catch((error) => {
46+ dbPromise = undefined;
47+ throw error;
48+ });
49+ }
50+ return dbPromise;
51+};
52+
53+export class DbService extends Context.Tag("@executor/cloud/DbService")<
54+ DbService,
55+ DrizzleDb
56+>() {
57+ static Live = Layer.effect(
58+ this,
59+ Effect.promise(getOrCreateDb),
60+ );
61+}
apps/cloud/src/services/executor.ts+2−2
@@ -5,7 +5,6 @@ · 4 unmodified lines
55 import { Effect } from "effect";
66
77 import { createExecutor } from "@executor/sdk";
8−import type { DrizzleDb } from "@executor/storage-postgres";
98 import { makePgConfig, makePgKv } from "@executor/storage-postgres";
109 import {
1110 openApiPlugin,
@@ -23,18 +22,19 @@ import { · 11 unmodified lines
2322 graphqlPlugin,
2423 makeKvOperationStore as makeKvGraphqlOperationStore,
2524 } from "@executor/plugin-graphql";
25+import { DbService } from "./db";
2626
2727 // ---------------------------------------------------------------------------
2828 // Create a fresh executor for a team (stateless, per-request)
2929 // ---------------------------------------------------------------------------
3030
3131 export const createTeamExecutor = (
32− db: DrizzleDb,
3332 teamId: string,
3433 teamName: string,
3534 encryptionKey: string,
3635 ) =>
3736 Effect.gen(function* () {
37+ const db = yield* DbService;
3838 const kv = makePgKv(db, teamId);
3939 const config = makePgConfig(db, {
4040 teamId,
apps/cloud/src/services/schema.ts+40−0
@@ -0,0 +1,40 @@
1+// ---------------------------------------------------------------------------
2+// Cloud-specific identity & multi-tenancy tables
3+// ---------------------------------------------------------------------------
4+
5+import { pgTable, text, timestamp, primaryKey } from "drizzle-orm/pg-core";
6+
7+export const users = pgTable("users", {
8+ id: text("id").primaryKey(),
9+ email: text("email").notNull().unique(),
10+ name: text("name"),
11+ avatarUrl: text("avatar_url"),
12+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
13+});
14+
15+export const teams = pgTable("teams", {
16+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
17+ name: text("name").notNull(),
18+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
19+});
20+
21+export const teamMembers = pgTable(
22+ "team_members",
23+ {
24+ teamId: text("team_id").notNull(),
25+ userId: text("user_id").notNull(),
26+ role: text("role").notNull().$default(() => "member"),
27+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
28+ },
29+ (table) => [primaryKey({ columns: [table.teamId, table.userId] })],
30+);
31+
32+export const invitations = pgTable("invitations", {
33+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
34+ teamId: text("team_id").notNull(),
35+ email: text("email").notNull(),
36+ invitedBy: text("invited_by").notNull(),
37+ status: text("status").notNull().$default(() => "pending"),
38+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
39+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
40+});
apps/cloud/src/services/user-store.tsrenamed from packages/core/storage-postgres/src/user-store.ts+1−1
@@ -5,7 +5,7 @@ · 4 unmodified lines
55 import { eq, and } from "drizzle-orm";
66
77 import { users, teams, teamMembers, invitations } from "./schema";
8−import type { DrizzleDb } from "./types";
8+import type { DrizzleDb } from "@executor/storage-postgres";
99
1010 export type User = typeof users.$inferSelect;
1111 export type Team = typeof teams.$inferSelect;
apps/cloud/src/web/auth.tsx+14−2
@@ -1,5 +1,5 @@
11 import React, { createContext, useContext } from "react";
2−import { useAtomValue, Result } from "@effect-atom/atom-react";
2+import { useAtomMount, useAtomValue, Result } from "@effect-atom/atom-react";
33
44 import { CloudApiClient } from "./client";
55
@@ -40,7 +40,8 @@ const AuthContext = createContext<AuthState>({ status: "loading" }); · 34 unmodified lines
4040
4141 export const useAuth = () => useContext(AuthContext);
4242
43−export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
43+const AuthProviderClient = ({ children }: { children: React.ReactNode }) => {
44+ useAtomMount(authAtom);
4445 const result = useAtomValue(authAtom);
4546
4647 const state: AuthState = Result.match(result, {
@@ -55,3 +56,14 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { · 8 unmodified lines
5556
5657 return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
5758 };
59+
60+export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
61+ if (typeof window === "undefined") {
62+ return (
63+ <AuthContext.Provider value={{ status: "loading" }}>
64+ {children}
65+ </AuthContext.Provider>
66+ );
67+ }
68+ return <AuthProviderClient>{children}</AuthProviderClient>;
69+};
apps/cloud/src/web/pages/login.tsx+2−1
@@ -1,4 +1,5 @@
11 import React from "react";
2+import { AUTH_PATHS } from "../../auth/api";
23
34 export const LoginPage = () => {
45 return (
@@ -11,7 +12,7 @@ export const LoginPage = () => { · 6 unmodified lines
1112 </p>
1213 </div>
1314 <a
14− href="/auth/login"
15+ href={AUTH_PATHS.login}
1516 className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 transition-colors"
1617 >
1718 Sign in
apps/cloud/src/web/shell.tsx+2−1
@@ -4,6 +4,7 @@ import { useAtomRefresh, useAtomValue, Result } from "@effect-atom/atom-react"; · 3 unmodified lines
44 import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms";
55 import { useScope } from "@executor/react/api/scope-context";
66 import { Button } from "@executor/react/components/button";
7+import { AUTH_PATHS } from "../auth/api";
78 import { useAuth } from "./auth";
89
910 // ── NavItem ──────────────────────────────────────────────────────────────
@@ -109,7 +110,7 @@ function UserFooter() { · 99 unmodified lines
109110 </p>
110111 )}
111112 </div>
112− <form action="/auth/logout" method="post">
113+ <form action={AUTH_PATHS.logout} method="post">
113114 <button
114115 type="submit"
115116 className="shrink-0 rounded-md p-1 text-muted-foreground/50 transition-colors hover:bg-sidebar-active hover:text-foreground"
apps/local/src/routeTree.gen.ts+21−21
@@ -12,9 +12,9 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
1212 import { Route as ToolsRouteImport } from './routes/tools'
1313 import { Route as SecretsRouteImport } from './routes/secrets'
1414 import { Route as IndexRouteImport } from './routes/index'
15−import { Route as V1SplatRouteImport } from './routes/v1.$'
1615 import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'
1716 import { Route as McpSplatRouteImport } from './routes/mcp.$'
17+import { Route as ApiSplatRouteImport } from './routes/api.$'
1818 import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey'
1919
2020 const ToolsRoute = ToolsRouteImport.update({
@@ -32,11 +32,6 @@ const IndexRoute = IndexRouteImport.update({ · 11 unmodified lines
3232 path: '/',
3333 getParentRoute: () => rootRouteImport,
3434 } as any)
35−const V1SplatRoute = V1SplatRouteImport.update({
36− id: '/v1/$',
37− path: '/v1/$',
38− getParentRoute: () => rootRouteImport,
39−} as any)
4035 const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({
4136 id: '/sources/$namespace',
4237 path: '/sources/$namespace',
@@ -47,6 +42,11 @@ const McpSplatRoute = McpSplatRouteImport.update({ · 4 unmodified lines
4742 path: '/mcp/$',
4843 getParentRoute: () => rootRouteImport,
4944 } as any)
45+const ApiSplatRoute = ApiSplatRouteImport.update({
46+ id: '/api/$',
47+ path: '/api/$',
48+ getParentRoute: () => rootRouteImport,
49+} as any)
5050 const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({
5151 id: '/sources/add/$pluginKey',
5252 path: '/sources/add/$pluginKey',
@@ -57,18 +57,18 @@ export interface FileRoutesByFullPath { · 4 unmodified lines
5757 '/': typeof IndexRoute
5858 '/secrets': typeof SecretsRoute
5959 '/tools': typeof ToolsRoute
60+ '/api/$': typeof ApiSplatRoute
6061 '/mcp/$': typeof McpSplatRoute
6162 '/sources/$namespace': typeof SourcesNamespaceRoute
62− '/v1/$': typeof V1SplatRoute
6363 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
6464 }
6565 export interface FileRoutesByTo {
6666 '/': typeof IndexRoute
6767 '/secrets': typeof SecretsRoute
6868 '/tools': typeof ToolsRoute
69+ '/api/$': typeof ApiSplatRoute
6970 '/mcp/$': typeof McpSplatRoute
7071 '/sources/$namespace': typeof SourcesNamespaceRoute
71− '/v1/$': typeof V1SplatRoute
7272 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
7373 }
7474 export interface FileRoutesById {
@@ -76,9 +76,9 @@ export interface FileRoutesById { · 1 unmodified line
7676 '/': typeof IndexRoute
7777 '/secrets': typeof SecretsRoute
7878 '/tools': typeof ToolsRoute
79+ '/api/$': typeof ApiSplatRoute
7980 '/mcp/$': typeof McpSplatRoute
8081 '/sources/$namespace': typeof SourcesNamespaceRoute
81− '/v1/$': typeof V1SplatRoute
8282 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute
8383 }
8484 export interface FileRouteTypes {
@@ -87,27 +87,27 @@ export interface FileRouteTypes { · 2 unmodified lines
8787 | '/'
8888 | '/secrets'
8989 | '/tools'
90+ | '/api/$'
9091 | '/mcp/$'
9192 | '/sources/$namespace'
92− | '/v1/$'
9393 | '/sources/add/$pluginKey'
9494 fileRoutesByTo: FileRoutesByTo
9595 to:
9696 | '/'
9797 | '/secrets'
9898 | '/tools'
99+ | '/api/$'
99100 | '/mcp/$'
100101 | '/sources/$namespace'
101− | '/v1/$'
102102 | '/sources/add/$pluginKey'
103103 id:
104104 | '__root__'
105105 | '/'
106106 | '/secrets'
107107 | '/tools'
108+ | '/api/$'
108109 | '/mcp/$'
109110 | '/sources/$namespace'
110− | '/v1/$'
111111 | '/sources/add/$pluginKey'
112112 fileRoutesById: FileRoutesById
113113 }
@@ -115,9 +115,9 @@ export interface RootRouteChildren { · 1 unmodified line
115115 IndexRoute: typeof IndexRoute
116116 SecretsRoute: typeof SecretsRoute
117117 ToolsRoute: typeof ToolsRoute
118+ ApiSplatRoute: typeof ApiSplatRoute
118119 McpSplatRoute: typeof McpSplatRoute
119120 SourcesNamespaceRoute: typeof SourcesNamespaceRoute
120− V1SplatRoute: typeof V1SplatRoute
121121 SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute
122122 }
123123
@@ -144,13 +144,6 @@ declare module '@tanstack/react-router' { · 20 unmodified lines
144144 preLoaderRoute: typeof IndexRouteImport
145145 parentRoute: typeof rootRouteImport
146146 }
147− '/v1/$': {
148− id: '/v1/$'
149− path: '/v1/$'
150− fullPath: '/v1/$'
151− preLoaderRoute: typeof V1SplatRouteImport
152− parentRoute: typeof rootRouteImport
153− }
154147 '/sources/$namespace': {
155148 id: '/sources/$namespace'
156149 path: '/sources/$namespace'
@@ -165,6 +158,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines
165158 preLoaderRoute: typeof McpSplatRouteImport
166159 parentRoute: typeof rootRouteImport
167160 }
161+ '/api/$': {
162+ id: '/api/$'
163+ path: '/api/$'
164+ fullPath: '/api/$'
165+ preLoaderRoute: typeof ApiSplatRouteImport
166+ parentRoute: typeof rootRouteImport
167+ }
168168 '/sources/add/$pluginKey': {
169169 id: '/sources/add/$pluginKey'
170170 path: '/sources/add/$pluginKey'
@@ -179,9 +179,9 @@ const rootRouteChildren: RootRouteChildren = { · 8 unmodified lines
179179 IndexRoute: IndexRoute,
180180 SecretsRoute: SecretsRoute,
181181 ToolsRoute: ToolsRoute,
182+ ApiSplatRoute: ApiSplatRoute,
182183 McpSplatRoute: McpSplatRoute,
183184 SourcesNamespaceRoute: SourcesNamespaceRoute,
184− V1SplatRoute: V1SplatRoute,
185185 SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute,
186186 }
187187 export const routeTree = rootRouteImport
apps/local/src/routes/api.$.ts+18−0
@@ -0,0 +1,18 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+const handle = async (request: Request) => {
4+ const { handleApiRequest } = await import("../server/api-handler");
5+ return handleApiRequest(request);
6+};
7+
8+export const Route = createFileRoute("/api/$")({
9+ server: {
10+ handlers: {
11+ GET: ({ request }) => handle(request),
12+ POST: ({ request }) => handle(request),
13+ PUT: ({ request }) => handle(request),
14+ DELETE: ({ request }) => handle(request),
15+ PATCH: ({ request }) => handle(request),
16+ },
17+ },
18+});
apps/local/src/routes/v1.$.ts+0−14
@@ -1,14 +0,0 @@
1−import { createFileRoute } from "@tanstack/react-router";
2−import { handleApiRequest } from "../server/api-handler";
3−
4−export const Route = createFileRoute("/v1/$")({
5− server: {
6− handlers: {
7− GET: ({ request }) => handleApiRequest(request),
8− POST: ({ request }) => handleApiRequest(request),
9− PUT: ({ request }) => handleApiRequest(request),
10− DELETE: ({ request }) => handleApiRequest(request),
11− PATCH: ({ request }) => handleApiRequest(request),
12− },
13− },
14−});
apps/local/src/server/api-handler.ts+5−1
@@ -20,7 +20,11 @@ const getHandlers = () => { · 19 unmodified lines
2020
2121 export const handleApiRequest = async (request: Request): Promise<Response> => {
2222 const handlers = await getHandlers();
23− return handlers.api.handler(request);
23+ // Strip /api prefix — the route catch-all (api.$.ts) matches /api/*
24+ // but the Effect handler endpoints are defined without the prefix
25+ const url = new URL(request.url);
26+ url.pathname = url.pathname.replace(/^\/api/, "");
27+ return handlers.api.handler(new Request(url, request));
2428 };
2529
2630 export const handleMcpRequest = async (request: Request): Promise<Response> => {
apps/local/src/server/main.ts+1−1
@@ -62,7 +62,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 61 unmodified lines
6262 );
6363
6464 const api = HttpApiBuilder.toWebHandler(
65− HttpApiSwagger.layer().pipe(
65+ HttpApiSwagger.layer({ path: "/docs" }).pipe(
6666 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
6767 Layer.provideMerge(LocalApiBase),
6868 Layer.provideMerge(pluginExtensions),
packages/core/api/src/executions/api.ts+1−1
@@ -61,4 +61,4 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 60 unmodified lines
6161 .addSuccess(ResumeResponse)
6262 .addError(ExecutionNotFoundError),
6363 )
64− .prefix("/v1") {}
64+ {}
packages/core/api/src/scope/api.ts+1−1
@@ -21,4 +21,4 @@ export class ScopeApi extends HttpApiGroup.make("scope") · 20 unmodified lines
2121 HttpApiEndpoint.get("info")`/scope`
2222 .addSuccess(ScopeInfoResponse),
2323 )
24− .prefix("/v1") {}
24+ {}
packages/core/api/src/secrets/api.ts+1−1
@@ -86,4 +86,4 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 85 unmodified lines
8686 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
8787 .addError(SecretNotFound),
8888 )
89− .prefix("/v1") {}
89+ {}
packages/core/api/src/sources/api.ts+1−1
@@ -77,4 +77,4 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 76 unmodified lines
7777 .setPayload(DetectRequest)
7878 .addSuccess(Schema.Array(DetectResultResponse)),
7979 )
80− .prefix("/v1") {}
80+ {}
packages/core/api/src/tools/api.ts+1−1
@@ -57,4 +57,4 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 56 unmodified lines
5757 .addSuccess(ToolSchemaResponse)
5858 .addError(ToolNotFound),
5959 )
60− .prefix("/v1") {}
60+ {}
packages/core/storage-postgres/src/index.test.ts+0−55
@@ -21,7 +21,6 @@ import { · 20 unmodified lines
2121
2222 import { makePgConfig } from "./index";
2323 import { makePgKv } from "./pg-kv";
24−import { makeUserStore } from "./user-store";
2524 import * as schema from "./schema";
2625
2726 // ---------------------------------------------------------------------------
@@ -355,57 +354,3 @@ describe("Executor with Postgres storage", () => { · 327 unmodified lines
355354 );
356355 });
357356
358−// ---------------------------------------------------------------------------
359−// User Store (not part of Executor, tested directly)
360−// ---------------------------------------------------------------------------
361−
362−describe("UserStore", () => {
363− it("upsert and get user", async () => {
364− const store = makeUserStore(db);
365− const user = await store.upsertUser({
366− id: "user-1",
367− email: "test@example.com",
368− name: "Test User",
369− });
370− expect(user.id).toBe("user-1");
371−
372− const fetched = await store.getUser("user-1");
373− expect(fetched!.name).toBe("Test User");
374−
375− await store.upsertUser({ id: "user-1", email: "test@example.com", name: "Updated" });
376− expect((await store.getUser("user-1"))!.name).toBe("Updated");
377− });
378−
379− it("create team and manage members", async () => {
380− const store = makeUserStore(db);
381− await store.upsertUser({ id: "u1", email: "a@example.com", name: "A" });
382− await store.upsertUser({ id: "u2", email: "b@example.com", name: "B" });
383−
384− const team = await store.createTeam("My Team");
385− await store.addMember(team.id, "u1", "owner");
386− await store.addMember(team.id, "u2", "member");
387−
388− expect(await store.listMembers(team.id)).toHaveLength(2);
389−
390− const teams = await store.getTeamsForUser("u1");
391− expect(teams[0]!.teamName).toBe("My Team");
392−
393− await store.removeMember(team.id, "u2");
394− expect(await store.listMembers(team.id)).toHaveLength(1);
395− });
396−
397− it("invitations workflow", async () => {
398− const store = makeUserStore(db);
399− await store.upsertUser({ id: "u1", email: "owner@example.com" });
400− const team = await store.createTeam("Team");
401− await store.addMember(team.id, "u1", "owner");
402−
403− const invitation = await store.createInvitation(team.id, "new@example.com", "u1");
404− expect(invitation.status).toBe("pending");
405− expect(await store.getPendingInvitations("new@example.com")).toHaveLength(1);
406−
407− await store.acceptInvitation(invitation.id);
408− expect(await store.getPendingInvitations("new@example.com")).toHaveLength(0);
409− });
410−
411−});
packages/core/storage-postgres/src/index.ts+0−2
@@ -29,8 +29,6 @@ export { makePgKv } from "./pg-kv"; · 28 unmodified lines
2929 export { makePgToolRegistry } from "./tool-registry";
3030 export { makePgSecretStore } from "./secret-store";
3131 export { makePgPolicyEngine } from "./policy-engine";
32−export { makeUserStore } from "./user-store";
33−export type { User, Team, TeamMember, Invitation } from "./user-store";
3432 export { encrypt, decrypt } from "./crypto";
3533 export type { DrizzleDb } from "./types";
3634 export * from "./schema";
packages/core/storage-postgres/src/schema.ts+0−39
@@ -19,45 +19,6 @@ const bytea = customType<{ data: Buffer }>({ · 18 unmodified lines
1919 },
2020 });
2121
22−// ---------------------------------------------------------------------------
23−// Identity & multi-tenancy
24−// ---------------------------------------------------------------------------
25−
26−export const users = pgTable("users", {
27− id: text("id").primaryKey(),
28− email: text("email").notNull().unique(),
29− name: text("name"),
30− avatarUrl: text("avatar_url"),
31− createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
32−});
33−
34−export const teams = pgTable("teams", {
35− id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
36− name: text("name").notNull(),
37− createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
38−});
39−
40−export const teamMembers = pgTable(
41− "team_members",
42− {
43− teamId: text("team_id").notNull(),
44− userId: text("user_id").notNull(),
45− role: text("role").notNull().$default(() => "member"),
46− createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
47− },
48− (table) => [primaryKey({ columns: [table.teamId, table.userId] })],
49−);
50−
51−export const invitations = pgTable("invitations", {
52− id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
53− teamId: text("team_id").notNull(),
54− email: text("email").notNull(),
55− invitedBy: text("invited_by").notNull(),
56− status: text("status").notNull().$default(() => "pending"),
57− createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
58− expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
59−});
60−
6122 // ---------------------------------------------------------------------------
6223 // Domain data — all team-scoped
6324 // ---------------------------------------------------------------------------
packages/plugins/google-discovery/src/api/group.ts+1−1
@@ -125,4 +125,4 @@ export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") · 124 unmodified lines
125125 .addSuccess(HtmlResponse)
126126 .addError(ApiError),
127127 )
128− .prefix("/v1") {}
128+ {}
packages/plugins/graphql/src/api/group.ts+1−1
@@ -58,4 +58,4 @@ export class GraphqlGroup extends HttpApiGroup.make("graphql") · 57 unmodified lines
5858 .addError(IntrospectionError)
5959 .addError(ExtractionError),
6060 )
61− .prefix("/v1") {}
61+ {}
packages/plugins/mcp/src/api/group.ts+1−1
@@ -194,4 +194,4 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 193 unmodified lines
194194 .addSuccess(HtmlResponse)
195195 .addError(McpApiError),
196196 )
197− .prefix("/v1") {}
197+ {}
packages/plugins/onepassword/src/api/group.ts+1−1
@@ -70,4 +70,4 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 69 unmodified lines
7070 .addSuccess(ListVaultsResponse)
7171 .addError(OpError),
7272 )
73− .prefix("/v1") {}
73+ {}
packages/plugins/openapi/src/api/group.ts+1−1
@@ -70,4 +70,4 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 69 unmodified lines
7070 .addError(ParseError)
7171 .addError(ExtractionError),
7272 )
73− .prefix("/v1") {}
73+ {}
packages/react/src/api/base-url.tsx+2−2
@@ -2,8 +2,8 @@ const DEFAULT_BASE_URL = "http://127.0.0.1:4000"; · 1 unmodified line
22
33 let baseUrl =
44 typeof window !== "undefined" && typeof window.location?.origin === "string"
5− ? window.location.origin
6− : DEFAULT_BASE_URL;
5+ ? `${window.location.origin}/api`
6+ : `${DEFAULT_BASE_URL}/api`;
77
88 export const getBaseUrl = (): string => baseUrl;
99