diff --git a/changelog.d/features/11340-web-session-contract.md b/changelog.d/features/11340-web-session-contract.md new file mode 100644 index 0000000000..33ee58cf9b --- /dev/null +++ b/changelog.d/features/11340-web-session-contract.md @@ -0,0 +1 @@ +- **feat(providers):** publish a management-authenticated versioned web-session credential contract from OmniRoute's canonical browser credential metadata ([#11340](https://github.com/diegosouzapw/OmniRoute/pull/11340)) — thanks @Zartharas diff --git a/src/app/api/providers/web-session-contract/route.ts b/src/app/api/providers/web-session-contract/route.ts new file mode 100644 index 0000000000..aaa0754d51 --- /dev/null +++ b/src/app/api/providers/web-session-contract/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { buildWebSessionContract } from "@/lib/providers/webSessionContract"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + return NextResponse.json(buildWebSessionContract()); +} diff --git a/src/lib/providers/webSessionContract.ts b/src/lib/providers/webSessionContract.ts new file mode 100644 index 0000000000..58ee726f5b --- /dev/null +++ b/src/lib/providers/webSessionContract.ts @@ -0,0 +1,58 @@ +import { + listExtractionConfigs, + type TokenSource, +} from "@omniroute/open-sse/services/tokenExtractionConfig.ts"; +import { getWebSessionCredentialRequirement } from "@/shared/providers/webSessionCredentials"; + +export const WEB_SESSION_CONTRACT_VERSION = 1; + +export interface WebSessionContractProvider { + providerId: string; + displayName: string; + loginUrl: string; + homeUrl: string; + tokenSources: TokenSource[]; + credential: { + kind: "cookie" | "token"; + storageKeys: string[]; + acceptsFullCookieHeader: boolean; + }; +} + +export interface WebSessionContract { + version: typeof WEB_SESSION_CONTRACT_VERSION; + providers: WebSessionContractProvider[]; +} + +/** + * Publish only the canonical, non-secret metadata needed by external + * credential brokers to capture credentials in the same shape OmniRoute + * accepts. Provider instructions, polling state, and credential values are + * intentionally excluded. + */ +export function buildWebSessionContract(): WebSessionContract { + const providers = listExtractionConfigs().flatMap((config) => { + const requirement = getWebSessionCredentialRequirement(config.providerId); + if (!requirement || requirement.kind === "none") return []; + + return [ + { + providerId: config.providerId, + displayName: config.displayName, + loginUrl: config.loginUrl, + homeUrl: config.homeUrl, + tokenSources: config.tokenSources.map((source) => ({ ...source })), + credential: { + kind: requirement.kind, + storageKeys: [...requirement.storageKeys], + acceptsFullCookieHeader: requirement.acceptsFullCookieHeader, + }, + }, + ]; + }); + + return { + version: WEB_SESSION_CONTRACT_VERSION, + providers, + }; +} diff --git a/tests/unit/web-session-contract.test.ts b/tests/unit/web-session-contract.test.ts new file mode 100644 index 0000000000..7c43e441a4 --- /dev/null +++ b/tests/unit/web-session-contract.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { listExtractionConfigs } from "../../open-sse/services/tokenExtractionConfig.ts"; +import { + buildWebSessionContract, + WEB_SESSION_CONTRACT_VERSION, +} from "../../src/lib/providers/webSessionContract.ts"; +import { getWebSessionCredentialRequirement } from "../../src/shared/providers/webSessionCredentials.ts"; + +test("web-session contract mirrors canonical extraction and credential metadata", () => { + const contract = buildWebSessionContract(); + assert.equal(contract.version, WEB_SESSION_CONTRACT_VERSION); + + const expected = listExtractionConfigs().flatMap((config) => { + const requirement = getWebSessionCredentialRequirement(config.providerId); + return requirement && requirement.kind !== "none" ? [{ config, requirement }] : []; + }); + + assert.equal(contract.providers.length, expected.length); + assert.equal( + new Set(contract.providers.map((provider) => provider.providerId)).size, + expected.length + ); + + for (const { config, requirement } of expected) { + const published = contract.providers.find( + (provider) => provider.providerId === config.providerId + ); + assert.ok(published, `${config.providerId} must be published`); + assert.equal(published.displayName, config.displayName); + assert.equal(published.loginUrl, config.loginUrl); + assert.equal(published.homeUrl, config.homeUrl); + assert.deepEqual(published.tokenSources, config.tokenSources); + assert.equal(published.credential.kind, requirement.kind); + assert.deepEqual(published.credential.storageKeys, [...requirement.storageKeys]); + assert.equal(published.credential.acceptsFullCookieHeader, requirement.acceptsFullCookieHeader); + } +}); + +test("web-session contract preserves representative token and cookie semantics", () => { + const providers = new Map( + buildWebSessionContract().providers.map((provider) => [provider.providerId, provider]) + ); + + assert.equal(providers.get("deepseek-web")?.credential.kind, "token"); + assert.equal(providers.get("zai-web")?.credential.kind, "token"); + assert.equal(providers.get("gemini-web")?.credential.kind, "cookie"); + assert.equal(providers.get("qwen-web")?.credential.kind, "cookie"); + + assert.ok( + providers + .get("deepseek-web") + ?.tokenSources.some((source) => source.type === "localStorage" && source.key === "userToken") + ); + assert.ok( + providers + .get("gemini-web") + ?.tokenSources.some( + (source) => + source.type === "cookie" && + source.name === "__Secure-1PSID" && + source.domain === ".google.com" + ) + ); +}); + +test("web-session contract excludes credential values and operator-only guidance", () => { + const serialized = JSON.stringify(buildWebSessionContract()); + + for (const forbidden of [ + "placeholder", + "instructions", + "pollingConfig", + "credentialName", + "guideSteps", + "guideNote", + ]) { + assert.equal( + serialized.includes(`\"${forbidden}\"`), + false, + `${forbidden} must not be published` + ); + } +}); + +test("web-session contract route remains management-authenticated", () => { + const source = readFileSync( + new URL("../../src/app/api/providers/web-session-contract/route.ts", import.meta.url), + "utf8" + ); + + const authCall = source.indexOf("requireManagementAuth(request)"); + const responseCall = source.indexOf("NextResponse.json(buildWebSessionContract())"); + + assert.ok(authCall >= 0, "route must require management authentication"); + assert.ok(responseCall > authCall, "authentication must run before contract publication"); +});