Unified · Split
| @@ -1,40 +1,45 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Cloud API — core handlers from @executor/api + cloud-specific plugins + auth | 2 | // Cloud API — protected core API + public auth endpoints |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | HttpApi, | ||
| 6 | HttpApiBuilder, | 7 | HttpApiBuilder, |
| 7 | HttpApiSwagger, | 8 | HttpApiSwagger, |
| 8 | HttpMiddleware, | 9 | HttpMiddleware, |
| 9 | HttpServer, | 10 | HttpServer, |
| 10 | } from "@effect/platform"; | 11 | } from "@effect/platform"; |
| 11 | import { Effect, Layer } from "effect"; | 12 | import { Effect, Layer } from "effect"; |
| 13 | import { setCookie } from "@tanstack/react-start/server"; | ||
| 12 | 14 | ||
| 13 | import { addGroup, CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api"; | 15 | import { addGroup, CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api"; |
| 14 | import { createExecutionEngine } from "@executor/execution"; | 16 | import { createExecutionEngine } from "@executor/execution"; |
| 15 | import { OpenApiGroup, OpenApiExtensionService, OpenApiHandlers } from "@executor/plugin-openapi/api"; | 17 | import { OpenApiGroup, OpenApiExtensionService, OpenApiHandlers } from "@executor/plugin-openapi/api"; |
| 16 | import { McpGroup, McpExtensionService, McpHandlers } from "@executor/plugin-mcp/api"; | 18 | 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"; | ||
| 18 | import { GraphqlGroup, GraphqlExtensionService, GraphqlHandlers } from "@executor/plugin-graphql/api"; | 24 | import { GraphqlGroup, GraphqlExtensionService, GraphqlHandlers } from "@executor/plugin-graphql/api"; |
| 19 | 25 | ||
| 20 | import { createTeamExecutor } from "./services/executor"; | 26 | import { CloudAuthApi, CloudAuthPublicApi } from "./auth/api"; |
| 21 | import { CloudAuthApi } from "./auth/api"; | ||
| 22 | import { CloudAuthHandlers } from "./auth/handlers"; | ||
| 23 | import { AuthContext, UserStoreService } from "./auth/context"; | 27 | import { AuthContext, UserStoreService } from "./auth/context"; |
| 28 | import { CloudAuthHandlers, CloudAuthPublicHandlers } from "./auth/handlers"; | ||
| 24 | import { WorkOSAuth } from "./auth/workos"; | 29 | import { WorkOSAuth } from "./auth/workos"; |
| 25 | import type { DrizzleDb } from "./services/db"; | 30 | import { DbService } from "./services/db"; |
| 26 | 31 | import { createTeamExecutor } from "./services/executor"; | |
| 27 | // --------------------------------------------------------------------------- | ||
| 28 | // Cloud API — core + cloud plugins + cloud auth (no onepassword) | ||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | 32 | ||
| 31 | const CloudApi = addGroup(OpenApiGroup) | 33 | const ProtectedCloudApi = addGroup(OpenApiGroup) |
| 32 | .add(McpGroup) | 34 | .add(McpGroup) |
| 33 | .add(GoogleDiscoveryGroup) | 35 | .add(GoogleDiscoveryGroup) |
| 34 | .add(GraphqlGroup) | 36 | .add(GraphqlGroup) |
| 35 | .add(CloudAuthApi); | 37 | .add(CloudAuthApi); |
| 36 | 38 | ||
| 37 | const CloudApiBase = HttpApiBuilder.api(CloudApi).pipe( | 39 | const PublicCloudApi = HttpApi.make("cloudPublic") |
| 40 | .add(CloudAuthPublicApi); | ||
| 41 | |||
| 42 | const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe( | ||
| 38 | Layer.provide(CoreHandlers), | 43 | Layer.provide(CoreHandlers), |
| 39 | Layer.provide(Layer.mergeAll( | 44 | Layer.provide(Layer.mergeAll( |
| 40 | OpenApiHandlers, | 45 | OpenApiHandlers, |
| @@ -45,100 +50,180 @@ const CloudApiBase = HttpApiBuilder.api(CloudApi).pipe( · 4 unmodified lines | |||
| 45 | )), | 50 | )), |
| 46 | ); | 51 | ); |
| 47 | 52 | ||
| 48 | // --------------------------------------------------------------------------- | 53 | const PublicCloudApiLive = HttpApiBuilder.api(PublicCloudApi).pipe( |
| 49 | // Cookie parser | 54 | Layer.provide(CloudAuthPublicHandlers), |
| 50 | // --------------------------------------------------------------------------- | 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 | ); | ||
| 51 | 75 | ||
| 52 | const parseCookie = (cookieHeader: string | null, name: string): string | null => { | 76 | const parseCookie = (cookieHeader: string | null, name: string): string | null => { |
| 53 | if (!cookieHeader) return null; | 77 | if (!cookieHeader) return null; |
| 54 | const match = cookieHeader | 78 | const match = cookieHeader |
| 55 | .split(";") | 79 | .split(";") |
| 56 | .map((c) => c.trim()) | 80 | .map((value) => value.trim()) |
| 57 | .find((c) => c.startsWith(`${name}=`)); | 81 | .find((value) => value.startsWith(`${name}=`)); |
| 58 | if (!match) return null; | 82 | if (!match) return null; |
| 59 | return match.slice(name.length + 1) || null; | 83 | return match.slice(name.length + 1) || null; |
| 60 | }; | 84 | }; |
| 61 | 85 | ||
| 62 | // --------------------------------------------------------------------------- | 86 | const isPublicPath = (pathname: string): boolean => |
| 63 | // Create API handler with auth-based executor resolution | 87 | pathname === "/auth/login" || pathname === "/auth/callback"; |
| 64 | // --------------------------------------------------------------------------- | ||
| 65 | 88 | ||
| 66 | export const createCloudApiHandler = (db: DrizzleDb, encryptionKey: string) => { | 89 | const unauthorized = (message: string): Response => |
| 67 | return async (request: Request): Promise<Response> => { | 90 | Response.json({ error: message }, { status: 401 }); |
| 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 | ); | ||
| 75 | 91 | ||
| 76 | if (!auth) { | 92 | const COOKIE_OPTIONS = { |
| 77 | return Response.json({ error: "Unauthorized" }, { status: 401 }); | 93 | path: "/", |
| 78 | } | 94 | httpOnly: true, |
| 95 | sameSite: "lax" as const, | ||
| 96 | maxAge: 60 * 60 * 24 * 7, | ||
| 97 | secure: process.env.NODE_ENV === "production", | ||
| 98 | }; | ||
| 79 | 99 | ||
| 80 | const teamId = parseCookie(request.headers.get("cookie"), "executor_team"); | 100 | const resolveAuth = (request: Request) => |
| 81 | if (!teamId) { | 101 | Effect.gen(function* () { |
| 82 | return Response.json({ error: "No team selected" }, { status: 401 }); | 102 | const workos = yield* WorkOSAuth; |
| 83 | } | 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 | ); | ||
| 84 | 134 | ||
| 85 | // Resolve team name | 135 | const team = yield* users.use((store) => |
| 86 | const userStore = UserStoreService.layer(db); | 136 | store.createTeam(`${user.name ?? user.email}'s Team`), |
| 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)), | ||
| 92 | ); | 137 | ); |
| 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)); | ||
| 93 | const teamName = team?.name ?? "Unknown Team"; | 149 | 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 | }; | ||
| 94 | 198 | ||
| 95 | const executor = await Effect.runPromise( | 199 | export const handleApiRequest = async (request: Request): Promise<Response> => { |
| 96 | createTeamExecutor(db, teamId, teamName, encryptionKey), | 200 | const pathname = new URL(request.url).pathname; |
| 97 | ); | ||
| 98 | 201 | ||
| 99 | const pluginExtensions = Layer.mergeAll( | 202 | if (isPublicPath(pathname)) { |
| 100 | Layer.succeed(OpenApiExtensionService, executor.openapi), | 203 | return publicApiHandler.handler(request); |
| 101 | Layer.succeed(McpExtensionService, executor.mcp), | 204 | } |
| 102 | Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery), | ||
| 103 | Layer.succeed(GraphqlExtensionService, executor.graphql), | ||
| 104 | ); | ||
| 105 | 205 | ||
| 106 | const engine = createExecutionEngine({ executor }); | 206 | const auth = await resolveAuth(request); |
| 107 | 207 | if (!auth) return unauthorized("Unauthorized"); | |
| 108 | const handler = HttpApiBuilder.toWebHandler( | 208 | |
| 109 | HttpApiSwagger.layer().pipe( | 209 | const cookieTeamId = parseCookie(request.headers.get("cookie"), "executor_team"); |
| 110 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), | 210 | const teamId = await resolveTeamId(auth, cookieTeamId); |
| 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 | ); | ||
| 128 | 211 | ||
| 129 | try { | 212 | const executor = await resolveExecutor(teamId); |
| 130 | const response = await handler.handler(request); | 213 | const handler = createProtectedHandler(auth, teamId, executor); |
| 131 | 214 | ||
| 132 | if (auth.refreshedCookie) { | 215 | try { |
| 133 | const newResponse = new Response(response.body, response); | 216 | const response = await handler.handler(request); |
| 134 | newResponse.headers.append("Set-Cookie", auth.refreshedCookie); | ||
| 135 | return newResponse; | ||
| 136 | } | ||
| 137 | 217 | ||
| 138 | return response; | 218 | if (auth.refreshedSession) { |
| 139 | } finally { | 219 | setCookie("wos-session", auth.refreshedSession, COOKIE_OPTIONS); |
| 140 | await Effect.runPromise(executor.close()).catch(() => undefined); | 220 | } |
| 141 | handler.dispose(); | 221 | if (!cookieTeamId) { |
| 222 | setCookie("executor_team", teamId, COOKIE_OPTIONS); | ||
| 142 | } | 223 | } |
| 143 | }; | 224 | return response; |
| 225 | } finally { | ||
| 226 | await Effect.runPromise(executor.close()).catch(() => undefined); | ||
| 227 | await handler.dispose().catch(() => undefined); | ||
| 228 | } | ||
| 144 | }; | 229 | }; |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { UserStoreError, WorkOSError } from "./context"; | 3 | import { UserStoreError, WorkOSError } from "./errors"; |
| 4 | 4 | ||
| 5 | const AuthUser = Schema.Struct({ | 5 | const AuthUser = Schema.Struct({ |
| 6 | id: Schema.String, | 6 | id: Schema.String, |
| @@ -23,12 +23,14 @@ const AuthCallbackSearch = Schema.Struct({ · 16 unmodified lines | |||
| 23 | code: Schema.String, | 23 | code: Schema.String, |
| 24 | }); | 24 | }); |
| 25 | 25 | ||
| 26 | export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") | 26 | export const AUTH_PATHS = { |
| 27 | .add( | 27 | login: "/api/auth/login", |
| 28 | HttpApiEndpoint.get("me")`/auth/me` | 28 | logout: "/api/auth/logout", |
| 29 | .addSuccess(AuthMeResponse) | 29 | callback: "/api/auth/callback", |
| 30 | .addError(UserStoreError), | 30 | } as const; |
| 31 | ) | 31 | |
| 32 | /** Public auth endpoints — no authentication required */ | ||
| 33 | export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") | ||
| 32 | .add( | 34 | .add( |
| 33 | HttpApiEndpoint.get("login")`/auth/login`, | 35 | HttpApiEndpoint.get("login")`/auth/login`, |
| 34 | ) | 36 | ) |
| @@ -37,7 +39,16 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 2 unmodified lines | |||
| 37 | .setUrlParams(AuthCallbackSearch) | 39 | .setUrlParams(AuthCallbackSearch) |
| 38 | .addError(UserStoreError) | 40 | .addError(UserStoreError) |
| 39 | .addError(WorkOSError), | 41 | .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), | ||
| 40 | ) | 50 | ) |
| 41 | .add( | 51 | .add( |
| 42 | HttpApiEndpoint.post("logout")`/auth/logout`, | 52 | HttpApiEndpoint.post("logout")`/auth/logout`, |
| 43 | ) {} | 53 | ) |
| 54 | {} | ||
| @@ -1,20 +1,7 @@ | |||
| 1 | import { Context, Effect, Layer, Schema } from "effect"; | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | import { makeUserStore } from "@executor/storage-postgres"; | 2 | import { makeUserStore } from "../services/user-store"; |
| 3 | import type { DrizzleDb } from "../services/db"; | 3 | import { DbService } from "../services/db"; |
| 4 | 4 | import { UserStoreError } from "./errors"; | |
| 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 | ) {} | ||
| 18 | 5 | ||
| 19 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 20 | // AuthContext — resolved per-request from sealed session | 7 | // AuthContext — resolved per-request from sealed session |
| @@ -37,9 +24,7 @@ export class AuthContext extends Context.Tag("@executor/cloud/AuthContext")< · 16 unmodified lines | |||
| 37 | 24 | ||
| 38 | type RawStore = ReturnType<typeof makeUserStore>; | 25 | type RawStore = ReturnType<typeof makeUserStore>; |
| 39 | 26 | ||
| 40 | const makeService = (db: DrizzleDb) => { | 27 | const makeService = (store: RawStore) => { |
| 41 | const store = makeUserStore(db); | ||
| 42 | |||
| 43 | const use = <A>(fn: (s: RawStore) => Promise<A>) => | 28 | const use = <A>(fn: (s: RawStore) => Promise<A>) => |
| 44 | Effect.tryPromise({ | 29 | Effect.tryPromise({ |
| 45 | try: () => fn(store), | 30 | try: () => fn(store), |
| @@ -55,6 +40,8 @@ export class UserStoreService extends Context.Tag("@executor/cloud/UserStoreServ · 9 unmodified lines | |||
| 55 | UserStoreService, | 40 | UserStoreService, |
| 56 | UserStoreServiceType | 41 | UserStoreServiceType |
| 57 | >() { | 42 | >() { |
| 58 | static layer = (db: DrizzleDb) => | 43 | static Live = Layer.effect( |
| 59 | Layer.succeed(this, makeService(db)); | 44 | this, |
| 45 | Effect.map(DbService, (db) => makeService(makeUserStore(db))), | ||
| 46 | ); | ||
| 60 | } | 47 | } |
| @@ -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 | ) {} | ||
| @@ -1,40 +1,38 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; | 1 | import { HttpApi, HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | import { deleteCookie, setCookie } from "@tanstack/react-start/server"; | ||
| 3 | 4 | ||
| 4 | import { addGroup } from "@executor/api"; | 5 | import { addGroup } from "@executor/api"; |
| 5 | import { CloudAuthApi } from "./api"; | 6 | import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; |
| 6 | import { AuthContext, UserStoreService } from "./context"; | 7 | import { AuthContext, UserStoreService } from "./context"; |
| 7 | import { WorkOSAuth, makeSessionCookie, clearSessionCookie } from "./workos"; | 8 | import { WorkOSAuth } from "./workos"; |
| 8 | 9 | ||
| 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 | }; | ||
| 10 | 17 | ||
| 11 | export const CloudAuthHandlers = HttpApiBuilder.group( | 18 | // --------------------------------------------------------------------------- |
| 12 | ApiWithCloudAuth, | 19 | // Public auth handlers (no authentication required) |
| 13 | "cloudAuth", | 20 | // --------------------------------------------------------------------------- |
| 21 | |||
| 22 | const PublicAuthApi = HttpApi.make("cloudPublic") | ||
| 23 | .add(CloudAuthPublicApi); | ||
| 24 | |||
| 25 | export const CloudAuthPublicHandlers = HttpApiBuilder.group( | ||
| 26 | PublicAuthApi, | ||
| 27 | "cloudAuthPublic", | ||
| 14 | (handlers) => | 28 | (handlers) => |
| 15 | handlers | 29 | 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 | ) | ||
| 33 | .handleRaw("login", () => | 30 | .handleRaw("login", () => |
| 34 | Effect.gen(function* () { | 31 | Effect.gen(function* () { |
| 35 | const workos = yield* WorkOSAuth; | 32 | const workos = yield* WorkOSAuth; |
| 36 | const baseUrl = process.env.APP_URL ?? `http://localhost:${process.env.PORT ?? "3000"}`; | 33 | const req = yield* HttpServerRequest.HttpServerRequest; |
| 37 | const url = workos.getAuthorizationUrl(`${baseUrl}/auth/callback`); | 34 | const origin = new URL(req.url, `http://${req.headers["host"]}`).origin; |
| 35 | const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`); | ||
| 38 | return HttpServerResponse.redirect(url, { status: 302 }); | 36 | return HttpServerResponse.redirect(url, { status: 302 }); |
| 39 | }), | 37 | }), |
| 40 | ) | 38 | ) |
| @@ -81,28 +79,46 @@ export const CloudAuthHandlers = HttpApiBuilder.group( · 40 unmodified lines | |||
| 81 | return HttpServerResponse.text("Failed to create session", { status: 500 }); | 79 | return HttpServerResponse.text("Failed to create session", { status: 500 }); |
| 82 | } | 80 | } |
| 83 | 81 | ||
| 84 | return HttpServerResponse.redirect("/", { | 82 | setCookie("wos-session", sealedSession, COOKIE_OPTIONS); |
| 85 | status: 302, | 83 | setCookie("executor_team", teamId, COOKIE_OPTIONS); |
| 86 | headers: { | 84 | return HttpServerResponse.redirect("/", { status: 302 }); |
| 87 | "Set-Cookie": [ | 85 | }), |
| 88 | makeSessionCookie(sealedSession), | 86 | ), |
| 89 | `executor_team=${teamId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800`, | 87 | ); |
| 90 | ].join(", "), | 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, | ||
| 91 | }, | 112 | }, |
| 92 | }); | 113 | team: team ? { id: team.id, name: team.name } : null, |
| 114 | }; | ||
| 93 | }), | 115 | }), |
| 94 | ) | 116 | ) |
| 95 | .handleRaw("logout", () => | 117 | .handleRaw("logout", () => |
| 96 | Effect.succeed( | 118 | Effect.sync(() => { |
| 97 | HttpServerResponse.redirect("/", { | 119 | deleteCookie("wos-session", { path: "/" }); |
| 98 | status: 302, | 120 | deleteCookie("executor_team", { path: "/" }); |
| 99 | headers: { | 121 | return HttpServerResponse.redirect("/", { status: 302 }); |
| 100 | "Set-Cookie": [ | 122 | }), |
| 101 | clearSessionCookie(), | ||
| 102 | "executor_team=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", | ||
| 103 | ].join(", "), | ||
| 104 | }, | ||
| 105 | }), | ||
| 106 | ), | ||
| 107 | ), | 123 | ), |
| 108 | ); | 124 | ); |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { Context, Effect, Layer } from "effect"; | 5 | import { Context, Effect, Layer } from "effect"; |
| 6 | import { WorkOS } from "@workos-inc/node"; | 6 | import { WorkOS } from "@workos-inc/node"; |
| 7 | import { WorkOSError } from "./context"; | 7 | import { WorkOSError } from "./errors"; |
| 8 | 8 | ||
| 9 | const COOKIE_NAME = "wos-session"; | 9 | const COOKIE_NAME = "wos-session"; |
| 10 | 10 | ||
| @@ -67,7 +67,7 @@ const make = Effect.gen(function* () { · 56 unmodified lines | |||
| 67 | lastName: result.user.lastName, | 67 | lastName: result.user.lastName, |
| 68 | avatarUrl: result.user.profilePictureUrl, | 68 | avatarUrl: result.user.profilePictureUrl, |
| 69 | sessionId: result.sessionId, | 69 | sessionId: result.sessionId, |
| 70 | refreshedCookie: undefined as string | undefined, | 70 | refreshedSession: undefined as string | undefined, |
| 71 | }; | 71 | }; |
| 72 | } | 72 | } |
| 73 | 73 | ||
| @@ -87,7 +87,7 @@ const make = Effect.gen(function* () { · 13 unmodified lines | |||
| 87 | lastName: refreshed.user.lastName, | 87 | lastName: refreshed.user.lastName, |
| 88 | avatarUrl: refreshed.user.profilePictureUrl, | 88 | avatarUrl: refreshed.user.profilePictureUrl, |
| 89 | sessionId: refreshed.sessionId, | 89 | sessionId: refreshed.sessionId, |
| 90 | refreshedCookie: makeSessionCookie(refreshed.sealedSession), | 90 | refreshedSession: refreshed.sealedSession, |
| 91 | }; | 91 | }; |
| 92 | }), | 92 | }), |
| 93 | }; | 93 | }; |
| @@ -104,25 +104,6 @@ export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")< · 10 unmodified lines | |||
| 104 | ); | 104 | ); |
| 105 | } | 105 | } |
| 106 | 106 | ||
| 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 | |||
| 126 | const parseCookie = (cookieHeader: string | null, name: string): string | null => { | 107 | const parseCookie = (cookieHeader: string | null, name: string): string | null => { |
| 127 | if (!cookieHeader) return null; | 108 | if (!cookieHeader) return null; |
| 128 | const match = cookieHeader | 109 | const match = cookieHeader |
| @@ -12,9 +12,8 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines | |||
| 12 | import { Route as ToolsRouteImport } from './routes/tools' | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | import { Route as SecretsRouteImport } from './routes/secrets' | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | import { Route as IndexRouteImport } from './routes/index' | 14 | import { Route as IndexRouteImport } from './routes/index' |
| 15 | import { Route as V1SplatRouteImport } from './routes/v1.$' | ||
| 16 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' | 15 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' |
| 17 | import { Route as AuthSplatRouteImport } from './routes/auth.$' | 16 | import { Route as ApiSplatRouteImport } from './routes/api.$' |
| 18 | import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' | 17 | import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' |
| 19 | 18 | ||
| 20 | const ToolsRoute = ToolsRouteImport.update({ | 19 | const ToolsRoute = ToolsRouteImport.update({ |
| @@ -32,19 +31,14 @@ const IndexRoute = IndexRouteImport.update({ · 11 unmodified lines | |||
| 32 | path: '/', | 31 | path: '/', |
| 33 | getParentRoute: () => rootRouteImport, | 32 | getParentRoute: () => rootRouteImport, |
| 34 | } as any) | 33 | } as any) |
| 35 | const V1SplatRoute = V1SplatRouteImport.update({ | ||
| 36 | id: '/v1/$', | ||
| 37 | path: '/v1/$', | ||
| 38 | getParentRoute: () => rootRouteImport, | ||
| 39 | } as any) | ||
| 40 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ | 34 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ |
| 41 | id: '/sources/$namespace', | 35 | id: '/sources/$namespace', |
| 42 | path: '/sources/$namespace', | 36 | path: '/sources/$namespace', |
| 43 | getParentRoute: () => rootRouteImport, | 37 | getParentRoute: () => rootRouteImport, |
| 44 | } as any) | 38 | } as any) |
| 45 | const AuthSplatRoute = AuthSplatRouteImport.update({ | 39 | const ApiSplatRoute = ApiSplatRouteImport.update({ |
| 46 | id: '/auth/$', | 40 | id: '/api/$', |
| 47 | path: '/auth/$', | 41 | path: '/api/$', |
| 48 | getParentRoute: () => rootRouteImport, | 42 | getParentRoute: () => rootRouteImport, |
| 49 | } as any) | 43 | } as any) |
| 50 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ | 44 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ |
| @@ -57,18 +51,16 @@ export interface FileRoutesByFullPath { · 6 unmodified lines | |||
| 57 | '/': typeof IndexRoute | 51 | '/': typeof IndexRoute |
| 58 | '/secrets': typeof SecretsRoute | 52 | '/secrets': typeof SecretsRoute |
| 59 | '/tools': typeof ToolsRoute | 53 | '/tools': typeof ToolsRoute |
| 60 | '/auth/$': typeof AuthSplatRoute | 54 | '/api/$': typeof ApiSplatRoute |
| 61 | '/sources/$namespace': typeof SourcesNamespaceRoute | 55 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 62 | '/v1/$': typeof V1SplatRoute | ||
| 63 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | 56 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 64 | } | 57 | } |
| 65 | export interface FileRoutesByTo { | 58 | export interface FileRoutesByTo { |
| 66 | '/': typeof IndexRoute | 59 | '/': typeof IndexRoute |
| 67 | '/secrets': typeof SecretsRoute | 60 | '/secrets': typeof SecretsRoute |
| 68 | '/tools': typeof ToolsRoute | 61 | '/tools': typeof ToolsRoute |
| 69 | '/auth/$': typeof AuthSplatRoute | 62 | '/api/$': typeof ApiSplatRoute |
| 70 | '/sources/$namespace': typeof SourcesNamespaceRoute | 63 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 71 | '/v1/$': typeof V1SplatRoute | ||
| 72 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | 64 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 73 | } | 65 | } |
| 74 | export interface FileRoutesById { | 66 | export interface FileRoutesById { |
| @@ -76,9 +68,8 @@ export interface FileRoutesById { · 1 unmodified line | |||
| 76 | '/': typeof IndexRoute | 68 | '/': typeof IndexRoute |
| 77 | '/secrets': typeof SecretsRoute | 69 | '/secrets': typeof SecretsRoute |
| 78 | '/tools': typeof ToolsRoute | 70 | '/tools': typeof ToolsRoute |
| 79 | '/auth/$': typeof AuthSplatRoute | 71 | '/api/$': typeof ApiSplatRoute |
| 80 | '/sources/$namespace': typeof SourcesNamespaceRoute | 72 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 81 | '/v1/$': typeof V1SplatRoute | ||
| 82 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | 73 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 83 | } | 74 | } |
| 84 | export interface FileRouteTypes { | 75 | export interface FileRouteTypes { |
| @@ -87,27 +78,24 @@ export interface FileRouteTypes { · 2 unmodified lines | |||
| 87 | | '/' | 78 | | '/' |
| 88 | | '/secrets' | 79 | | '/secrets' |
| 89 | | '/tools' | 80 | | '/tools' |
| 90 | | '/auth/$' | 81 | | '/api/$' |
| 91 | | '/sources/$namespace' | 82 | | '/sources/$namespace' |
| 92 | | '/v1/$' | ||
| 93 | | '/sources/add/$pluginKey' | 83 | | '/sources/add/$pluginKey' |
| 94 | fileRoutesByTo: FileRoutesByTo | 84 | fileRoutesByTo: FileRoutesByTo |
| 95 | to: | 85 | to: |
| 96 | | '/' | 86 | | '/' |
| 97 | | '/secrets' | 87 | | '/secrets' |
| 98 | | '/tools' | 88 | | '/tools' |
| 99 | | '/auth/$' | 89 | | '/api/$' |
| 100 | | '/sources/$namespace' | 90 | | '/sources/$namespace' |
| 101 | | '/v1/$' | ||
| 102 | | '/sources/add/$pluginKey' | 91 | | '/sources/add/$pluginKey' |
| 103 | id: | 92 | id: |
| 104 | | '__root__' | 93 | | '__root__' |
| 105 | | '/' | 94 | | '/' |
| 106 | | '/secrets' | 95 | | '/secrets' |
| 107 | | '/tools' | 96 | | '/tools' |
| 108 | | '/auth/$' | 97 | | '/api/$' |
| 109 | | '/sources/$namespace' | 98 | | '/sources/$namespace' |
| 110 | | '/v1/$' | ||
| 111 | | '/sources/add/$pluginKey' | 99 | | '/sources/add/$pluginKey' |
| 112 | fileRoutesById: FileRoutesById | 100 | fileRoutesById: FileRoutesById |
| 113 | } | 101 | } |
| @@ -115,9 +103,8 @@ export interface RootRouteChildren { · 1 unmodified line | |||
| 115 | IndexRoute: typeof IndexRoute | 103 | IndexRoute: typeof IndexRoute |
| 116 | SecretsRoute: typeof SecretsRoute | 104 | SecretsRoute: typeof SecretsRoute |
| 117 | ToolsRoute: typeof ToolsRoute | 105 | ToolsRoute: typeof ToolsRoute |
| 118 | AuthSplatRoute: typeof AuthSplatRoute | 106 | ApiSplatRoute: typeof ApiSplatRoute |
| 119 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute | 107 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute |
| 120 | V1SplatRoute: typeof V1SplatRoute | ||
| 121 | SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute | 108 | SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute |
| 122 | } | 109 | } |
| 123 | 110 | ||
| @@ -144,13 +131,6 @@ declare module '@tanstack/react-router' { · 20 unmodified lines | |||
| 144 | preLoaderRoute: typeof IndexRouteImport | 131 | preLoaderRoute: typeof IndexRouteImport |
| 145 | parentRoute: typeof rootRouteImport | 132 | parentRoute: typeof rootRouteImport |
| 146 | } | 133 | } |
| 147 | '/v1/$': { | ||
| 148 | id: '/v1/$' | ||
| 149 | path: '/v1/$' | ||
| 150 | fullPath: '/v1/$' | ||
| 151 | preLoaderRoute: typeof V1SplatRouteImport | ||
| 152 | parentRoute: typeof rootRouteImport | ||
| 153 | } | ||
| 154 | '/sources/$namespace': { | 134 | '/sources/$namespace': { |
| 155 | id: '/sources/$namespace' | 135 | id: '/sources/$namespace' |
| 156 | path: '/sources/$namespace' | 136 | path: '/sources/$namespace' |
| @@ -158,11 +138,11 @@ declare module '@tanstack/react-router' { · 1 unmodified line | |||
| 158 | preLoaderRoute: typeof SourcesNamespaceRouteImport | 138 | preLoaderRoute: typeof SourcesNamespaceRouteImport |
| 159 | parentRoute: typeof rootRouteImport | 139 | parentRoute: typeof rootRouteImport |
| 160 | } | 140 | } |
| 161 | '/auth/$': { | 141 | '/api/$': { |
| 162 | id: '/auth/$' | 142 | id: '/api/$' |
| 163 | path: '/auth/$' | 143 | path: '/api/$' |
| 164 | fullPath: '/auth/$' | 144 | fullPath: '/api/$' |
| 165 | preLoaderRoute: typeof AuthSplatRouteImport | 145 | preLoaderRoute: typeof ApiSplatRouteImport |
| 166 | parentRoute: typeof rootRouteImport | 146 | parentRoute: typeof rootRouteImport |
| 167 | } | 147 | } |
| 168 | '/sources/add/$pluginKey': { | 148 | '/sources/add/$pluginKey': { |
| @@ -179,9 +159,8 @@ const rootRouteChildren: RootRouteChildren = { · 10 unmodified lines | |||
| 179 | IndexRoute: IndexRoute, | 159 | IndexRoute: IndexRoute, |
| 180 | SecretsRoute: SecretsRoute, | 160 | SecretsRoute: SecretsRoute, |
| 181 | ToolsRoute: ToolsRoute, | 161 | ToolsRoute: ToolsRoute, |
| 182 | AuthSplatRoute: AuthSplatRoute, | 162 | ApiSplatRoute: ApiSplatRoute, |
| 183 | SourcesNamespaceRoute: SourcesNamespaceRoute, | 163 | SourcesNamespaceRoute: SourcesNamespaceRoute, |
| 184 | V1SplatRoute: V1SplatRoute, | ||
| 185 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, | 164 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, |
| 186 | } | 165 | } |
| 187 | export const routeTree = rootRouteImport | 166 | export const routeTree = rootRouteImport |
| @@ -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 | }); | ||
| @@ -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 | }); | ||
| @@ -1,9 +1,9 @@ | |||
| 1 | import { createCloudApiHandler } from "../api"; | 1 | import { handleApiRequest as _handleApiRequest } from "../api"; |
| 2 | import { getDb } from "../services/db"; | ||
| 3 | 2 | ||
| 4 | const handlerPromise = getDb().then((db) => | 3 | export const handleApiRequest = (request: Request) => { |
| 5 | createCloudApiHandler(db, process.env.ENCRYPTION_KEY ?? "local-dev-encryption-key"), | 4 | // Strip /api prefix — route catch-all (api.$.ts) matches /api/* |
| 6 | ); | 5 | // but Effect endpoints are defined without the prefix |
| 7 | 6 | const url = new URL(request.url); | |
| 8 | export const handleApiRequest = async (request: Request) => | 7 | url.pathname = url.pathname.replace(/^\/api/, ""); |
| 9 | (await handlerPromise)(request); | 8 | return _handleApiRequest(new Request(url, request)); |
| 9 | }; | ||
| @@ -2,10 +2,14 @@ · 1 unmodified line | |||
| 2 | // Database service — PGlite for dev, node-postgres for prod | 2 | // Database service — PGlite for dev, node-postgres for prod |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { Context, Effect, Layer } from "effect"; | ||
| 5 | import { resolve } from "node:path"; | 6 | 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"; | ||
| 7 | import type { DrizzleDb } from "@executor/storage-postgres"; | 9 | import type { DrizzleDb } from "@executor/storage-postgres"; |
| 8 | 10 | ||
| 11 | const schema = { ...sharedSchema, ...cloudSchema }; | ||
| 12 | |||
| 9 | export type { DrizzleDb }; | 13 | export type { DrizzleDb }; |
| 10 | 14 | ||
| 11 | const MIGRATIONS_DIR = resolve( | 15 | const MIGRATIONS_DIR = resolve( |
| @@ -13,27 +17,45 @@ const MIGRATIONS_DIR = resolve( · 1 unmodified line | |||
| 13 | "../../../../packages/core/storage-postgres/drizzle", | 17 | "../../../../packages/core/storage-postgres/drizzle", |
| 14 | ); | 18 | ); |
| 15 | 19 | ||
| 16 | let db: DrizzleDb | null = null; | 20 | let dbPromise: Promise<DrizzleDb> | undefined; |
| 17 | |||
| 18 | export const getDb = async (): Promise<DrizzleDb> => { | ||
| 19 | if (db) return db; | ||
| 20 | 21 | ||
| 22 | const createDb = async (): Promise<DrizzleDb> => { | ||
| 21 | if (process.env.DATABASE_URL) { | 23 | if (process.env.DATABASE_URL) { |
| 22 | const { drizzle } = await import("drizzle-orm/node-postgres"); | 24 | const { drizzle } = await import("drizzle-orm/node-postgres"); |
| 23 | const { migrate } = await import("drizzle-orm/node-postgres/migrator"); | 25 | const { migrate } = await import("drizzle-orm/node-postgres/migrator"); |
| 24 | const { Pool } = await import("pg"); | 26 | const { Pool } = await import("pg"); |
| 25 | const pool = new Pool({ connectionString: process.env.DATABASE_URL }); | 27 | const pool = new Pool({ connectionString: process.env.DATABASE_URL }); |
| 26 | db = drizzle(pool, { schema }) as DrizzleDb; | 28 | const 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; | ||
| 35 | await migrate(db as any, { migrationsFolder: MIGRATIONS_DIR }); | 29 | await migrate(db as any, { migrationsFolder: MIGRATIONS_DIR }); |
| 30 | return db; | ||
| 36 | } | 31 | } |
| 37 | 32 | ||
| 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 }); | ||
| 38 | return db; | 40 | return db; |
| 39 | }; | 41 | }; |
| 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 | } | ||
| @@ -5,7 +5,6 @@ · 4 unmodified lines | |||
| 5 | import { Effect } from "effect"; | 5 | import { Effect } from "effect"; |
| 6 | 6 | ||
| 7 | import { createExecutor } from "@executor/sdk"; | 7 | import { createExecutor } from "@executor/sdk"; |
| 8 | import type { DrizzleDb } from "@executor/storage-postgres"; | ||
| 9 | import { makePgConfig, makePgKv } from "@executor/storage-postgres"; | 8 | import { makePgConfig, makePgKv } from "@executor/storage-postgres"; |
| 10 | import { | 9 | import { |
| 11 | openApiPlugin, | 10 | openApiPlugin, |
| @@ -23,18 +22,19 @@ import { · 11 unmodified lines | |||
| 23 | graphqlPlugin, | 22 | graphqlPlugin, |
| 24 | makeKvOperationStore as makeKvGraphqlOperationStore, | 23 | makeKvOperationStore as makeKvGraphqlOperationStore, |
| 25 | } from "@executor/plugin-graphql"; | 24 | } from "@executor/plugin-graphql"; |
| 25 | import { DbService } from "./db"; | ||
| 26 | 26 | ||
| 27 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| 28 | // Create a fresh executor for a team (stateless, per-request) | 28 | // Create a fresh executor for a team (stateless, per-request) |
| 29 | // --------------------------------------------------------------------------- | 29 | // --------------------------------------------------------------------------- |
| 30 | 30 | ||
| 31 | export const createTeamExecutor = ( | 31 | export const createTeamExecutor = ( |
| 32 | db: DrizzleDb, | ||
| 33 | teamId: string, | 32 | teamId: string, |
| 34 | teamName: string, | 33 | teamName: string, |
| 35 | encryptionKey: string, | 34 | encryptionKey: string, |
| 36 | ) => | 35 | ) => |
| 37 | Effect.gen(function* () { | 36 | Effect.gen(function* () { |
| 37 | const db = yield* DbService; | ||
| 38 | const kv = makePgKv(db, teamId); | 38 | const kv = makePgKv(db, teamId); |
| 39 | const config = makePgConfig(db, { | 39 | const config = makePgConfig(db, { |
| 40 | teamId, | 40 | teamId, |
| @@ -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 | }); | ||
| @@ -5,7 +5,7 @@ · 4 unmodified lines | |||
| 5 | import { eq, and } from "drizzle-orm"; | 5 | import { eq, and } from "drizzle-orm"; |
| 6 | 6 | ||
| 7 | import { users, teams, teamMembers, invitations } from "./schema"; | 7 | import { users, teams, teamMembers, invitations } from "./schema"; |
| 8 | import type { DrizzleDb } from "./types"; | 8 | import type { DrizzleDb } from "@executor/storage-postgres"; |
| 9 | 9 | ||
| 10 | export type User = typeof users.$inferSelect; | 10 | export type User = typeof users.$inferSelect; |
| 11 | export type Team = typeof teams.$inferSelect; | 11 | export type Team = typeof teams.$inferSelect; |
| @@ -1,5 +1,5 @@ | |||
| 1 | import React, { createContext, useContext } from "react"; | 1 | 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"; |
| 3 | 3 | ||
| 4 | import { CloudApiClient } from "./client"; | 4 | import { CloudApiClient } from "./client"; |
| 5 | 5 | ||
| @@ -40,7 +40,8 @@ const AuthContext = createContext<AuthState>({ status: "loading" }); · 34 unmodified lines | |||
| 40 | 40 | ||
| 41 | export const useAuth = () => useContext(AuthContext); | 41 | export const useAuth = () => useContext(AuthContext); |
| 42 | 42 | ||
| 43 | export const AuthProvider = ({ children }: { children: React.ReactNode }) => { | 43 | const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { |
| 44 | useAtomMount(authAtom); | ||
| 44 | const result = useAtomValue(authAtom); | 45 | const result = useAtomValue(authAtom); |
| 45 | 46 | ||
| 46 | const state: AuthState = Result.match(result, { | 47 | const state: AuthState = Result.match(result, { |
| @@ -55,3 +56,14 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { · 8 unmodified lines | |||
| 55 | 56 | ||
| 56 | return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>; | 57 | return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>; |
| 57 | }; | 58 | }; |
| 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 | }; | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import React from "react"; | 1 | import React from "react"; |
| 2 | import { AUTH_PATHS } from "../../auth/api"; | ||
| 2 | 3 | ||
| 3 | export const LoginPage = () => { | 4 | export const LoginPage = () => { |
| 4 | return ( | 5 | return ( |
| @@ -11,7 +12,7 @@ export const LoginPage = () => { · 6 unmodified lines | |||
| 11 | </p> | 12 | </p> |
| 12 | </div> | 13 | </div> |
| 13 | <a | 14 | <a |
| 14 | href="/auth/login" | 15 | href={AUTH_PATHS.login} |
| 15 | 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" | 16 | 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" |
| 16 | > | 17 | > |
| 17 | Sign in | 18 | Sign in |
| @@ -4,6 +4,7 @@ import { useAtomRefresh, useAtomValue, Result } from "@effect-atom/atom-react"; · 3 unmodified lines | |||
| 4 | import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms"; | 4 | import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms"; |
| 5 | import { useScope } from "@executor/react/api/scope-context"; | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | import { Button } from "@executor/react/components/button"; | 6 | import { Button } from "@executor/react/components/button"; |
| 7 | import { AUTH_PATHS } from "../auth/api"; | ||
| 7 | import { useAuth } from "./auth"; | 8 | import { useAuth } from "./auth"; |
| 8 | 9 | ||
| 9 | // ── NavItem ────────────────────────────────────────────────────────────── | 10 | // ── NavItem ────────────────────────────────────────────────────────────── |
| @@ -109,7 +110,7 @@ function UserFooter() { · 99 unmodified lines | |||
| 109 | </p> | 110 | </p> |
| 110 | )} | 111 | )} |
| 111 | </div> | 112 | </div> |
| 112 | <form action="/auth/logout" method="post"> | 113 | <form action={AUTH_PATHS.logout} method="post"> |
| 113 | <button | 114 | <button |
| 114 | type="submit" | 115 | type="submit" |
| 115 | className="shrink-0 rounded-md p-1 text-muted-foreground/50 transition-colors hover:bg-sidebar-active hover:text-foreground" | 116 | className="shrink-0 rounded-md p-1 text-muted-foreground/50 transition-colors hover:bg-sidebar-active hover:text-foreground" |
| @@ -12,9 +12,9 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines | |||
| 12 | import { Route as ToolsRouteImport } from './routes/tools' | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | import { Route as SecretsRouteImport } from './routes/secrets' | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | import { Route as IndexRouteImport } from './routes/index' | 14 | import { Route as IndexRouteImport } from './routes/index' |
| 15 | import { Route as V1SplatRouteImport } from './routes/v1.$' | ||
| 16 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' | 15 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' |
| 17 | import { Route as McpSplatRouteImport } from './routes/mcp.$' | 16 | import { Route as McpSplatRouteImport } from './routes/mcp.$' |
| 17 | import { Route as ApiSplatRouteImport } from './routes/api.$' | ||
| 18 | import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' | 18 | import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' |
| 19 | 19 | ||
| 20 | const ToolsRoute = ToolsRouteImport.update({ | 20 | const ToolsRoute = ToolsRouteImport.update({ |
| @@ -32,11 +32,6 @@ const IndexRoute = IndexRouteImport.update({ · 11 unmodified lines | |||
| 32 | path: '/', | 32 | path: '/', |
| 33 | getParentRoute: () => rootRouteImport, | 33 | getParentRoute: () => rootRouteImport, |
| 34 | } as any) | 34 | } as any) |
| 35 | const V1SplatRoute = V1SplatRouteImport.update({ | ||
| 36 | id: '/v1/$', | ||
| 37 | path: '/v1/$', | ||
| 38 | getParentRoute: () => rootRouteImport, | ||
| 39 | } as any) | ||
| 40 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ | 35 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ |
| 41 | id: '/sources/$namespace', | 36 | id: '/sources/$namespace', |
| 42 | path: '/sources/$namespace', | 37 | path: '/sources/$namespace', |
| @@ -47,6 +42,11 @@ const McpSplatRoute = McpSplatRouteImport.update({ · 4 unmodified lines | |||
| 47 | path: '/mcp/$', | 42 | path: '/mcp/$', |
| 48 | getParentRoute: () => rootRouteImport, | 43 | getParentRoute: () => rootRouteImport, |
| 49 | } as any) | 44 | } as any) |
| 45 | const ApiSplatRoute = ApiSplatRouteImport.update({ | ||
| 46 | id: '/api/$', | ||
| 47 | path: '/api/$', | ||
| 48 | getParentRoute: () => rootRouteImport, | ||
| 49 | } as any) | ||
| 50 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ | 50 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ |
| 51 | id: '/sources/add/$pluginKey', | 51 | id: '/sources/add/$pluginKey', |
| 52 | path: '/sources/add/$pluginKey', | 52 | path: '/sources/add/$pluginKey', |
| @@ -57,18 +57,18 @@ export interface FileRoutesByFullPath { · 4 unmodified lines | |||
| 57 | '/': typeof IndexRoute | 57 | '/': typeof IndexRoute |
| 58 | '/secrets': typeof SecretsRoute | 58 | '/secrets': typeof SecretsRoute |
| 59 | '/tools': typeof ToolsRoute | 59 | '/tools': typeof ToolsRoute |
| 60 | '/api/$': typeof ApiSplatRoute | ||
| 60 | '/mcp/$': typeof McpSplatRoute | 61 | '/mcp/$': typeof McpSplatRoute |
| 61 | '/sources/$namespace': typeof SourcesNamespaceRoute | 62 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 62 | '/v1/$': typeof V1SplatRoute | ||
| 63 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | 63 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 64 | } | 64 | } |
| 65 | export interface FileRoutesByTo { | 65 | export interface FileRoutesByTo { |
| 66 | '/': typeof IndexRoute | 66 | '/': typeof IndexRoute |
| 67 | '/secrets': typeof SecretsRoute | 67 | '/secrets': typeof SecretsRoute |
| 68 | '/tools': typeof ToolsRoute | 68 | '/tools': typeof ToolsRoute |
| 69 | '/api/$': typeof ApiSplatRoute | ||
| 69 | '/mcp/$': typeof McpSplatRoute | 70 | '/mcp/$': typeof McpSplatRoute |
| 70 | '/sources/$namespace': typeof SourcesNamespaceRoute | 71 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 71 | '/v1/$': typeof V1SplatRoute | ||
| 72 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | 72 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 73 | } | 73 | } |
| 74 | export interface FileRoutesById { | 74 | export interface FileRoutesById { |
| @@ -76,9 +76,9 @@ export interface FileRoutesById { · 1 unmodified line | |||
| 76 | '/': typeof IndexRoute | 76 | '/': typeof IndexRoute |
| 77 | '/secrets': typeof SecretsRoute | 77 | '/secrets': typeof SecretsRoute |
| 78 | '/tools': typeof ToolsRoute | 78 | '/tools': typeof ToolsRoute |
| 79 | '/api/$': typeof ApiSplatRoute | ||
| 79 | '/mcp/$': typeof McpSplatRoute | 80 | '/mcp/$': typeof McpSplatRoute |
| 80 | '/sources/$namespace': typeof SourcesNamespaceRoute | 81 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 81 | '/v1/$': typeof V1SplatRoute | ||
| 82 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | 82 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 83 | } | 83 | } |
| 84 | export interface FileRouteTypes { | 84 | export interface FileRouteTypes { |
| @@ -87,27 +87,27 @@ export interface FileRouteTypes { · 2 unmodified lines | |||
| 87 | | '/' | 87 | | '/' |
| 88 | | '/secrets' | 88 | | '/secrets' |
| 89 | | '/tools' | 89 | | '/tools' |
| 90 | | '/api/$' | ||
| 90 | | '/mcp/$' | 91 | | '/mcp/$' |
| 91 | | '/sources/$namespace' | 92 | | '/sources/$namespace' |
| 92 | | '/v1/$' | ||
| 93 | | '/sources/add/$pluginKey' | 93 | | '/sources/add/$pluginKey' |
| 94 | fileRoutesByTo: FileRoutesByTo | 94 | fileRoutesByTo: FileRoutesByTo |
| 95 | to: | 95 | to: |
| 96 | | '/' | 96 | | '/' |
| 97 | | '/secrets' | 97 | | '/secrets' |
| 98 | | '/tools' | 98 | | '/tools' |
| 99 | | '/api/$' | ||
| 99 | | '/mcp/$' | 100 | | '/mcp/$' |
| 100 | | '/sources/$namespace' | 101 | | '/sources/$namespace' |
| 101 | | '/v1/$' | ||
| 102 | | '/sources/add/$pluginKey' | 102 | | '/sources/add/$pluginKey' |
| 103 | id: | 103 | id: |
| 104 | | '__root__' | 104 | | '__root__' |
| 105 | | '/' | 105 | | '/' |
| 106 | | '/secrets' | 106 | | '/secrets' |
| 107 | | '/tools' | 107 | | '/tools' |
| 108 | | '/api/$' | ||
| 108 | | '/mcp/$' | 109 | | '/mcp/$' |
| 109 | | '/sources/$namespace' | 110 | | '/sources/$namespace' |
| 110 | | '/v1/$' | ||
| 111 | | '/sources/add/$pluginKey' | 111 | | '/sources/add/$pluginKey' |
| 112 | fileRoutesById: FileRoutesById | 112 | fileRoutesById: FileRoutesById |
| 113 | } | 113 | } |
| @@ -115,9 +115,9 @@ export interface RootRouteChildren { · 1 unmodified line | |||
| 115 | IndexRoute: typeof IndexRoute | 115 | IndexRoute: typeof IndexRoute |
| 116 | SecretsRoute: typeof SecretsRoute | 116 | SecretsRoute: typeof SecretsRoute |
| 117 | ToolsRoute: typeof ToolsRoute | 117 | ToolsRoute: typeof ToolsRoute |
| 118 | ApiSplatRoute: typeof ApiSplatRoute | ||
| 118 | McpSplatRoute: typeof McpSplatRoute | 119 | McpSplatRoute: typeof McpSplatRoute |
| 119 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute | 120 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute |
| 120 | V1SplatRoute: typeof V1SplatRoute | ||
| 121 | SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute | 121 | SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute |
| 122 | } | 122 | } |
| 123 | 123 | ||
| @@ -144,13 +144,6 @@ declare module '@tanstack/react-router' { · 20 unmodified lines | |||
| 144 | preLoaderRoute: typeof IndexRouteImport | 144 | preLoaderRoute: typeof IndexRouteImport |
| 145 | parentRoute: typeof rootRouteImport | 145 | parentRoute: typeof rootRouteImport |
| 146 | } | 146 | } |
| 147 | '/v1/$': { | ||
| 148 | id: '/v1/$' | ||
| 149 | path: '/v1/$' | ||
| 150 | fullPath: '/v1/$' | ||
| 151 | preLoaderRoute: typeof V1SplatRouteImport | ||
| 152 | parentRoute: typeof rootRouteImport | ||
| 153 | } | ||
| 154 | '/sources/$namespace': { | 147 | '/sources/$namespace': { |
| 155 | id: '/sources/$namespace' | 148 | id: '/sources/$namespace' |
| 156 | path: '/sources/$namespace' | 149 | path: '/sources/$namespace' |
| @@ -165,6 +158,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines | |||
| 165 | preLoaderRoute: typeof McpSplatRouteImport | 158 | preLoaderRoute: typeof McpSplatRouteImport |
| 166 | parentRoute: typeof rootRouteImport | 159 | parentRoute: typeof rootRouteImport |
| 167 | } | 160 | } |
| 161 | '/api/$': { | ||
| 162 | id: '/api/$' | ||
| 163 | path: '/api/$' | ||
| 164 | fullPath: '/api/$' | ||
| 165 | preLoaderRoute: typeof ApiSplatRouteImport | ||
| 166 | parentRoute: typeof rootRouteImport | ||
| 167 | } | ||
| 168 | '/sources/add/$pluginKey': { | 168 | '/sources/add/$pluginKey': { |
| 169 | id: '/sources/add/$pluginKey' | 169 | id: '/sources/add/$pluginKey' |
| 170 | path: '/sources/add/$pluginKey' | 170 | path: '/sources/add/$pluginKey' |
| @@ -179,9 +179,9 @@ const rootRouteChildren: RootRouteChildren = { · 8 unmodified lines | |||
| 179 | IndexRoute: IndexRoute, | 179 | IndexRoute: IndexRoute, |
| 180 | SecretsRoute: SecretsRoute, | 180 | SecretsRoute: SecretsRoute, |
| 181 | ToolsRoute: ToolsRoute, | 181 | ToolsRoute: ToolsRoute, |
| 182 | ApiSplatRoute: ApiSplatRoute, | ||
| 182 | McpSplatRoute: McpSplatRoute, | 183 | McpSplatRoute: McpSplatRoute, |
| 183 | SourcesNamespaceRoute: SourcesNamespaceRoute, | 184 | SourcesNamespaceRoute: SourcesNamespaceRoute, |
| 184 | V1SplatRoute: V1SplatRoute, | ||
| 185 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, | 185 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, |
| 186 | } | 186 | } |
| 187 | export const routeTree = rootRouteImport | 187 | export const routeTree = rootRouteImport |
| @@ -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 | }); | ||
| @@ -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 | }); | ||
| @@ -20,7 +20,11 @@ const getHandlers = () => { · 19 unmodified lines | |||
| 20 | 20 | ||
| 21 | export const handleApiRequest = async (request: Request): Promise<Response> => { | 21 | export const handleApiRequest = async (request: Request): Promise<Response> => { |
| 22 | const handlers = await getHandlers(); | 22 | 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)); | ||
| 24 | }; | 28 | }; |
| 25 | 29 | ||
| 26 | export const handleMcpRequest = async (request: Request): Promise<Response> => { | 30 | export const handleMcpRequest = async (request: Request): Promise<Response> => { |
| @@ -62,7 +62,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 61 unmodified lines | |||
| 62 | ); | 62 | ); |
| 63 | 63 | ||
| 64 | const api = HttpApiBuilder.toWebHandler( | 64 | const api = HttpApiBuilder.toWebHandler( |
| 65 | HttpApiSwagger.layer().pipe( | 65 | HttpApiSwagger.layer({ path: "/docs" }).pipe( |
| 66 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), | 66 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 67 | Layer.provideMerge(LocalApiBase), | 67 | Layer.provideMerge(LocalApiBase), |
| 68 | Layer.provideMerge(pluginExtensions), | 68 | Layer.provideMerge(pluginExtensions), |
| @@ -61,4 +61,4 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 60 unmodified lines | |||
| 61 | .addSuccess(ResumeResponse) | 61 | .addSuccess(ResumeResponse) |
| 62 | .addError(ExecutionNotFoundError), | 62 | .addError(ExecutionNotFoundError), |
| 63 | ) | 63 | ) |
| 64 | .prefix("/v1") {} | 64 | {} |
| @@ -21,4 +21,4 @@ export class ScopeApi extends HttpApiGroup.make("scope") · 20 unmodified lines | |||
| 21 | HttpApiEndpoint.get("info")`/scope` | 21 | HttpApiEndpoint.get("info")`/scope` |
| 22 | .addSuccess(ScopeInfoResponse), | 22 | .addSuccess(ScopeInfoResponse), |
| 23 | ) | 23 | ) |
| 24 | .prefix("/v1") {} | 24 | {} |
| @@ -86,4 +86,4 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 85 unmodified lines | |||
| 86 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | 86 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) |
| 87 | .addError(SecretNotFound), | 87 | .addError(SecretNotFound), |
| 88 | ) | 88 | ) |
| 89 | .prefix("/v1") {} | 89 | {} |
| @@ -77,4 +77,4 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 76 unmodified lines | |||
| 77 | .setPayload(DetectRequest) | 77 | .setPayload(DetectRequest) |
| 78 | .addSuccess(Schema.Array(DetectResultResponse)), | 78 | .addSuccess(Schema.Array(DetectResultResponse)), |
| 79 | ) | 79 | ) |
| 80 | .prefix("/v1") {} | 80 | {} |
| @@ -57,4 +57,4 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 56 unmodified lines | |||
| 57 | .addSuccess(ToolSchemaResponse) | 57 | .addSuccess(ToolSchemaResponse) |
| 58 | .addError(ToolNotFound), | 58 | .addError(ToolNotFound), |
| 59 | ) | 59 | ) |
| 60 | .prefix("/v1") {} | 60 | {} |
| @@ -21,7 +21,6 @@ import { · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | import { makePgConfig } from "./index"; | 22 | import { makePgConfig } from "./index"; |
| 23 | import { makePgKv } from "./pg-kv"; | 23 | import { makePgKv } from "./pg-kv"; |
| 24 | import { makeUserStore } from "./user-store"; | ||
| 25 | import * as schema from "./schema"; | 24 | import * as schema from "./schema"; |
| 26 | 25 | ||
| 27 | // --------------------------------------------------------------------------- | 26 | // --------------------------------------------------------------------------- |
| @@ -355,57 +354,3 @@ describe("Executor with Postgres storage", () => { · 327 unmodified lines | |||
| 355 | ); | 354 | ); |
| 356 | }); | 355 | }); |
| 357 | 356 | ||
| 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 | }); | ||
| @@ -29,8 +29,6 @@ export { makePgKv } from "./pg-kv"; · 28 unmodified lines | |||
| 29 | export { makePgToolRegistry } from "./tool-registry"; | 29 | export { makePgToolRegistry } from "./tool-registry"; |
| 30 | export { makePgSecretStore } from "./secret-store"; | 30 | export { makePgSecretStore } from "./secret-store"; |
| 31 | export { makePgPolicyEngine } from "./policy-engine"; | 31 | export { makePgPolicyEngine } from "./policy-engine"; |
| 32 | export { makeUserStore } from "./user-store"; | ||
| 33 | export type { User, Team, TeamMember, Invitation } from "./user-store"; | ||
| 34 | export { encrypt, decrypt } from "./crypto"; | 32 | export { encrypt, decrypt } from "./crypto"; |
| 35 | export type { DrizzleDb } from "./types"; | 33 | export type { DrizzleDb } from "./types"; |
| 36 | export * from "./schema"; | 34 | export * from "./schema"; |
| @@ -19,45 +19,6 @@ const bytea = customType<{ data: Buffer }>({ · 18 unmodified lines | |||
| 19 | }, | 19 | }, |
| 20 | }); | 20 | }); |
| 21 | 21 | ||
| 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 | |||
| 61 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| 62 | // Domain data — all team-scoped | 23 | // Domain data — all team-scoped |
| 63 | // --------------------------------------------------------------------------- | 24 | // --------------------------------------------------------------------------- |
| @@ -125,4 +125,4 @@ export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") · 124 unmodified lines | |||
| 125 | .addSuccess(HtmlResponse) | 125 | .addSuccess(HtmlResponse) |
| 126 | .addError(ApiError), | 126 | .addError(ApiError), |
| 127 | ) | 127 | ) |
| 128 | .prefix("/v1") {} | 128 | {} |
| @@ -58,4 +58,4 @@ export class GraphqlGroup extends HttpApiGroup.make("graphql") · 57 unmodified lines | |||
| 58 | .addError(IntrospectionError) | 58 | .addError(IntrospectionError) |
| 59 | .addError(ExtractionError), | 59 | .addError(ExtractionError), |
| 60 | ) | 60 | ) |
| 61 | .prefix("/v1") {} | 61 | {} |
| @@ -194,4 +194,4 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 193 unmodified lines | |||
| 194 | .addSuccess(HtmlResponse) | 194 | .addSuccess(HtmlResponse) |
| 195 | .addError(McpApiError), | 195 | .addError(McpApiError), |
| 196 | ) | 196 | ) |
| 197 | .prefix("/v1") {} | 197 | {} |
| @@ -70,4 +70,4 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 69 unmodified lines | |||
| 70 | .addSuccess(ListVaultsResponse) | 70 | .addSuccess(ListVaultsResponse) |
| 71 | .addError(OpError), | 71 | .addError(OpError), |
| 72 | ) | 72 | ) |
| 73 | .prefix("/v1") {} | 73 | {} |
| @@ -70,4 +70,4 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 69 unmodified lines | |||
| 70 | .addError(ParseError) | 70 | .addError(ParseError) |
| 71 | .addError(ExtractionError), | 71 | .addError(ExtractionError), |
| 72 | ) | 72 | ) |
| 73 | .prefix("/v1") {} | 73 | {} |
| @@ -2,8 +2,8 @@ const DEFAULT_BASE_URL = "http://127.0.0.1:4000"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | let baseUrl = | 3 | let baseUrl = |
| 4 | typeof window !== "undefined" && typeof window.location?.origin === "string" | 4 | typeof window !== "undefined" && typeof window.location?.origin === "string" |
| 5 | ? window.location.origin | 5 | ? `${window.location.origin}/api` |
| 6 | : DEFAULT_BASE_URL; | 6 | : `${DEFAULT_BASE_URL}/api`; |
| 7 | 7 | ||
| 8 | export const getBaseUrl = (): string => baseUrl; | 8 | export const getBaseUrl = (): string => baseUrl; |
| 9 | 9 | ||