diff --git a/.changeset/storyboard-ux-fixes.md b/.changeset/storyboard-ux-fixes.md new file mode 100644 index 000000000..5757b8cec --- /dev/null +++ b/.changeset/storyboard-ux-fixes.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Storyboard UX: short-circuit comply() when products fail, inline agent connect form, filter picker by capabilities, OAuth auth fallback, generative creative storyboard diff --git a/docs/storyboards/creative_generative.yaml b/docs/storyboards/creative_generative.yaml new file mode 100644 index 000000000..1264f6d44 --- /dev/null +++ b/docs/storyboards/creative_generative.yaml @@ -0,0 +1,317 @@ +id: creative_generative +version: "1.0.0" +title: "Generative creative agent" +category: creative_generative +summary: "Agent that takes a brief and generates finished creatives from scratch — no input assets required." + +narrative: | + You run a generative creative platform — an AI ad network, a generative DSP, or any system + that creates ad creatives from a natural-language brief and brand identity. The buyer doesn't + push assets to you. Instead, they describe what they want, point you at a brand.json, and + your agent produces finished creatives ready for trafficking. + + This is fundamentally different from template-based transformation (Celtra) or library-based + retrieval (Innovid). Your agent creates something new. The buyer sends a brief, optionally + with seed assets or constraints, and your platform generates creatives — potentially in + multiple formats at once. + + This storyboard walks through the generation flow: format discovery, brief-driven generation, + multi-format builds, iterative refinement, and quality progression from draft to production. + +agent: + interaction_model: stateless_generate + capabilities: + - supports_generation + examples: + - "OpenAds" + - "AI ad networks" + - "Generative DSPs" + +caller: + role: buyer_agent + example: "Scope3 (DSP)" + +prerequisites: + description: | + The caller needs a brand identity (brand.json at the brand's domain) for the agent + to resolve visual identity — logos, colors, fonts, tone. The test kit provides a + sample brand with campaign parameters. + test_kit: "test-kits/acme-outdoor.yaml" + +phases: + - id: format_discovery + title: "Discover generative formats" + narrative: | + The buyer needs to know what your agent can generate. Unlike a template platform + where formats are fixed dimensions, generative formats describe what the agent + produces — the output type, constraints, and what inputs it accepts. A generative + format might be "display_300x250_generative" that accepts a brief and produces + a finished banner, or "social_post_generative" that creates platform-native content. + + steps: + - id: discover_formats + title: "Discover available generative formats" + narrative: | + The buyer asks: "What can you generate?" Your platform returns the formats + you support. Each format describes the output type, dimensions, and what + inputs it needs (brief text, brand reference, seed images, etc.). + task: list_creative_formats + schema_ref: "creative/list-creative-formats-request.json" + response_schema_ref: "creative/list-creative-formats-response.json" + doc_ref: "/creative/task-reference/list_creative_formats" + comply_scenario: creative_sync + stateful: false + expected: | + Return your generative formats. Each format should include: + - format_id with your agent_url and a unique id + - Human-readable name and description + - Asset slots describing what inputs are accepted (brief text, images, etc.) + - Render dimensions for the output + - Variables for any dynamic fields the buyer can control + + sample_request: {} + + validations: + - check: response_schema + description: "Response matches list-creative-formats-response.json schema" + - check: field_present + path: "formats" + description: "Response contains a formats array" + - check: field_present + path: "formats[0].format_id.agent_url" + description: "Each format has a format_id with agent_url" + + - id: generate_from_brief + title: "Generate from brief" + narrative: | + The buyer describes what they want in natural language and your agent generates + a creative from scratch. The brief includes the campaign message, target audience + context, and a brand reference so your agent can resolve visual identity. + + This is the core generative flow. The buyer doesn't provide finished assets — + they provide intent, and your agent creates. + + steps: + - id: build_draft + title: "Generate a draft creative from brief" + narrative: | + The buyer sends a brief describing the campaign and a target format. Your + agent generates a draft creative — fast, lower-fidelity output the buyer can + review before committing to a production build. + + The brief is passed as a message alongside the target format. The brand + reference lets your agent resolve brand.json for visual identity. + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + comply_scenario: creative_flow + stateful: false + expected: | + Return a generated creative manifest: + - creative_manifest with the generated assets (images, copy, serving code) + - format_id matching the target format + - If include_preview is true, include a preview render + + The output should be a coherent creative that reflects the brief and brand + identity — not a template with placeholder text. + + sample_request: + message: "Create a display banner for a summer outdoor gear sale. Bold, adventurous tone. Headline should emphasize 40% off. Target audience: active adults 25-54." + target_format_id: + agent_url: "https://your-agent.example.com" + id: "display_300x250_generative" + brand: + domain: "acme-outdoor.example.com" + quality: "draft" + include_preview: true + + validations: + - check: response_schema + description: "Response matches build-creative-response.json schema" + - check: field_present + path: "creative_manifest.assets" + description: "Output manifest includes generated assets" + - check: field_present + path: "creative_manifest.format_id" + description: "Output manifest includes format_id" + + - id: refine + title: "Refine the creative" + narrative: | + The buyer reviews the draft and wants changes. They send the generated manifest + back with refinement instructions. Your agent modifies the creative based on the + feedback — adjusting copy, swapping imagery, or changing the layout. + + This is iterative: the buyer can refine multiple times until they're satisfied, + then request a production-quality build. + + steps: + - id: refine_creative + title: "Refine with feedback" + narrative: | + The buyer passes the generated manifest back with a message describing what + to change. Your agent applies the refinements and returns an updated creative. + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + comply_scenario: creative_flow + stateful: false + expected: | + Return a refined creative manifest reflecting the requested changes. + The output should preserve what worked in the original while applying + the refinements. + + sample_request: + message: "Make the headline larger. Replace the mountain imagery with a trail running scene. Add a CTA button that says 'Shop Now'." + creative_manifest: + format_id: + agent_url: "https://your-agent.example.com" + id: "display_300x250_generative" + assets: + - asset_id: "generated_image" + asset_type: "image" + url: "https://your-agent.example.com/generated/abc123.jpg" + - asset_id: "headline" + asset_type: "text" + text: "Summer Sale — 40% Off All Gear" + target_format_id: + agent_url: "https://your-agent.example.com" + id: "display_300x250_generative" + brand: + domain: "acme-outdoor.example.com" + quality: "draft" + include_preview: true + + validations: + - check: response_schema + description: "Response matches build-creative-response.json schema" + - check: field_present + path: "creative_manifest.assets" + description: "Refined manifest includes assets" + + - id: multi_format + title: "Multi-format generation" + narrative: | + The buyer needs the creative in multiple sizes. Instead of generating each + format separately, they pass target_format_ids (plural) and your agent produces + all formats in a single call. This is where generative agents shine — adapting + a concept across formats while maintaining visual coherence. + + steps: + - id: build_multi_format + title: "Generate for multiple formats" + narrative: | + The buyer passes the refined manifest with multiple target formats. Your + agent generates a creative for each format, adapting layout, copy, and + imagery to fit each size while maintaining brand consistency. + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + comply_scenario: creative_flow + stateful: false + expected: | + Return creative manifests for each requested format in creative_manifests + (plural). Each manifest should: + - Have a format_id matching one of the target formats + - Contain complete, format-appropriate assets + - Maintain visual coherence across formats + + If a format cannot be produced, include it with an error — don't fail + the entire request. + + sample_request: + message: "Generate production-ready versions for all three sizes." + creative_manifest: + format_id: + agent_url: "https://your-agent.example.com" + id: "display_300x250_generative" + assets: + - asset_id: "generated_image" + asset_type: "image" + url: "https://your-agent.example.com/generated/abc123-refined.jpg" + - asset_id: "headline" + asset_type: "text" + text: "Summer Sale — 40% Off All Gear" + - asset_id: "cta" + asset_type: "text" + text: "Shop Now" + target_format_ids: + - agent_url: "https://your-agent.example.com" + id: "display_300x250_generative" + - agent_url: "https://your-agent.example.com" + id: "display_728x90_generative" + - agent_url: "https://your-agent.example.com" + id: "display_320x50_generative" + brand: + domain: "acme-outdoor.example.com" + quality: "production" + + validations: + - check: response_schema + description: "Response matches build-creative-response.json schema" + - check: field_present + path: "creative_manifests" + description: "Response contains creative_manifests array (plural)" + + - id: production_build + title: "Production build" + narrative: | + The buyer is satisfied with the concept and needs a final, production-quality + creative ready for trafficking. They switch quality from draft to production. + Your agent generates the finished output with full-fidelity assets. + + steps: + - id: build_production + title: "Build at production quality" + narrative: | + The buyer requests a production-quality build of the approved concept. Your + agent generates the final creative with full-fidelity rendering, polished + assets, and serving code ready for trafficking. + task: build_creative + schema_ref: "media-buy/build-creative-request.json" + response_schema_ref: "media-buy/build-creative-response.json" + doc_ref: "/creative/task-reference/build_creative" + comply_scenario: creative_flow + stateful: false + expected: | + Return a production-quality creative manifest: + - Full-fidelity generated assets + - Serving code (HTML, JavaScript, or VAST) ready for trafficking + - Provenance metadata if AI-generated (digital_source_type, ai_tool) + + sample_request: + message: "Final production build. No changes needed." + creative_manifest: + format_id: + agent_url: "https://your-agent.example.com" + id: "display_300x250_generative" + assets: + - asset_id: "generated_image" + asset_type: "image" + url: "https://your-agent.example.com/generated/abc123-refined.jpg" + - asset_id: "headline" + asset_type: "text" + text: "Summer Sale — 40% Off All Gear" + - asset_id: "cta" + asset_type: "text" + text: "Shop Now" + target_format_id: + agent_url: "https://your-agent.example.com" + id: "display_300x250_generative" + brand: + domain: "acme-outdoor.example.com" + quality: "production" + include_preview: true + + validations: + - check: response_schema + description: "Response matches build-creative-response.json schema" + - check: field_present + path: "creative_manifest.assets" + description: "Production manifest includes assets" + - check: field_present + path: "creative_manifest.format_id" + description: "Production manifest includes format_id" diff --git a/server/public/dashboard-agents.html b/server/public/dashboard-agents.html index caeeee6b1..f7840d05b 100644 --- a/server/public/dashboard-agents.html +++ b/server/public/dashboard-agents.html @@ -214,6 +214,10 @@ font-weight: var(--font-semibold); color: var(--color-primary-600); text-decoration: none; + background: none; + border: none; + cursor: pointer; + padding: 0; } .agent-action-link:hover { text-decoration: underline; @@ -228,6 +232,45 @@ color: var(--color-text-muted); } + .agent-connect-form { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: flex-end; + padding: var(--space-3) 0; + } + .agent-connect-form label { + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--text-xs); + color: var(--color-text-secondary); + } + .agent-connect-form input, + .agent-connect-form select { + font-size: var(--text-sm); + padding: var(--space-1) var(--space-2); + border: var(--border-1) solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-card); + } + .agent-connect-form input { min-width: 260px; } + .agent-connect-save { + background: var(--color-primary-600); + color: white; + border: none; + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + cursor: pointer; + } + .agent-connect-save:hover { background: var(--color-primary-700); } + .agent-connect-save:disabled { opacity: 0.6; cursor: not-allowed; } + .agent-connect-msg { + font-size: var(--text-xs); + margin-top: var(--space-1); + } + .agent-lifecycle-select { font-size: var(--text-xs); padding: 2px var(--space-2); @@ -544,6 +587,27 @@

