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:
Source Open
2026-04-20 11:46:00 -07:00
committed by GitHub
parent f0e4f6db18
commit 8c3f32e3b2
8 changed files with 214 additions and 5 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
})
}

View File

@@ -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>) {

View 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)
})

View File

@@ -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

View File

@@ -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

View File

@@ -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