Skip to content
Draft
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
11 changes: 6 additions & 5 deletions apps/cloud/src/api.request-scope.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,12 @@ describe("makeApiLive (prod handler factory) request scoping", () => {
}).handler;

// Hit a protected route. ExecutionStackMiddleware short-circuits with
// 403 (no session cookie) but not before `requestScopedMiddleware`
// has built the per-request layer. We don't care about the response —
// only that the layer was built once per request.
await handler(new Request("http://test.local/scope"));
await handler(new Request("http://test.local/scope"));
// 401/404 (no session cookie / unknown org) but not before
// `requestScopedMiddleware` has built the per-request layer. We don't
// care about the response — only that the layer was built once per
// request. The protected API mounts under `/api/:org/...`.
await handler(new Request("http://test.local/api/test_org/scope"));
await handler(new Request("http://test.local/api/test_org/scope"));

expect(counts.acquires).toBe(2);
expect(counts.releases).toBe(2);
Expand Down
54 changes: 49 additions & 5 deletions apps/cloud/src/api/protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { WorkOSAuth } from "../auth/workos";
import { AutumnService } from "../services/autumn";
import { DbService } from "../services/db";
import { makeExecutionStack } from "../services/execution-stack";
import { resolveOrgContext } from "../services/url-context";
import { HttpResponseError } from "./error-response";
import { RequestScopedServicesLive } from "./layers";
import {
Expand All @@ -33,6 +34,16 @@ import {
} from "./protected-layers";
import { requestScopedMiddleware } from "./request-scoped";

// Pull the URL `:org` segment from a request path. The protected API mounts
// under `/api/:org/...` — anything else is a programming error and surfaces as
// a typed `no_organization` response so the framework's error pipeline can
// render it.
const orgHandleFromPath = (pathname: string): string | null => {
const parts = pathname.split("/").filter((part) => part.length > 0);
if (parts.length < 2 || parts[0] !== "api") return null;
return parts[1] ?? null;
};

// Pre-compute the per-plugin `Effect.provideService(extensionService,
// executor[id])` chain. The plugin spec carries the Service tag so
// this file doesn't import each plugin's `*/api` directly.
Expand Down Expand Up @@ -79,19 +90,41 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{
const webRequest = yield* HttpServerRequest.toWeb(request);
const workos = yield* WorkOSAuth;
const session = yield* workos.authenticateRequest(webRequest);
if (!session || !session.organizationId) {
if (!session) {
return yield* new HttpResponseError({
status: 403,
status: 401,
code: "unauthorized",
message: "Unauthorized",
});
}
// The URL is the source of truth for active org. Pull the handle
// off the request path, resolve it to an org row, and verify
// membership against WorkOS — independent of `session.organizationId`.
const url = new URL(webRequest.url);
const handle = orgHandleFromPath(url.pathname);
if (!handle) {
return yield* new HttpResponseError({
status: 404,
code: "no_organization",
message: "No organization in session",
message: "Missing organization in URL",
});
}
const org = yield* authorizeOrganization(session.userId, session.organizationId);
const resolved = yield* resolveOrgContext(handle).pipe(
Effect.catchTag("OrganizationHandleNotFound", () => Effect.succeed(null)),
);
if (!resolved) {
return yield* new HttpResponseError({
status: 404,
code: "no_organization",
message: `Organization "${handle}" not found`,
});
}
const org = yield* authorizeOrganization(session.userId, resolved.organization.id);
if (!org) {
return yield* new HttpResponseError({
status: 403,
code: "no_organization",
message: "No organization in session",
message: "Not a member of this organization",
});
}
const auth = AuthContext.of({
Expand All @@ -116,6 +149,16 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{
}),
);

// Layer that swaps the boot router with a `:org`-prefixed view, so every
// route registered by `ProtectedCloudApiLive` mounts under `/api/:org/*`.
// `HttpRouter.prefixed` returns a wrapper that delegates to the underlying
// router state — the outer router-config layer still owns the actual
// FindMyWay instance, so non-protected routes (auth, autumn, swagger) keep
// their unprefixed paths.
const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)(
Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org")),
);

// `rsLive` is the per-request DB layer. Combining it into the auth
// middleware collapses `requires: DbService | UserStoreService` to
// never (so `.layer` is a real Layer instead of the "Need to combine"
Expand All @@ -131,6 +174,7 @@ export const makeProtectedApiLive = (
).layer;
return ProtectedCloudApiLive.pipe(
Layer.provide(protectedMiddleware),
Layer.provide(PrefixedRouterLayer),
Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
Layer.provideMerge(RouterConfig),
);
Expand Down
24 changes: 5 additions & 19 deletions apps/cloud/src/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,18 @@ const AuthUser = Schema.Struct({

const AuthOrganization = Schema.Struct({
id: Schema.String,
handle: Schema.String,
name: Schema.String,
});

const AuthMeResponse = Schema.Struct({
user: AuthUser,
organization: Schema.NullOr(AuthOrganization),
});

const AuthOrganizationSummary = Schema.Struct({
id: Schema.String,
name: Schema.String,
/** Memberships, with the org `handle` URL routes use. Sorted alphabetically. */
organizations: Schema.Array(AuthOrganization),
});

const AuthOrganizationsResponse = Schema.Struct({
organizations: Schema.Array(AuthOrganizationSummary),
activeOrganizationId: Schema.NullOr(Schema.String),
});

const SwitchOrganizationBody = Schema.Struct({
organizationId: Schema.String,
organizations: Schema.Array(AuthOrganization),
});

const CreateOrganizationBody = Schema.Struct({
Expand All @@ -40,6 +32,7 @@ const CreateOrganizationBody = Schema.Struct({

const CreateOrganizationResponse = Schema.Struct({
id: Schema.String,
handle: Schema.String,
name: Schema.String,
});

Expand All @@ -52,7 +45,6 @@ export const AUTH_PATHS = {
login: "/api/auth/login",
logout: "/api/auth/logout",
callback: "/api/auth/callback",
switchOrganization: "/api/auth/switch-organization",
} as const;

const AuthErrors = [UserStoreError, WorkOSError] as const;
Expand Down Expand Up @@ -82,12 +74,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
error: WorkOSError,
}),
)
.add(
HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", {
payload: SwitchOrganizationBody,
error: WorkOSError,
}),
)
.add(
HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
payload: CreateOrganizationBody,
Expand Down
119 changes: 63 additions & 56 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
import { HttpServerResponse } from "effect/unstable/http";
import { Duration, Effect } from "effect";
import { setCookie, deleteCookie } from "@tanstack/react-start/server";
import { deleteCookie } from "@tanstack/react-start/server";

import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
import { SessionContext } from "./middleware";
import { UserStoreService } from "./context";
import { authorizeOrganization } from "./authorize-organization";
import { env } from "cloudflare:workers";
import { WorkOSError } from "./errors";
import { WorkOSAuth } from "./workos";

const COOKIE_OPTIONS = {
Expand Down Expand Up @@ -172,9 +170,39 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
.handle("me", () =>
Effect.gen(function* () {
const session = yield* SessionContext;
const org = session.organizationId
? yield* authorizeOrganization(session.accountId, session.organizationId)
: null;
const users = yield* UserStoreService;
const workos = yield* WorkOSAuth;

const memberships = yield* workos.listUserMemberships(session.accountId);
// Mirror each org locally so the local handle exists; ignore mirror
// failures (the directory layer already has the canonical name +
// membership — a transient db error here shouldn't blank the user's
// org list).
const orgs = yield* Effect.all(
memberships.data.map((m) =>
workos.getOrganization(m.organizationId).pipe(
Effect.flatMap((org) =>
users
.use((s) =>
s.upsertOrganization({ id: org.id, name: org.name }),
)
.pipe(
Effect.map((mirror) => ({
id: mirror.id,
handle: mirror.handle,
name: org.name,
})),
),
),
Effect.orElseSucceed(() => null),
),
),
{ concurrency: "unbounded" },
);

const organizations = orgs
.filter(<T,>(v: T | null): v is T => v !== null)
.sort((a, b) => a.name.localeCompare(b.name));

return {
user: {
Expand All @@ -183,7 +211,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
name: session.name,
avatarUrl: session.avatarUrl,
},
organization: org ? { id: org.id, name: org.name } : null,
organizations,
};
}),
)
Expand All @@ -194,37 +222,37 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
.handle("organizations", () =>
Effect.gen(function* () {
const workos = yield* WorkOSAuth;
const users = yield* UserStoreService;
const session = yield* SessionContext;

const memberships = yield* workos.listUserMemberships(session.accountId);
const organizations = yield* Effect.all(
const orgs = yield* Effect.all(
memberships.data.map((m) =>
workos.getOrganization(m.organizationId).pipe(
Effect.map((org) => ({ id: org.id, name: org.name })),
Effect.flatMap((org) =>
users
.use((s) =>
s.upsertOrganization({ id: org.id, name: org.name }),
)
.pipe(
Effect.map((mirror) => ({
id: mirror.id,
handle: mirror.handle,
name: org.name,
})),
),
),
Effect.orElseSucceed(() => null),
),
),
{ concurrency: "unbounded" },
);

return {
organizations: organizations.filter((org): org is NonNullable<typeof org> => org !== null),
activeOrganizationId: session.organizationId,
};
}),
)
.handle("switchOrganization", ({ payload }) =>
Effect.gen(function* () {
const workos = yield* WorkOSAuth;
const session = yield* SessionContext;
const organizations = orgs
.filter(<T,>(v: T | null): v is T => v !== null)
.sort((a, b) => a.name.localeCompare(b.name));

const refreshed = yield* workos.refreshSession(
session.sealedSession,
payload.organizationId,
);
if (refreshed) {
setCookie("wos-session", refreshed, COOKIE_OPTIONS);
}
return { organizations };
}),
)
.handle("createOrganization", ({ payload }) =>
Expand All @@ -236,36 +264,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
const name = payload.name.trim();
const org = yield* workos.createOrganization(name);
yield* workos.createMembership(org.id, session.accountId, "admin");
yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));

// Try to attach the new org to the current session. This can fail
// (or silently return a session still scoped to the old org) when
// the caller's current session is stale — most commonly after the
// user was removed from the org their cookie is pinned to. In that
// case we can't repair the session in-place, so we clear the
// cookie and fail loudly; the frontend will bounce to login and
// the callback's rehydrate path will pick up the new membership.
const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
const verified = refreshed
? yield* workos.authenticateSealedSession(refreshed)
: null;

if (!refreshed || !verified || verified.organizationId !== org.id) {
yield* Effect.logWarning(
"createOrganization: unable to attach new org to current session",
{
userId: session.accountId,
newOrgId: org.id,
refreshReturnedSession: refreshed != null,
verifiedOrgId: verified?.organizationId ?? null,
},
);
deleteCookie("wos-session", { path: "/" });
return yield* Effect.fail(new WorkOSError());
}

setCookie("wos-session", refreshed, COOKIE_OPTIONS);
return { id: org.id, name: org.name };
const mirrored = yield* users.use((s) =>
s.upsertOrganization({ id: org.id, name: org.name }),
);
// No session refresh — the URL is the source of truth for active
// org now, so the client just navigates to /:handle/... after
// create. The WorkOS session's `organizationId` only proves login
// identity; it doesn't gate per-org access (that goes through the
// membership check on the URL-resolved org).
return { id: org.id, handle: mirrored.handle, name: org.name };
}),
),
);
Loading
Loading