Agents

`; } + const platformTypes = [ + ['display_ad_server', 'Display ad server'], + ['video_ad_server', 'Video ad server'], + ['social_platform', 'Social platform'], + ['pmax_platform', 'Performance max'], + ['dsp', 'DSP'], + ['retail_media', 'Retail media'], + ['search_platform', 'Search platform'], + ['audio_platform', 'Audio platform'], + ['creative_transformer', 'Creative transformer'], + ['creative_library', 'Creative library'], + ['creative_ad_server', 'Creative ad server'], + ['si_platform', 'SI platform'], + ['ai_ad_network', 'AI ad network'], + ['ai_platform', 'AI platform'], + ['generative_dsp', 'Generative DSP'], + ]; + const platformTypeOptions = platformTypes.map(([val, label]) => + '' + ).join(''); + return agents.map(agent => { const data = complianceMap?.get(agent.url); const cs = data?.status; @@ -564,11 +628,14 @@

Agents

not yet checked -
- ${hasAuth - ? 'Auth configured. Compliance check will run on the next heartbeat cycle.' - : 'Connect this agent through Addie to save credentials and start compliance monitoring.'} -
+ ${hasAuth + ? '
Auth configured. Compliance check will run on the next heartbeat cycle.
' + : '
' + + '' + + '' + + '' + + '' + + '
'}
@@ -647,12 +714,19 @@

Agents

Last checked: ${escapeHtml(lastChecked)}
- ${!hasAuth ? 'Connect agent' : ''} - + ${!hasAuth ? '' : ''} +
+ ${!hasAuth ? '' : ''} @@ -693,6 +767,71 @@

Agents

} }); + // Connect agent toggle + document.addEventListener('click', function(e) { + const toggle = e.target.closest('.agent-connect-toggle'); + if (!toggle) return; + const cardId = toggle.dataset.cardId; + const panel = document.getElementById(cardId + '-connect'); + if (panel) { + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; + } + }); + + // Connect agent save + document.addEventListener('click', async function(e) { + const saveBtn = e.target.closest('.agent-connect-save'); + if (!saveBtn) return; + const form = saveBtn.closest('.agent-connect-form'); + if (!form) return; + + const agentUrl = form.dataset.agentUrl; + const authToken = form.querySelector('[name="auth_token"]').value.trim(); + const authType = form.querySelector('[name="auth_type"]').value; + const platformType = form.querySelector('[name="platform_type"]').value; + + if (!authToken) { + form.querySelector('[name="auth_token"]').focus(); + return; + } + + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const body = { auth_token: authToken, auth_type: authType }; + if (platformType) body.platform_type = platformType; + + const res = await fetch('/api/registry/agents/' + encodeURIComponent(agentUrl) + '/connect', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Failed to connect'); + } + + // Replace the form with a success message and reload after a moment + const card = form.closest('.agent-compliance-card'); + form.innerHTML = '
Connected. Compliance check will run on the next heartbeat cycle.
'; + setTimeout(() => location.reload(), 1500); + } catch (err) { + saveBtn.disabled = false; + saveBtn.textContent = 'Save'; + let msg = form.querySelector('.agent-connect-msg'); + if (!msg) { + msg = document.createElement('div'); + msg.className = 'agent-connect-msg'; + form.appendChild(msg); + } + msg.style.color = 'var(--color-error-600)'; + msg.textContent = err.message; + } + }); + // Monitoring pause toggle document.addEventListener('change', async function(e) { const toggle = e.target.closest('.monitoring-pause-toggle'); @@ -952,10 +1091,21 @@

Agents

} } + // Map storyboard category prefixes to agent tracks + function storyboardMatchesAgent(sb, agentTracks) { + if (!agentTracks) return true; + const cat = sb.category || ''; + if (cat.startsWith('media_buy')) return !!agentTracks.media_buy && agentTracks.media_buy !== 'skip'; + if (cat.startsWith('creative')) return !!agentTracks.creative && agentTracks.creative !== 'skip'; + if (cat.startsWith('signal')) return !!agentTracks.signals && agentTracks.signals !== 'skip'; + return true; + } + // Shared: render the storyboard picker into a panel - async function renderStoryboardPicker(panel, agentUrl, cardId) { + async function renderStoryboardPicker(panel, agentUrl, cardId, agentTracks) { panel.style.display = 'block'; panel.innerHTML = 'Loading storyboards...'; + if (agentTracks) panel.dataset.agentTracks = JSON.stringify(agentTracks); try { const res = await fetch('/api/storyboards', { credentials: 'include' }); const data = await res.json(); @@ -963,8 +1113,16 @@

Agents

panel.innerHTML = 'No storyboards available yet.'; return; } - let html = '
'; - for (const sb of data.storyboards) { + + const recommended = data.storyboards.filter(sb => storyboardMatchesAgent(sb, agentTracks)); + const other = data.storyboards.filter(sb => !storyboardMatchesAgent(sb, agentTracks)); + + let html = ''; + if (recommended.length > 0 && other.length > 0) { + html += '
Recommended for this agent
'; + } + html += '
'; + for (const sb of recommended) { html += '
'; html += '
'; html += '
' + escapeHtml(sb.title) + '
'; @@ -974,6 +1132,24 @@

Agents

html += '
'; } html += '
'; + + if (other.length > 0) { + html += '
'; + html += 'Other storyboards (' + other.length + ')'; + html += '
'; + for (const sb of other) { + html += '
'; + html += '
'; + html += '
' + escapeHtml(sb.title) + '
'; + html += '
' + escapeHtml(sb.summary) + '
'; + html += '
'; + html += '' + sb.phase_count + ' phases, ' + sb.step_count + ' steps'; + html += '
'; + } + html += '
'; + html += '
'; + } + panel.innerHTML = html; } catch { panel.innerHTML = 'Failed to load storyboards.'; @@ -992,7 +1168,9 @@

Agents

panel.style.display = 'none'; return; } - await renderStoryboardPicker(panel, agentUrl, cardId); + let agentTracks = null; + try { agentTracks = JSON.parse(storyboardBtn.dataset.agentTracks || 'null'); } catch {} + await renderStoryboardPicker(panel, agentUrl, cardId, agentTracks); return; } @@ -1064,7 +1242,11 @@

Agents

const agentUrl = backBtn.dataset.agentUrl; const cardId = backBtn.dataset.cardId; const panel = document.getElementById(cardId + '-storyboard'); - if (panel) await renderStoryboardPicker(panel, agentUrl, cardId); + if (panel) { + let tracks = null; + try { tracks = JSON.parse(panel.dataset.agentTracks || 'null'); } catch {} + await renderStoryboardPicker(panel, agentUrl, cardId, tracks); + } return; } @@ -1159,6 +1341,7 @@

Agents

} panel.innerHTML = html; + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch { progressEl.textContent = 'Failed to run storyboard evaluation.'; runBtn.disabled = false; @@ -1231,6 +1414,7 @@

Agents

} panel.innerHTML = html; + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch { progressEl.textContent = 'Failed to run comparison.'; compareBtn.disabled = false; diff --git a/server/src/addie/services/compliance-testing.ts b/server/src/addie/services/compliance-testing.ts index f40d72ffc..d057b66b4 100644 --- a/server/src/addie/services/compliance-testing.ts +++ b/server/src/addie/services/compliance-testing.ts @@ -1,6 +1,7 @@ import { testAllScenarios, DEFAULT_SCENARIOS, + SCENARIO_REQUIREMENTS, setAgentTesterLogger, type TestOptions, type TestResult, @@ -420,6 +421,18 @@ function buildPlatformCoherence( }; } +/** + * Check whether every scenario in a track requires get_products. + * If so, the track cannot produce any results when products are unavailable. + */ +function isTrackProductDependent(track: ComplianceTrack): boolean { + const scenarios = TRACK_SCENARIOS[track]; + return scenarios.every((scenario) => { + const reqs = SCENARIO_REQUIREMENTS[scenario]; + return reqs && reqs.includes('get_products'); + }); +} + const ALL_KNOWN_SCENARIOS = new Set( (Object.values(TRACK_SCENARIOS) as TestScenario[][]).flat(), ); @@ -454,22 +467,83 @@ export async function comply(agentUrl: string, options: ComplyOptions = {}): Pro ? options.tracks : (Object.keys(TRACK_SCENARIOS) as ComplianceTrack[]); - const suite = await testAllScenarios(agentUrl, { + // Phase 1: Run core + products tracks to check product availability + const foundationTracks: ComplianceTrack[] = ['core', 'products'].filter( + (t) => requestedTracks.includes(t as ComplianceTrack), + ) as ComplianceTrack[]; + const remainingTracks = requestedTracks.filter((t) => !foundationTracks.includes(t)); + + const foundationScenarios = scenarioList.filter((s) => { + const foundationScenarioSet = new Set(foundationTracks.flatMap((t) => TRACK_SCENARIOS[t])); + return foundationScenarioSet.has(s); + }); + + const foundationSuite = await testAllScenarios(agentUrl, { ...options, - scenarios: scenarioList, + scenarios: foundationScenarios.length > 0 ? foundationScenarios : buildScenarioList(foundationTracks), }); - const trackResults = buildTrackResults(requestedTracks, suite.results); + // Check if product discovery produced any passing results + const productsTrackResults = buildTrackResults( + foundationTracks.filter((t) => t === 'products'), + foundationSuite.results, + ); + const productsAvailable = productsTrackResults.some( + (t) => t.status === 'pass' || t.status === 'partial', + ); + + // Phase 2: Run remaining tracks, skipping product-dependent ones if products failed + let phase2Tracks: ComplianceTrack[]; + let skippedTracks: ComplianceTrack[]; + if (productsAvailable || remainingTracks.length === 0) { + phase2Tracks = remainingTracks; + skippedTracks = []; + } else { + phase2Tracks = remainingTracks.filter((t) => !isTrackProductDependent(t)); + skippedTracks = remainingTracks.filter((t) => isTrackProductDependent(t)); + } + + let allResults = [...foundationSuite.results]; + let totalDurationMs = foundationSuite.total_duration_ms; + + if (phase2Tracks.length > 0) { + const phase2ScenarioSet = new Set(phase2Tracks.flatMap((t) => TRACK_SCENARIOS[t])); + const phase2Scenarios = options.scenarios?.length + ? scenarioList.filter((s) => phase2ScenarioSet.has(s)) + : buildScenarioList(phase2Tracks); + + const phase2Suite = await testAllScenarios(agentUrl, { + ...options, + scenarios: phase2Scenarios, + }); + allResults = [...allResults, ...phase2Suite.results]; + totalDurationMs += phase2Suite.total_duration_ms; + } + + // Build track results — skipped product-dependent tracks get 'skip' status automatically + // since they have no scenario results in allResults + const trackResults = buildTrackResults(requestedTracks, allResults); + const tracks_passed = trackResults.filter((track) => track.status === 'pass').length; const tracks_failed = trackResults.filter((track) => track.status === 'fail').length; const tracks_partial = trackResults.filter((track) => track.status === 'partial').length; const tracks_skipped = trackResults.filter((track) => track.status === 'skip').length; const observations = buildObservations(trackResults); + + if (skippedTracks.length > 0) { + const skippedLabels = skippedTracks.map((t) => TRACK_LABELS[t]).join(', '); + observations.push({ + severity: 'warning', + category: 'products', + message: `Product discovery failed — skipped dependent tracks: ${skippedLabels}. Fix product schema issues first.`, + }); + } + const headline = `${tracks_passed} track${tracks_passed === 1 ? '' : 's'} passed, ${tracks_failed} failed, ${tracks_partial} partial, ${tracks_skipped} skipped`; // Hard-fail agents that do not support v3 - const agentVersion = suite.agent_profile.adcp_version; + const agentVersion = foundationSuite.agent_profile.adcp_version; const v3GateFailed = !agentVersion || agentVersion === 'v2'; if (v3GateFailed) { observations.push({ @@ -482,7 +556,7 @@ export async function comply(agentUrl: string, options: ComplyOptions = {}): Pro } return { - agent_profile: suite.agent_profile, + agent_profile: foundationSuite.agent_profile, tracks: trackResults, summary: { headline: v3GateFailed ? `v3 required — ${headline}` : headline, @@ -492,8 +566,8 @@ export async function comply(agentUrl: string, options: ComplyOptions = {}): Pro tracks_skipped, }, observations, - total_duration_ms: suite.total_duration_ms, - dry_run: suite.dry_run, + total_duration_ms: totalDurationMs, + dry_run: foundationSuite.dry_run, platform_coherence: buildPlatformCoherence(options.platform_type, trackResults), v3_gate_failed: v3GateFailed || undefined, }; diff --git a/server/src/db/compliance-db.ts b/server/src/db/compliance-db.ts index 752c3428c..56ac46b61 100644 --- a/server/src/db/compliance-db.ts +++ b/server/src/db/compliance-db.ts @@ -419,13 +419,16 @@ export class ComplianceDatabase { ): Promise<{ type: 'bearer'; token: string } | { type: 'basic'; username: string; password: string } | undefined> { try { const result = await query( - `SELECT ac.organization_id, ac.auth_token_encrypted, ac.auth_token_iv, ac.auth_type + `SELECT ac.organization_id, + ac.auth_token_encrypted, ac.auth_token_iv, ac.auth_type, + ac.oauth_access_token_encrypted, ac.oauth_access_token_iv, + ac.oauth_token_expires_at FROM agent_contexts ac JOIN member_profiles mp ON mp.workos_organization_id = ac.organization_id WHERE ac.agent_url = $1 AND mp.agents @> $2::jsonb - AND ac.auth_token_encrypted IS NOT NULL + AND (ac.auth_token_encrypted IS NOT NULL OR ac.oauth_access_token_encrypted IS NOT NULL) ORDER BY ac.updated_at DESC NULLS LAST LIMIT 1`, [agentUrl, JSON.stringify([{ url: agentUrl }])], @@ -434,17 +437,39 @@ export class ComplianceDatabase { const row = result.rows[0]; if (!row) return undefined; - const token = decryptToken(row.auth_token_encrypted, row.auth_token_iv, row.organization_id); + // Prefer static token when available + if (row.auth_token_encrypted) { + const token = decryptToken(row.auth_token_encrypted, row.auth_token_iv, row.organization_id); - if (row.auth_type === 'basic') { - const decoded = Buffer.from(token, 'base64').toString(); - const colonIndex = decoded.indexOf(':'); - if (colonIndex >= 0) { - return { type: 'basic', username: decoded.slice(0, colonIndex), password: decoded.slice(colonIndex + 1) }; + if (row.auth_type === 'basic') { + const decoded = Buffer.from(token, 'base64').toString(); + const colonIndex = decoded.indexOf(':'); + if (colonIndex >= 0) { + return { type: 'basic', username: decoded.slice(0, colonIndex), password: decoded.slice(colonIndex + 1) }; + } } + + return { type: 'bearer', token }; + } + + // Fall back to OAuth access token + if (row.oauth_access_token_encrypted && row.oauth_access_token_iv) { + // Check expiration with 5-minute buffer + if (row.oauth_token_expires_at) { + const expiresAt = new Date(row.oauth_token_expires_at); + if (expiresAt.getTime() - Date.now() <= 5 * 60 * 1000) { + logger.debug({ agentUrl, expiresAt }, 'OAuth token expired or expiring soon for compliance auth'); + return undefined; + } + } else { + logger.debug({ agentUrl }, 'OAuth token has no expiration recorded'); + } + + const token = decryptToken(row.oauth_access_token_encrypted, row.oauth_access_token_iv, row.organization_id); + return { type: 'bearer', token }; } - return { type: 'bearer', token }; + return undefined; } catch (error) { logger.debug({ error, agentUrl }, 'Could not resolve owner auth for heartbeat'); return undefined; diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index 187001fd2..88e6286a9 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -54,6 +54,8 @@ import { PropertyCheckService } from "../services/property-check.js"; import { PropertyCheckDatabase } from "../db/property-check-db.js"; import { BulkPropertyCheckService } from "../services/bulk-property-check.js"; import { ComplianceDatabase, type LifecycleStage } from "../db/compliance-db.js"; +import { AgentContextDatabase } from "../db/agent-context-db.js"; +import { getAllPlatformTypes } from "../addie/services/compliance-testing.js"; import { getRequestLog, getRequestCount } from "../db/outbound-log-db.js"; const logger = createLogger("registry-api"); @@ -61,6 +63,7 @@ const propertyCheckService = new PropertyCheckService(); const propertyCheckDb = new PropertyCheckDatabase(); const bulkCheckService = new BulkPropertyCheckService(); const complianceDb = new ComplianceDatabase(); +const agentContextDb = new AgentContextDatabase(); /** Strip protocol, path, query, and fragment from a URL to extract the domain. */ function extractDomain(raw: string): string { @@ -2539,6 +2542,91 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router { } }); + router.put("/registry/agents/:encodedUrl/connect", brandCreationRateLimiter, ...complianceWriteMiddleware, async (req, res) => { + try { + const agentUrl = decodeURIComponent(req.params.encodedUrl); + if (!validateAgentUrlParam(agentUrl)) { + return res.status(400).json({ error: "Invalid agent URL" }); + } + + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { auth_token, auth_type, platform_type } = req.body; + + if (auth_token && typeof auth_token !== "string") { + return res.status(400).json({ error: "auth_token must be a string" }); + } + if (auth_token && auth_token.length > 4096) { + return res.status(400).json({ error: "auth_token exceeds maximum length" }); + } + + const validAuthTypes = ["bearer", "basic"]; + if (auth_token && auth_type && !validAuthTypes.includes(auth_type)) { + return res.status(400).json({ error: `Invalid auth_type. Valid types: ${validAuthTypes.join(", ")}` }); + } + const resolvedAuthType = validAuthTypes.includes(auth_type) ? auth_type : "bearer"; + + if (platform_type && typeof platform_type !== "string") { + return res.status(400).json({ error: "platform_type must be a string" }); + } + const validPlatformTypes = new Set(getAllPlatformTypes() as string[]); + if (platform_type && !validPlatformTypes.has(platform_type)) { + return res.status(400).json({ + error: `Invalid platform_type. Valid types: ${[...validPlatformTypes].join(", ")}`, + }); + } + + // Verify ownership and get org ID in a single query + const orgResult = await query( + `SELECT mp.workos_organization_id + FROM member_profiles mp + JOIN organization_memberships om + ON om.workos_organization_id = mp.workos_organization_id + WHERE mp.agents @> $1::jsonb + AND om.workos_user_id = $2 + LIMIT 1`, + [JSON.stringify([{ url: agentUrl }]), req.user.id], + ); + + if (orgResult.rows.length === 0) { + return res.status(403).json({ error: "You do not have permission to modify this agent" }); + } + + const orgId = orgResult.rows[0].workos_organization_id; + + // Get or create agent context + let context = await agentContextDb.getByOrgAndUrl(orgId, agentUrl); + if (!context) { + context = await agentContextDb.create({ + organization_id: orgId, + agent_url: agentUrl, + created_by: req.user.id, + }); + } + + // Save auth token if provided + if (auth_token) { + await agentContextDb.saveAuthToken(context.id, auth_token, resolvedAuthType); + } + + // Set platform type if provided + if (platform_type) { + await complianceDb.upsertRegistryMetadata(agentUrl, { platform_type }); + } + + res.json({ + connected: true, + has_auth: !!auth_token || context.has_auth_token, + platform_type: platform_type || undefined, + }); + } catch (error) { + logger.error({ err: error, path: req.path }, "Failed to connect agent"); + res.status(500).json({ error: "Failed to connect agent" }); + } + }); + // ── Storyboards ──────────────────────────────────────────────── router.get("/storyboards", async (req, res) => {