mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(den): proxy legacy org-scoped routes (#1502)
* fix(den): support legacy org invitation routes Restore the old org-scoped invitation paths and rehydrate sessions to a user's first organization when activeOrg is missing so older clients keep working. Include the shared types workspace in Den Docker images so the local verification stack can still build. * fix(den): proxy legacy org-scoped routes Route legacy '/v1/orgs/:orgId/*' requests into the new unscoped '/v1/*' handlers while preserving the current '/v1/orgs/...' endpoints. Pass the org id through middleware so old clients keep the intended workspace context and add coverage for the generic proxy behavior. * refactor(den): trim invitation route churn Drop the leftover invitation-specific handler extraction now that legacy org-scoped forwarding is handled generically in the org router. Keep the proxy behavior intact while returning the invitation routes closer to their original shape. --------- Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<UserOrganizationsContext>
|
||||
}> = 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)
|
||||
|
||||
@@ -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<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
registerOrgCoreRoutes(app)
|
||||
registerOrgApiKeyRoutes(app)
|
||||
@@ -22,4 +49,22 @@ export function registerOrgRoutes<T extends { Variables: OrgRouteVariables }>(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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
|
||||
95
ee/apps/den-api/test/org-invitations.test.ts
Normal file
95
ee/apps/den-api/test/org-invitations.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user