diff --git a/ee/apps/den-api/src/middleware/organization-context.ts b/ee/apps/den-api/src/middleware/organization-context.ts index 4a177ef3..3ece7243 100644 --- a/ee/apps/den-api/src/middleware/organization-context.ts +++ b/ee/apps/den-api/src/middleware/organization-context.ts @@ -3,7 +3,7 @@ import type { MiddlewareHandler } from "hono" import { getApiKeyScopedOrganizationId, isScopedApiKeyForOrganization } from "../api-keys.js" import { getOrganizationContextForUser, resolveUserOrganizations, type OrganizationContext } from "../orgs.js" import type { AuthContextVariables } from "../session.js" -import type { UserOrganizationsContext } from "./user-organizations.js" +import { getLegacyProxyOrganizationId, hydrateSessionActiveOrganization, shouldHydrateSessionActiveOrganization, type UserOrganizationsContext } from "./user-organizations.js" export type OrganizationContextVariables = { organizationContext: OrganizationContext @@ -18,14 +18,15 @@ export const resolveOrganizationContextMiddleware: MiddlewareHandler<{ } const apiKey = c.get("apiKey") - const scopedOrganizationId = getApiKeyScopedOrganizationId(apiKey) + const scopedOrganizationId = getApiKeyScopedOrganizationId(apiKey) ?? getLegacyProxyOrganizationId(c.req.raw.headers) let organizationId = c.get("activeOrganizationId") ?? null let organizationSlug = c.get("activeOrganizationSlug") ?? null if (!organizationId) { + const session = c.get("session") const resolved = await resolveUserOrganizations({ - activeOrganizationId: scopedOrganizationId ?? c.get("session")?.activeOrganizationId ?? null, + activeOrganizationId: scopedOrganizationId ?? session?.activeOrganizationId ?? null, userId: normalizeDenTypeId("user", user.id), }) @@ -36,6 +37,17 @@ export const resolveOrganizationContextMiddleware: MiddlewareHandler<{ organizationId = scopedOrganizationId ? scopedOrgs[0]?.id ?? null : resolved.activeOrgId organizationSlug = scopedOrganizationId ? scopedOrgs[0]?.slug ?? null : resolved.activeOrgSlug + if (shouldHydrateSessionActiveOrganization({ + scopedOrganizationId, + sessionActiveOrganizationId: session?.activeOrganizationId, + resolvedActiveOrganizationId: organizationId, + })) { + await hydrateSessionActiveOrganization(session, organizationId) + if (session) { + c.set("session", { ...session, activeOrganizationId: organizationId }) + } + } + c.set("userOrganizations", scopedOrgs) c.set("activeOrganizationId", organizationId) c.set("activeOrganizationSlug", organizationSlug) diff --git a/ee/apps/den-api/src/middleware/user-organizations.ts b/ee/apps/den-api/src/middleware/user-organizations.ts index 22b085d8..94d4d948 100644 --- a/ee/apps/den-api/src/middleware/user-organizations.ts +++ b/ee/apps/den-api/src/middleware/user-organizations.ts @@ -1,15 +1,54 @@ import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" import type { MiddlewareHandler } from "hono" import { getApiKeyScopedOrganizationId } from "../api-keys.js" -import { resolveUserOrganizations, type UserOrgSummary } from "../orgs.js" +import { resolveUserOrganizations, setSessionActiveOrganization, type UserOrgSummary } from "../orgs.js" import type { AuthContextVariables } from "../session.js" +export const LEGACY_ORG_PROXY_HEADER = "x-openwork-legacy-org-id" + export type UserOrganizationsContext = { userOrganizations: UserOrgSummary[] activeOrganizationId: string | null activeOrganizationSlug: string | null } +type SessionLike = AuthContextVariables["session"] + +export function getLegacyProxyOrganizationId(headers: Headers) { + const rawOrganizationId = headers.get(LEGACY_ORG_PROXY_HEADER)?.trim() + if (!rawOrganizationId) { + return null + } + + try { + return normalizeDenTypeId("organization", rawOrganizationId) + } catch { + return null + } +} + +export function shouldHydrateSessionActiveOrganization(input: { + resolvedActiveOrganizationId: string | null + scopedOrganizationId: string | null + sessionActiveOrganizationId?: string | null +}) { + return !input.scopedOrganizationId && !input.sessionActiveOrganizationId && !!input.resolvedActiveOrganizationId +} + +export async function hydrateSessionActiveOrganization(session: SessionLike, organizationId: string | null) { + if (!session?.id || !organizationId || session.activeOrganizationId === organizationId) { + return + } + + try { + const sessionId = normalizeDenTypeId("session", session.id) + const normalizedOrganizationId = normalizeDenTypeId("organization", organizationId) + await setSessionActiveOrganization(sessionId, normalizedOrganizationId) + } catch { + return + } +} + export const resolveUserOrganizationsMiddleware: MiddlewareHandler<{ Variables: AuthContextVariables & Partial }> = async (c, next) => { @@ -20,7 +59,7 @@ export const resolveUserOrganizationsMiddleware: MiddlewareHandler<{ const session = c.get("session") const apiKey = c.get("apiKey") - const scopedOrganizationId = getApiKeyScopedOrganizationId(apiKey) + const scopedOrganizationId = getApiKeyScopedOrganizationId(apiKey) ?? getLegacyProxyOrganizationId(c.req.raw.headers) const resolved = await resolveUserOrganizations({ activeOrganizationId: scopedOrganizationId ?? session?.activeOrganizationId ?? null, userId: normalizeDenTypeId("user", user.id), @@ -35,6 +74,17 @@ export const resolveUserOrganizationsMiddleware: MiddlewareHandler<{ ? scopedOrgs[0]?.slug ?? null : resolved.activeOrgSlug + if (shouldHydrateSessionActiveOrganization({ + scopedOrganizationId, + sessionActiveOrganizationId: session?.activeOrganizationId, + resolvedActiveOrganizationId: activeOrganizationId, + })) { + await hydrateSessionActiveOrganization(session, activeOrganizationId) + if (session) { + c.set("session", { ...session, activeOrganizationId }) + } + } + c.set("userOrganizations", scopedOrgs) c.set("activeOrganizationId", activeOrganizationId) c.set("activeOrganizationSlug", activeOrganizationSlug) diff --git a/ee/apps/den-api/src/routes/org/index.ts b/ee/apps/den-api/src/routes/org/index.ts index 5f35cbd8..8958483b 100644 --- a/ee/apps/den-api/src/routes/org/index.ts +++ b/ee/apps/den-api/src/routes/org/index.ts @@ -1,5 +1,6 @@ import type { Hono } from "hono" import { registerOrgApiKeyRoutes } from "./api-keys.js" +import { LEGACY_ORG_PROXY_HEADER } from "../../middleware/user-organizations.js" import type { OrgRouteVariables } from "./shared.js" import { registerOrgCoreRoutes } from "./core.js" import { registerOrgInvitationRoutes } from "./invitations.js" @@ -11,6 +12,32 @@ import { registerOrgSkillRoutes } from "./skills.js" import { registerOrgTeamRoutes } from "./teams.js" import { registerOrgTemplateRoutes } from "./templates.js" +const LEGACY_ORG_PATH_PREFIX = "/v1/orgs/" + +function extractLegacyOrgProxyTarget(pathname: string) { + if (!pathname.startsWith(LEGACY_ORG_PATH_PREFIX)) { + return null + } + + const remainder = pathname.slice(LEGACY_ORG_PATH_PREFIX.length) + const slashIndex = remainder.indexOf("/") + if (slashIndex <= 0) { + return null + } + + const organizationId = remainder.slice(0, slashIndex) + if (!organizationId.startsWith("org_")) { + return null + } + + const targetPath = `/v1${remainder.slice(slashIndex)}` + if (targetPath === pathname) { + return null + } + + return { organizationId, targetPath } +} + export function registerOrgRoutes(app: Hono) { registerOrgCoreRoutes(app) registerOrgApiKeyRoutes(app) @@ -22,4 +49,22 @@ export function registerOrgRoutes(ap registerOrgSkillRoutes(app) registerOrgTeamRoutes(app) registerOrgTemplateRoutes(app) + + app.all("/v1/orgs/:orgId/*", async (c) => { + const url = new URL(c.req.raw.url) + const target = extractLegacyOrgProxyTarget(url.pathname) + if (!target) { + return c.json({ error: "not_found" }, 404) + } + + const proxiedUrl = new URL(url) + proxiedUrl.pathname = target.targetPath + + const headers = new Headers(c.req.raw.headers) + headers.set(LEGACY_ORG_PROXY_HEADER, target.organizationId) + + const proxiedRequest = new Request(new Request(proxiedUrl, c.req.raw), { headers }) + + return app.fetch(proxiedRequest, c.env) + }) } diff --git a/ee/apps/den-api/src/routes/org/invitations.ts b/ee/apps/den-api/src/routes/org/invitations.ts index 2341d85e..ae20fa06 100644 --- a/ee/apps/den-api/src/routes/org/invitations.ts +++ b/ee/apps/den-api/src/routes/org/invitations.ts @@ -40,6 +40,7 @@ const inviteEmailDomainNotAllowedSchema = z.object({ }).meta({ ref: "InviteEmailDomainNotAllowedError" }) type InvitationId = typeof InvitationTable.$inferSelect.id + const orgInvitationParamsSchema = idParamSchema("invitationId", "invitation") export function registerOrgInvitationRoutes(app: Hono) { diff --git a/ee/apps/den-api/test/org-invitations.test.ts b/ee/apps/den-api/test/org-invitations.test.ts new file mode 100644 index 00000000..29b4096f --- /dev/null +++ b/ee/apps/den-api/test/org-invitations.test.ts @@ -0,0 +1,95 @@ +import { beforeAll, expect, test } from "bun:test" +import { Hono } from "hono" + +function seedRequiredEnv() { + process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_test" + process.env.DEN_DB_ENCRYPTION_KEY = process.env.DEN_DB_ENCRYPTION_KEY ?? "x".repeat(32) + process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "y".repeat(32) + process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:8790" + process.env.CORS_ORIGINS = process.env.CORS_ORIGINS ?? "http://127.0.0.1:8790" +} + +let invitationModule: typeof import("../src/routes/org/invitations.js") +let orgRoutesModule: typeof import("../src/routes/org/index.js") +let userOrganizationsModule: typeof import("../src/middleware/user-organizations.js") + +beforeAll(async () => { + seedRequiredEnv() + invitationModule = await import("../src/routes/org/invitations.js") + orgRoutesModule = await import("../src/routes/org/index.js") + userOrganizationsModule = await import("../src/middleware/user-organizations.js") +}) + +function createOrgApp() { + const app = new Hono() + orgRoutesModule.registerOrgRoutes(app) + return app +} + +test("legacy org-scoped paths proxy into the unscoped handlers", async () => { + const app = createOrgApp() + const response = await app.request("http://den.local/v1/orgs/org_123/invitations", { + body: JSON.stringify({ email: "teammate@example.com", role: "admin" }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("legacy org-scoped proxy also reaches non-invitation org resources", async () => { + const app = createOrgApp() + const response = await app.request("http://den.local/v1/orgs/org_123/teams", { + body: JSON.stringify({ memberIds: [], name: "Legacy Team" }), + headers: { + "content-type": "application/json", + }, + method: "POST", + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("current org endpoints are not swallowed by the legacy proxy", async () => { + const app = createOrgApp() + const response = await app.request("http://den.local/v1/orgs/invitations/preview?id=bad", { + method: "GET", + }) + + expect(response.status).toBe(400) +}) + +test("invitation cancel still validates against the unscoped handler", async () => { + const app = new Hono() + invitationModule.registerOrgInvitationRoutes(app) + const response = await app.request("http://den.local/v1/invitations/invitation_123/cancel", { + method: "POST", + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("session hydration only runs when a user session is missing an active organization", () => { + expect(userOrganizationsModule.shouldHydrateSessionActiveOrganization({ + scopedOrganizationId: null, + sessionActiveOrganizationId: null, + resolvedActiveOrganizationId: "organization_first", + })).toBe(true) + + expect(userOrganizationsModule.shouldHydrateSessionActiveOrganization({ + scopedOrganizationId: null, + sessionActiveOrganizationId: "organization_existing", + resolvedActiveOrganizationId: "organization_existing", + })).toBe(false) + + expect(userOrganizationsModule.shouldHydrateSessionActiveOrganization({ + scopedOrganizationId: "organization_scoped", + sessionActiveOrganizationId: null, + resolvedActiveOrganizationId: "organization_scoped", + })).toBe(false) +}) diff --git a/packaging/docker/Dockerfile.den b/packaging/docker/Dockerfile.den index 79122758..733e0d54 100644 --- a/packaging/docker/Dockerfile.den +++ b/packaging/docker/Dockerfile.den @@ -7,12 +7,14 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY .npmrc /app/.npmrc COPY patches /app/patches +COPY packages/types/package.json /app/packages/types/package.json COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json COPY ee/apps/den-api/package.json /app/ee/apps/den-api/package.json RUN pnpm install --frozen-lockfile +COPY packages/types /app/packages/types COPY ee/packages/utils /app/ee/packages/utils COPY ee/packages/den-db /app/ee/packages/den-db COPY ee/apps/den-api /app/ee/apps/den-api diff --git a/packaging/docker/Dockerfile.den-web b/packaging/docker/Dockerfile.den-web index 88c05d4b..c9ff26e3 100644 --- a/packaging/docker/Dockerfile.den-web +++ b/packaging/docker/Dockerfile.den-web @@ -7,12 +7,14 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY .npmrc /app/.npmrc COPY patches /app/patches +COPY packages/types/package.json /app/packages/types/package.json COPY packages/ui/package.json /app/packages/ui/package.json COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json COPY ee/apps/den-web/package.json /app/ee/apps/den-web/package.json RUN pnpm install --frozen-lockfile --filter @openwork-ee/den-web... +COPY packages/types /app/packages/types COPY packages/ui /app/packages/ui COPY ee/packages/utils /app/ee/packages/utils COPY ee/apps/den-web /app/ee/apps/den-web diff --git a/packaging/docker/Dockerfile.den-worker-proxy b/packaging/docker/Dockerfile.den-worker-proxy index 8400453e..671312e7 100644 --- a/packaging/docker/Dockerfile.den-worker-proxy +++ b/packaging/docker/Dockerfile.den-worker-proxy @@ -7,12 +7,14 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY .npmrc /app/.npmrc COPY patches /app/patches +COPY packages/types/package.json /app/packages/types/package.json COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json COPY ee/apps/den-worker-proxy/package.json /app/ee/apps/den-worker-proxy/package.json RUN pnpm install --frozen-lockfile +COPY packages/types /app/packages/types COPY ee/packages/utils /app/ee/packages/utils COPY ee/packages/den-db /app/ee/packages/den-db COPY ee/apps/den-worker-proxy /app/ee/apps/den-worker-proxy