Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ CREATE TABLE "accounts" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberships" (
"account_id" text NOT NULL,
"organization_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "memberships_account_id_organization_id_pk" PRIMARY KEY("account_id","organization_id")
);
--> statement-breakpoint
CREATE TABLE "organizations" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
Expand Down Expand Up @@ -70,3 +77,6 @@ CREATE TABLE "tools" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tools_id_organization_id_pk" PRIMARY KEY("id","organization_id")
);
--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
69 changes: 68 additions & 1 deletion apps/cloud/drizzle/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"id": "54fdb041-bab4-45f5-bb24-5d1c61489dea",
"id": "1a3473eb-f8df-44f3-aaa4-16b1574e4a65",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
Expand Down Expand Up @@ -30,6 +30,73 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.memberships": {
"name": "memberships",
"schema": "",
"columns": {
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"memberships_account_id_accounts_id_fk": {
"name": "memberships_account_id_accounts_id_fk",
"tableFrom": "memberships",
"tableTo": "accounts",
"columnsFrom": [
"account_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"memberships_organization_id_organizations_id_fk": {
"name": "memberships_organization_id_organizations_id_fk",
"tableFrom": "memberships",
"tableTo": "organizations",
"columnsFrom": [
"organization_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"memberships_account_id_organization_id_pk": {
"name": "memberships_account_id_organization_id_pk",
"columns": [
"account_id",
"organization_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.organizations": {
"name": "organizations",
"schema": "",
Expand Down
4 changes: 2 additions & 2 deletions apps/cloud/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1775718432762,
"tag": "0000_gigantic_terrax",
"when": 1775764846378,
"tag": "0000_redundant_night_nurse",
"breakpoints": true
}
]
Expand Down
2 changes: 1 addition & 1 deletion apps/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@tanstack/react-router": "catalog:",
"@tanstack/react-start": "catalog:",
"@workos-inc/node": "^7.0.0",
"@workos-inc/node": "^8.11.1",
"agents": "^0.10.0",
"drizzle-orm": "catalog:",
"effect": "catalog:",
Expand Down
35 changes: 26 additions & 9 deletions apps/cloud/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,30 @@ const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe(
);

// ---------------------------------------------------------------------------
// Static web handlers — built once at module load
// Public auth web handler
// ---------------------------------------------------------------------------
//
// Build per-request, not once at module load. `toWebHandler` creates a
// single long-lived `Layer.MemoMap` that memoizes `DbService.Live`'s
// `Layer.scoped` acquire — the resulting `sql` connection is created in
// the module scope, not the request scope. Workerd tears down TCP sockets
// at request boundaries, so the second request on a cached handler fails
// with "Cannot perform I/O on behalf of a different request". Building a
// fresh handler per request gives each request its own layer scope and a
// fresh socket. Auth endpoints (login/callback/me/logout) are infrequent
// so the overhead is negligible.
// ---------------------------------------------------------------------------

const RouterConfig = HttpRouter.setRouterConfig({ maxParamLength: 1000 });

const nonProtectedHandler = HttpApiBuilder.toWebHandler(
NonProtectedApiLive.pipe(
Layer.provideMerge(SharedServices),
Layer.provideMerge(RouterConfig),
),
{ middleware: HttpMiddleware.logger },
);
const createNonProtectedHandler = () =>
HttpApiBuilder.toWebHandler(
NonProtectedApiLive.pipe(
Layer.provideMerge(SharedServices),
Layer.provideMerge(RouterConfig),
),
{ middleware: HttpMiddleware.logger },
);

// ---------------------------------------------------------------------------
// Protected handler — must be built per-request because the executor varies
Expand Down Expand Up @@ -178,7 +190,12 @@ export const handleApiRequest = async (request: Request): Promise<Response> => {
const pathname = new URL(request.url).pathname;

if (isAuthPath(pathname)) {
return nonProtectedHandler.handler(request);
const handler = createNonProtectedHandler();
try {
return await handler.handler(request);
} finally {
await handler.dispose();
}
}

// Protected path — build the executor lazily for the request's org.
Expand Down
10 changes: 0 additions & 10 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ const AuthCallbackSearch = Schema.Struct({
code: Schema.String,
});

const CreateOrganizationRequest = Schema.Struct({
name: Schema.String,
});

export const AUTH_PATHS = {
login: "/api/auth/login",
logout: "/api/auth/logout",
Expand Down Expand Up @@ -56,11 +52,5 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
.add(
HttpApiEndpoint.post("logout")`/auth/logout`,
)
.add(
HttpApiEndpoint.post("createOrganization")`/auth/organization`
.setPayload(CreateOrganizationRequest)
.addError(UserStoreError)
.addError(WorkOSError),
)
.middleware(SessionAuth)
{}
13 changes: 11 additions & 2 deletions apps/cloud/src/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,17 @@ const makeService = (store: RawStore) => {
const use = <A>(fn: (s: RawStore) => Promise<A>) =>
Effect.tryPromise({
try: () => fn(store),
catch: (cause) => new UserStoreError({ cause }),
}).pipe(Effect.withSpan("user_store"));
catch: (cause) => cause,
}).pipe(
Effect.tapError((cause) =>
Effect.sync(() => {
// eslint-disable-next-line no-console
console.error("[user_store] query failed:", cause);
}),
),
Effect.mapError(() => new UserStoreError()),
Effect.withSpan("user_store"),
);

return { use };
};
Expand Down
11 changes: 9 additions & 2 deletions apps/cloud/src/auth/errors.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { HttpApiSchema } from "@effect/platform";
import { Schema } from "effect";

// Tagged errors returned to HTTP clients. The response payload must NEVER
// include internal details (SQL, stack traces, WorkOS error bodies) — those
// can leak schema + secrets. Keep the schema empty and log the `cause`
// server-side via Effect's logger when the error is constructed.
export class UserStoreError extends Schema.TaggedError<UserStoreError>()(
"UserStoreError",
{ cause: Schema.Unknown },
{},
HttpApiSchema.annotations({ status: 500 }),
) {}

export class WorkOSError extends Schema.TaggedError<WorkOSError>()(
"WorkOSError",
{ cause: Schema.Unknown },
{},
HttpApiSchema.annotations({ status: 500 }),
) {}
46 changes: 8 additions & 38 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpApi, HttpApiBuilder, HttpServerRequest, HttpServerResponse } from "@effect/platform";
import { HttpApi, HttpApiBuilder, HttpServerResponse } from "@effect/platform";
import { Effect } from "effect";
import { setCookie, deleteCookie, getCookie } from "@tanstack/react-start/server";
import { setCookie, deleteCookie } from "@tanstack/react-start/server";

import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
import { SessionContext } from "./middleware";
Expand All @@ -13,12 +13,12 @@ const COOKIE_OPTIONS = {
httpOnly: true,
sameSite: "lax" as const,
maxAge: 60 * 60 * 24 * 7,
secure: server.NODE_ENV === "production",
secure: true,
};

// ---------------------------------------------------------------------------
// Single non-protected API surface — public (login/callback) + session
// (me/logout/createOrganization). The session group has SessionAuth on it.
// (me/logout). The session group has SessionAuth on it.
// ---------------------------------------------------------------------------

export const NonProtectedApi = HttpApi.make("cloudWeb")
Expand All @@ -37,13 +37,10 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
.handleRaw("login", () =>
Effect.gen(function* () {
const workos = yield* WorkOSAuth;
const req = yield* HttpServerRequest.HttpServerRequest;
// Prefer APP_URL (set explicitly in dev/prod config) since the
// request's Host header is the internal proxy target in dev, not
// the public URL WorkOS needs to redirect back to.
const origin = server.APP_URL
? server.APP_URL
: new URL(req.url, `${req.headers["x-forwarded-proto"] ?? "https"}://${req.headers["host"]}`).origin;
// Use the explicit public site URL — in dev, the request's Host
// header points at the internal proxy target, not the public URL
// WorkOS needs to redirect back to.
const origin = server.VITE_PUBLIC_SITE_URL;
const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`);
return HttpServerResponse.redirect(url, { status: 302 });
}),
Expand Down Expand Up @@ -132,32 +129,5 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
deleteCookie("wos-session", { path: "/" });
return HttpServerResponse.redirect("/", { status: 302 });
}),
)
.handle("createOrganization", ({ payload }) =>
Effect.gen(function* () {
const session = yield* SessionContext;
const workos = yield* WorkOSAuth;
const users = yield* UserStoreService;

// Create the org in WorkOS
const org = yield* workos.createOrganization(payload.name);

// Add the current user as a member
yield* workos.createMembership(org.id, session.accountId);

// Mirror locally
yield* users.use((s) =>
s.upsertOrganization({ id: org.id, name: org.name }),
);

// Refresh the session with the new org context
const currentSession = getCookie("wos-session") ?? null;
if (currentSession) {
const newSession = yield* workos.refreshSession(currentSession, org.id);
if (newSession) {
setCookie("wos-session", newSession, COOKIE_OPTIONS);
}
}
}),
),
);
17 changes: 13 additions & 4 deletions apps/cloud/src/auth/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// ---------------------------------------------------------------------------

import { Context, Effect, Layer } from "effect";
import { WorkOS } from "@workos-inc/node";
import { WorkOS } from "@workos-inc/node/worker";
import { WorkOSError } from "./errors";
import { server } from "../env";

Expand All @@ -23,13 +23,22 @@ const make = Effect.gen(function* () {
return yield* Effect.die(new Error("WORKOS_COOKIE_PASSWORD must be at least 32 characters"));
}

const workos = new WorkOS(apiKey, { clientId });
const workos = new WorkOS({ apiKey, clientId });

const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>
Effect.tryPromise({
try: () => fn(workos),
catch: (cause) => new WorkOSError({ cause }),
}).pipe(Effect.withSpan("workos"));
catch: (cause) => cause,
}).pipe(
Effect.tapError((cause) =>
Effect.sync(() => {
// eslint-disable-next-line no-console
console.error("[workos] call failed:", cause);
}),
),
Effect.mapError(() => new WorkOSError()),
Effect.withSpan("workos"),
);

const authenticateSealedSession = (sessionData: string) =>
Effect.gen(function* () {
Expand Down
4 changes: 2 additions & 2 deletions apps/cloud/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const serverShape = {
WORKOS_API_KEY: Env.string("WORKOS_API_KEY"),
WORKOS_CLIENT_ID: Env.string("WORKOS_CLIENT_ID"),
WORKOS_COOKIE_PASSWORD: Env.string("WORKOS_COOKIE_PASSWORD"),
APP_URL: Env.stringOr("APP_URL", ""),
VITE_PUBLIC_SITE_URL: Env.stringOr("VITE_PUBLIC_SITE_URL", ""),
};

type SharedEnv = Readonly<{
Expand All @@ -32,7 +32,7 @@ type ServerEnv = SharedEnv & Readonly<{
WORKOS_API_KEY: string;
WORKOS_CLIENT_ID: string;
WORKOS_COOKIE_PASSWORD: string;
APP_URL: string;
VITE_PUBLIC_SITE_URL: string;
}>;

type WebEnv = Readonly<Record<string, never>>;
Expand Down
Loading