From 30ef40da5bab968e8d95c8cafa82f8cf4e7f8055 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:54:03 -0700 Subject: [PATCH 1/2] Test organization settings MFA and ordinary access --- .../account/org-api-key-revoke.node.test.ts | 2 +- apps/cloud/src/auth/admin-mfa-proof.test.ts | 143 ++++++++++++ .../src/auth/mirror-feeders.node.test.ts | 24 +- apps/cloud/src/org/handlers.test.ts | 7 +- apps/cloud/test-stubs/verified-settings.ts | 23 ++ bun.lock | 61 ++++- e2e/cloud/admin-mfa-api.test.ts | 131 +++++++++++ e2e/cloud/connection-owner-isolation.test.ts | 4 +- .../mcp-workos-blip-session-survival.test.ts | 3 +- e2e/cloud/member-invite-seat-limit.test.ts | 12 +- e2e/cloud/org-delete.test.ts | 2 + e2e/cloud/org-settings-mfa.test.ts | 221 ++++++++++++++++++ e2e/cloud/spec-update-convergence.test.ts | 4 +- e2e/cloud/support/admin-mfa.ts | 126 ++++++++++ e2e/cloud/support/session.ts | 4 +- e2e/package.json | 3 +- 16 files changed, 743 insertions(+), 27 deletions(-) create mode 100644 apps/cloud/src/auth/admin-mfa-proof.test.ts create mode 100644 apps/cloud/test-stubs/verified-settings.ts create mode 100644 e2e/cloud/admin-mfa-api.test.ts create mode 100644 e2e/cloud/org-settings-mfa.test.ts create mode 100644 e2e/cloud/support/admin-mfa.ts diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 48db9c2df3..a43456e1ed 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -196,7 +196,7 @@ const providerWith = (accountId: string) => { stubDirectory, stubApiKeys, stubAutumn, - Layer.succeed(AccountCaller)({ session: session(accountId) }), + Layer.succeed(AccountCaller)({ session: session(accountId), adminVerified: false }), ), ), ), diff --git a/apps/cloud/src/auth/admin-mfa-proof.test.ts b/apps/cloud/src/auth/admin-mfa-proof.test.ts new file mode 100644 index 0000000000..96fa7a8f03 --- /dev/null +++ b/apps/cloud/src/auth/admin-mfa-proof.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { SignJWT } from "jose"; +import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof"; + +const secret = "a-test-only-cookie-password-of-32-characters"; +const identity = { userId: "user_test", sessionId: "session_test" }; +const now = 1_800_000_000_000; +const proof = { + mode: "challenge" as const, + factorId: "factor_test", + challengeId: "challenge_test", + exp: now / 1000 + 900, +}; +const signed = signAdminMfaProof(secret, identity, "verified", proof, now); + +describe("admin verification cookie", () => { + it.effect("accepts a valid proof for the same user and session", () => + Effect.gen(function* () { + const token = yield* signed; + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toEqual(proof); + }), + ); + + it.effect("refuses missing, modified, and unsigned cookies", () => + Effect.gen(function* () { + const token = yield* signed; + const parts = token.split("."); + const unsigned = `${btoa('{"alg":"none"}')}.${parts[1]}.`; + for (const value of [ + undefined, + "", + "bad.cookie", + `${token.slice(0, 50)}x${token.slice(51)}`, + unsigned, + ]) { + expect(yield* readAdminMfaProof(secret, identity, "verified", value, now)).toBeNull(); + } + }), + ); + + it.effect("refuses another session, another user, and another signing key", () => + Effect.gen(function* () { + const token = yield* signed; + for (const other of [ + { ...identity, userId: "other" }, + { ...identity, sessionId: "other" }, + ]) { + expect(yield* readAdminMfaProof(secret, other, "verified", token, now)).toBeNull(); + } + expect( + yield* readAdminMfaProof(`${secret}-rotated`, identity, "verified", token, now), + ).toBeNull(); + }), + ); + + it.effect("cannot promote an unfinished challenge to verified access", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "challenge", + { ...proof, mode: "enroll", exp: now / 1000 + 300 }, + now, + ); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "challenge", token, now + 299_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "challenge", token, now + 300_000), + ).toBeNull(); + }), + ); + + it.effect("honors the signed expiration and refuses a future-issued cookie", () => + Effect.gen(function* () { + const token = yield* signed; + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 899_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 900_000), + ).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now - 10_000), + ).toBeNull(); + }), + ); + + it.effect("keeps the verified session unlocked beyond the former fifteen-minute window", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { + ...proof, + exp: now / 1000 + 7 * 86400, + }, + now, + ); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 3600_000), + ).not.toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 7 * 86400_000), + ).toBeNull(); + }), + ); + + it.effect("caps token age even when the supplied expiration is longer", () => + Effect.gen(function* () { + const token = yield* signAdminMfaProof( + secret, + identity, + "verified", + { ...proof, exp: now / 1000 + 8 * 86400 }, + now, + ); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + expect( + yield* readAdminMfaProof(secret, identity, "verified", token, now + 901_000), + ).toBeNull(); + }), + ); + + it.effect("rejects a signed cookie with missing issued-at or another algorithm", () => + Effect.gen(function* () { + for (const algorithm of ["HS256", "HS384"]) { + const jwt = new SignJWT({ ...proof }) + .setProtectedHeader({ alg: algorithm }) + .setIssuer("executor:admin-mfa:verified") + .setSubject(identity.userId) + .setAudience(identity.sessionId); + // HS256 lacks iat; HS384 is otherwise valid but outside the allowlist. + if (algorithm === "HS384") jwt.setIssuedAt(now / 1000); + const token = yield* Effect.promise(() => jwt.sign(new TextEncoder().encode(secret))); + expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull(); + } + }), + ); +}); diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index b1cb9ba1de..d166e49b83 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -1,3 +1,4 @@ +import { verifiedSettingsCookie } from "../../test-stubs/verified-settings"; // --------------------------------------------------------------------------- // The membership mirror's FEEDERS, end to end through the code that runs in // production, against the real PGlite Postgres every cloud unit test runs on @@ -594,6 +595,7 @@ describe("session handlers read membership from the mirror", () => { authenticateSealedSession: () => Effect.succeed({ userId, + sessionId: "test-settings-session", email: `${userId}@placeholder.test`, organizationId: null, } as never), @@ -710,11 +712,11 @@ describe("session handlers read membership from the mirror", () => { return slug; }; - const deleteOrganizationRequest = (org: string) => + const deleteOrganizationRequest = async (org: string, userId: string) => new Request("http://test.local/auth/delete-organization", { method: "POST", headers: { - cookie: "wos-session=sealed", + cookie: `wos-session=sealed; ${await verifiedSettingsCookie(userId)}`, "content-type": "application/json", [ORG_SELECTOR_HEADER]: org, }, @@ -761,7 +763,7 @@ describe("session handlers read membership from the mirror", () => { // requires an ACTIVE membership, so the invite grants no deletion right. await seedMembership(userId, org, "pending", "admin"); - const response = await sessionHandler(userId)(deleteOrganizationRequest(org)); + const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId)); // The selector resolves no active membership, so the request fails at the // org check (NoOrganization) — the handler never reaches the WorkOS @@ -775,7 +777,7 @@ describe("session handlers read membership from the mirror", () => { const org = freshId("org"); await seedMembership(userId, org, "active", "member"); - const response = await sessionHandler(userId)(deleteOrganizationRequest(org)); + const response = await sessionHandler(userId)(await deleteOrganizationRequest(org, userId)); expect(response.status).toBe(403); expect( @@ -805,7 +807,7 @@ describe("session handlers read membership from the mirror", () => { }), }, }); - const first = await failing(deleteOrganizationRequest(org)); + const first = await failing(await deleteOrganizationRequest(org, admin)); expect(first.status, "the failed purge is surfaced, not hidden").toBe(500); expect(workosDeletes).toEqual([org]); expect(purges).toEqual(["deleteOrganizationCascade"]); @@ -825,7 +827,7 @@ describe("session handlers read membership from the mirror", () => { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), }, }); - const second = await retry(deleteOrganizationRequest(org)); + const second = await retry(await deleteOrganizationRequest(org, admin)); expect(second.status, "the admin's own membership still admits the retry").toBe(200); expect(await second.json()).toEqual({ success: true }); expect( @@ -879,7 +881,7 @@ describe("session handlers read membership from the mirror", () => { }, }); - const first = await handler(deleteOrganizationRequest(org)); + const first = await handler(await deleteOrganizationRequest(org, admin)); expect(first.status, "the failed billing cancel is surfaced, not hidden").toBe(500); expect(await first.json()).toMatchObject({ _tag: "OrganizationDeletionIncomplete", @@ -894,7 +896,7 @@ describe("session handlers read membership from the mirror", () => { ).toEqual([admin, member].sort()); expect(await authorized(member, org), "yet nobody is authorized: the mark stands").toBe(false); - const second = await handler(deleteOrganizationRequest(org)); + const second = await handler(await deleteOrganizationRequest(org, admin)); expect(second.status, "the admin's own membership row still admits the retry").toBe(200); expect(await second.json()).toEqual({ success: true }); expect(workosDeletes, "WorkOS is asked once billing is cancelled").toEqual([org]); @@ -920,7 +922,7 @@ describe("session handlers read membership from the mirror", () => { services: servicesWithFailingPurge(purges), autumn: deletingAutumn, workos: { deleteOrganization: () => Effect.void }, - })(deleteOrganizationRequest(org)); + })(await deleteOrganizationRequest(org, admin)); expect(first.status).toBe(500); expect(purges).toEqual(["deleteOrganizationCascade"]); @@ -933,7 +935,7 @@ describe("session handlers read membership from the mirror", () => { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), }, }); - const second = await retry(deleteOrganizationRequest(org)); + const second = await retry(await deleteOrganizationRequest(org, admin)); expect(second.status, "the retry is admitted from the mirror").toBe(200); expect(await second.json()).toEqual({ success: true }); expect(await readMembers(org), "and the purge ran").toEqual([]); @@ -1158,7 +1160,7 @@ describe("account service writes through to the mirror", () => { workos, stubApiKeys, options.autumn ?? stubAutumn, - Layer.succeed(AccountCaller)({ session: session(ADMIN) }), + Layer.succeed(AccountCaller)({ session: session(ADMIN), adminVerified: true }), ), ), Layer.provideMerge(stores), diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index 1cae2aa9a2..b5c99af906 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -1,3 +1,4 @@ +import { verifiedSettingsCookie } from "../../test-stubs/verified-settings"; import { afterAll, describe, expect, it } from "@effect/vitest"; import { Data, Effect, Layer } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; @@ -239,6 +240,7 @@ const workosForCaller = (deleted: string[]) => authenticateSealedSession: () => Effect.succeed({ userId: CALLER, + sessionId: "test-settings-session", email: "caller@placeholder.test", organizationId: ORG, }), @@ -274,7 +276,10 @@ const deleteDomain = async (role: "admin" | "member") => { const response = await app.handler( new Request(`https://executor.test/org/domains/${DOMAIN}`, { method: "DELETE", - headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG }, + headers: { + cookie: `wos-session=sealed; ${await verifiedSettingsCookie(CALLER)}`, + [ORG_SELECTOR_HEADER]: ORG, + }, }), // beta.59: the handler type expects a context argument; this layer stack // needs none at runtime — pass undefined like the api.request-scope tests. diff --git a/apps/cloud/test-stubs/verified-settings.ts b/apps/cloud/test-stubs/verified-settings.ts new file mode 100644 index 0000000000..d5f2f17200 --- /dev/null +++ b/apps/cloud/test-stubs/verified-settings.ts @@ -0,0 +1,23 @@ +import { env } from "cloudflare:workers"; +import { Effect } from "effect"; +import { ADMIN_MFA_COOKIE, signAdminMfaProof } from "../src/auth/admin-mfa-proof"; + +/** A signed proof for HTTP fixtures; the WorkOS stub must return this session id. */ +export const verifiedSettingsCookie = async (userId: string): Promise => { + const now = Date.now(); + const proof = await Effect.runPromise( + signAdminMfaProof( + env.WORKOS_COOKIE_PASSWORD, + { userId, sessionId: "test-settings-session" }, + "verified", + { + factorId: "test-factor", + challengeId: "test-challenge", + mode: "challenge", + exp: now / 1000 + 900, + }, + now, + ), + ); + return `${ADMIN_MFA_COOKIE}=${proof}`; +}; diff --git a/bun.lock b/bun.lock index 87589fabdd..78ee6bcded 100644 --- a/bun.lock +++ b/bun.lock @@ -356,7 +356,7 @@ "version": "0.0.50", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.2", + "@executor-js/emulate": "0.14.3-mfa.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -381,6 +381,7 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", + "otpauth": "^9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", @@ -1811,7 +1812,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], + "@executor-js/emulate": ["@executor-js/emulate@0.14.3-mfa.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "jose": "^6", "openid-client": "^6.8.4", "otpauth": "9.5.2", "picocolors": "^1.1.1", "qrcode": "1.5.4", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-XdXLM+Q5lWtfkgAqa95atZmpKAjel9A29ulJeDCgMlKwPerQJ1d8yWsAn5b3iq68Bq5HGwtjZt7ebBuwZP3dBw=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], @@ -2221,7 +2222,7 @@ "@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="], - "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -3521,6 +3522,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], "caseless": ["caseless@0.12.0", "", {}, "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="], @@ -3765,6 +3768,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -3819,6 +3824,8 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -3899,8 +3906,6 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -4875,6 +4880,8 @@ "ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="], + "otpauth": ["otpauth@9.5.2", "", { "dependencies": { "@noble/hashes": "2.4.0" } }, "sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxc-parser": ["oxc-parser@0.121.0", "", { "dependencies": { "@oxc-project/types": "^0.121.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.121.0", "@oxc-parser/binding-android-arm64": "0.121.0", "@oxc-parser/binding-darwin-arm64": "0.121.0", "@oxc-parser/binding-darwin-x64": "0.121.0", "@oxc-parser/binding-freebsd-x64": "0.121.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.121.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.121.0", "@oxc-parser/binding-linux-arm64-gnu": "0.121.0", "@oxc-parser/binding-linux-arm64-musl": "0.121.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.121.0", "@oxc-parser/binding-linux-riscv64-musl": "0.121.0", "@oxc-parser/binding-linux-s390x-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-gnu": "0.121.0", "@oxc-parser/binding-linux-x64-musl": "0.121.0", "@oxc-parser/binding-openharmony-arm64": "0.121.0", "@oxc-parser/binding-wasm32-wasi": "0.121.0", "@oxc-parser/binding-win32-arm64-msvc": "0.121.0", "@oxc-parser/binding-win32-ia32-msvc": "0.121.0", "@oxc-parser/binding-win32-x64-msvc": "0.121.0" } }, "sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg=="], @@ -4995,7 +5002,7 @@ "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], @@ -5077,6 +5084,8 @@ "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -5255,6 +5264,8 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], @@ -5341,6 +5352,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -5755,6 +5768,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -5871,6 +5886,8 @@ "@better-auth/core/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@better-auth/utils/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -5961,6 +5978,8 @@ "@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/emulate/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "@executor-js/example-all-plugins/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], @@ -5969,6 +5988,8 @@ "@executor-js/fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@executor-js/host-selfhost/@executor-js/emulate": ["@executor-js/emulate@0.14.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-rUzfQFq1dO3qwzW83jL7kEikLLPXTjqLTSU9qpVdbYyqMF/Ef8YgwH+hw0tbqNEkuGFwuZKkMkDUPPfkidMamg=="], + "@executor-js/mcporter/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@executor-js/mcporter/rolldown": ["rolldown@1.0.1", "", { "dependencies": { "@oxc-project/types": "=0.130.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.1", "@rolldown/binding-darwin-arm64": "1.0.1", "@rolldown/binding-darwin-x64": "1.0.1", "@rolldown/binding-freebsd-x64": "1.0.1", "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", "@rolldown/binding-linux-arm64-gnu": "1.0.1", "@rolldown/binding-linux-arm64-musl": "1.0.1", "@rolldown/binding-linux-ppc64-gnu": "1.0.1", "@rolldown/binding-linux-s390x-gnu": "1.0.1", "@rolldown/binding-linux-x64-gnu": "1.0.1", "@rolldown/binding-linux-x64-musl": "1.0.1", "@rolldown/binding-openharmony-arm64": "1.0.1", "@rolldown/binding-wasm32-wasi": "1.0.1", "@rolldown/binding-win32-arm64-msvc": "1.0.1", "@rolldown/binding-win32-x64-msvc": "1.0.1" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ=="], @@ -6003,6 +6024,8 @@ "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/js-png/pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -6213,6 +6236,8 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@paralleldrive/cuid2/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], @@ -6475,6 +6500,8 @@ "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "better-auth/@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -6575,8 +6602,6 @@ "electron-vite/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], - "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -6741,6 +6766,8 @@ "protobufjs/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], @@ -7109,6 +7136,10 @@ "@executor-js/e2e/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@executor-js/host-selfhost/@executor-js/emulate/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "@executor-js/host-selfhost/@executor-js/emulate/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "@executor-js/mcporter/rolldown/@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@executor-js/mcporter/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.1", "", { "os": "android", "cpu": "arm64" }, "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg=="], @@ -7757,6 +7788,14 @@ "protobufjs/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "request/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -8005,6 +8044,10 @@ "ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "qrcode/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "superagent/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -8033,6 +8076,8 @@ "googleapis-common/google-auth-library/gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "temp/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], diff --git a/e2e/cloud/admin-mfa-api.test.ts b/e2e/cloud/admin-mfa-api.test.ts new file mode 100644 index 0000000000..e048c9c625 --- /dev/null +++ b/e2e/cloud/admin-mfa-api.test.ts @@ -0,0 +1,131 @@ +import { expect } from "@effect/vitest"; +import { Effect, Option, Schema } from "effect"; +import { TOTP } from "otpauth"; +import { scenario } from "../src/scenario"; +import { Target } from "../src/services"; +import { responseCookies } from "./support/admin-mfa"; + +const decodeSetup = Schema.decodeUnknownOption( + Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }), +); + +scenario( + "Admin MFA API · requires same-origin requests and binds verification to the session", + {}, + Effect.gen(function* () { + const target = yield* Target; + const identity = yield* target.newIdentity(); + const other = yield* target.newIdentity(); + yield* Effect.promise(async () => { + const original = identity.headers?.cookie ?? ""; + const headers = { ...identity.headers, "content-type": "application/json" }; + const unverifiedWorkspace = await fetch(new URL("/api/policies", target.baseUrl), { + headers, + }); + expect(unverifiedWorkspace.status).toBe(200); + const key = await fetch(new URL("/api/account/api-keys", target.baseUrl), { + method: "POST", + headers: { ...headers, origin: new URL(target.baseUrl).origin }, + body: JSON.stringify({ name: "unverified-admin" }), + }); + expect(key.status).toBe(200); + const billing = await fetch(new URL("/api/billing/getOrCreateCustomer", target.baseUrl), { + method: "POST", + headers, + body: "{}", + }); + expect(billing.status).toBe(200); + const post = (action: string, cookie: string, code?: string) => + fetch(new URL(`/api/auth/admin-mfa/${action}`, target.baseUrl), { + method: "POST", + headers: { ...headers, origin: new URL(target.baseUrl).origin, cookie }, + body: JSON.stringify(code === undefined ? {} : { code }), + }); + const noOrigin = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers, + body: "{}", + }); + expect(noOrigin.status).toBe(403); + const crossOrigin = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers: { ...headers, origin: "https://other.example" }, + body: "{}", + }); + expect(crossOrigin.status).toBe(403); + const started = await post("start", original); + expect(started.status).toBe(200); + const setup = Option.getOrNull(decodeSetup(await started.json())); + if (!setup) throw new Error("Expected enrollment setup"); + const pending = responseCookies(original, started); + const challenge = pending + .split("; ") + .find((pair) => pair.startsWith("__Host-executor-admin-challenge=")); + if (!challenge) throw new Error("Expected pending challenge cookie"); + const crossUser = await fetch(new URL("/api/auth/admin-mfa/verify", target.baseUrl), { + method: "POST", + headers: { + ...other.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + cookie: `${other.headers?.cookie ?? ""}; ${challenge}`, + }, + body: JSON.stringify({ code: new TOTP({ secret: setup.secret }).generate() }), + }); + expect(crossUser.status).toBe(400); + const verified = await post("verify", pending, new TOTP({ secret: setup.secret }).generate()); + expect(verified.status).toBe(200); + const proofCookie = verified.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("__Host-executor-admin-mfa=")); + expect(proofCookie).toMatch(/HttpOnly/i); + expect(proofCookie).toMatch(/Secure/i); + expect(proofCookie).toMatch(/SameSite=Strict/i); + expect(proofCookie).not.toMatch(/Max-Age|Expires/i); + const verifiedCookies = responseCookies(pending, verified); + const status = await fetch(new URL("/api/auth/admin-mfa", target.baseUrl), { + headers: { ...headers, cookie: verifiedCookies }, + }); + expect(await status.json()).toMatchObject({ state: "verified" }); + expect( + (await post("verify", pending, new TOTP({ secret: setup.secret }).generate())).status, + ).toBe(400); + + // A later verification uses the existing factor and never returns its secret. + const repeat = await post("start", original); + expect(await repeat.json()).toEqual({ kind: "challenge" }); + const repeated = await post( + "verify", + responseCookies(original, repeat), + new TOTP({ secret: setup.secret }).generate(), + ); + expect(repeated.status).toBe(200); + }); + }), +); + +scenario( + "Admin MFA API · restarting enrollment cannot reset the rate limit", + {}, + Effect.gen(function* () { + const target = yield* Target; + const identity = yield* target.newIdentity(); + yield* Effect.promise(async () => { + const statuses: number[] = []; + for (let attempt = 0; attempt < 6; attempt++) { + const response = await fetch(new URL("/api/auth/admin-mfa/start", target.baseUrl), { + method: "POST", + headers: { + ...identity.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }, + body: "{}", + }); + statuses.push(response.status); + await response.text(); + } + expect(statuses).toEqual([200, 200, 200, 200, 200, 429]); + }); + }), +); diff --git a/e2e/cloud/connection-owner-isolation.test.ts b/e2e/cloud/connection-owner-isolation.test.ts index 4a04b3bad7..a20a2003f5 100644 --- a/e2e/cloud/connection-owner-isolation.test.ts +++ b/e2e/cloud/connection-owner-isolation.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud-only: the connection OWNER model, with real multi-user organizations. // Every connection is filed under `owner: "org"` (shared with the whole tenant) // or `owner: "user"` (this subject's own). The org membership is built through @@ -115,7 +116,8 @@ const orgSelectorOf = (identity: Identity): string => { * Returns the member identity with its requests scoped to that org. */ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => Effect.gen(function* () { - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; diff --git a/e2e/cloud/mcp-workos-blip-session-survival.test.ts b/e2e/cloud/mcp-workos-blip-session-survival.test.ts index d6a5022aa5..30ae7d3d31 100644 --- a/e2e/cloud/mcp-workos-blip-session-survival.test.ts +++ b/e2e/cloud/mcp-workos-blip-session-survival.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud: an MCP session's relationship to WorkOS after the membership mirror. // // Membership is authorized from the local mirror on every /mcp request @@ -181,7 +182,7 @@ scenario( // An admin's org with one plain member, joined through the real invite → // accept flow. The member is the one whose access is revoked. - const admin = yield* target.newIdentity(); + const admin = yield* verifyAdmin(target.baseUrl, yield* target.newIdentity()); const invitee = yield* target.newIdentity({ org: false }); const member = yield* joinOrg(target, admin, invitee); const bearer = yield* mcp.mintBearer(emailOf(member)); diff --git a/e2e/cloud/member-invite-seat-limit.test.ts b/e2e/cloud/member-invite-seat-limit.test.ts index e465c1396e..ee76349525 100644 --- a/e2e/cloud/member-invite-seat-limit.test.ts +++ b/e2e/cloud/member-invite-seat-limit.test.ts @@ -1,3 +1,4 @@ +import { verifyAdminInBrowser } from "./support/admin-mfa"; // Cloud-only (billing): the free plan advertises "Up to 3 members", 3 // INCLUSIVE. A fresh org's admin holds seat 1, so two invites fill seats 2 and // 3; at that point "Invite member" opens an upgrade prompt (linking to billing) @@ -40,6 +41,7 @@ scenario( // A fresh user who owns a brand-new free org: the admin holds seat 1. const identity = yield* target.newIdentity(); + let verifiedCookie = ""; const client = yield* apiClient(AccountHttpApi, identity); yield* browser.session(identity, async ({ page, step }) => { @@ -56,6 +58,10 @@ scenario( await step("Open the organization members page", async () => { await visit(page, `/${slug}/org`); + await verifyAdminInBrowser(page); + verifiedCookie = (await page.context().cookies()) + .map(({ name, value }) => `${name}=${value}`) + .join("; "); await page.getByRole("button", { name: "Invite member" }).waitFor(); }); @@ -114,7 +120,11 @@ scenario( const refused = yield* Effect.promise(() => fetch(new URL("/api/account/members/invite", target.baseUrl), { method: "POST", - headers: { ...(identity.headers ?? {}), "content-type": "application/json" }, + headers: { + ...(identity.headers ?? {}), + cookie: verifiedCookie, + "content-type": "application/json", + }, body: JSON.stringify({ email: "over-the-cap@example.com" }), }), ); diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index bb3f9f187c..c096c2ca5b 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -1,3 +1,4 @@ +import { verifyAdminInBrowser } from "./support/admin-mfa"; // Cloud-specific (browser): an admin permanently deletes their organization. // A fresh user creates an org through onboarding, opens Organization settings, // and uses the danger-zone "Delete organization" flow — which requires @@ -40,6 +41,7 @@ scenario( await step("Open Organization settings and find the danger zone", async () => { await visit(page, `/${slug}/org`); + await verifyAdminInBrowser(page); // The admin-only danger zone renders (a member would not see it). await page.getByText("Permanently delete this organization").waitFor(); }); diff --git a/e2e/cloud/org-settings-mfa.test.ts b/e2e/cloud/org-settings-mfa.test.ts new file mode 100644 index 0000000000..6647b3d143 --- /dev/null +++ b/e2e/cloud/org-settings-mfa.test.ts @@ -0,0 +1,221 @@ +import { expect } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import { verifyAdmin, verifyAdminInBrowser, responseCookies } from "./support/admin-mfa"; +import { activeOrg, forBrowser, joinOrg } from "./support/session"; + +const decodeKey = Schema.decodeUnknownSync( + Schema.Struct({ id: Schema.String, value: Schema.String }), +); +const decodeUsers = Schema.decodeUnknownSync( + Schema.Struct({ + users: Schema.Array(Schema.Struct({ email: Schema.NullOr(Schema.String) })), + }), +); + +scenario( + "Organization MFA · protects settings without blocking workspace or backend credentials", + {}, + Effect.gen(function* () { + const target = yield* Target; + const locked = yield* target.newIdentity(); + const other = yield* target.newIdentity(); + const org = yield* activeOrg(target, locked); + const unlocked = yield* verifyAdmin(target.baseUrl, locked); + yield* Effect.promise(async () => { + const send = ( + headers: Readonly> | undefined, + method: string, + path: string, + body?: unknown, + ) => + fetch(new URL(path, target.baseUrl), { + method, + headers: { + ...headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + for (const path of [ + "/api/admin/users", + "/api/account/members", + "/api/account/roles", + "/api/policies", + "/api/account/api-keys", + "/api/account/org-api-keys", + ]) { + expect((await send(locked.headers, "GET", path)).status, path).toBe(200); + } + const settings = [ + { method: "PATCH", path: "/api/account/name", body: { name: "Verified workspace" } }, + { + method: "POST", + path: "/api/account/members/invite", + body: { email: "invited@example.com", roleSlug: "member" }, + }, + { method: "DELETE", path: "/api/account/members/membership_missing" }, + { + method: "PATCH", + path: "/api/account/members/membership_missing/role", + body: { roleSlug: "admin" }, + }, + { method: "GET", path: "/api/org/domains" }, + { method: "POST", path: "/api/org/domains/verify-link", body: {} }, + { method: "DELETE", path: "/api/org/domains/domain_missing" }, + { + method: "POST", + path: "/api/auth/delete-organization", + body: { confirmName: "Never delete this fixture" }, + }, + ]; + for (const action of settings) { + expect( + (await send(locked.headers, action.method, action.path, action.body)).status, + action.path, + ).toBe(403); + } + expect( + (await send(unlocked.headers, "PATCH", "/api/account/name", { name: "Verified workspace" })) + .status, + ).toBe(200); + expect((await send(unlocked.headers, "GET", "/api/org/domains")).status).toBe(200); + const proof = unlocked.headers?.cookie + ?.split("; ") + .find((pair) => pair.startsWith("__Host-executor-admin-mfa=")); + if (!proof) throw new Error("Verification returned no proof"); + expect( + ( + await send( + { ...other.headers, cookie: `${other.headers?.cookie}; ${proof}` }, + "PATCH", + "/api/account/name", + { name: "Cross-user attempt" }, + ) + ).status, + ).toBe(403); + + // Key management is on its own screen and remains available without MFA. + const minted = await send(locked.headers, "POST", "/api/account/org-api-keys", { + name: "Backend reader", + }); + expect(minted.status).toBe(200); + const key = decodeKey(await minted.json()); + try { + const lock = await send(unlocked.headers, "POST", "/api/auth/admin-mfa/lock", {}); + expect(lock.status).toBe(200); + const relocked = { + ...unlocked.headers, + cookie: responseCookies(unlocked.headers?.cookie ?? "", lock), + }; + expect( + (await send(relocked, "PATCH", "/api/account/name", { name: "Relocked attempt" })).status, + ).toBe(403); + const email = locked.credentials?.email; + if (!email) throw new Error("Test identity has no email"); + const bearer = { authorization: `Bearer ${key.value}` }; + for (const path of [ + "/api/admin/users", + `/api/admin/users/with-connections?email=${encodeURIComponent(email)}`, + ]) { + const response = await send(bearer, "GET", path); + expect(response.status, path).toBe(200); + expect(decodeUsers(await response.json()).users.map((user) => user.email)).toContain( + email, + ); + } + expect( + ( + await send( + { ...bearer, "x-executor-organization": org.id }, + "PATCH", + "/api/account/name", + { name: "Machine settings attempt" }, + ) + ).status, + ).toBe(401); + } finally { + expect( + (await send(locked.headers, "DELETE", `/api/account/org-api-keys/${key.id}`)).status, + ).toBe(200); + } + }); + }), +); + +scenario( + "Organization MFA · browser unlock is confined to organization settings", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const admin = yield* target.newIdentity(); + const org = yield* activeOrg(target, admin); + yield* browser.session(forBrowser(admin), async ({ page, step }) => { + await step("Open integrations without verifying", async () => { + await visit(page, `/${org.slug}/integrations/add/openapi`); + await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); + }); + await step( + "Organization settings asks for an authenticator before showing controls", + async () => { + await visit(page, `/${org.slug}/org`); + await page.getByRole("heading", { name: "Unlock organization settings" }).waitFor(); + expect(await page.getByLabel("Organization name", { exact: true }).count()).toBe(0); + expect(await page.getByRole("button", { name: "Delete", exact: true }).count()).toBe(0); + }, + ); + await step("Enroll and verify, then edit the organization name", async () => { + await verifyAdminInBrowser(page); + await page.getByLabel("Organization name", { exact: true }).fill("Verified organization"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByText("Organization name updated", { exact: true }).waitFor(); + }); + await step("Return to organization settings without another challenge", async () => { + await visit(page, `/${org.slug}/api-keys`); + await page.getByRole("heading", { name: "Personal keys", exact: true }).waitFor(); + await visit(page, `/${org.slug}/org`); + await page.getByLabel("Organization name", { exact: true }).waitFor(); + expect( + await page.getByRole("heading", { name: "Unlock organization settings" }).count(), + ).toBe(0); + }); + await step("Lock settings and keep API key management available", async () => { + await page.getByRole("button", { name: "Lock organization settings" }).click(); + await page.getByRole("heading", { name: "Unlock organization settings" }).waitFor(); + await visit(page, `/${org.slug}/api-keys`); + await page.getByRole("button", { name: "New org key" }).waitFor(); + }); + }); + }), +); + +scenario( + "Organization MFA · verification does not elevate a member to admin", + {}, + Effect.gen(function* () { + const target = yield* Target; + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + const verified = yield* verifyAdmin(target.baseUrl, member); + yield* Effect.promise(async () => { + const headers = { + ...verified.headers, + origin: new URL(target.baseUrl).origin, + "content-type": "application/json", + }; + const domains = await fetch(new URL("/api/org/domains", target.baseUrl), { headers }); + expect(domains.status).toBe(200); + const rename = await fetch(new URL("/api/account/name", target.baseUrl), { + method: "PATCH", + headers, + body: JSON.stringify({ name: "Member cannot rename" }), + }); + expect(rename.status).toBe(403); + }); + }), +); diff --git a/e2e/cloud/spec-update-convergence.test.ts b/e2e/cloud/spec-update-convergence.test.ts index 0a9ca49647..7c893b9be6 100644 --- a/e2e/cloud/spec-update-convergence.test.ts +++ b/e2e/cloud/spec-update-convergence.test.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./support/admin-mfa"; // Cloud-only (needs real multi-user organizations): when one member refreshes // a shared integration's spec, a DIFFERENT member's OWN connection converges to // the new tool catalog on that member's next read — not just the editor's. @@ -150,7 +151,8 @@ const joinOrg = (target: TargetShape, admin: Identity, member: Identity) => Effect.gen(function* () { const adminSelector = admin.headers?.[ORG_SELECTOR_HEADER]; if (!adminSelector) throw new Error("admin identity carries no org selector header"); - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, }); const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string }; diff --git a/e2e/cloud/support/admin-mfa.ts b/e2e/cloud/support/admin-mfa.ts new file mode 100644 index 0000000000..2e28cb4248 --- /dev/null +++ b/e2e/cloud/support/admin-mfa.ts @@ -0,0 +1,126 @@ +import { Effect, Option, Schema } from "effect"; +import { TOTP } from "otpauth"; +import type { Page } from "playwright"; +import type { Identity } from "../../src/target"; + +// Each synthetic identity owns a test authenticator, reused for subsequent challenges. +const testAuthenticators = new Map(); + +const Setup = Schema.Struct({ kind: Schema.Literal("enroll"), secret: Schema.String }); +const decodeSetup = Schema.decodeUnknownOption(Setup); +const Challenge = Schema.Struct({ kind: Schema.Literal("challenge") }); +const decodeChallenge = Schema.decodeUnknownOption(Challenge); +const Verified = Schema.Struct({ verified: Schema.Literal(true) }); +const decodeVerified = Schema.decodeUnknownOption(Verified); +const decodeVerifiedState = Schema.decodeUnknownOption( + Schema.Struct({ state: Schema.Literal("verified") }), +); + +/** Apply response cookie rotations and deletions to a test client's cookie header. */ +export const responseCookies = (current: string, response: Response): string => { + const cookies = new Map(browserCookies(current).map(({ name, value }) => [name, value])); + for (const header of response.headers.getSetCookie()) { + const pair = header.split(";")[0]; + if (!pair) throw new Error("Empty response cookie"); + const separator = pair.indexOf("="); + if (separator < 1) throw new Error("Invalid response cookie"); + const name = pair.slice(0, separator); + if (/;\s*max-age=0(?:;|$)/i.test(header)) cookies.delete(name); + else cookies.set(name, pair.slice(separator + 1)); + } + return [...cookies].map(([name, value]) => `${name}=${value}`).join("; "); +}; + +/** Read all cookie pairs, including admin verification, into browser fixtures. */ +export const browserCookies = (cookie: string): NonNullable => + cookie + .split(";") + .map((pair) => pair.trim()) + .filter(Boolean) + .map((pair) => { + const separator = pair.indexOf("="); + if (separator < 1) throw new Error("Invalid test cookie"); + const name = pair.slice(0, separator); + return { + name, + value: pair.slice(separator + 1), + ...(name.startsWith("__Host-") ? { secure: true } : {}), + }; + }); + +/** Verify a test admin through the product, retaining the test authenticator for later sign-ins. */ +export const verifyAdmin = (baseUrl: string, identity: Identity): Effect.Effect => + Effect.promise(async () => { + const email = identity.credentials?.email; + if (!email) throw new Error("Test identity has no email"); + const headers = { + ...identity.headers, + origin: new URL(baseUrl).origin, + "content-type": "application/json", + }; + const status = await fetch(new URL("/api/auth/admin-mfa", baseUrl), { headers }); + if (status.ok && Option.isSome(decodeVerifiedState(await status.json()))) return identity; + const started = await fetch(new URL("/api/auth/admin-mfa/start", baseUrl), { + method: "POST", + headers, + body: "{}", + }); + if (!started.ok) throw new Error(`Admin enrollment failed (${started.status})`); + const raw: unknown = await started.json(); + const setup = Option.getOrNull(decodeSetup(raw)); + const secret = + setup?.secret ?? + (Option.isSome(decodeChallenge(raw)) ? testAuthenticators.get(email) : undefined); + if (!secret) throw new Error("Missing test authenticator"); + testAuthenticators.set(email, secret); + const pending = responseCookies(identity.headers?.cookie ?? "", started); + const verified = await fetch(new URL("/api/auth/admin-mfa/verify", baseUrl), { + method: "POST", + headers: { ...headers, cookie: pending }, + body: JSON.stringify({ code: new TOTP({ secret }).generate() }), + }); + if (!verified.ok || Option.isNone(decodeVerified(await verified.json()))) + throw new Error(`Admin verification failed (${verified.status})`); + const proof = verified.headers + .getSetCookie() + .find((cookie) => cookie.startsWith("__Host-executor-admin-mfa=")) + ?.split(";")[0]; + if (!proof) throw new Error("Admin verification set no proof cookie"); + const cookie = responseCookies(pending, verified); + return { + ...identity, + headers: { ...identity.headers, cookie }, + cookies: browserCookies(cookie), + }; + }); + +/** Complete the visible MFA prompt using enrollment or this test identity's authenticator. */ +export const verifyAdminInBrowser = async (page: Page, secret?: string): Promise => { + await page.getByRole("heading", { name: "Unlock organization settings" }).waitFor(); + const [started] = await Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/auth/admin-mfa/start")), + page.getByRole("button", { name: "Continue", exact: true }).click(), + ]); + const raw: unknown = await started.json(); + const setup = Option.getOrNull(decodeSetup(raw)); + const key = setup?.secret ?? (Option.isSome(decodeChallenge(raw)) ? secret : undefined); + if (!started.ok() || !key) throw new Error("Could not open the test authenticator"); + await page.getByLabel("Six-digit code").fill(new TOTP({ secret: key }).generate()); + const [verified] = await Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/auth/admin-mfa/verify")), + page.getByRole("button", { name: "Verify", exact: true }).click(), + ]); + if (!verified.ok()) throw new Error("Browser admin verification failed"); + await page + .getByRole("heading", { name: "Unlock organization settings" }) + .waitFor({ state: "detached" }); + // Successful verification reloads the document, so Chromium can discard that + // response body. Check the persisted session through the product instead. + const selector = new URL(page.url()).pathname.split("/")[1]; + if (!selector) throw new Error("Admin verification has no organization scope"); + const status = await page.request.get("/api/auth/admin-mfa", { + headers: { "x-executor-organization": selector }, + }); + if (!status.ok() || Option.isNone(decodeVerifiedState(await status.json()))) + throw new Error("The browser session is not verified"); +}; diff --git a/e2e/cloud/support/session.ts b/e2e/cloud/support/session.ts index cb8b0d6cf7..ef4c59ed18 100644 --- a/e2e/cloud/support/session.ts +++ b/e2e/cloud/support/session.ts @@ -1,3 +1,4 @@ +import { verifyAdmin } from "./admin-mfa"; // Cloud session + membership helpers shared by the scenarios that need MORE // than one identity in an org. // @@ -127,7 +128,8 @@ export const joinOrg = ( options: { readonly roleSlug?: string } = {}, ): Effect.Effect => Effect.gen(function* () { - const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, { + const verifiedAdmin = yield* verifyAdmin(target.baseUrl, admin); + const inviteResponse = yield* postJson(target, "/api/account/members/invite", verifiedAdmin, { email: member.credentials?.email, ...(options.roleSlug === undefined ? {} : { roleSlug: options.roleSlug }), }); diff --git a/e2e/package.json b/e2e/package.json index 10f9a8e40f..d1dd4ecf97 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.14.2", + "@executor-js/emulate": "0.14.3-mfa.0", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-graphql": "workspace:*", "@executor-js/plugin-mcp": "workspace:*", @@ -48,6 +48,7 @@ "@vitejs/plugin-react": "catalog:", "graphql": "^16.12.0", "iron-webcrypto": "^2.0.0", + "otpauth": "^9.5.2", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" From 7353cd878eff6513b5eb36bc4216e9f750b2eda3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:59:53 -0700 Subject: [PATCH 2/2] Verify organization settings in seat billing fixture --- e2e/cloud/member-seat-billing-sync.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/cloud/member-seat-billing-sync.test.ts b/e2e/cloud/member-seat-billing-sync.test.ts index 28922a6dba..059d08d2e4 100644 --- a/e2e/cloud/member-seat-billing-sync.test.ts +++ b/e2e/cloud/member-seat-billing-sync.test.ts @@ -23,6 +23,7 @@ import { AccountHttpApi } from "@executor-js/api"; import { scenario } from "../src/scenario"; import { Api, Autumn, Billing, Mcp, Target } from "../src/services"; import type { Identity } from "../src/target"; +import { verifyAdmin } from "./support/admin-mfa"; // apps/cloud/src/extensions/billing/plans.ts → MEMBER_LIMITS.free, mirrored by // the free plan's members item in autumn.config.ts. @@ -56,7 +57,8 @@ scenario( const identity = yield* target.newIdentity(); const bearer = yield* mcp.mintBearer(emailOf(identity)); const customerId = orgIdOf(bearer); - const client = yield* apiClient(AccountHttpApi, identity); + const verifiedIdentity = yield* verifyAdmin(target.baseUrl, identity); + const client = yield* apiClient(AccountHttpApi, verifiedIdentity); const seats = yield* autumn.expectMemberSeats(customerId, 1); expect(seats.granted, "the free plan's members item grants the advertised seats").toBe(