diff --git a/apps/cloud/src/auth/v2-moved.test.ts b/apps/cloud/src/auth/v2-moved.test.ts new file mode 100644 index 0000000000..10f2674cbc --- /dev/null +++ b/apps/cloud/src/auth/v2-moved.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { v2ImportAnswer, type V2ImportSource } from "./v2-import"; +import { + liveV2MovedSource, + parseV2Moved, + v2Moved, + V2_MOVED_AT_KEY, + V2_MOVED_SLUG_KEY, + type V2MovedSource, +} from "./v2-moved"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; +import { WorkOSError } from "./errors"; + +const USER = "user_signed_in"; +const ORG = "org_example"; +const V2_ORIGIN = new URL("https://app.example.test"); +const MOVED_AT = "2026-10-09T17:30:00.000Z"; + +const movedMetadata = (overrides: Record = {}) => ({ + [V2_MOVED_SLUG_KEY]: "example-team", + [V2_MOVED_AT_KEY]: MOVED_AT, + ...overrides, +}); + +describe("v2 moved metadata", () => { + it("reads the v2 slug and move time into the org's v2 page", () => { + expect(parseV2Moved(movedMetadata(), V2_ORIGIN)).toEqual({ + v2Url: "https://app.example.test/org/example-team", + v2Slug: "example-team", + movedAt: MOVED_AT, + }); + }); + + it("ignores unrelated metadata keys", () => { + expect(parseV2Moved(movedMetadata({ plan_note: "anything" }), V2_ORIGIN)?.v2Slug).toBe( + "example-team", + ); + }); + + it("normalizes a timestamp with an offset to UTC", () => { + expect( + parseV2Moved(movedMetadata({ [V2_MOVED_AT_KEY]: "2026-10-09T10:30:00-07:00" }), V2_ORIGIN) + ?.movedAt, + ).toBe(MOVED_AT); + }); + + it("is not moved without both keys", () => { + expect(parseV2Moved({}, V2_ORIGIN)).toBeNull(); + expect(parseV2Moved({ [V2_MOVED_SLUG_KEY]: "example-team" }, V2_ORIGIN)).toBeNull(); + expect(parseV2Moved({ [V2_MOVED_AT_KEY]: MOVED_AT }, V2_ORIGIN)).toBeNull(); + }); + + it("is not moved when the slug is not a v2 slug", () => { + for (const slug of [ + "", + "Example-Team", + "example_team", + "-example", + "example-", + "example--team", + "example/team", + "../admin", + "example team", + "a".repeat(46), + 42, + null, + ]) { + expect( + parseV2Moved(movedMetadata({ [V2_MOVED_SLUG_KEY]: slug }), V2_ORIGIN), + String(slug), + ).toBeNull(); + } + expect( + parseV2Moved(movedMetadata({ [V2_MOVED_SLUG_KEY]: "a".repeat(45) }), V2_ORIGIN), + "45 characters is v2's longest slug", + ).not.toBeNull(); + }); + + it("is not moved when the move time is not an ISO timestamp", () => { + for (const movedAt of [ + "", + "2026-10-09", + "2026-10-09T17:30:00", + "October 9, 2026", + "2026-13-40T99:99:00Z", + "1791000000000", + 1791000000000, + null, + ]) { + expect( + parseV2Moved(movedMetadata({ [V2_MOVED_AT_KEY]: movedAt }), V2_ORIGIN), + String(movedAt), + ).toBeNull(); + } + }); +}); + +/** A metadata read answering `metadata`, or failing; records each read. */ +const movedSourceOf = (metadata: Record | "unavailable") => { + const reads: string[] = []; + const source: V2MovedSource = { + organizationMetadata: (organizationId) => + Effect.suspend(() => { + reads.push(organizationId); + return metadata === "unavailable" ? Effect.fail("unavailable") : Effect.succeed(metadata); + }), + }; + return { source, reads }; +}; + +/** An import source that admits the user, recording whether it was asked. */ +const importSourceOf = (eligible: boolean) => { + const reads: string[] = []; + const source: V2ImportSource = { + emailVerified: () => Effect.sync(() => (reads.push("user"), true)), + activeMemberIds: () => Effect.sync(() => (reads.push("members"), eligible ? [USER] : [])), + subscriptions: () => Effect.sync(() => (reads.push("billing"), [])), + }; + return { source, reads }; +}; + +describe("v2 moved read", () => { + it.effect("reads the selected org's metadata", () => + Effect.gen(function* () { + const { source, reads } = movedSourceOf(movedMetadata()); + expect((yield* v2Moved(source, ORG, V2_ORIGIN))?.v2Slug).toBe("example-team"); + expect(reads).toEqual([ORG]); + }), + ); + + it.effect("is not moved when the read fails", () => + Effect.gen(function* () { + const { source } = movedSourceOf("unavailable"); + expect(yield* v2Moved(source, ORG, V2_ORIGIN)).toBeNull(); + }), + ); +}); + +describe("v2 moved live read", () => { + /** WorkOS answering `getOrganization` with `answer`; every other call dies. */ + const workosAnswering = (answer: (organizationId: string) => Effect.Effect) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => + prop === "getOrganization" + ? answer + : () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), + ); + + const organization = (metadata: Record) => ({ + object: "organization", + id: ORG, + name: "Example Org", + allowProfilesOutsideOrganization: false, + domains: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + externalId: null, + metadata, + }); + + const movedLive = (layer: Layer.Layer) => + Effect.gen(function* () { + return yield* v2Moved(yield* liveV2MovedSource, ORG, V2_ORIGIN); + }).pipe(Effect.provide(layer)); + + it.effect("reads the move from the WorkOS organization's metadata", () => + Effect.gen(function* () { + const asked: string[] = []; + const moved = yield* movedLive( + workosAnswering((organizationId) => + Effect.sync(() => (asked.push(organizationId), organization(movedMetadata()))), + ), + ); + expect(moved?.v2Url).toBe("https://app.example.test/org/example-team"); + expect(asked).toEqual([ORG]); + }), + ); + + it.effect("is not moved for an organization without the keys", () => + Effect.gen(function* () { + expect(yield* movedLive(workosAnswering(() => Effect.succeed(organization({}))))).toBeNull(); + }), + ); + + it.effect("is not moved when WorkOS fails", () => + Effect.gen(function* () { + const failure = new WorkOSError({ status: 503 }); + expect(yield* movedLive(workosAnswering(() => Effect.fail(failure)))).toBeNull(); + }), + ); +}); + +describe("v2 import answer", () => { + const configured = { + V2: { fetch: () => Promise.resolve(new Response(null)) }, + V2_SIGN_UP_URL: "https://app.example.test/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }; + + const answer = ( + env: typeof configured | Record, + moved: ReturnType, + importing: ReturnType, + ) => + v2ImportAnswer({ + env, + importSource: Effect.succeed(importing.source), + movedSource: Effect.succeed(moved.source), + userId: USER, + organizationId: ORG, + }); + + it.effect("answers where a moved org lives, without offering the import again", () => + Effect.gen(function* () { + const moved = movedSourceOf(movedMetadata()); + const importing = importSourceOf(true); + expect(yield* answer(configured, moved, importing)).toEqual({ + url: null, + moved: { + v2Url: "https://app.example.test/org/example-team", + v2Slug: "example-team", + movedAt: MOVED_AT, + }, + }); + expect(importing.reads, "a moved org's eligibility is never read").toEqual([]); + }), + ); + + it.effect("offers the import to an eligible org that has not moved", () => + Effect.gen(function* () { + expect(yield* answer(configured, movedSourceOf({}), importSourceOf(true))).toEqual({ + url: "https://app.example.test/v1-import", + moved: null, + }); + expect(yield* answer(configured, movedSourceOf({}), importSourceOf(false))).toEqual({ + url: null, + moved: null, + }); + }), + ); + + it.effect("treats malformed or unreadable metadata as not moved", () => + Effect.gen(function* () { + for (const moved of [ + movedSourceOf(movedMetadata({ [V2_MOVED_SLUG_KEY]: "Not A Slug" })), + movedSourceOf(movedMetadata({ [V2_MOVED_AT_KEY]: "yesterday" })), + movedSourceOf("unavailable"), + ]) { + expect(yield* answer(configured, moved, importSourceOf(true))).toEqual({ + url: "https://app.example.test/v1-import", + moved: null, + }); + } + }), + ); + + it.effect("answers nothing without a v2 host, without reading anything", () => + Effect.gen(function* () { + const moved = movedSourceOf(movedMetadata()); + const importing = importSourceOf(true); + expect(yield* answer({}, moved, importing)).toEqual({ url: null, moved: null }); + expect(moved.reads).toEqual([]); + expect(importing.reads).toEqual([]); + }), + ); +}); diff --git a/apps/cloud/src/web/v2-moved-snooze.test.ts b/apps/cloud/src/web/v2-moved-snooze.test.ts new file mode 100644 index 0000000000..5ef6d1c031 --- /dev/null +++ b/apps/cloud/src/web/v2-moved-snooze.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + isV2MovedSnoozed, + snoozeV2Moved, + V2_MOVED_DISMISS_SNOOZE_MS, + V2_MOVED_FEEDBACK_SNOOZE_MS, + v2MovedSnoozeKey, + type SnoozeStorage, +} from "./v2-moved-snooze"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = Date.parse("2026-10-09T12:00:00.000Z"); +const ORG = "org_example"; +const OTHER_ORG = "org_other"; + +const memoryStorage = (): SnoozeStorage & { readonly items: Map } => { + const items = new Map(); + return { + items, + getItem: (key) => items.get(key) ?? null, + setItem: (key, value) => void items.set(key, value), + }; +}; + +/** Storage that throws on every access, like a disabled localStorage. */ +const throwingStorage: SnoozeStorage = { + getItem: () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- fixture: disabled localStorage throws a DOMException + throw new Error("SecurityError"); + }, + setItem: () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- fixture: disabled localStorage throws a DOMException + throw new Error("SecurityError"); + }, +}; + +describe("v2 moved notice snooze", () => { + it("snoozes for 7 days on dismissal and 30 days after feedback", () => { + expect(V2_MOVED_DISMISS_SNOOZE_MS).toBe(7 * DAY_MS); + expect(V2_MOVED_FEEDBACK_SNOOZE_MS).toBe(30 * DAY_MS); + }); + + it("shows the notice until it is snoozed, and again once the snooze ends", () => { + const storage = memoryStorage(); + expect(isV2MovedSnoozed(storage, ORG, NOW)).toBe(false); + + snoozeV2Moved(storage, ORG, NOW, V2_MOVED_DISMISS_SNOOZE_MS); + expect(isV2MovedSnoozed(storage, ORG, NOW)).toBe(true); + expect(isV2MovedSnoozed(storage, ORG, NOW + 7 * DAY_MS - 1)).toBe(true); + expect(isV2MovedSnoozed(storage, ORG, NOW + 7 * DAY_MS)).toBe(false); + }); + + it("snoozes one organization at a time", () => { + const storage = memoryStorage(); + snoozeV2Moved(storage, ORG, NOW, V2_MOVED_FEEDBACK_SNOOZE_MS); + expect(isV2MovedSnoozed(storage, ORG, NOW + 29 * DAY_MS)).toBe(true); + expect(isV2MovedSnoozed(storage, OTHER_ORG, NOW)).toBe(false); + expect([...storage.items.keys()]).toEqual([v2MovedSnoozeKey(ORG)]); + }); + + it("shows the notice when the stored value is unreadable", () => { + const storage = memoryStorage(); + storage.setItem(v2MovedSnoozeKey(ORG), "not a number"); + expect(isV2MovedSnoozed(storage, ORG, NOW)).toBe(false); + }); + + it("shows the notice without storage, and never throws", () => { + expect(isV2MovedSnoozed(null, ORG, NOW)).toBe(false); + expect(isV2MovedSnoozed(throwingStorage, ORG, NOW)).toBe(false); + expect(() => snoozeV2Moved(null, ORG, NOW, V2_MOVED_DISMISS_SNOOZE_MS)).not.toThrow(); + expect(() => + snoozeV2Moved(throwingStorage, ORG, NOW, V2_MOVED_DISMISS_SNOOZE_MS), + ).not.toThrow(); + }); +}); diff --git a/e2e/cloud/v2-moved-notice.test.ts b/e2e/cloud/v2-moved-notice.test.ts new file mode 100644 index 0000000000..54cc37efa6 --- /dev/null +++ b/e2e/cloud/v2-moved-notice.test.ts @@ -0,0 +1,259 @@ +// Cloud-only: the notice for an organization Executor v2 has already +// imported. v2 records the move on the v1 WorkOS organization's metadata +// (`executor_v2_org_slug`, `executor_v2_moved_at`); each scenario writes those +// keys through the WorkOS emulator's real Update Organization route, reads the +// real `/api/auth/v2-import` answer, and checks the dashboard in the browser. +// +// The notice's analytics events are not asserted here: the e2e stack mounts no +// PostHog client, so `trackEvent` is a no-op. Their names and properties are +// fixed by the typed catalog in `packages/react/src/api/analytics.tsx`. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { Page } from "playwright"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { revisit, settle, visit } from "../src/surfaces/browser"; +import type { Identity, Target as TargetShape } from "../src/target"; +import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; +import { cookieOf } from "./support/session"; + +const V2_IMPORT_API = "/api/auth/v2-import"; +const V2_SLUG = "moved-example"; +// Noon UTC, so the badge reads the same calendar day in any browser timezone +// between UTC-11 and UTC+11. +const MOVED_AT = "2026-10-09T12:00:00.000Z"; +const MOVED_BADGE = "Moved Oct 9, 2026"; + +interface V2ImportAnswer { + readonly url: string | null; + readonly moved: { + readonly v2Url: string; + readonly v2Slug: string; + readonly movedAt: string; + } | null; +} + +const v2ImportOf = (target: TargetShape, identity: Identity): Effect.Effect => + Effect.promise(async () => { + const response = await fetch(new URL(V2_IMPORT_API, target.baseUrl), { + headers: identity.headers, + }); + if (!response.ok) throw new Error(`${V2_IMPORT_API} failed (${response.status})`); + return (await response.json()) as V2ImportAnswer; + }); + +/** The identity's active org, with the name the dashboard shows. */ +const orgOf = ( + target: TargetShape, + identity: Identity, +): Effect.Effect<{ readonly id: string; readonly slug: string; readonly name: string }> => + Effect.promise(async () => { + const response = await fetch(new URL("/api/auth/me", target.baseUrl), { + headers: { cookie: cookieOf(identity) }, + }); + if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`); + const body = (await response.json()) as { + organization: { id: string; slug: string; name: string } | null; + }; + if (!body.organization) throw new Error("identity has no active organization"); + return body.organization; + }); + +/** Write WorkOS organization metadata the way v2 does: Update Organization. */ +const setOrgMetadata = ( + organizationId: string, + metadata: Record, +): Effect.Effect => + Effect.promise(async () => { + const response = await fetch( + `http://127.0.0.1:${WORKOS_EMULATOR_PORT}/organizations/${organizationId}`, + { + method: "PUT", + headers: { "content-type": "application/json", authorization: "Bearer sk_test_emulate" }, + body: JSON.stringify({ metadata }), + }, + ); + if (!response.ok) throw new Error(`WorkOS update organization failed (${response.status})`); + await response.body?.cancel(); + }); + +const markMoved = (organizationId: string) => + setOrgMetadata(organizationId, { + executor_v2_org_slug: V2_SLUG, + executor_v2_moved_at: MOVED_AT, + }); + +const noticeOf = (page: Page) => page.getByRole("dialog"); + +/** Open the dashboard and wait for the v2 answer it renders the notice from. */ +const openDashboard = async (page: Page, slug: string) => { + const answered = page.waitForResponse((response) => response.url().endsWith(V2_IMPORT_API)); + await visit(page, `/${slug}`); + await answered; + await settle(page); +}; + +scenario( + "v2 moved · a moved org's dashboard says where it lives in v2, and ✕ hides it", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const org = yield* orgOf(target, identity); + + yield* markMoved(org.id); + + const answer = yield* v2ImportOf(target, identity); + expect(answer.url, "a moved org is not offered the import again").toBeNull(); + expect(answer.moved, "the move v2 recorded is answered").not.toBeNull(); + const v2Url = new URL(answer.moved!.v2Url); + expect(v2Url.pathname, "the link opens the org's page in v2").toBe(`/org/${V2_SLUG}`); + expect(v2Url.origin, "the org's page is on v2's host, not v1's").not.toBe( + new URL(target.baseUrl).origin, + ); + expect(answer.moved!.movedAt).toBe(MOVED_AT); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the moved org's dashboard", async () => { + await openDashboard(page, org.slug); + }); + + await step("See the notice with the org's v1 and v2 homes", async () => { + const notice = noticeOf(page); + await notice.waitFor(); + await notice + .getByRole("heading", { name: `${org.name} now lives in Executor v2`, exact: true }) + .waitFor(); + expect(await notice.getByText(MOVED_BADGE).count(), "the move date badge").toBe(1); + const pair = await notice.getByTestId("v2-moved-pair").textContent(); + expect(pair).toContain(`v1 · ${org.name}`); + expect(pair).toContain(`v2 · ${V2_SLUG}`); + expect(await notice.getByText("Changes made here won't sync to v2.").count()).toBe(1); + + const open = notice.getByRole("link", { name: `Open ${org.name} in v2` }); + expect(await open.getAttribute("href"), "the button opens the org in v2").toBe( + answer.moved!.v2Url, + ); + expect(await open.getAttribute("target"), "it opens in a new tab").toBe("_blank"); + expect(await notice.getByRole("button", { name: "Keep using v1" }).count()).toBe(1); + }); + + await step("Close it with ✕", async () => { + await noticeOf(page).getByRole("button", { name: "Close" }).click(); + await noticeOf(page).waitFor({ state: "hidden" }); + }); + + await step( + 'See "Open in Executor v2" in the sidebar instead of the import link', + async () => { + const link = page.getByRole("link", { name: "Open in Executor v2" }); + expect(await link.getAttribute("href")).toBe(answer.moved!.v2Url); + expect(await link.getAttribute("target")).toBe("_blank"); + expect(await page.getByRole("link", { name: "Move to Executor v2" }).count()).toBe(0); + }, + ); + + await step("Reload and see the notice stays hidden", async () => { + const answered = page.waitForResponse((response) => response.url().endsWith(V2_IMPORT_API)); + await revisit(page); + await answered; + await settle(page); + await page.getByRole("link", { name: "Open in Executor v2" }).waitFor(); + expect(await noticeOf(page).count(), "✕ snoozes the notice in this browser").toBe(0); + }); + }); + }), +); + +scenario( + "v2 moved · keeping v1 asks why, and sending the answer hides the notice", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const org = yield* orgOf(target, identity); + yield* markMoved(org.id); + + yield* browser.session(identity, async ({ page, step }) => { + const notice = noticeOf(page); + const send = notice.getByRole("button", { name: "Send and stay in v1" }); + const answerField = notice.getByRole("textbox"); + + await step("Open the moved org's dashboard", async () => { + await openDashboard(page, org.slug); + await notice.waitFor(); + }); + + await step('Click "Keep using v1" and see the feedback step', async () => { + await notice.getByRole("button", { name: "Keep using v1" }).click(); + await notice.getByRole("heading", { name: "What went wrong?" }).waitFor(); + expect( + await notice.getByText("Tell us why you're staying on v1, so we can fix it.").count(), + ).toBe(1); + expect(await notice.getByRole("radio").count(), "free text only, no options").toBe(0); + expect(await send.isDisabled(), "nothing to send yet").toBe(true); + }); + + await step("Type only spaces and see Send stays disabled", async () => { + await answerField.fill(" "); + expect(await send.isDisabled(), "a blank answer is not an answer").toBe(true); + }); + + await step("Go back, then return to the feedback step", async () => { + await notice.getByRole("button", { name: "Back" }).click(); + await notice.getByRole("button", { name: "Keep using v1" }).waitFor(); + await notice.getByRole("button", { name: "Keep using v1" }).click(); + await notice.getByRole("heading", { name: "What went wrong?" }).waitFor(); + }); + + await step("Write why and send it", async () => { + await answerField.fill("Synthetic feedback: a feature I use is not in v2 yet."); + expect(await send.isDisabled(), "a written answer can be sent").toBe(false); + await send.click(); + await notice.waitFor({ state: "hidden" }); + }); + + await step("Reload and see the notice stays hidden", async () => { + const answered = page.waitForResponse((response) => response.url().endsWith(V2_IMPORT_API)); + await revisit(page); + await answered; + await settle(page); + await page.getByRole("link", { name: "Open in Executor v2" }).waitFor(); + expect(await noticeOf(page).count(), "sending the answer snoozes the notice").toBe(0); + }); + }); + }), +); + +scenario( + "v2 moved · malformed move metadata shows no notice and keeps the import link", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + const org = yield* orgOf(target, identity); + + yield* setOrgMetadata(org.id, { + executor_v2_org_slug: "Not A Slug", + executor_v2_moved_at: "yesterday", + }); + + const answer = yield* v2ImportOf(target, identity); + expect(answer.moved, "malformed metadata is not a move").toBeNull(); + expect(answer.url, "the sole member of a free org is still offered the import").not.toBeNull(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the dashboard", async () => { + await openDashboard(page, org.slug); + }); + await step("See the import link and no notice", async () => { + await page.getByRole("link", { name: "Move to Executor v2" }).waitFor(); + expect(await noticeOf(page).count()).toBe(0); + }); + }); + }), +); diff --git a/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch b/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch index dd32e79318..704aea49c0 100644 --- a/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch +++ b/patches/@executor-js%2Femulate@0.14.3-mfa.0.patch @@ -28,3 +28,69 @@ index 0590e15752d2536b7d8e63f378c92ac7910a91be..de28354560fa4ef00122ef4407b667d9 { operationId: "customers.list", method: "POST", path: "/v1/customers.list", status: "hand-authored" }, { operationId: "customers.update", method: "POST", path: "/v1/customers.update", status: "hand-authored" }, { operationId: "balances.track", method: "POST", path: "/v1/balances.track", status: "hand-authored" }, +diff --git a/dist/dist-JYIVTHI5.js b/dist/dist-JYIVTHI5.js +index 34e94fd406b65f83f30ea92b08e8ca7466b32838..7b7892bd71851575b23176c07722bdcbc66440ae 100644 +--- a/dist/dist-JYIVTHI5.js ++++ b/dist/dist-JYIVTHI5.js +@@ -445,7 +445,7 @@ function serializeOrganization(org, domains = []) { + domains: domains.map(serializeOrganizationDomain), + stripe_customer_id: null, + external_id: org.external_id, +- metadata: {}, ++ metadata: org.metadata ?? {}, + created_at: org.created_at, + updated_at: org.updated_at + }; +@@ -881,6 +881,24 @@ function userManagementRoutes(ctx) { + return c.json(serializeApiKey(key, { includeValue: true }), 201); + }); + } ++function mergeOrganizationMetadata(existing, update) { ++ if (update === null || typeof update !== "object" || Array.isArray(update)) { ++ return { ok: false, reason: "metadata must be an object" }; ++ } ++ const entries = Object.entries(update); ++ if (entries.length === 0) return { ok: true, metadata: {} }; ++ const merged = { ...existing }; ++ for (const [key, value] of entries) { ++ if (value === null) { ++ delete merged[key]; ++ continue; ++ } ++ if (typeof value !== "string") return { ok: false, reason: "metadata values must be strings" }; ++ merged[key] = value; ++ } ++ if (Object.keys(merged).length > 10) return { ok: false, reason: "metadata has more than 10 keys" }; ++ return { ok: true, metadata: merged }; ++} + function organizationRoutes(ctx) { + const { app, store, baseUrl } = ctx; + const ws = () => getWorkosStore(store); +@@ -888,10 +906,13 @@ function organizationRoutes(ctx) { + const body = await c.req.json().catch(() => ({})); + const name = String(body.name ?? ""); + if (!name) return workosError(c, 422, "invalid_request", "name is required"); ++ const metadata = body.metadata === void 0 ? { ok: true, metadata: {} } : mergeOrganizationMetadata({}, body.metadata); ++ if (!metadata.ok) return workosError(c, 422, "invalid_request", metadata.reason); + const organization = ws().organizations.insert({ + workos_id: workosId("org"), + name, +- external_id: typeof body.external_id === "string" ? body.external_id : null ++ external_id: typeof body.external_id === "string" ? body.external_id : null, ++ metadata: metadata.metadata + }); + return c.json( + serializeOrganization(organization, ws().organizationDomains.findBy("organization_id", organization.workos_id)), +@@ -909,8 +930,11 @@ function organizationRoutes(ctx) { + const organization = ws().organizations.findOneBy("workos_id", c.req.param("id")); + if (!organization) return workosError(c, 404, "entity_not_found", "Organization not found."); + const body = await c.req.json().catch(() => ({})); ++ const metadata = body.metadata === void 0 ? { ok: true, metadata: organization.metadata ?? {} } : mergeOrganizationMetadata(organization.metadata ?? {}, body.metadata); ++ if (!metadata.ok) return workosError(c, 422, "invalid_request", metadata.reason); + const updated = ws().organizations.update(organization.id, { +- name: typeof body.name === "string" && body.name ? body.name : organization.name ++ name: typeof body.name === "string" && body.name ? body.name : organization.name, ++ metadata: metadata.metadata + }); + return c.json( + serializeOrganization(updated, ws().organizationDomains.findBy("organization_id", updated.workos_id))