From 479ca1f9a8045f9d564971c351344f2bb6267a6f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 6 Oct 2026 12:28:24 +0000 Subject: [PATCH 1/3] fix(compliance): correct hosted credentials, probes, and URL audit --- package-lock.json | 52 ++-- package.json | 2 +- server/src/addie/config-version.ts | 2 +- server/src/addie/mcp/member-tools.ts | 8 +- .../src/addie/services/compliance-testing.ts | 15 +- server/src/db/agent-context-db.ts | 6 + server/src/routes/registry-api.ts | 51 +++- .../scripts/agent-url-key-migration-audit.ts | 88 ++++++ ...t-agent-url-canonicalization-collisions.ts | 279 +++++------------- ...agent-context-db-oauth-cc-resource.test.ts | 19 ++ .../agent-url-key-migration-audit.test.ts | 75 +++++ .../unit/compliance-target-discovery.test.ts | 24 ++ .../hosted-capability-probe-callers.test.ts | 79 +++++ .../unit/hosted-route-storyboard.test.ts | 4 + .../perspective-asset-cache-privacy.test.ts | 6 +- server/tests/unit/proxy-address-trust.test.ts | 51 ++++ .../unit/registry-clear-auth-route.test.ts | 90 ++++++ ...xed-trace-rollout-typecheck-wiring.test.ts | 7 +- 18 files changed, 610 insertions(+), 248 deletions(-) create mode 100644 server/src/scripts/agent-url-key-migration-audit.ts create mode 100644 server/tests/unit/agent-url-key-migration-audit.test.ts create mode 100644 server/tests/unit/hosted-capability-probe-callers.test.ts create mode 100644 server/tests/unit/proxy-address-trust.test.ts create mode 100644 server/tests/unit/registry-clear-auth-route.test.ts diff --git a/package-lock.json b/package-lock.json index b021cae49e7..9234fa5208b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -186,9 +186,9 @@ } }, "node_modules/@adcp/sdk/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1989,9 +1989,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", - "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.8.0", @@ -8100,9 +8100,9 @@ } }, "node_modules/@slack/bolt/node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "license": "MIT", "peer": true, "engines": { @@ -10468,9 +10468,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14146,9 +14146,9 @@ "license": "MIT" }, "node_modules/fstream/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -20611,9 +20611,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -20621,6 +20621,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -22647,9 +22651,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -23988,9 +23992,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.10.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", - "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/package.json b/package.json index 87ed2e04ced..d6b2731c75a 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,7 @@ "test": "npm run test:sdk-response-conformance && npm run test:docs-nav && npm run test:owned-links && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:performance-feedback && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:fast-channel-collections && npm run test:collection-selection-echo && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:functional-request-signing-contract && npm run test:webhook-receiver-envelope && npm run test:webhook-emission-storyboard && npm run test:oauth-setup-vectors && npm run test:a2a-profile-extension && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:targeting-null-clear && npm run test:targeting-aware-discovery && npm run test:demographic-targeting && npm run test:audience-activation-methods && npm run test:principal && npm run test:audience-external-sources && npm run test:language-targeting && npm run test:availability-horizon && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:signal-governance-storyboard && npm run test:governance-task-slices && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-fixture-resolution && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:storyboard-buyer-fixture-lifecycle-tools && npm run test:storyboard-buyer-fixture-products && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:storyboard-generate-phase-scope && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:build-schemas-vendor-metric-uniqueness && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:compliance-snippets && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck && npm run test:contracts && npm run test:example-validation && npm run test:test-coverage", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit:server-unit": "node scripts/precommit-server-unit.cjs", - "precommit": "bash scripts/with-timeout.sh 180 npm run test:unit && npm run test:test-dynamic-imports && npm run test:format-identity-boundaries && npm run test:callapi-state-change && bash scripts/with-timeout.sh 1500 npm run precommit:server-unit && npm run typecheck", + "precommit": "bash scripts/with-timeout.sh 300 npm run test:unit && npm run test:test-dynamic-imports && npm run test:format-identity-boundaries && npm run test:callapi-state-change && bash scripts/with-timeout.sh 1500 npm run precommit:server-unit && npm run typecheck", "test:storyboards": "bash scripts/run-storyboards-matrix.sh", "test:storyboards:3.0-compat": "bash scripts/run-storyboards-3-0-compat.sh", "prepare": "sh scripts/configure-git-hooks.sh", diff --git a/server/src/addie/config-version.ts b/server/src/addie/config-version.ts index 9ef6f3d88ad..142e2633b4e 100644 --- a/server/src/addie/config-version.ts +++ b/server/src/addie/config-version.ts @@ -30,7 +30,7 @@ import { loadRules, loadResponseStyle } from './rules/index.js'; * Format: YYYY.MM.N where N is incremented for multiple changes in a month * Example: 2025.01.1, 2025.01.2, 2025.02.1 */ -export const CODE_VERSION = '2026.10.4'; +export const CODE_VERSION = '2026.10.5'; // Types export interface ConfigVersion { diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 6140284e509..f97bc3b0ec1 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -62,6 +62,7 @@ import { hasTrustworthyComplianceTarget, selectComplianceTargetForAgent, selectComplianceTargetForAgentSelection, + hostedCapabilityDiscoveryOptions, selectedComplianceTargetMatchesObservedProfile, UNRESOLVED_COMPLIANCE_TARGET_MESSAGE, type ComplyOptions, @@ -5371,9 +5372,10 @@ export function createMemberToolHandlers( let profile: AgentProfile | undefined; let discoveryProbeError: string | undefined; try { - const caps = await testCapabilityDiscovery(resolved.resolvedUrl, withSdkSafeTransport({ - ...(authOption && { auth: authOption }), - })); + const caps = await testCapabilityDiscovery(resolved.resolvedUrl, hostedCapabilityDiscoveryOptions( + { ...(authOption && { auth: authOption }) }, + hasExplicitComplianceTarget(input) ? runTarget : undefined, + )); profile = caps.profile; discoveryProbeError = capabilityDiscoveryProbeError(caps); if (!hasExplicitComplianceTarget(input)) { diff --git a/server/src/addie/services/compliance-testing.ts b/server/src/addie/services/compliance-testing.ts index fbb0b394289..6ce5ae7bdb8 100644 --- a/server/src/addie/services/compliance-testing.ts +++ b/server/src/addie/services/compliance-testing.ts @@ -22,6 +22,7 @@ import { type TrackResult, type AdvisoryObservation, type SampleBrief, + type TestOptions, } from '@adcp/sdk/testing'; import { hostedComplianceTarget, @@ -58,6 +59,16 @@ import type { const logger = createLogger('addie-compliance-testing'); export const HOSTED_TARGET_DISCOVERY_TIMEOUT_MS = 30_000; +/** Pin selected targets; bootstrap discovery must not inherit the SDK's version. */ +export function hostedCapabilityDiscoveryOptions( + options: TestOptions, + target?: HostedComplianceTarget, +): TestOptions { + return withSdkSafeTransport(target + ? withHostedTestOptions({ ...options, versionEnvelope: 'auto' }, target) + : { ...options, adcpVersion: undefined, versionEnvelope: 'major-only' }); +} + export interface ComplianceTargetSelection { target: HostedComplianceTarget; confirmed: boolean; @@ -362,9 +373,7 @@ export async function selectComplianceTargetForAgentSelection( // gets a chance to report supported_versions. const discovery = await discoverCapabilitiesWithDeadline( agentUrl, - compatibleSeededTarget - ? withHostedTestOptions(options, compatibleSeededTarget) - : options, + hostedCapabilityDiscoveryOptions(options, compatibleSeededTarget), ); const target = mode === 'canonical' ? selectCanonicalHostedComplianceTargetForProfile(discovery.profile, fallback) diff --git a/server/src/db/agent-context-db.ts b/server/src/db/agent-context-db.ts index d074d339ca0..1506c2af7a5 100644 --- a/server/src/db/agent-context-db.ts +++ b/server/src/db/agent-context-db.ts @@ -1018,6 +1018,8 @@ export class AgentContextDatabase { * `client_secret` is encrypted at rest regardless of whether it's a literal * value or a `$ENV:VAR_NAME` reference — the SDK resolves the reference at * exchange time, the server just stores and returns. + * Saving selects client credentials over any connect-form bearer/basic token; + * clear that token atomically so every resolver observes the same switch. */ async saveOAuthClientCredentials(id: string, creds: OAuthClientCredentials): Promise { const context = await this.getById(id); @@ -1038,6 +1040,10 @@ export class AgentContextDatabase { oauth_cc_resource = $6, oauth_cc_audience = $7, oauth_cc_auth_method = $8, + auth_token_encrypted = NULL, + auth_token_iv = NULL, + auth_token_hint = NULL, + auth_type = 'bearer', updated_at = NOW() WHERE id = $9`, [ diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index 438e06a95b9..5ab016332a1 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -68,6 +68,7 @@ import { hasTrustworthyComplianceTarget, selectComplianceTargetForAgent, selectComplianceTargetForAgentSelection, + hostedCapabilityDiscoveryOptions, selectedComplianceTargetMatchesObservedProfile, UNRESOLVED_COMPLIANCE_TARGET_MESSAGE, } from "../addie/services/compliance-testing.js"; @@ -4569,6 +4570,27 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "delete", + path: "/api/registry/agents/{encodedUrl}/connect", + operationId: "removeAgentAuthToken", + summary: "Clear saved bearer or basic credentials", + description: "Clear the selected organization's static credential while retaining OAuth configuration and compliance history. Requires authentication and ownership.", + tags: ["Agent Compliance"], + security: [{ bearerAuth: [] }, { oauth2: [] }], + request: { + params: z.object({ encodedUrl: z.string() }), + query: z.object({ org: z.string().optional() }), + }, + responses: { + 204: { description: "Static credential cleared" }, + 400: { description: "Invalid parameters", content: { "application/json": { schema: ErrorSchema } } }, + 401: { description: "Authentication required", content: { "application/json": { schema: ErrorSchema } } }, + 403: { description: "Not authorized", content: { "application/json": { schema: ErrorSchema } } }, + 500: { description: "Server error", content: { "application/json": { schema: ErrorSchema } } }, + }, +}); + registry.registerPath({ method: "put", path: "/api/registry/agents/{encodedUrl}/oauth-client-credentials", @@ -9557,6 +9579,33 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { }, ); + router.delete("/registry/agents/:encodedUrl/connect", ...complianceWriteMiddleware, async (req, res) => { + try { + const rawAgentUrl = decodeURIComponent(req.params.encodedUrl); + if (!validateAgentUrlParam(rawAgentUrl)) { + return res.status(400).json({ error: "Invalid agent URL" }); + } + const agentUrl = canonicalizeAgentUrl(rawAgentUrl) ?? rawAgentUrl; + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + const orgSelection = parseRequestedOrganizationQuery(req.query); + if (!orgSelection.ok) { + return res.status(400).json({ error: "org must be a non-empty organization ID" }); + } + const orgId = await resolveOwnerOrgForUser(req.user.id, agentUrl, orgSelection.organizationId); + if (!orgId) { + return res.status(403).json({ error: "You do not have permission to modify this agent" }); + } + const context = await agentContextDb.getByOrgAndUrl(orgId, agentUrl); + if (context) await agentContextDb.removeAuthToken(context.id); + return res.status(204).end(); + } catch (error) { + logger.error({ err: error, path: req.path }, "Failed to clear agent auth token"); + return res.status(500).json({ error: "Failed to clear agent auth token" }); + } + }); + /** * Dry-run the saved client-credentials config by exchanging at the token * endpoint and discarding the result. Converts the dashboard's "save and @@ -9707,7 +9756,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { try { const caps = await testCapabilityDiscovery( agentUrl, - withSdkSafeTransport({ ...(probeAuth && { auth: probeAuth }) }), + hostedCapabilityDiscoveryOptions({ ...(probeAuth && { auth: probeAuth }) }), ); profile = caps.profile; diff --git a/server/src/scripts/agent-url-key-migration-audit.ts b/server/src/scripts/agent-url-key-migration-audit.ts new file mode 100644 index 00000000000..ad3cef5fd4b --- /dev/null +++ b/server/src/scripts/agent-url-key-migration-audit.ts @@ -0,0 +1,88 @@ +import { canonicalTargetUri } from '@adcp/sdk/signing'; + +/** Frozen pre-migration algorithm so before/after reports stay comparable. */ +function legacyRegistryAgentUrlKey(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed === '*') return '*'; + if (!trimmed || trimmed.includes('*') || /[\s\x00-\x1f]/.test(trimmed)) return null; + const lower = trimmed.toLowerCase(); + let end = lower.length; + while (end > 0 && lower[end - 1] === '/') end -= 1; + return lower.slice(0, end) || null; +} + +/** Candidate keys only: live readers and writers retain their current semantics. */ +export function proposedRegistryAgentUrlKey(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed === '*') return '*'; + if (!trimmed || trimmed.includes('*') || trimmed.includes('\\') || /[\s\x00-\x1f\x7f]/.test(trimmed)) return null; + try { + const url = new URL(trimmed); + if (!['https:', 'http:'].includes(url.protocol) || !trimmed.includes('://') || url.username || url.password) return null; + // The SDK accepts ASCII URI authorities; WHATWG URL supplies the IDN + // A-label first. Preserve the original path/query bytes for SDK handling. + const authorityStart = trimmed.indexOf('://') + 3; + const suffixOffset = trimmed.slice(authorityStart).search(/[/?#]/); + if (suffixOffset === 0) return null; + const authority = trimmed.slice(authorityStart, suffixOffset < 0 ? undefined : authorityStart + suffixOffset); + if (authority.includes('@')) return null; + const suffix = suffixOffset < 0 ? '' : trimmed.slice(authorityStart + suffixOffset); + const canonical = canonicalTargetUri(`${url.protocol}//${url.host}${suffix}`, '3.2'); + // Root-served registry agents keep their existing key when there is no query. + const parsed = new URL(canonical); + return parsed.pathname === '/' && !canonical.includes('?') ? canonical.slice(0, -1) : canonical; + } catch { + return null; + } +} + +export interface AgentUrlAuditRow { + store: string; + rowId: string; + rawUrl: string; + storedKey?: string; +} + +export function auditAgentUrlKeyMigration(rows: AgentUrlAuditRow[]) { + const entries = rows.map(row => ({ + ...row, + legacyKey: legacyRegistryAgentUrlKey(row.rawUrl), + proposedKey: proposedRegistryAgentUrlKey(row.rawUrl), + })); + const groupBy = (field: 'legacyKey' | 'proposedKey') => { + const groups = new Map(); + for (const entry of entries) { + const key = entry[field]; + if (key === null) continue; + const group = groups.get(key) ?? []; + group.push(entry); + groups.set(key, group); + } + return [...groups.entries()]; + }; + const splits = groupBy('legacyKey').flatMap(([legacyKey, candidates]) => { + const proposedKeys = [...new Set(candidates.map(row => row.proposedKey).filter(key => key !== null))]; + return proposedKeys.length > 1 ? [{ legacyKey, proposedKeys, candidates }] : []; + }); + const merges = groupBy('proposedKey').flatMap(([proposedKey, candidates]) => { + const legacyKeys = [...new Set(candidates.map(row => row.legacyKey).filter(key => key !== null))]; + return legacyKeys.length > 1 ? [{ proposedKey, legacyKeys, candidates }] : []; + }); + return { + rowCount: rows.length, + nonRootTrailingSlashRows: entries.filter(row => { + if (!row.proposedKey || row.proposedKey === '*') return false; + const path = new URL(row.proposedKey).pathname; + return path !== '/' && path.endsWith('/'); + }), + bareQueryRows: entries.filter(row => row.rawUrl.split('#', 1)[0].endsWith('?')), + invalidRows: entries.filter(row => row.proposedKey === null), + changedRows: entries.filter(row => row.proposedKey !== null && (row.storedKey ?? row.rawUrl) !== row.proposedKey), + splitCandidates: splits, + mergeCandidates: merges, + // Already normalized keys cannot recover lost path case/trailing slashes. + rawSourceRequired: entries.filter(row => row.proposedKey && row.proposedKey !== '*' + && row.rawUrl === row.legacyKey + && (new URL(row.proposedKey).pathname !== '/' || row.rawUrl.includes('?'))), + }; +} diff --git a/server/src/scripts/audit-agent-url-canonicalization-collisions.ts b/server/src/scripts/audit-agent-url-canonicalization-collisions.ts index 1b030ad5bdd..5665a04ac9d 100644 --- a/server/src/scripts/audit-agent-url-canonicalization-collisions.ts +++ b/server/src/scripts/audit-agent-url-canonicalization-collisions.ts @@ -1,225 +1,80 @@ /** - * Audit `agent_url` collisions that would collapse under the canonicalizer - * added in PR #4551 / issue #3573. Read-only — emits a report only. + * Read-only preflight for #7830. Compares legacy keys with proposed keys, + * including source manifests so old merges that now require splits are visible. * - * Two stores are surveyed: + * DATABASE_URL=… npx tsx server/src/scripts/audit-agent-url-canonicalization-collisions.ts + * fly ssh console -a adcp-docs -C 'node /app/dist/scripts/audit-agent-url-canonicalization-collisions.js' * - * 1. `agent_registry_metadata` (PK = `agent_url`) — direct readers - * (compliance heartbeat, lifecycle dashboards) won't get the - * `FederatedIndexService` read-side fallback, so duplicates here - * are the ones that need a one-shot cleanup the most. - * - * 2. `member_profiles.agents` (JSONB array of {url, ...}) — duplicates - * here drive the badge-drop bug #3573 describes. PR #4551's - * canonical-key + `?? raw` fallback papers over reads, but the - * stored bytes stay non-canonical until the member next saves. - * - * Canonical form mirrors `canonicalizeAgentUrl` (publisher-db.ts): - * trim → lower → strip trailing slashes. Wildcards / whitespace-only - * rows are surfaced separately so an operator can decide what to do - * with them; they would canonicalize to null in JS. - * - * Usage (dev): - * DATABASE_URL=… npx tsx server/src/scripts/audit-agent-url-canonicalization-collisions.ts - * - * Usage (prod, via fly ssh): - * fly ssh console -a adcp-docs -C 'node /app/dist/scripts/audit-agent-url-canonicalization-collisions.js' + * Split/merge groups are candidates across stores, not a row deletion plan. + * Authorization scope, ownership, credentials and history need review. */ - import { initializeDatabase, getPool, closeDatabase } from '../db/client.js'; import { getDatabaseConfig } from '../config.js'; - -interface MetadataCollisionRow { - canonical: string; - raw_urls: string[]; - row_count: number; -} - -interface ProfileCollisionRow { - profile_id: string; - profile_slug: string; - canonical: string; - raw_urls: string[]; - row_count: number; -} - -interface CrossStoreMismatch { - canonical: string; - metadata_raw: string | null; - profile_raws: string[]; -} +import { auditAgentUrlKeyMigration, type AgentUrlAuditRow } from './agent-url-key-migration-audit.js'; async function main(): Promise { - const dbConfig = getDatabaseConfig(); - if (!dbConfig) { - console.error('DATABASE_URL is required'); - process.exit(1); - } - initializeDatabase(dbConfig); - const pool = getPool(); - - // SQL canonicalization mirrors canonicalizeAgentUrl: - // trim → lower → strip 1+ trailing slashes. - // `regexp_replace(..., '/+$', '')` strips all trailing slashes in one pass. - // Wildcard '*' is kept literal (no trailing slashes to strip anyway). - const sqlCanonical = `regexp_replace(lower(trim(agent_url)), '/+$', '')`; - - // 1. agent_registry_metadata collisions - const metadataCollisions = await pool.query(` - SELECT - ${sqlCanonical} AS canonical, - array_agg(DISTINCT agent_url ORDER BY agent_url) AS raw_urls, - COUNT(*)::int AS row_count - FROM agent_registry_metadata - WHERE agent_url IS NOT NULL - GROUP BY ${sqlCanonical} - HAVING COUNT(DISTINCT agent_url) > 1 - ORDER BY COUNT(*) DESC, canonical - `); - - // 2. member_profiles.agents JSONB collisions (within a single profile) - const profileCollisions = await pool.query(` - WITH unnested AS ( - SELECT - mp.id AS profile_id, - mp.slug AS profile_slug, - (elem ->> 'url') AS raw_url, - regexp_replace(lower(trim(elem ->> 'url')), '/+$', '') AS canonical - FROM member_profiles mp, - jsonb_array_elements(mp.agents) elem - WHERE elem ->> 'url' IS NOT NULL - ) - SELECT - profile_id, - profile_slug, - canonical, - array_agg(DISTINCT raw_url ORDER BY raw_url) AS raw_urls, - COUNT(*)::int AS row_count - FROM unnested - GROUP BY profile_id, profile_slug, canonical - HAVING COUNT(DISTINCT raw_url) > 1 - ORDER BY COUNT(*) DESC, profile_slug, canonical - `); - - // 3. Cross-store mismatch — one canonical key shared between the two - // stores but with different raw spellings. This is the case that - // silently drops the member badge: registered side wrote one shape, - // discovered/metadata side has another. - const crossStore = await pool.query(` - WITH meta AS ( - SELECT - agent_url AS raw, - regexp_replace(lower(trim(agent_url)), '/+$', '') AS canonical - FROM agent_registry_metadata - WHERE agent_url IS NOT NULL - ), - profile_urls AS ( - SELECT DISTINCT - (elem ->> 'url') AS raw, - regexp_replace(lower(trim(elem ->> 'url')), '/+$', '') AS canonical - FROM member_profiles mp, - jsonb_array_elements(mp.agents) elem - WHERE elem ->> 'url' IS NOT NULL - ), - joined AS ( - SELECT - COALESCE(m.canonical, p.canonical) AS canonical, - m.raw AS metadata_raw, - p.raw AS profile_raw - FROM meta m - FULL OUTER JOIN profile_urls p ON m.canonical = p.canonical - ) - SELECT - canonical, - MAX(metadata_raw) AS metadata_raw, - array_agg(DISTINCT profile_raw) FILTER (WHERE profile_raw IS NOT NULL) AS profile_raws - FROM joined - GROUP BY canonical - HAVING - -- Drop the common case where both stores agree byte-for-byte. - NOT ( - COUNT(DISTINCT metadata_raw) <= 1 - AND COUNT(DISTINCT profile_raw) <= 1 - AND ( - MAX(metadata_raw) IS NULL - OR MAX(profile_raw) IS NULL - OR MAX(metadata_raw) = MAX(profile_raw) - ) - ) - ORDER BY canonical - `); - - // 4. Whitespace / wildcard / null-canonical rows in metadata — these - // would canonicalize to null in JS and need operator attention - // regardless of #4551. - const malformed = await pool.query<{ agent_url: string }>(` - SELECT agent_url - FROM agent_registry_metadata - WHERE agent_url ~ '[[:space:]]' - OR agent_url = '' - OR agent_url LIKE '%*%' - `); - - console.log('=== agent_url canonicalization sweep (PR #4551 / issue #3573) ===\n'); - - console.log(`1. agent_registry_metadata collisions: ${metadataCollisions.rowCount}`); - if (metadataCollisions.rowCount && metadataCollisions.rowCount > 0) { - for (const row of metadataCollisions.rows) { - console.log(` canonical=${row.canonical} rows=${row.row_count}`); - for (const raw of row.raw_urls) console.log(` - ${raw}`); - } - } - console.log(''); - - console.log(`2. member_profiles.agents intra-profile collisions: ${profileCollisions.rowCount}`); - if (profileCollisions.rowCount && profileCollisions.rowCount > 0) { - for (const row of profileCollisions.rows) { - console.log(` profile=${row.profile_slug} [${row.profile_id}]`); - console.log(` canonical=${row.canonical} rows=${row.row_count}`); - for (const raw of row.raw_urls) console.log(` - ${raw}`); - } - } - console.log(''); - - console.log(`3. Cross-store mismatches (registered vs metadata raw spelling differs but canonical matches): ${crossStore.rowCount}`); - if (crossStore.rowCount && crossStore.rowCount > 0) { - for (const row of crossStore.rows) { - console.log(` canonical=${row.canonical}`); - console.log(` metadata_raw=${row.metadata_raw ?? '(missing)'}`); - const profiles = row.profile_raws ?? []; - if (profiles.length === 0) { - console.log(` profile_raws=(none)`); - } else { - for (const raw of profiles) console.log(` profile_raw=${raw}`); - } - } - } - console.log(''); - - console.log(`4. Malformed metadata rows (whitespace / wildcard / empty): ${malformed.rowCount}`); - if (malformed.rowCount && malformed.rowCount > 0) { - for (const row of malformed.rows) { - console.log(` - ${JSON.stringify(row.agent_url)}`); + if (process.argv.includes('--apply')) throw new Error('This audit is read-only; --apply is not supported.'); + const config = getDatabaseConfig(); + if (!config) throw new Error('DATABASE_URL is required'); + initializeDatabase(config); + const client = await getPool().connect(); + try { + await client.query('BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY'); + await client.query("SET LOCAL statement_timeout = '30s'"); + const tables = await client.query<{ table_name: string; has_canonical: boolean }>(` + SELECT c.table_name, bool_or(c.column_name = 'agent_url_canonical') AS has_canonical + FROM information_schema.columns c + JOIN information_schema.tables t USING (table_schema, table_name) + WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE' + AND c.column_name IN ('agent_url', 'agent_url_canonical') + GROUP BY c.table_name HAVING bool_or(c.column_name = 'agent_url') + ORDER BY c.table_name + `); + const rows: AgentUrlAuditRow[] = []; + for (const table of tables.rows) { + // Identifiers come from information_schema and are quoted, never inputs. + const identifier = '"' + table.table_name.replaceAll('"', '""') + '"'; + const result = await client.query<{ row_id: string; raw_url: string; stored_key: string | null }>(` + SELECT COALESCE(to_jsonb(t)->>'id', to_jsonb(t)->>'organization_id', agent_url) AS row_id, + agent_url AS raw_url, ${table.has_canonical ? 'agent_url_canonical' : 'NULL::text'} AS stored_key + FROM public.${identifier} t WHERE agent_url IS NOT NULL + `); + for (const row of result.rows) rows.push({ + store: table.table_name, rowId: row.row_id, rawUrl: row.raw_url, + ...(row.stored_key !== null && { storedKey: row.stored_key }), + }); } + const profiles = await client.query<{ row_id: string; raw_url: string }>(` + SELECT mp.id::text || ':' || a.ordinality::text AS row_id, a.elem->>'url' AS raw_url + FROM member_profiles mp CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(mp.agents) = 'array' THEN mp.agents ELSE '[]'::jsonb END + ) WITH ORDINALITY AS a(elem, ordinality) + WHERE jsonb_typeof(a.elem->'url') = 'string' + `); + rows.push(...profiles.rows.map(row => ({ store: 'member_profiles.agents', rowId: row.row_id, rawUrl: row.raw_url }))); + const manifests = await client.query<{ row_id: string; raw_url: string }>(` + SELECT p.domain || ':' || a.ordinality::text AS row_id, a.elem->>'url' AS raw_url + FROM publishers p CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(p.adagents_json->'authorized_agents') = 'array' + THEN p.adagents_json->'authorized_agents' ELSE '[]'::jsonb END + ) WITH ORDINALITY AS a(elem, ordinality) + WHERE jsonb_typeof(a.elem->'url') = 'string' + `); + rows.push(...manifests.rows.map(row => ({ store: 'publishers.adagents_json', rowId: row.row_id, rawUrl: row.raw_url }))); + console.log(JSON.stringify({ + issue: 7830, + liveKeysChanged: false, + surveyedTables: tables.rows.map(row => row.table_name), + ...auditAgentUrlKeyMigration(rows), + }, null, 2)); + await client.query('ROLLBACK'); + } finally { + client.release(); + await closeDatabase(); } - console.log(''); - - const totalIssues = - (metadataCollisions.rowCount ?? 0) - + (profileCollisions.rowCount ?? 0) - + (crossStore.rowCount ?? 0) - + (malformed.rowCount ?? 0); - - if (totalIssues === 0) { - console.log('Clean. No backfill needed; PR #4551 prevents new drift going forward.'); - } else { - console.log(`Total findings: ${totalIssues}. A one-shot cleanup is warranted; see issue #3573 "out of scope" guidance.`); - } - - await closeDatabase(); } -main().catch((err) => { - console.error('audit failed:', err); - process.exit(1); +main().catch(error => { + console.error('Agent URL migration audit failed:', error); + process.exitCode = 1; }); diff --git a/server/tests/unit/agent-context-db-oauth-cc-resource.test.ts b/server/tests/unit/agent-context-db-oauth-cc-resource.test.ts index 011f7714afe..60115df8ecd 100644 --- a/server/tests/unit/agent-context-db-oauth-cc-resource.test.ts +++ b/server/tests/unit/agent-context-db-oauth-cc-resource.test.ts @@ -64,6 +64,25 @@ describe('AgentContextDatabase — oauth_cc_resource save/load (RFC 8707 multi-r // ── Save: TEXT column encoding ───────────────────────────────────────── describe('saveOAuthClientCredentials', () => { + it('clears saved bearer/basic credentials in the same update that selects client credentials', async () => { + mockGetById(); + mockedEncrypt.mockReturnValueOnce({ encrypted: 'enc', iv: 'iv' }); + mockUpdate(); + + await db.saveOAuthClientCredentials('ctx_1', { + token_endpoint: 'https://auth.example.com/oauth/token', + client_id: 'client_abc', + client_secret: 'secret', + }); + + const [sql] = mockedQuery.mock.calls[1]; + expect(sql).toContain('oauth_cc_client_secret_encrypted = $3'); + expect(sql).toContain('auth_token_encrypted = NULL'); + expect(sql).toContain('auth_token_iv = NULL'); + expect(sql).toContain('auth_token_hint = NULL'); + expect(mockedQuery).toHaveBeenCalledTimes(2); + }); + it('stores an array resource with the v1a: prefix', async () => { mockGetById(); mockedEncrypt.mockReturnValueOnce({ encrypted: 'enc', iv: 'iv' }); diff --git a/server/tests/unit/agent-url-key-migration-audit.test.ts b/server/tests/unit/agent-url-key-migration-audit.test.ts new file mode 100644 index 00000000000..6b54971cb32 --- /dev/null +++ b/server/tests/unit/agent-url-key-migration-audit.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { auditAgentUrlKeyMigration, proposedRegistryAgentUrlKey } from '../../src/scripts/agent-url-key-migration-audit.js'; + +const row = (rawUrl: string, store = 'metadata') => ({ store, rowId: rawUrl, rawUrl }); + +describe('registry URL migration preflight', () => { + it('preserves endpoint path case and non-root trailing slashes', () => { + expect(proposedRegistryAgentUrlKey('HTTPS://Agent.Example/tenants/Acme/mcp/')).toBe('https://agent.example/tenants/Acme/mcp/'); + const audit = auditAgentUrlKeyMigration([ + row('https://agent.example/tenants/Acme/mcp/'), + row('https://agent.example/tenants/acme/mcp'), + ]); + expect(audit.splitCandidates).toHaveLength(1); + expect(audit.splitCandidates[0].proposedKeys).toHaveLength(2); + expect(audit.nonRootTrailingSlashRows).toHaveLength(1); + }); + + it('normalizes equivalent URLs and exposes merge candidates', () => { + const audit = auditAgentUrlKeyMigration([ + row('https://agent.example:443/a/../MCP/%7e#fragment'), + row('https://agent.example/MCP/~'), + ]); + expect(audit.mergeCandidates).toHaveLength(1); + expect(audit.mergeCandidates[0].proposedKey).toBe('https://agent.example/MCP/~'); + expect(proposedRegistryAgentUrlKey('https://bücher.example')).toBe('https://xn--bcher-kva.example'); + }); + + it('retains existing root keys while preserving empty-query identity', () => { + expect(proposedRegistryAgentUrlKey('https://agent.example/')).toBe('https://agent.example'); + expect(proposedRegistryAgentUrlKey('https://agent.example')).toBe('https://agent.example'); + expect(proposedRegistryAgentUrlKey('https://agent.example?')).toBe('https://agent.example/?'); + expect(proposedRegistryAgentUrlKey('https://agent.example/mcp?')).toBe('https://agent.example/mcp?'); + expect(auditAgentUrlKeyMigration([row('https://agent.example?')]).bareQueryRows).toHaveLength(1); + }); + + it('preserves query bytes and normalizes one DNS root dot', () => { + expect(proposedRegistryAgentUrlKey('https://agent.example./mcp')).toBe('https://agent.example/mcp'); + expect(proposedRegistryAgentUrlKey('https://agent.example../mcp')).toBeNull(); + expect(proposedRegistryAgentUrlKey('https://agent.example/mcp?x=%7e')).toBe('https://agent.example/mcp?x=%7e'); + const audit = auditAgentUrlKeyMigration([ + row('https://agent.example/mcp?x=%7e'), row('https://agent.example/mcp?x=~'), + ]); + expect(audit.mergeCandidates).toHaveLength(0); + }); + + it('keeps the wildcard sentinel and rejects malformed or credential-bearing URLs', () => { + expect(proposedRegistryAgentUrlKey(' * ')).toBe('*'); + for (const raw of ['', 'https://agent.example/*', 'https://agent.example/a b', 'https://agent.example/a\u0000', 'ftp://agent.example', 'https://user:secret@agent.example', 'https://@agent.example/mcp', 'https://:@agent.example/mcp', 'https:///p', 'https://agent.example\\mcp', 'https://agent.example\\tenants\\Acme/mcp', 'invalid']) { + expect(proposedRegistryAgentUrlKey(raw)).toBeNull(); + } + }); + + it('compares persisted keys and source manifests to expose lost identities', () => { + const audit = auditAgentUrlKeyMigration([ + { ...row('https://agent.example/MCP/', 'catalog'), storedKey: 'https://agent.example/mcp' }, + row('https://agent.example/mcp', 'metadata'), + row('https://agent.example/MCP/', 'manifest'), + ]); + expect(audit.changedRows).toHaveLength(1); + expect(audit.rawSourceRequired).toHaveLength(1); + expect(audit.splitCandidates).toHaveLength(1); + expect(audit.splitCandidates[0].candidates).toHaveLength(3); + }); + + it('surfaces invalid stored values without stopping the audit', () => { + const audit = auditAgentUrlKeyMigration([row('https://agent.example/mcp'), row('invalid')]); + expect(audit.invalidRows).toHaveLength(1); + expect(audit.rowCount).toBe(2); + }); + + it('requires source verification for already lowercased root queries', () => { + const audit = auditAgentUrlKeyMigration([row('https://agent.example/?token=abc')]); + expect(audit.rawSourceRequired).toHaveLength(1); + }); +}); diff --git a/server/tests/unit/compliance-target-discovery.test.ts b/server/tests/unit/compliance-target-discovery.test.ts index 59591c65d97..a6f5dbe8985 100644 --- a/server/tests/unit/compliance-target-discovery.test.ts +++ b/server/tests/unit/compliance-target-discovery.test.ts @@ -78,9 +78,32 @@ import { HOSTED_TARGET_DISCOVERY_TIMEOUT_MS, classifyCapabilityResolutionErrorWithDeclaredProtocols, comply, + hostedCapabilityDiscoveryOptions, selectComplianceTargetForAgentSelection, } from '../../src/addie/services/compliance-testing.js'; +describe('hosted capability probe versions', () => { + it('pins explicit recommendation targets and preserves auth and safe transport', () => { + const options = hostedCapabilityDiscoveryOptions( + { auth: { type: 'bearer', token: 'secret' } }, + { requested: '3.1.20', version: '3.1.20', complianceDir: '/compliance/3.1.20', schemaRoot: '/schemas/3.1.20' }, + ); + expect(options).toMatchObject({ + adcpVersion: '3.1.20', + versionEnvelope: 'auto', + auth: { type: 'bearer', token: 'secret' }, + transport: { fetchFn: mocks.safeFetch }, + }); + }); + + it('uses major-only recovery before a target is selected', () => { + const options = hostedCapabilityDiscoveryOptions({ adcpVersion: '3.2.0-rc.7' }); + expect(options.versionEnvelope).toBe('major-only'); + expect(options.adcpVersion).toBeUndefined(); + expect(options.transport?.fetchFn).toBe(mocks.safeFetch); + }); +}); + describe('hosted compliance target discovery deadline', () => { afterEach(() => { vi.clearAllMocks(); @@ -121,6 +144,7 @@ describe('hosted compliance target discovery deadline', () => { test_session_id: 'heartbeat-test', userAgent: 'heartbeat-agent', auth: { type: 'bearer', token: 'secret' }, + versionEnvelope: 'major-only', signal: expect.any(AbortSignal), transport: { fetchFn: expect.any(Function) }, }); diff --git a/server/tests/unit/hosted-capability-probe-callers.test.ts b/server/tests/unit/hosted-capability-probe-callers.test.ts new file mode 100644 index 00000000000..03dcc905a56 --- /dev/null +++ b/server/tests/unit/hosted-capability-probe-callers.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const mocks = vi.hoisted(() => ({ discovery: vi.fn(), owner: vi.fn() })); +vi.hoisted(() => { + process.env.WORKOS_API_KEY ||= 'sk_test_probe_versions'; + process.env.WORKOS_CLIENT_ID ||= 'client_test_probe_versions'; +}); +vi.mock('@adcp/sdk/testing', async (importOriginal) => ({ + ...(await importOriginal()), + testCapabilityDiscovery: mocks.discovery, +})); +vi.mock('../../src/services/agent-ownership.js', async (importOriginal) => ({ + ...(await importOriginal()), + resolveOwnerOrgForUser: mocks.owner, +})); + +import { createMemberToolHandlers } from '../../src/addie/mcp/member-tools.js'; +import { AgentContextDatabase } from '../../src/db/agent-context-db.js'; +import { createRegistryApiRouter, type RegistryApiConfig } from '../../src/routes/registry-api.js'; + +describe('hosted capability discovery callers', () => { + beforeEach(() => { + mocks.discovery.mockReset().mockResolvedValue({ + profile: { adcp_supported_versions: ['3.1'], supported_protocols: [], specialisms: [] }, + steps: [], + }); + mocks.owner.mockReset().mockResolvedValue('org_owner'); + }); + afterEach(() => vi.restoreAllMocks()); + + it('recommend_storyboards probes the explicit target rather than the SDK default', async () => { + await createMemberToolHandlers(null).get('recommend_storyboards')!({ + agent_url: 'https://test-agent.adcontextprotocol.org/sales/mcp', + compliance_target: '3.1.24', + }); + expect(mocks.discovery).toHaveBeenCalledOnce(); + expect(mocks.discovery.mock.calls[0][1]).toMatchObject({ + adcpVersion: '3.1.24', versionEnvelope: 'auto', + auth: { type: 'bearer', token: expect.any(String) }, + transport: { fetchFn: expect.any(Function) }, + }); + }); + + it('recommend_storyboards uses major-only discovery before selecting a target', async () => { + await createMemberToolHandlers(null).get('recommend_storyboards')!({ + agent_url: 'https://test-agent.adcontextprotocol.org/sales/mcp', + }); + expect(mocks.discovery).toHaveBeenCalledOnce(); + expect(mocks.discovery.mock.calls[0][1].versionEnvelope).toBe('major-only'); + expect(mocks.discovery.mock.calls[0][1].adcpVersion).toBeUndefined(); + }); + + it('applicable-storyboards uses the same major-only discovery strategy', async () => { + vi.spyOn(AgentContextDatabase.prototype, 'getAuthInfoByOrgAndUrl').mockResolvedValue(null); + vi.spyOn(AgentContextDatabase.prototype, 'getByOrgAndUrl').mockResolvedValue(null); + const app = express(); + const requireAuth: import('express').RequestHandler = (req, _res, next) => { + req.user = { id: 'user_owner', email: 'owner@example.com' } as typeof req.user; + next(); + }; + app.use('/api', createRegistryApiRouter({ + brandManager: {} as RegistryApiConfig['brandManager'], + brandDb: {} as RegistryApiConfig['brandDb'], + propertyDb: {} as RegistryApiConfig['propertyDb'], + adagentsManager: {} as RegistryApiConfig['adagentsManager'], + healthChecker: {} as RegistryApiConfig['healthChecker'], + crawler: {} as RegistryApiConfig['crawler'], + capabilityDiscovery: {} as RegistryApiConfig['capabilityDiscovery'], + registryRequestsDb: { trackRequest: async () => {}, markResolved: async () => true }, + requireAuth, optionalAuth: requireAuth, + })); + await request(app).get('/api/registry/agents/' + encodeURIComponent('https://agent.example/mcp') + '/applicable-storyboards'); + expect(mocks.discovery).toHaveBeenCalledOnce(); + expect(mocks.discovery.mock.calls[0][1].versionEnvelope).toBe('major-only'); + expect(mocks.discovery.mock.calls[0][1].adcpVersion).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/hosted-route-storyboard.test.ts b/server/tests/unit/hosted-route-storyboard.test.ts index 0d70c9ffa49..28e1aac3235 100644 --- a/server/tests/unit/hosted-route-storyboard.test.ts +++ b/server/tests/unit/hosted-route-storyboard.test.ts @@ -175,6 +175,10 @@ async function startGatedSeller(): Promise<{ url: string; seen: Seen[]; server: } res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })); }); + // SDK schema initialization can block this shared event loop longer than + // Node's default idle-socket lifetime; retain fixture connections for the + // entire bounded compliance run instead of racing a pooled-socket reset. + server.keepAliveTimeout = 90_000; await new Promise(done => server.listen(0, '127.0.0.1', done)); const { port } = server.address() as AddressInfo; return { url: `http://127.0.0.1:${port}/mcp`, seen, server }; diff --git a/server/tests/unit/perspective-asset-cache-privacy.test.ts b/server/tests/unit/perspective-asset-cache-privacy.test.ts index f97fda36015..d629fc11485 100644 --- a/server/tests/unit/perspective-asset-cache-privacy.test.ts +++ b/server/tests/unit/perspective-asset-cache-privacy.test.ts @@ -117,7 +117,11 @@ describe('perspective asset cache privacy', () => { [true, undefined, 'public, max-age=0, must-revalidate'], [false, 'draft-author', 'private, no-store'], ])('sets visibility-aware asset caching when is_public=%s', async (isPublic, userId, expected) => { - mocks.query.mockResolvedValueOnce({ rows: [{ id: 'perspective-1', is_public: isPublic }] }); + // HTTPServer also schedules unrelated documentation reads; bind the + // visibility fixture to its query instead of consuming the next DB call. + mocks.query.mockImplementation(async (sql: string) => ({ + rows: sql.includes('AS is_public') ? [{ id: 'perspective-1', is_public: isPublic }] : [], + })); mocks.getAssetData.mockResolvedValueOnce({ file_data: Buffer.from('asset bytes'), file_mime_type: 'image/png', diff --git a/server/tests/unit/proxy-address-trust.test.ts b/server/tests/unit/proxy-address-trust.test.ts new file mode 100644 index 00000000000..c00bd59db56 --- /dev/null +++ b/server/tests/unit/proxy-address-trust.test.ts @@ -0,0 +1,51 @@ +import { createRequire } from 'node:module'; +import { describe, expect, it } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const require = createRequire(import.meta.url); +const proxyAddress = require('proxy-addr') as { + compile(ranges: string[]): (address: string) => boolean; +}; + +describe('proxy address trust boundaries', () => { + // CVE-2026-90711: these IPv6 ranges previously trusted every IPv4 peer. + it.each(['::ffff:10.0.0.0/8', '::/1'])( + 'does not accept a spoofed forwarded identity through %s', + async (subnet) => { + const app = express(); + app.set('trust proxy', subnet); + app.get('/identity', (req, res) => res.json({ ip: req.ip, ips: req.ips })); + + const response = await request(app) + .get('/identity') + .set('X-Forwarded-For', '198.51.100.40'); + + expect(response.status).toBe(200); + expect(['127.0.0.1', '::ffff:127.0.0.1']).toContain(response.body.ip); + expect(response.body.ips).toEqual([]); + }, + ); + + it('retains correctly specified mapped and plain IPv4 trust ranges', () => { + for (const subnet of ['::ffff:10.0.0.0/104', '10.0.0.0/8']) { + const trust = proxyAddress.compile([subnet]); + expect(trust('10.1.2.3')).toBe(true); + expect(trust('198.51.100.40')).toBe(false); + } + }); + + it('accepts forwarded identity from an explicitly trusted loopback proxy', async () => { + const app = express(); + app.set('trust proxy', 'loopback'); + app.get('/identity', (req, res) => res.json({ ip: req.ip, ips: req.ips })); + + const response = await request(app) + .get('/identity') + .set('X-Forwarded-For', '198.51.100.40'); + + expect(response.status).toBe(200); + expect(response.body.ip).toBe('198.51.100.40'); + expect(response.body.ips).toEqual(['198.51.100.40']); + }); +}); diff --git a/server/tests/unit/registry-clear-auth-route.test.ts b/server/tests/unit/registry-clear-auth-route.test.ts new file mode 100644 index 00000000000..055394da318 --- /dev/null +++ b/server/tests/unit/registry-clear-auth-route.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const owner = vi.hoisted(() => vi.fn()); +vi.hoisted(() => { + process.env.WORKOS_API_KEY ||= 'sk_test_clear_auth'; + process.env.WORKOS_CLIENT_ID ||= 'client_test_clear_auth'; +}); +vi.mock('../../src/services/agent-ownership.js', async (importOriginal) => ({ + ...(await importOriginal()), + resolveOwnerOrgForUser: owner, +})); + +import { AgentContextDatabase } from '../../src/db/agent-context-db.js'; +import { createRegistryApiRouter, type RegistryApiConfig } from '../../src/routes/registry-api.js'; + +function app(authenticated = true) { + const app = express(); + app.use(express.json()); + const requireAuth: import('express').RequestHandler = (req, _res, next) => { + if (authenticated) req.user = { id: 'user_owner', email: 'owner@example.com' } as typeof req.user; + next(); + }; + app.use('/api', createRegistryApiRouter({ + brandManager: {} as RegistryApiConfig['brandManager'], + brandDb: {} as RegistryApiConfig['brandDb'], + propertyDb: {} as RegistryApiConfig['propertyDb'], + adagentsManager: {} as RegistryApiConfig['adagentsManager'], + healthChecker: {} as RegistryApiConfig['healthChecker'], + crawler: {} as RegistryApiConfig['crawler'], + capabilityDiscovery: {} as RegistryApiConfig['capabilityDiscovery'], + registryRequestsDb: { trackRequest: async () => {}, markResolved: async () => true }, + requireAuth, + optionalAuth: requireAuth, + })); + return app; +} + +const endpoint = '/api/registry/agents/' + encodeURIComponent('https://agent.example/mcp') + '/connect'; + +describe('DELETE saved static agent credentials', () => { + let getContext: ReturnType; + let removeToken: ReturnType; + + beforeEach(() => { + owner.mockReset().mockResolvedValue('org_owner'); + getContext = vi.spyOn(AgentContextDatabase.prototype, 'getByOrgAndUrl').mockResolvedValue({ id: 'ctx_owner' } as never); + removeToken = vi.spyOn(AgentContextDatabase.prototype, 'removeAuthToken').mockResolvedValue(); + }); + afterEach(() => vi.restoreAllMocks()); + + it('clears only the selected owner context without removing the agent', async () => { + const response = await request(app()).delete(endpoint).query({ org: 'org_owner' }); + expect(response.status).toBe(204); + expect(owner).toHaveBeenCalledWith('user_owner', 'https://agent.example/mcp', 'org_owner'); + expect(getContext).toHaveBeenCalledWith('org_owner', 'https://agent.example/mcp'); + expect(removeToken).toHaveBeenCalledExactlyOnceWith('ctx_owner'); + }); + + it('is idempotent when no credential context exists', async () => { + getContext.mockResolvedValue(null); + expect((await request(app()).delete(endpoint)).status).toBe(204); + expect(removeToken).not.toHaveBeenCalled(); + }); + + it('refuses non-owners before reading or clearing credentials', async () => { + owner.mockResolvedValue(null); + expect((await request(app()).delete(endpoint).query({ org: 'org_other' })).status).toBe(403); + expect(getContext).not.toHaveBeenCalled(); + expect(removeToken).not.toHaveBeenCalled(); + }); + + it('requires authentication', async () => { + expect((await request(app(false)).delete(endpoint)).status).toBe(401); + expect(owner).not.toHaveBeenCalled(); + expect(removeToken).not.toHaveBeenCalled(); + }); + + it('rejects malformed organization selectors', async () => { + expect((await request(app()).delete(endpoint).query({ org: '' })).status).toBe(400); + expect(owner).not.toHaveBeenCalled(); + expect(removeToken).not.toHaveBeenCalled(); + }); + + it('reports storage failures instead of confirming credential removal', async () => { + removeToken.mockRejectedValue(new Error('store unavailable')); + expect((await request(app()).delete(endpoint)).status).toBe(500); + }); +}); diff --git a/tests/fixed-trace-rollout-typecheck-wiring.test.ts b/tests/fixed-trace-rollout-typecheck-wiring.test.ts index 8f0afd09b50..b0119e5cb8e 100644 --- a/tests/fixed-trace-rollout-typecheck-wiring.test.ts +++ b/tests/fixed-trace-rollout-typecheck-wiring.test.ts @@ -43,8 +43,11 @@ describe("fixed-trace rollout test-aware typecheck wiring", () => { const compiler = spawnSync( process.platform === "win32" ? "npx.cmd" : "npx", ["tsc", "--project", "server/tsconfig.json", "--noEmit", "--pretty", "false"], - { cwd: root, encoding: "utf8" }, + // Cold compilation exceeds 30 s on supported development VMs. Bound + // the wrapper wait separately from the surrounding Vitest deadline. + { cwd: root, encoding: "utf8", timeout: 200_000 }, ); + expect(compiler.error, `${compiler.error?.message ?? ""}${compiler.stdout}${compiler.stderr}`).toBeUndefined(); expect(compiler.status, `${compiler.stdout}${compiler.stderr}`).toBe(0); const orphaned = { @@ -61,5 +64,5 @@ describe("fixed-trace rollout test-aware typecheck wiring", () => { } finally { rmSync(isolated, { recursive: true, force: true }); } - }, 30_000); + }, 210_000); }); From 5c417c662b8946ab5125cfd2a44de6ca97e76d55 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 6 Oct 2026 14:14:26 +0000 Subject: [PATCH 2/3] build(openapi): refresh static credential removal operation --- static/openapi/registry.yaml | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml index cf3bd5cddcc..3b46764b42d 100644 --- a/static/openapi/registry.yaml +++ b/static/openapi/registry.yaml @@ -13878,6 +13878,53 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + delete: + operationId: removeAgentAuthToken + summary: Clear saved bearer or basic credentials + description: Clear the selected organization's static credential while retaining OAuth configuration and compliance history. Requires authentication and ownership. + tags: + - Agent Compliance + security: + - bearerAuth: [] + - oauth2: [] + parameters: + - schema: + type: string + required: true + name: encodedUrl + in: path + - schema: + type: string + required: false + name: org + in: query + responses: + "204": + description: Static credential cleared + "400": + description: Invalid parameters + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Not authorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/registry/agents/{encodedUrl}/oauth-client-credentials: put: operationId: saveAgentOAuthClientCredentials From 9be693bc745ebd769578820b829707b8d4823853 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 6 Oct 2026 13:12:29 +0000 Subject: [PATCH 3/3] fix(runtime): persist cold-boot idempotency and preserve heartbeat cadence --- server/src/db/compliance-db.ts | 11 +- server/src/training-agent/idempotency.ts | 12 +- .../compliance-run-publication.test.ts | 137 +++++++++++++++++ ...aining-agent-idempotency-cold-boot.test.ts | 141 ++++++++++++++++++ 4 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 server/tests/integration/training-agent-idempotency-cold-boot.test.ts diff --git a/server/src/db/compliance-db.ts b/server/src/db/compliance-db.ts index 1ab34131c32..34f0f699005 100644 --- a/server/src/db/compliance-db.ts +++ b/server/src/db/compliance-db.ts @@ -959,15 +959,21 @@ export class ComplianceDatabase { tracksSummary[t.track] = t.status; } - // 4. Upsert the materialized status and capture transition + // 4. Upsert the materialized status and capture transition. + // Owner-initiated runs publish the verdict but leave the independent + // heartbeat schedule and pending requeues untouched (#7680). const statusResult = await client.query( `WITH schedule_next AS ( INSERT INTO agent_registry_metadata (agent_url, next_compliance_check_at) - VALUES ($1, NOW() + INTERVAL '12 hours') + VALUES ($1, CASE WHEN $7 = 'owner_test' THEN NULL ELSE NOW() + INTERVAL '12 hours' END) ON CONFLICT (agent_url) DO UPDATE SET next_compliance_check_at = NOW() + make_interval(hours => agent_registry_metadata.check_interval_hours), compliance_inconclusive_streak = 0, requeued_at = NULL + -- TriggeredBy constrains $7 at compile time. Adding a trigger opts it + -- into cadence advancement unless this predicate and the INSERT + -- CASE above are updated together. + WHERE $7 != 'owner_test' ) INSERT INTO agent_compliance_status ( agent_url, status, last_checked_at, @@ -1024,6 +1030,7 @@ export class ComplianceDatabase { input.headline ?? null, input.requested_compliance_target ?? null, input.adcp_version ?? null, + input.triggered_by ?? 'heartbeat', ], ); diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index af625d28395..62f2d68d659 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -19,7 +19,8 @@ * public sandbox token is in use (otherwise every caller on that token * sees the same oracle). * - `getIdempotencyStore` — returns a process-wide store backed by - * Postgres when a DB pool is available, in-memory otherwise. + * Postgres in production (with lazy pool lookup) or when a DB pool is + * available, in-memory otherwise. */ import { @@ -472,7 +473,12 @@ function validateTtl(seconds: number): number { } function fencedPgBackend(): FencedIdempotencyBackend { - const db = getPool(); + // Tenant registry prewarming starts before initializeDatabase(). Resolve + // the pool only when querying so construction never downgrades production + // to process-local memory; operations still fail closed until DB startup. + const db = { + query: (text: string, values?: unknown[]) => getPool().query(text, values), + }; const base = pgBackend(db); return { ...base, @@ -504,7 +510,7 @@ function fencedPgBackend(): FencedIdempotencyBackend { export function getIdempotencyStore(): OwnedIdempotencyStore { if (storeInstance) return storeInstance; - const backend = isDatabaseInitialized() + const backend = process.env.NODE_ENV === 'production' || isDatabaseInitialized() ? fencedPgBackend() : memoryBackend(); const base = createHashAwareIdempotencyStore({ backend, ttlSeconds: REPLAY_TTL_SECONDS }); diff --git a/server/tests/integration/compliance-run-publication.test.ts b/server/tests/integration/compliance-run-publication.test.ts index ca2e1dc6d4d..a2da6da9a30 100644 --- a/server/tests/integration/compliance-run-publication.test.ts +++ b/server/tests/integration/compliance-run-publication.test.ts @@ -351,6 +351,143 @@ describe.skipIf(!process.env.DATABASE_URL)('compliance publication transaction', } }); + describe('owner-initiated runs leave the heartbeat schedule alone (#7680)', () => { + const urls: string[] = []; + const freshAgent = async (options: { withMetadata?: boolean } = {}) => { + const url = `https://${randomUUID()}.example.test/mcp`; + urls.push(url); + // Metadata alone is not a current-agent source (#7844). Register each + // fixture with the owning profile so due-selection exercises scheduling. + await pool.query( + `UPDATE member_profiles SET agents = agents || $2::jsonb + WHERE workos_organization_id = $1`, + [ownerOrgId, JSON.stringify([{ url }])], + ); + if (options.withMetadata ?? true) await db.upsertRegistryMetadata(url, { lifecycle_stage: 'production' }); + return url; + }; + const schedule = async (url: string) => (await pool.query<{ + next_compliance_check_at: Date | null; requeued_at: Date | null; hours_until_next: number | null; + }>( + `SELECT next_compliance_check_at, requeued_at, + EXTRACT(EPOCH FROM next_compliance_check_at - NOW()) / 3600 AS hours_until_next + FROM agent_registry_metadata WHERE agent_url = $1`, + [url], + )).rows[0]; + const isDue = async (url: string) => + (await db.getAgentsDueForCheck(100000)).some(agent => agent.agent_url === url); + + afterAll(async () => { + for (const table of ['agent_compliance_step_diagnostics', 'agent_storyboard_status', 'agent_verification_badges', + 'agent_compliance_status', 'agent_compliance_runs', 'agent_registry_metadata']) { + await pool.query(`DELETE FROM ${table} WHERE agent_url = ANY($1::text[])`, [urls]); + } + }); + + it('keeps a pending requeue when a full-suite owner test publishes', async () => { + const url = await freshAgent(); + await db.requeueForHeartbeat(url); + const before = await schedule(url); + await db.recordComplianceRun({ ...input, agent_url: url, triggered_by: 'owner_test' }); + + const after = await schedule(url); + expect(after.next_compliance_check_at).toBeNull(); + expect(after.requeued_at).toEqual(before.requeued_at); + expect(after.requeued_at).not.toBeNull(); + expect(await isDue(url)).toBe(true); + // The owner verdict still publishes to the public card. + expect((await db.getComplianceStatus(url))?.status).toBe('passing'); + }); + + it('consumes the requeue when the heartbeat serving it publishes', async () => { + const url = await freshAgent(); + await db.requeueForHeartbeat(url); + await db.recordComplianceRun({ ...input, agent_url: url, triggered_by: 'heartbeat' }); + + const after = await schedule(url); + expect(after.requeued_at).toBeNull(); + expect(Number(after.hours_until_next)).toBeCloseTo(12, 1); + expect(await isDue(url)).toBe(false); + }); + + it.each(['manual', 'webhook'] as const)('advances the cadence and clears the requeue for %s runs', async (triggeredBy) => { + const url = await freshAgent(); + await db.requeueForHeartbeat(url); + await db.recordComplianceRun({ ...input, agent_url: url, triggered_by: triggeredBy }); + + const after = await schedule(url); + expect(after.requeued_at).toBeNull(); + expect(Number(after.hours_until_next)).toBeCloseTo(12, 1); + }); + + it('defaults an unlabelled run to heartbeat scheduling', async () => { + const url = await freshAgent(); + await db.requeueForHeartbeat(url); + expect(input.triggered_by).toBeUndefined(); + await db.recordComplianceRun({ ...input, agent_url: url }); + + const after = await schedule(url); + expect(after.requeued_at).toBeNull(); + expect(Number(after.hours_until_next)).toBeCloseTo(12, 1); + }); + + it('does not push a scheduled heartbeat further out when an owner test publishes', async () => { + const url = await freshAgent(); + await pool.query( + `UPDATE agent_registry_metadata SET next_compliance_check_at = NOW() + INTERVAL '2 hours' WHERE agent_url = $1`, + [url], + ); + const before = await schedule(url); + await db.recordComplianceRun({ ...input, agent_url: url, triggered_by: 'owner_test' }); + + expect((await schedule(url)).next_compliance_check_at).toEqual(before.next_compliance_check_at); + }); + + it('leaves an overdue heartbeat due after an owner test publishes', async () => { + const url = await freshAgent(); + await pool.query( + `UPDATE agent_registry_metadata SET next_compliance_check_at = NOW() - INTERVAL '1 hour' WHERE agent_url = $1`, + [url], + ); + await db.recordComplianceRun({ ...input, agent_url: url, triggered_by: 'owner_test' }); + + expect(await isDue(url)).toBe(true); + }); + + it('leaves a first-ever owner test immediately eligible for an independent heartbeat', async () => { + const url = await freshAgent({ withMetadata: false }); + expect(await schedule(url)).toBeUndefined(); + expect(await isDue(url)).toBe(true); + await db.recordComplianceRun({ ...input, agent_url: url, triggered_by: 'owner_test' }); + + const after = await schedule(url); + expect(after).toBeDefined(); + expect(after.next_compliance_check_at).toBeNull(); + expect(await isDue(url)).toBe(true); + }); + + it('still schedules a first-ever heartbeat on the default cadence', async () => { + const url = await freshAgent({ withMetadata: false }); + await db.recordComplianceRun({ ...input, agent_url: url, triggered_by: 'heartbeat' }); + + expect(Number((await schedule(url)).hours_until_next)).toBeCloseTo(12, 1); + }); + + it('leaves scheduling untouched for a scoped (non-authoritative) owner test', async () => { + const url = await freshAgent(); + await db.requeueForHeartbeat(url); + await db.recordComplianceRun({ + ...input, agent_url: url, triggered_by: 'owner_test', + is_authoritative: false, replace_storyboard_statuses: false, + storyboard_statuses: [{ storyboard_id: 'first', status: 'passing', steps_passed: 1, steps_total: 1 }], + }); + + const after = await schedule(url); + expect(after.next_compliance_check_at).toBeNull(); + expect(after.requeued_at).not.toBeNull(); + }); + }); + it('uses only complete public profiles for version and specialism fallback', async () => { const complete = await db.recordComplianceRun(input); const epoch = Date.parse('2030-01-01T00:00:00Z'); diff --git a/server/tests/integration/training-agent-idempotency-cold-boot.test.ts b/server/tests/integration/training-agent-idempotency-cold-boot.test.ts new file mode 100644 index 00000000000..0fdde4c553e --- /dev/null +++ b/server/tests/integration/training-agent-idempotency-cold-boot.test.ts @@ -0,0 +1,141 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Pool } from 'pg'; +import { closeDatabase, initializeDatabase, isDatabaseInitialized } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import { + clearIdempotencyCache, + getIdempotencyStore, + getSdkIdempotencyStore, + type OwnedIdempotencyStore, +} from '../../src/training-agent/idempotency.js'; + +describe.skipIf(!process.env.DATABASE_URL)('training-agent idempotency across cold boot and stores', () => { + const principal = `cold-boot-${randomUUID()}`; + const keys: string[] = []; + let pool: Pool | undefined; + let earlyStore: OwnedIdempotencyStore; + let secondStore: OwnedIdempotencyStore; + let earlyReadiness: PromiseSettledResult; + let earlyMutation: PromiseSettledResult; + + const request = (suffix: string, budget = 5_000) => { + const key = `cold-boot-${suffix}-${randomUUID()}`; + keys.push(`${principal}\u001F${key}`); + return { principal, key, payload: { budget } }; + }; + + beforeAll(async () => { + vi.stubEnv('NODE_ENV', 'production'); + await clearIdempotencyCache(); + expect(isDatabaseInitialized()).toBe(false); + + // Tenant prewarming resolves the SDK adapter before database startup. + // Construction must succeed, but readiness and mutations must fail closed. + const sdkStore = getSdkIdempotencyStore(); + earlyStore = getIdempotencyStore(); + // Record both outcomes without aborting setup: the original memory backend + // must still reach the real-PG cross-store regressions below. + [earlyReadiness, earlyMutation] = await Promise.allSettled([ + Promise.resolve().then(() => sdkStore.probe!()), + Promise.resolve().then(() => sdkStore.check(request('before-pool'))), + ]); + + pool = initializeDatabase({ connectionString: process.env.DATABASE_URL }); + await runMigrations(); + await earlyStore.probe!(); + + // Retain the earlier adapter while constructing another independent store. + // PostgreSQL backends do not own or close the caller's database pool. + await clearIdempotencyCache(); + secondStore = getIdempotencyStore(); + await secondStore.probe!(); + }, 180_000); + + afterAll(async () => { + try { + if (pool) { + await pool.query('DELETE FROM adcp_idempotency WHERE scoped_key = ANY($1::text[])', [keys]); + } + } finally { + await clearIdempotencyCache(); + await closeDatabase(); + vi.unstubAllEnvs(); + } + }); + + it('rejects readiness before PostgreSQL is initialized', () => { + expect(earlyReadiness.status).toBe('rejected'); + if (earlyReadiness.status === 'rejected') { + expect(earlyReadiness.reason).toBeInstanceOf(Error); + expect(earlyReadiness.reason.message).toMatch(/idempotency backend probe failed/); + } + }); + + it('rejects mutations before PostgreSQL is initialized', () => { + expect(earlyMutation.status).toBe('rejected'); + if (earlyMutation.status === 'rejected') { + expect(earlyMutation.reason).toBeInstanceOf(Error); + expect(earlyMutation.reason.message).toMatch(/database operation failed/); + } + }); + + it('persists an adapter created before initialization and replays from another store', async () => { + const input = request('replay'); + const claim = await earlyStore.check(input); + if (claim.kind !== 'miss') throw new Error('Expected the first request to claim the key'); + expect(await secondStore.check(input)).toMatchObject({ kind: 'in-flight' }); + expect(await secondStore.check({ ...input, payload: { budget: 25_000 } })).toEqual({ kind: 'conflict' }); + + await earlyStore.renew({ ...input, claimToken: claim.claimToken }); + const response = { media_buy_id: 'cold-boot-buy' }; + await earlyStore.save({ ...input, payloadHash: claim.payloadHash, claimToken: claim.claimToken, response }); + expect(await secondStore.check(input)).toEqual({ kind: 'replay', response }); + const rows = await pool!.query('SELECT response FROM adcp_idempotency WHERE scoped_key = $1', [ + `${input.principal}\u001F${input.key}`, + ]); + expect(rows.rows).toEqual([{ response }]); + }); + + it('grants exactly one concurrent claim across independent PostgreSQL stores', async () => { + const input = request('concurrent'); + const stores = [earlyStore, secondStore]; + const results = await Promise.all(stores.map(store => store.check(input))); + expect(results.map(result => result.kind).sort()).toEqual(['in-flight', 'miss']); + const winnerIndex = results.findIndex(result => result.kind === 'miss'); + const claim = results[winnerIndex]!; + if (claim.kind !== 'miss') throw new Error('Expected exactly one claim owner'); + const response = { media_buy_id: 'concurrent-buy' }; + await stores[winnerIndex]!.save({ ...input, payloadHash: claim.payloadHash, claimToken: claim.claimToken, response }); + expect(await stores[1 - winnerIndex]!.check(input)).toEqual({ kind: 'replay', response }); + }); + + it('keeps owner fencing when another store takes over a released claim', async () => { + const input = request('released'); + const original = await earlyStore.check(input); + if (original.kind !== 'miss') throw new Error('Expected the initial claim'); + await earlyStore.release({ ...input, claimToken: original.claimToken }); + const replacement = await secondStore.check(input); + if (replacement.kind !== 'miss') throw new Error('Expected a fresh owner after release'); + + await expect(earlyStore.renew({ ...input, claimToken: original.claimToken })).rejects.toThrow(/ownership was lost/); + await expect(earlyStore.save({ + ...input, payloadHash: original.payloadHash, claimToken: original.claimToken, + response: { media_buy_id: 'stale-owner' }, + })).rejects.toThrow(/ownership was lost/); + const response = { media_buy_id: 'replacement-owner' }; + await secondStore.save({ ...input, payloadHash: replacement.payloadHash, claimToken: replacement.claimToken, response }); + expect(await earlyStore.check(input)).toEqual({ kind: 'replay', response }); + }); + + it('retains PostgreSQL replay when development has an initialized pool', async () => { + const input = request('development'); + const claim = await earlyStore.check(input); + if (claim.kind !== 'miss') throw new Error('Expected the initial claim'); + const response = { media_buy_id: 'development-replay' }; + await earlyStore.save({ ...input, payloadHash: claim.payloadHash, claimToken: claim.claimToken, response }); + vi.stubEnv('NODE_ENV', 'development'); + await clearIdempotencyCache(); + expect(await getIdempotencyStore().check(input)).toEqual({ kind: 'replay', response }); + }); +});