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 | 5 | import { |
| 6 | + HttpApi, | |
| 6 | 7 | HttpApiBuilder, |
| 7 | 8 | HttpApiSwagger, |
| 8 | 9 | HttpMiddleware, |
| 9 | 10 | HttpServer, |
| 10 | 11 | } from "@effect/platform"; |
| 11 | 12 | import { Effect, Layer } from "effect"; |
| 13 | +import { setCookie } from "@tanstack/react-start/server"; | |
| 12 | 14 | |
| 13 | 15 | import { addGroup, CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api"; |
| 14 | 16 | import { createExecutionEngine } from "@executor/execution"; |
| 15 | 17 | import { OpenApiGroup, OpenApiExtensionService, OpenApiHandlers } from "@executor/plugin-openapi/api"; |
| 16 | 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 | 24 | import { GraphqlGroup, GraphqlExtensionService, GraphqlHandlers } from "@executor/plugin-graphql/api"; |
| 19 | 25 | |
| 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"; | |
| 23 | 27 | import { AuthContext, UserStoreService } from "./auth/context"; |
| 28 | +import { CloudAuthHandlers, CloudAuthPublicHandlers } from "./auth/handlers"; | |
| 24 | 29 | 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"; | |
| 30 | 32 | |
| 31 | −const CloudApi = addGroup(OpenApiGroup) | |
| 33 | +const ProtectedCloudApi = addGroup(OpenApiGroup) | |
| 32 | 34 | .add(McpGroup) |
| 33 | 35 | .add(GoogleDiscoveryGroup) |
| 34 | 36 | .add(GraphqlGroup) |
| 35 | 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 | 43 | Layer.provide(CoreHandlers), |
| 39 | 44 | Layer.provide(Layer.mergeAll( |
| 40 | 45 | OpenApiHandlers, |
| @@ -45,100 +50,180 @@ const CloudApiBase = HttpApiBuilder.api(CloudApi).pipe( · 4 unmodified lines | ||
| 45 | 50 | )), |
| 46 | 51 | ); |
| 47 | 52 | |
| 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 | +); | |
| 51 | 75 | |
| 52 | 76 | const parseCookie = (cookieHeader: string | null, name: string): string | null => { |
| 53 | 77 | if (!cookieHeader) return null; |
| 54 | 78 | const match = cookieHeader |
| 55 | 79 | .split(";") |
| 56 | − .map((c) => c.trim()) | |
| 57 | − .find((c) => c.startsWith(`${name}=`)); | |
| 80 | + .map((value) => value.trim()) | |
| 81 | + .find((value) => value.startsWith(`${name}=`)); | |
| 58 | 82 | if (!match) return null; |
| 59 | 83 | return match.slice(name.length + 1) || null; |
| 60 | 84 | }; |
| 61 | 85 | |
| 62 | −// --------------------------------------------------------------------------- | |
| 63 | −// Create API handler with auth-based executor resolution | |
| 64 | −// --------------------------------------------------------------------------- | |
| 86 | +const isPublicPath = (pathname: string): boolean => | |
| 87 | + pathname === "/auth/login" || pathname === "/auth/callback"; | |
| 65 | 88 | |
| 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 }); | |
| 75 | 91 | |
| 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 | +}; | |
| 79 | 99 | |
| 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 | + ); | |
| 84 | 134 | |
| 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`), | |
| 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 | 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( | |
| 96 | − createTeamExecutor(db, teamId, teamName, encryptionKey), | |
| 97 | − ); | |
| 199 | +export const handleApiRequest = async (request: Request): Promise<Response> => { | |
| 200 | + const pathname = new URL(request.url).pathname; | |
| 98 | 201 | |
| 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 | + } | |
| 105 | 205 | |
| 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); | |
| 128 | 211 | |
| 129 | − try { | |
| 130 | − const response = await handler.handler(request); | |
| 212 | + const executor = await resolveExecutor(teamId); | |
| 213 | + const handler = createProtectedHandler(auth, teamId, executor); | |
| 131 | 214 | |
| 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); | |
| 137 | 217 | |
| 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); | |
| 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 | 1 | import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { UserStoreError, WorkOSError } from "./context"; | |
| 3 | +import { UserStoreError, WorkOSError } from "./errors"; | |
| 4 | 4 | |
| 5 | 5 | const AuthUser = Schema.Struct({ |
| 6 | 6 | id: Schema.String, |
| @@ -23,12 +23,14 @@ const AuthCallbackSearch = Schema.Struct({ · 16 unmodified lines | ||
| 23 | 23 | code: Schema.String, |
| 24 | 24 | }); |
| 25 | 25 | |
| 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") | |
| 32 | 34 | .add( |
| 33 | 35 | HttpApiEndpoint.get("login")`/auth/login`, |
| 34 | 36 | ) |
| @@ -37,7 +39,16 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 2 unmodified lines | ||
| 37 | 39 | .setUrlParams(AuthCallbackSearch) |
| 38 | 40 | .addError(UserStoreError) |
| 39 | 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 | 51 | .add( |
| 42 | 52 | HttpApiEndpoint.post("logout")`/auth/logout`, |
| 43 | − ) {} | |
| 53 | + ) | |
| 54 | +{} | |
| @@ -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"; | |
| 18 | 5 | |
| 19 | 6 | // --------------------------------------------------------------------------- |
| 20 | 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 | 25 | type RawStore = ReturnType<typeof makeUserStore>; |
| 39 | 26 | |
| 40 | −const makeService = (db: DrizzleDb) => { | |
| 41 | − const store = makeUserStore(db); | |
| 42 | − | |
| 27 | +const makeService = (store: RawStore) => { | |
| 43 | 28 | const use = <A>(fn: (s: RawStore) => Promise<A>) => |
| 44 | 29 | Effect.tryPromise({ |
| 45 | 30 | try: () => fn(store), |
| @@ -55,6 +40,8 @@ export class UserStoreService extends Context.Tag("@executor/cloud/UserStoreServ · 9 unmodified lines | ||
| 55 | 40 | UserStoreService, |
| 56 | 41 | UserStoreServiceType |
| 57 | 42 | >() { |
| 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 | + ); | |
| 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 | 2 | import { Effect } from "effect"; |
| 3 | +import { deleteCookie, setCookie } from "@tanstack/react-start/server"; | |
| 3 | 4 | |
| 4 | 5 | import { addGroup } from "@executor/api"; |
| 5 | −import { CloudAuthApi } from "./api"; | |
| 6 | +import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; | |
| 6 | 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( | |
| 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", | |
| 14 | 28 | (handlers) => |
| 15 | 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 | 30 | .handleRaw("login", () => |
| 34 | 31 | Effect.gen(function* () { |
| 35 | 32 | 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}`); | |
| 38 | 36 | return HttpServerResponse.redirect(url, { status: 302 }); |
| 39 | 37 | }), |
| 40 | 38 | ) |
| @@ -81,28 +79,46 @@ export const CloudAuthHandlers = HttpApiBuilder.group( · 40 unmodified lines | ||
| 81 | 79 | return HttpServerResponse.text("Failed to create session", { status: 500 }); |
| 82 | 80 | } |
| 83 | 81 | |
| 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, | |
| 91 | 112 | }, |
| 92 | − }); | |
| 113 | + team: team ? { id: team.id, name: team.name } : null, | |
| 114 | + }; | |
| 93 | 115 | }), |
| 94 | 116 | ) |
| 95 | 117 | .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 | + }), | |
| 107 | 123 | ), |
| 108 | 124 | ); |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { Context, Effect, Layer } from "effect"; |
| 6 | 6 | import { WorkOS } from "@workos-inc/node"; |
| 7 | −import { WorkOSError } from "./context"; | |
| 7 | +import { WorkOSError } from "./errors"; | |
| 8 | 8 | |
| 9 | 9 | const COOKIE_NAME = "wos-session"; |
| 10 | 10 | |
| @@ -67,7 +67,7 @@ const make = Effect.gen(function* () { · 56 unmodified lines | ||
| 67 | 67 | lastName: result.user.lastName, |
| 68 | 68 | avatarUrl: result.user.profilePictureUrl, |
| 69 | 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 | 87 | lastName: refreshed.user.lastName, |
| 88 | 88 | avatarUrl: refreshed.user.profilePictureUrl, |
| 89 | 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 | 107 | const parseCookie = (cookieHeader: string | null, name: string): string | null => { |
| 127 | 108 | if (!cookieHeader) return null; |
| 128 | 109 | const match = cookieHeader |
| @@ -12,9 +12,8 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines | ||
| 12 | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | 14 | import { Route as IndexRouteImport } from './routes/index' |
| 15 | −import { Route as V1SplatRouteImport } from './routes/v1.$' | |
| 16 | 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 | 17 | import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' |
| 19 | 18 | |
| 20 | 19 | const ToolsRoute = ToolsRouteImport.update({ |
| @@ -32,19 +31,14 @@ const IndexRoute = IndexRouteImport.update({ · 11 unmodified lines | ||
| 32 | 31 | path: '/', |
| 33 | 32 | getParentRoute: () => rootRouteImport, |
| 34 | 33 | } as any) |
| 35 | −const V1SplatRoute = V1SplatRouteImport.update({ | |
| 36 | − id: '/v1/$', | |
| 37 | − path: '/v1/$', | |
| 38 | − getParentRoute: () => rootRouteImport, | |
| 39 | −} as any) | |
| 40 | 34 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ |
| 41 | 35 | id: '/sources/$namespace', |
| 42 | 36 | path: '/sources/$namespace', |
| 43 | 37 | getParentRoute: () => rootRouteImport, |
| 44 | 38 | } as any) |
| 45 | −const AuthSplatRoute = AuthSplatRouteImport.update({ | |
| 46 | − id: '/auth/$', | |
| 47 | − path: '/auth/$', | |
| 39 | +const ApiSplatRoute = ApiSplatRouteImport.update({ | |
| 40 | + id: '/api/$', | |
| 41 | + path: '/api/$', | |
| 48 | 42 | getParentRoute: () => rootRouteImport, |
| 49 | 43 | } as any) |
| 50 | 44 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ |
| @@ -57,18 +51,16 @@ export interface FileRoutesByFullPath { · 6 unmodified lines | ||
| 57 | 51 | '/': typeof IndexRoute |
| 58 | 52 | '/secrets': typeof SecretsRoute |
| 59 | 53 | '/tools': typeof ToolsRoute |
| 60 | − '/auth/$': typeof AuthSplatRoute | |
| 54 | + '/api/$': typeof ApiSplatRoute | |
| 61 | 55 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 62 | − '/v1/$': typeof V1SplatRoute | |
| 63 | 56 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 64 | 57 | } |
| 65 | 58 | export interface FileRoutesByTo { |
| 66 | 59 | '/': typeof IndexRoute |
| 67 | 60 | '/secrets': typeof SecretsRoute |
| 68 | 61 | '/tools': typeof ToolsRoute |
| 69 | − '/auth/$': typeof AuthSplatRoute | |
| 62 | + '/api/$': typeof ApiSplatRoute | |
| 70 | 63 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 71 | − '/v1/$': typeof V1SplatRoute | |
| 72 | 64 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 73 | 65 | } |
| 74 | 66 | export interface FileRoutesById { |
| @@ -76,9 +68,8 @@ export interface FileRoutesById { · 1 unmodified line | ||
| 76 | 68 | '/': typeof IndexRoute |
| 77 | 69 | '/secrets': typeof SecretsRoute |
| 78 | 70 | '/tools': typeof ToolsRoute |
| 79 | − '/auth/$': typeof AuthSplatRoute | |
| 71 | + '/api/$': typeof ApiSplatRoute | |
| 80 | 72 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 81 | − '/v1/$': typeof V1SplatRoute | |
| 82 | 73 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 83 | 74 | } |
| 84 | 75 | export interface FileRouteTypes { |
| @@ -87,27 +78,24 @@ export interface FileRouteTypes { · 2 unmodified lines | ||
| 87 | 78 | | '/' |
| 88 | 79 | | '/secrets' |
| 89 | 80 | | '/tools' |
| 90 | − | '/auth/$' | |
| 81 | + | '/api/$' | |
| 91 | 82 | | '/sources/$namespace' |
| 92 | − | '/v1/$' | |
| 93 | 83 | | '/sources/add/$pluginKey' |
| 94 | 84 | fileRoutesByTo: FileRoutesByTo |
| 95 | 85 | to: |
| 96 | 86 | | '/' |
| 97 | 87 | | '/secrets' |
| 98 | 88 | | '/tools' |
| 99 | − | '/auth/$' | |
| 89 | + | '/api/$' | |
| 100 | 90 | | '/sources/$namespace' |
| 101 | − | '/v1/$' | |
| 102 | 91 | | '/sources/add/$pluginKey' |
| 103 | 92 | id: |
| 104 | 93 | | '__root__' |
| 105 | 94 | | '/' |
| 106 | 95 | | '/secrets' |
| 107 | 96 | | '/tools' |
| 108 | − | '/auth/$' | |
| 97 | + | '/api/$' | |
| 109 | 98 | | '/sources/$namespace' |
| 110 | − | '/v1/$' | |
| 111 | 99 | | '/sources/add/$pluginKey' |
| 112 | 100 | fileRoutesById: FileRoutesById |
| 113 | 101 | } |
| @@ -115,9 +103,8 @@ export interface RootRouteChildren { · 1 unmodified line | ||
| 115 | 103 | IndexRoute: typeof IndexRoute |
| 116 | 104 | SecretsRoute: typeof SecretsRoute |
| 117 | 105 | ToolsRoute: typeof ToolsRoute |
| 118 | − AuthSplatRoute: typeof AuthSplatRoute | |
| 106 | + ApiSplatRoute: typeof ApiSplatRoute | |
| 119 | 107 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute |
| 120 | − V1SplatRoute: typeof V1SplatRoute | |
| 121 | 108 | SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute |
| 122 | 109 | } |
| 123 | 110 | |
| @@ -144,13 +131,6 @@ declare module '@tanstack/react-router' { · 20 unmodified lines | ||
| 144 | 131 | preLoaderRoute: typeof IndexRouteImport |
| 145 | 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 | 134 | '/sources/$namespace': { |
| 155 | 135 | id: '/sources/$namespace' |
| 156 | 136 | path: '/sources/$namespace' |
| @@ -158,11 +138,11 @@ declare module '@tanstack/react-router' { · 1 unmodified line | ||
| 158 | 138 | preLoaderRoute: typeof SourcesNamespaceRouteImport |
| 159 | 139 | parentRoute: typeof rootRouteImport |
| 160 | 140 | } |
| 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 | |
| 166 | 146 | parentRoute: typeof rootRouteImport |
| 167 | 147 | } |
| 168 | 148 | '/sources/add/$pluginKey': { |
| @@ -179,9 +159,8 @@ const rootRouteChildren: RootRouteChildren = { · 10 unmodified lines | ||
| 179 | 159 | IndexRoute: IndexRoute, |
| 180 | 160 | SecretsRoute: SecretsRoute, |
| 181 | 161 | ToolsRoute: ToolsRoute, |
| 182 | − AuthSplatRoute: AuthSplatRoute, | |
| 162 | + ApiSplatRoute: ApiSplatRoute, | |
| 183 | 163 | SourcesNamespaceRoute: SourcesNamespaceRoute, |
| 184 | − V1SplatRoute: V1SplatRoute, | |
| 185 | 164 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, |
| 186 | 165 | } |
| 187 | 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"; | |
| 2 | −import { getDb } from "../services/db"; | |
| 1 | +import { handleApiRequest as _handleApiRequest } from "../api"; | |
| 3 | 2 | |
| 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 | +}; | |
| @@ -2,10 +2,14 @@ · 1 unmodified line | ||
| 2 | 2 | // Database service — PGlite for dev, node-postgres for prod |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | +import { Context, Effect, Layer } from "effect"; | |
| 5 | 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 | 9 | import type { DrizzleDb } from "@executor/storage-postgres"; |
| 8 | 10 | |
| 11 | +const schema = { ...sharedSchema, ...cloudSchema }; | |
| 12 | + | |
| 9 | 13 | export type { DrizzleDb }; |
| 10 | 14 | |
| 11 | 15 | const MIGRATIONS_DIR = resolve( |
| @@ -13,27 +17,45 @@ const MIGRATIONS_DIR = resolve( · 1 unmodified line | ||
| 13 | 17 | "../../../../packages/core/storage-postgres/drizzle", |
| 14 | 18 | ); |
| 15 | 19 | |
| 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; | |
| 20 | 21 | |
| 22 | +const createDb = async (): Promise<DrizzleDb> => { | |
| 21 | 23 | if (process.env.DATABASE_URL) { |
| 22 | 24 | const { drizzle } = await import("drizzle-orm/node-postgres"); |
| 23 | 25 | const { migrate } = await import("drizzle-orm/node-postgres/migrator"); |
| 24 | 26 | const { Pool } = await import("pg"); |
| 25 | 27 | 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; | |
| 35 | 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 | 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 | 5 | import { Effect } from "effect"; |
| 6 | 6 | |
| 7 | 7 | import { createExecutor } from "@executor/sdk"; |
| 8 | −import type { DrizzleDb } from "@executor/storage-postgres"; | |
| 9 | 8 | import { makePgConfig, makePgKv } from "@executor/storage-postgres"; |
| 10 | 9 | import { |
| 11 | 10 | openApiPlugin, |
| @@ -23,18 +22,19 @@ import { · 11 unmodified lines | ||
| 23 | 22 | graphqlPlugin, |
| 24 | 23 | makeKvOperationStore as makeKvGraphqlOperationStore, |
| 25 | 24 | } from "@executor/plugin-graphql"; |
| 25 | +import { DbService } from "./db"; | |
| 26 | 26 | |
| 27 | 27 | // --------------------------------------------------------------------------- |
| 28 | 28 | // Create a fresh executor for a team (stateless, per-request) |
| 29 | 29 | // --------------------------------------------------------------------------- |
| 30 | 30 | |
| 31 | 31 | export const createTeamExecutor = ( |
| 32 | − db: DrizzleDb, | |
| 33 | 32 | teamId: string, |
| 34 | 33 | teamName: string, |
| 35 | 34 | encryptionKey: string, |
| 36 | 35 | ) => |
| 37 | 36 | Effect.gen(function* () { |
| 37 | + const db = yield* DbService; | |
| 38 | 38 | const kv = makePgKv(db, teamId); |
| 39 | 39 | const config = makePgConfig(db, { |
| 40 | 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 | 5 | import { eq, and } from "drizzle-orm"; |
| 6 | 6 | |
| 7 | 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 | 10 | export type User = typeof users.$inferSelect; |
| 11 | 11 | export type Team = typeof teams.$inferSelect; |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 4 | import { CloudApiClient } from "./client"; |
| 5 | 5 | |
| @@ -40,7 +40,8 @@ const AuthContext = createContext<AuthState>({ status: "loading" }); · 34 unmodified lines | ||
| 40 | 40 | |
| 41 | 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 | 45 | const result = useAtomValue(authAtom); |
| 45 | 46 | |
| 46 | 47 | const state: AuthState = Result.match(result, { |
| @@ -55,3 +56,14 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { · 8 unmodified lines | ||
| 55 | 56 | |
| 56 | 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 | 1 | import React from "react"; |
| 2 | +import { AUTH_PATHS } from "../../auth/api"; | |
| 2 | 3 | |
| 3 | 4 | export const LoginPage = () => { |
| 4 | 5 | return ( |
| @@ -11,7 +12,7 @@ export const LoginPage = () => { · 6 unmodified lines | ||
| 11 | 12 | </p> |
| 12 | 13 | </div> |
| 13 | 14 | <a |
| 14 | − href="/auth/login" | |
| 15 | + href={AUTH_PATHS.login} | |
| 15 | 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 | 18 | Sign in |
| @@ -4,6 +4,7 @@ import { useAtomRefresh, useAtomValue, Result } from "@effect-atom/atom-react"; · 3 unmodified lines | ||
| 4 | 4 | import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms"; |
| 5 | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | 6 | import { Button } from "@executor/react/components/button"; |
| 7 | +import { AUTH_PATHS } from "../auth/api"; | |
| 7 | 8 | import { useAuth } from "./auth"; |
| 8 | 9 | |
| 9 | 10 | // ── NavItem ────────────────────────────────────────────────────────────── |
| @@ -109,7 +110,7 @@ function UserFooter() { · 99 unmodified lines | ||
| 109 | 110 | </p> |
| 110 | 111 | )} |
| 111 | 112 | </div> |
| 112 | − <form action="/auth/logout" method="post"> | |
| 113 | + <form action={AUTH_PATHS.logout} method="post"> | |
| 113 | 114 | <button |
| 114 | 115 | type="submit" |
| 115 | 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 | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | 14 | import { Route as IndexRouteImport } from './routes/index' |
| 15 | −import { Route as V1SplatRouteImport } from './routes/v1.$' | |
| 16 | 15 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' |
| 17 | 16 | import { Route as McpSplatRouteImport } from './routes/mcp.$' |
| 17 | +import { Route as ApiSplatRouteImport } from './routes/api.$' | |
| 18 | 18 | import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' |
| 19 | 19 | |
| 20 | 20 | const ToolsRoute = ToolsRouteImport.update({ |
| @@ -32,11 +32,6 @@ const IndexRoute = IndexRouteImport.update({ · 11 unmodified lines | ||
| 32 | 32 | path: '/', |
| 33 | 33 | getParentRoute: () => rootRouteImport, |
| 34 | 34 | } as any) |
| 35 | −const V1SplatRoute = V1SplatRouteImport.update({ | |
| 36 | − id: '/v1/$', | |
| 37 | − path: '/v1/$', | |
| 38 | − getParentRoute: () => rootRouteImport, | |
| 39 | −} as any) | |
| 40 | 35 | const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ |
| 41 | 36 | id: '/sources/$namespace', |
| 42 | 37 | path: '/sources/$namespace', |
| @@ -47,6 +42,11 @@ const McpSplatRoute = McpSplatRouteImport.update({ · 4 unmodified lines | ||
| 47 | 42 | path: '/mcp/$', |
| 48 | 43 | getParentRoute: () => rootRouteImport, |
| 49 | 44 | } as any) |
| 45 | +const ApiSplatRoute = ApiSplatRouteImport.update({ | |
| 46 | + id: '/api/$', | |
| 47 | + path: '/api/$', | |
| 48 | + getParentRoute: () => rootRouteImport, | |
| 49 | +} as any) | |
| 50 | 50 | const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ |
| 51 | 51 | id: '/sources/add/$pluginKey', |
| 52 | 52 | path: '/sources/add/$pluginKey', |
| @@ -57,18 +57,18 @@ export interface FileRoutesByFullPath { · 4 unmodified lines | ||
| 57 | 57 | '/': typeof IndexRoute |
| 58 | 58 | '/secrets': typeof SecretsRoute |
| 59 | 59 | '/tools': typeof ToolsRoute |
| 60 | + '/api/$': typeof ApiSplatRoute | |
| 60 | 61 | '/mcp/$': typeof McpSplatRoute |
| 61 | 62 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 62 | − '/v1/$': typeof V1SplatRoute | |
| 63 | 63 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 64 | 64 | } |
| 65 | 65 | export interface FileRoutesByTo { |
| 66 | 66 | '/': typeof IndexRoute |
| 67 | 67 | '/secrets': typeof SecretsRoute |
| 68 | 68 | '/tools': typeof ToolsRoute |
| 69 | + '/api/$': typeof ApiSplatRoute | |
| 69 | 70 | '/mcp/$': typeof McpSplatRoute |
| 70 | 71 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 71 | − '/v1/$': typeof V1SplatRoute | |
| 72 | 72 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 73 | 73 | } |
| 74 | 74 | export interface FileRoutesById { |
| @@ -76,9 +76,9 @@ export interface FileRoutesById { · 1 unmodified line | ||
| 76 | 76 | '/': typeof IndexRoute |
| 77 | 77 | '/secrets': typeof SecretsRoute |
| 78 | 78 | '/tools': typeof ToolsRoute |
| 79 | + '/api/$': typeof ApiSplatRoute | |
| 79 | 80 | '/mcp/$': typeof McpSplatRoute |
| 80 | 81 | '/sources/$namespace': typeof SourcesNamespaceRoute |
| 81 | − '/v1/$': typeof V1SplatRoute | |
| 82 | 82 | '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute |
| 83 | 83 | } |
| 84 | 84 | export interface FileRouteTypes { |
| @@ -87,27 +87,27 @@ export interface FileRouteTypes { · 2 unmodified lines | ||
| 87 | 87 | | '/' |
| 88 | 88 | | '/secrets' |
| 89 | 89 | | '/tools' |
| 90 | + | '/api/$' | |
| 90 | 91 | | '/mcp/$' |
| 91 | 92 | | '/sources/$namespace' |
| 92 | − | '/v1/$' | |
| 93 | 93 | | '/sources/add/$pluginKey' |
| 94 | 94 | fileRoutesByTo: FileRoutesByTo |
| 95 | 95 | to: |
| 96 | 96 | | '/' |
| 97 | 97 | | '/secrets' |
| 98 | 98 | | '/tools' |
| 99 | + | '/api/$' | |
| 99 | 100 | | '/mcp/$' |
| 100 | 101 | | '/sources/$namespace' |
| 101 | − | '/v1/$' | |
| 102 | 102 | | '/sources/add/$pluginKey' |
| 103 | 103 | id: |
| 104 | 104 | | '__root__' |
| 105 | 105 | | '/' |
| 106 | 106 | | '/secrets' |
| 107 | 107 | | '/tools' |
| 108 | + | '/api/$' | |
| 108 | 109 | | '/mcp/$' |
| 109 | 110 | | '/sources/$namespace' |
| 110 | − | '/v1/$' | |
| 111 | 111 | | '/sources/add/$pluginKey' |
| 112 | 112 | fileRoutesById: FileRoutesById |
| 113 | 113 | } |
| @@ -115,9 +115,9 @@ export interface RootRouteChildren { · 1 unmodified line | ||
| 115 | 115 | IndexRoute: typeof IndexRoute |
| 116 | 116 | SecretsRoute: typeof SecretsRoute |
| 117 | 117 | ToolsRoute: typeof ToolsRoute |
| 118 | + ApiSplatRoute: typeof ApiSplatRoute | |
| 118 | 119 | McpSplatRoute: typeof McpSplatRoute |
| 119 | 120 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute |
| 120 | − V1SplatRoute: typeof V1SplatRoute | |
| 121 | 121 | SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute |
| 122 | 122 | } |
| 123 | 123 | |
| @@ -144,13 +144,6 @@ declare module '@tanstack/react-router' { · 20 unmodified lines | ||
| 144 | 144 | preLoaderRoute: typeof IndexRouteImport |
| 145 | 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 | 147 | '/sources/$namespace': { |
| 155 | 148 | id: '/sources/$namespace' |
| 156 | 149 | path: '/sources/$namespace' |
| @@ -165,6 +158,13 @@ declare module '@tanstack/react-router' { · 8 unmodified lines | ||
| 165 | 158 | preLoaderRoute: typeof McpSplatRouteImport |
| 166 | 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 | 168 | '/sources/add/$pluginKey': { |
| 169 | 169 | id: '/sources/add/$pluginKey' |
| 170 | 170 | path: '/sources/add/$pluginKey' |
| @@ -179,9 +179,9 @@ const rootRouteChildren: RootRouteChildren = { · 8 unmodified lines | ||
| 179 | 179 | IndexRoute: IndexRoute, |
| 180 | 180 | SecretsRoute: SecretsRoute, |
| 181 | 181 | ToolsRoute: ToolsRoute, |
| 182 | + ApiSplatRoute: ApiSplatRoute, | |
| 182 | 183 | McpSplatRoute: McpSplatRoute, |
| 183 | 184 | SourcesNamespaceRoute: SourcesNamespaceRoute, |
| 184 | − V1SplatRoute: V1SplatRoute, | |
| 185 | 185 | SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, |
| 186 | 186 | } |
| 187 | 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 | 21 | export const handleApiRequest = async (request: Request): Promise<Response> => { |
| 22 | 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 | 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 | 64 | const api = HttpApiBuilder.toWebHandler( |
| 65 | − HttpApiSwagger.layer().pipe( | |
| 65 | + HttpApiSwagger.layer({ path: "/docs" }).pipe( | |
| 66 | 66 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 67 | 67 | Layer.provideMerge(LocalApiBase), |
| 68 | 68 | Layer.provideMerge(pluginExtensions), |
| @@ -61,4 +61,4 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 60 unmodified lines | ||
| 61 | 61 | .addSuccess(ResumeResponse) |
| 62 | 62 | .addError(ExecutionNotFoundError), |
| 63 | 63 | ) |
| 64 | − .prefix("/v1") {} | |
| 64 | + {} | |
| @@ -21,4 +21,4 @@ export class ScopeApi extends HttpApiGroup.make("scope") · 20 unmodified lines | ||
| 21 | 21 | HttpApiEndpoint.get("info")`/scope` |
| 22 | 22 | .addSuccess(ScopeInfoResponse), |
| 23 | 23 | ) |
| 24 | − .prefix("/v1") {} | |
| 24 | + {} | |
| @@ -86,4 +86,4 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 85 unmodified lines | ||
| 86 | 86 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) |
| 87 | 87 | .addError(SecretNotFound), |
| 88 | 88 | ) |
| 89 | − .prefix("/v1") {} | |
| 89 | + {} | |
| @@ -77,4 +77,4 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 76 unmodified lines | ||
| 77 | 77 | .setPayload(DetectRequest) |
| 78 | 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 | 57 | .addSuccess(ToolSchemaResponse) |
| 58 | 58 | .addError(ToolNotFound), |
| 59 | 59 | ) |
| 60 | − .prefix("/v1") {} | |
| 60 | + {} | |
| @@ -21,7 +21,6 @@ import { · 20 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | import { makePgConfig } from "./index"; |
| 23 | 23 | import { makePgKv } from "./pg-kv"; |
| 24 | −import { makeUserStore } from "./user-store"; | |
| 25 | 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 | 29 | export { makePgToolRegistry } from "./tool-registry"; |
| 30 | 30 | export { makePgSecretStore } from "./secret-store"; |
| 31 | 31 | export { makePgPolicyEngine } from "./policy-engine"; |
| 32 | −export { makeUserStore } from "./user-store"; | |
| 33 | −export type { User, Team, TeamMember, Invitation } from "./user-store"; | |
| 34 | 32 | export { encrypt, decrypt } from "./crypto"; |
| 35 | 33 | export type { DrizzleDb } from "./types"; |
| 36 | 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 | 23 | // Domain data — all team-scoped |
| 63 | 24 | // --------------------------------------------------------------------------- |
| @@ -125,4 +125,4 @@ export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") · 124 unmodified lines | ||
| 125 | 125 | .addSuccess(HtmlResponse) |
| 126 | 126 | .addError(ApiError), |
| 127 | 127 | ) |
| 128 | − .prefix("/v1") {} | |
| 128 | + {} | |
| @@ -58,4 +58,4 @@ export class GraphqlGroup extends HttpApiGroup.make("graphql") · 57 unmodified lines | ||
| 58 | 58 | .addError(IntrospectionError) |
| 59 | 59 | .addError(ExtractionError), |
| 60 | 60 | ) |
| 61 | − .prefix("/v1") {} | |
| 61 | + {} | |
| @@ -194,4 +194,4 @@ export class McpGroup extends HttpApiGroup.make("mcp") · 193 unmodified lines | ||
| 194 | 194 | .addSuccess(HtmlResponse) |
| 195 | 195 | .addError(McpApiError), |
| 196 | 196 | ) |
| 197 | − .prefix("/v1") {} | |
| 197 | + {} | |
| @@ -70,4 +70,4 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 69 unmodified lines | ||
| 70 | 70 | .addSuccess(ListVaultsResponse) |
| 71 | 71 | .addError(OpError), |
| 72 | 72 | ) |
| 73 | − .prefix("/v1") {} | |
| 73 | + {} | |
| @@ -70,4 +70,4 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 69 unmodified lines | ||
| 70 | 70 | .addError(ParseError) |
| 71 | 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 | 3 | let baseUrl = |
| 4 | 4 | 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`; | |
| 7 | 7 | |
| 8 | 8 | export const getBaseUrl = (): string => baseUrl; |
| 9 | 9 | |