From 1f9a2841b534e0ce0bd9809e8c589a6071d07618 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 1 Oct 2026 00:02:16 +0000 Subject: [PATCH] feat(sites): serve brandjson.org and trustjson.org as neutral standard sites Phase 1 of moving brand.json tooling to its own home (RFC #7809 follow-up). - Host gate for brandjson.org / trustjson.org: www -> apex, own landing pages, robots/sitemap/llms, schemas and static assets pass through, public GET /api/brands/* only; everything else on brandjson.org 301s to agenticadvertising.org, and trustjson.org 404s. - brandjson.org/builder serves the existing builder with neutral chrome. Self-hosting stays the default; a new 'agency or platform hosts it' option generates an authoritative_location pointer; 'Host with AgenticAdvertising.org' hands the draft off via the URL fragment to the AAO builder, where the user reviews and saves it. - nav.js renders neutral standard-site chrome when the server marks a page with window.__ADCP_SITE__; registry sub-nav is skipped there. - trustjson.org landing page, marked draft. Co-Authored-By: Claude Opus 5.5 (1M context) --- server/public/brand-builder.html | 139 +++++++++++++++++- server/public/nav.js | 58 ++++++++ server/public/registry-nav.js | 3 + server/public/sites/brandjson/index.html | 66 +++++++++ server/public/sites/standard-site.css | 18 +++ server/public/sites/trustjson/index.html | 64 ++++++++ server/src/http.ts | 37 ++++- server/src/standalone-sites.ts | 136 +++++++++++++++++ .../tests/unit/standalone-sites-route.test.ts | 99 +++++++++++++ server/tests/unit/standalone-sites.test.ts | 71 +++++++++ 10 files changed, 685 insertions(+), 6 deletions(-) create mode 100644 server/public/sites/brandjson/index.html create mode 100644 server/public/sites/standard-site.css create mode 100644 server/public/sites/trustjson/index.html create mode 100644 server/src/standalone-sites.ts create mode 100644 server/tests/unit/standalone-sites-route.test.ts create mode 100644 server/tests/unit/standalone-sites.test.ts diff --git a/server/public/brand-builder.html b/server/public/brand-builder.html index 5e3dbe4daf7..8e2b1f1d82a 100644 --- a/server/public/brand-builder.html +++ b/server/public/brand-builder.html @@ -1200,10 +1200,11 @@

brand.json for

-
+
Log in as a member to save setup
+
@@ -1658,6 +1659,17 @@

Next steps

Place the file at this exact location on your server:

https://example.com/.well-known/brand.json

Create the .well-known folder if it doesn't exist.

+
+ Your agency or platform hosts it instead? +

Send them the brand.json above. Then publish this small pointer at the same location on your domain, so agents follow it to their copy.

+
+ + +

Must be an HTTPS URL that serves the file directly, without redirects.

+
+
{}
+ +
  • @@ -3933,6 +3945,117 @@

    ${escapeHtml(brand.name || brand.brand_id || 'Untitled Brand')}

    .catch(() => alert('Failed to copy instructions.')); } + // ============================================================================ + // Delegated hosting (agency or platform hosts the file) + // ============================================================================ + + function getDelegatePointer() { + const raw = (document.getElementById('delegate-url')?.value || '').trim(); + let url; + try { url = new URL(raw); } catch { return null; } + if (url.protocol !== 'https:') return null; + return { + "$schema": "https://adcontextprotocol.org/schemas/latest/brand.json", + "authoritative_location": url.href + }; + } + + function updateDelegatePointer() { + const pointer = getDelegatePointer(); + document.getElementById('delegate-pointer').textContent = pointer ? JSON.stringify(pointer, null, 2) : '{}'; + document.getElementById('delegate-download-btn').disabled = !pointer; + } + + function downloadDelegatePointer() { + const pointer = getDelegatePointer(); + if (!pointer) return; + const blob = new Blob([JSON.stringify(pointer, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'brand.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // ============================================================================ + // Handoff from brandjson.org to AgenticAdvertising.org hosting + // ============================================================================ + // brandjson.org has no AgenticAdvertising.org session, so hosting there + // continues on agenticadvertising.org. The draft travels in the URL + // fragment (never sent to a server) and is only loaded into the form; the + // user still reviews and saves it explicitly. + + const HANDOFF_MAX_JSON_CHARS = 100000; + + function toBase64Url(bytes) { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + + function fromBase64Url(text) { + let b64 = text.replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4) b64 += '='; + const bin = atob(b64); + return Uint8Array.from(bin, c => c.charCodeAt(0)); + } + + async function encodeDraft(json) { + const bytes = new TextEncoder().encode(JSON.stringify(json)); + if (typeof CompressionStream === 'function') { + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip')); + return 'v1.gz.' + toBase64Url(new Uint8Array(await new Response(stream).arrayBuffer())); + } + return 'v1.raw.' + toBase64Url(bytes); + } + + async function decodeDraft(token) { + const match = /^v1\.(gz|raw)\.([A-Za-z0-9_-]+)$/.exec(token); + if (!match) return null; + let bytes = fromBase64Url(match[2]); + if (match[1] === 'gz') { + if (typeof DecompressionStream !== 'function') return null; + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); + bytes = new Uint8Array(await new Response(stream).arrayBuffer()); + } + const text = new TextDecoder().decode(bytes); + if (text.length > HANDOFF_MAX_JSON_CHARS) return null; + const data = JSON.parse(text); + return data && typeof data === 'object' && !Array.isArray(data) ? data : null; + } + + function aaoOrigin() { + const host = window.location.hostname; + return host === 'localhost' || host === '127.0.0.1' ? window.location.origin : 'https://agenticadvertising.org'; + } + + async function hostWithAao() { + if (!currentDomain) return; + try { + const token = await encodeDraft(finalizeJson()); + window.location.href = `${aaoOrigin()}/brand/builder?domain=${encodeURIComponent(currentDomain)}#draft=${token}`; + } catch { + alert('Could not prepare your draft. Download it instead and upload it on AgenticAdvertising.org.'); + } + } + + async function applyHandoffDraft(token, domain) { + let data = null; + try { data = await decodeDraft(token); } catch { data = null; } + history.replaceState(null, '', window.location.pathname + window.location.search); + if (!data) { + alert('The draft from brandjson.org could not be loaded. Please rebuild it here.'); + return; + } + document.getElementById('builder-form').classList.remove('hidden'); + populateFromBrandJson(data, domain, domain); + document.getElementById('file-status').textContent = 'Imported from brandjson.org. Review, then save your setup.'; + updateRegistryButton(); + } + async function loadFromRegistry(domain) { try { const response = await fetch(`/api/brands/hosted/${encodeURIComponent(domain)}`); @@ -3965,13 +4088,21 @@

    ${escapeHtml(brand.name || brand.brand_id || 'Untitled Brand')}

    // Initialize // ============================================================================ - document.addEventListener('DOMContentLoaded', () => { - // Check for domain in URL params + document.addEventListener('DOMContentLoaded', async () => { + // On brandjson.org, hosting continues on AgenticAdvertising.org. + if (window.__ADCP_SITE__ === 'brandjson') { + document.getElementById('save-registry-group').style.display = 'none'; + document.getElementById('host-with-aao-btn').classList.remove('hidden'); + } + + // Check for domain in URL params, and a draft handed off from brandjson.org const params = new URLSearchParams(window.location.search); const domain = params.get('domain'); + const draftToken = window.location.hash.startsWith('#draft=') ? window.location.hash.slice('#draft='.length) : null; if (domain) { document.getElementById('domain-input').value = domain; - startBuilding(); + await startBuilding(); + if (draftToken) await applyHandoffDraft(draftToken, domain); } }); diff --git a/server/public/nav.js b/server/public/nav.js index 66409b14666..6c06b8ab5a2 100644 --- a/server/public/nav.js +++ b/server/public/nav.js @@ -26,6 +26,64 @@ return; } + // Standalone standard sites (brandjson.org, trustjson.org) get neutral + // chrome: the standard's own wordmark and links, with AgenticAdvertising.org + // credited as steward rather than as the site owner. + if (window.__ADCP_SITE__ === 'brandjson' || window.__ADCP_SITE__ === 'trustjson') { + renderStandaloneChrome(window.__ADCP_SITE__); + return; + } + + function renderStandaloneChrome(site) { + const sites = { + brandjson: { + name: 'brand.json', + links: [ + { href: '/builder', label: 'Builder' }, + { href: '/spec', label: 'Specification' }, + { href: '/schemas/v3/brand.json', label: 'Schema' }, + { href: 'https://github.com/adcontextprotocol/adcp', label: 'GitHub' }, + ], + }, + trustjson: { + name: 'trust.json', + links: [ + { href: 'https://github.com/adcontextprotocol/adcp/issues/7809', label: 'RFC' }, + { href: 'https://github.com/adcontextprotocol/adcp/pull/7819', label: 'Draft spec' }, + { href: 'https://github.com/adcontextprotocol/adcp', label: 'GitHub' }, + ], + }, + }; + const cfg = sites[site]; + const style = ` + .std-site-nav { position: fixed; top: 0; left: 0; right: 0; height: var(--nav-height); z-index: 100; + display: flex; align-items: center; justify-content: space-between; padding: 0 var(--space-5); + background: var(--color-bg-card); border-bottom: 1px solid var(--color-border); } + .std-site-nav a { color: var(--color-text-secondary); text-decoration: none; font-size: var(--text-sm); } + .std-site-nav a:hover { color: var(--color-text); } + .std-site-wordmark { font-family: var(--font-mono); font-weight: var(--font-bold); font-size: var(--text-lg); color: var(--color-text) !important; } + .std-site-links { display: flex; gap: var(--space-5); } + .std-site-footer { border-top: 1px solid var(--color-border); padding: var(--space-6) var(--space-5); background: var(--color-bg-card); + text-align: center; color: var(--color-text-muted); font-size: var(--text-sm); } + .std-site-footer a { color: var(--color-text-secondary); } + @media (max-width: 640px) { .std-site-links { gap: var(--space-3); } } + `; + const links = cfg.links.map(l => `${l.label}`).join(''); + const navHTML = ``; + const footerHTML = ``; + function mount() { + document.head.insertAdjacentHTML('beforeend', ``); + const navPlaceholder = document.getElementById('adcp-nav'); + if (navPlaceholder) navPlaceholder.outerHTML = navHTML; + else document.body.insertAdjacentHTML('afterbegin', navHTML); + const footerPlaceholder = document.getElementById('adcp-footer'); + if (footerPlaceholder) footerPlaceholder.outerHTML = footerHTML; + else if (!document.querySelector('footer')) document.body.insertAdjacentHTML('beforeend', footerHTML); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); + else mount(); + } + function escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); diff --git a/server/public/registry-nav.js b/server/public/registry-nav.js index 07001ec0c7a..64009439a5b 100644 --- a/server/public/registry-nav.js +++ b/server/public/registry-nav.js @@ -9,6 +9,9 @@ (function () { 'use strict'; + // Standalone standard sites have no registry sub-navigation. + if (window.__ADCP_SITE__) return; + var path = window.location.pathname; var params = new URLSearchParams(window.location.search); var tab = params.get('tab'); diff --git a/server/public/sites/brandjson/index.html b/server/public/sites/brandjson/index.html new file mode 100644 index 00000000000..0f486e776a2 --- /dev/null +++ b/server/public/sites/brandjson/index.html @@ -0,0 +1,66 @@ + + + + + + brand.json: publish your brand identity for AI agents + + + + + + + +
    + + +
    +

    brand.json

    +

    An open standard for publishing your brand identity where AI agents can find it: one file at /.well-known/brand.json.

    + +
    + +
    +

    What it is

    +

    brand.json tells agents who your brand is and how it wants to be represented: names, logos, colors, fonts, tone, visual guidelines, and the properties you own. Agents read it the same way crawlers read robots.txt: from a well-known location on your own domain.

    + +

    Who hosts it

    +
    +
    +

    You

    +

    Recommended. Put the file at https://yourdomain/.well-known/brand.json. The builder generates it and checks it once it's live.

    +
    +
    +

    Your agency or platform

    +

    They host the full file; you publish a small pointer on your domain with authoritative_location.

    +
    +
    +

    A hosting service

    +

    AgenticAdvertising.org can host it for you. Your domain still publishes the pointer, so you stay in control.

    +
    +
    + +

    A minimal example

    +
    {
    +  "$schema": "https://brandjson.org/schemas/v3/brand.json",
    +  "house": { "domain": "acme.example", "name": "Acme Corp" },
    +  "brands": [
    +    {
    +      "id": "acme",
    +      "names": [{ "en": "Acme" }],
    +      "logos": [{ "url": "https://acme.example/logo.svg" }],
    +      "colors": { "primary": "#1A4D8F" }
    +    }
    +  ]
    +}
    + +

    Schema

    +

    Validate against https://brandjson.org/schemas/v3/brand.json. The standard's agent and trust fields are moving to a separate record, trust.json, so brand.json can stay focused on identity.

    +
    + + + + diff --git a/server/public/sites/standard-site.css b/server/public/sites/standard-site.css new file mode 100644 index 00000000000..d1eb42717cf --- /dev/null +++ b/server/public/sites/standard-site.css @@ -0,0 +1,18 @@ +/* Shared layout for standalone standard sites (brandjson.org, trustjson.org). + Uses design system variables only - see /design-system.css. */ +body { padding-top: var(--nav-height); background: var(--color-bg-page); color: var(--color-text); font-family: var(--font-sans); } +.std-hero { padding: var(--space-16) var(--space-5) var(--space-12); text-align: center; background: var(--gradient-primary-dark); color: var(--color-text-on-dark); } +.std-hero h1 { font-family: var(--font-mono); font-size: var(--text-5xl); font-weight: var(--font-bold); margin: 0 0 var(--space-4); } +.std-hero p { font-size: var(--text-xl); color: var(--color-text-on-dark-secondary); max-width: 640px; margin: 0 auto var(--space-6); line-height: 1.5; } +.std-badge { display: inline-block; margin-bottom: var(--space-4); padding: var(--space-1) var(--space-3); border-radius: var(--radius-lg); background: var(--color-warning-100); color: var(--color-text); font-size: var(--text-sm); font-weight: var(--font-semibold); } +.std-actions { display: flex; gap: var(--space-3); justify-content: center; flex-wrap: wrap; } +.std-main { max-width: var(--container-md); margin: 0 auto; padding: var(--space-10) var(--space-5); } +.std-main h2 { font-size: var(--text-2xl); margin: var(--space-10) 0 var(--space-3); color: var(--color-text-heading); } +.std-main h2:first-child { margin-top: 0; } +.std-main p, .std-main li { color: var(--color-text-secondary); line-height: 1.6; } +.std-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4); margin-top: var(--space-4); } +.std-card { background: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--space-5); box-shadow: var(--shadow-sm); } +.std-card h3 { margin: 0 0 var(--space-2); font-size: var(--text-lg); color: var(--color-text-heading); } +.std-card p { margin: 0; font-size: var(--text-sm); } +.std-main pre { background: var(--color-code-bg); border: 1px solid var(--color-code-border); color: var(--color-code-text); border-radius: var(--radius-lg); padding: var(--space-4); overflow-x: auto; font-family: var(--font-mono); font-size: var(--text-sm); } +.std-main code { font-family: var(--font-mono); } diff --git a/server/public/sites/trustjson/index.html b/server/public/sites/trustjson/index.html new file mode 100644 index 00000000000..024c107222b --- /dev/null +++ b/server/public/sites/trustjson/index.html @@ -0,0 +1,64 @@ + + + + + + trust.json: which agents act for your organization (draft) + + + + + + + +
    + + +
    + Draft +

    trust.json

    +

    One file at /.well-known/trust.json that says which agents your organization runs, which keys they sign with, and who may act on your behalf.

    + +
    + +
    +

    What it answers

    +
    +
    +

    Which agents are ours?

    +

    agents[]: each agent's URL, roles, and signing keys, published from your own domain.

    +
    +
    +

    Who may act for us?

    +

    grants[]: authority you delegate to other organizations, scoped and time-boxed. Grants never carry keys.

    +
    +
    +

    Whose grants do we accept?

    +

    acknowledgements[]: a pointer to a grant someone else published. The terms stay in their file.

    +
    +
    + +

    Not just for advertising

    +

    The core is protocol-neutral. Roles and scopes are namespaced, for example adcp:sales for the Ad Context Protocol, so any agent protocol can define its own profile.

    + +

    A minimal example

    +
    {
    +  "agents": [
    +    {
    +      "url": "https://agents.acme.example/sales",
    +      "roles": ["adcp:sales"],
    +      "jwks_uri": "https://agents.acme.example/.well-known/jwks.json"
    +    }
    +  ]
    +}
    + +

    Status

    +

    trust.json is a draft under discussion in RFC #7809. Field names may change before v1. Identity stays in brand.json.

    +
    + + + + diff --git a/server/src/http.ts b/server/src/http.ts index 2ecf23db47f..13f46165639 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -54,6 +54,7 @@ import { getBrandPrimaryDomain, getBrandPrimaryDomainsForOrgs } from "./services import { getGitHubConnectedAccount, resolveGitHubConnectUrl, disconnectGitHub, buildPipesReturnTo } from "./services/pipes.js"; import { BrandDatabase, HostedBrandConflictError, canSurfaceBrandForMember, resolveBrandFromJson } from "./db/brand-db.js"; import { isDomainControlVerified, publicBrandJsonManifest } from "./services/brand-trust-fields.js"; +import { getStandaloneSite, resolveStandaloneRequest, type StandaloneSite } from "./standalone-sites.js"; import { CatalogEventsDatabase } from "./db/catalog-events-db.js"; import { AgentInventoryProfilesDatabase } from "./db/agent-inventory-profiles-db.js"; import { BrandManager } from "./brand-manager.js"; @@ -1223,6 +1224,12 @@ async function getUserFromRequest( return null; } +/** Marks pages served on a standalone standard site so nav.js renders neutral chrome. */ +function standaloneSiteScript(res: express.Response): string { + const site = res.locals.standaloneSite as StandaloneSite | undefined; + return site ? `\n` : ''; +} + function stripLegacyBrandContext(manifest: Record): Record { const { brand_context: _brandContext, ...publicManifest } = manifest; return publicManifest; @@ -1332,6 +1339,32 @@ export class HTTPServer { mountComplianceRoutes(this.app, path.join(distPath, 'compliance')); mountProtocolRoutes(this.app, path.join(distPath, 'protocol')); + // Standalone standard sites (brandjson.org, trustjson.org) expose only + // their own pages, schemas, and public read APIs. See standalone-sites.ts. + this.app.use(async (req, res, next) => { + const site = getStandaloneSite(req.hostname); + if (!site) return next(); + res.locals.standaloneSite = site; + const decision = resolveStandaloneRequest(site, req.hostname, req.method, req.path, req.originalUrl); + switch (decision.kind) { + case 'pass': + return next(); + case 'rewrite': + req.url = decision.url; + return next(); + case 'page': + return this.serveHtmlWithConfig(req, res, decision.file); + case 'redirect': + return res.redirect(decision.status, decision.location); + case 'text': + res.setHeader('Content-Type', decision.contentType); + res.setHeader('Cache-Control', 'public, max-age=300'); + return res.send(decision.body); + case 'not_found': + return res.status(404).type('text/plain').send('Not found'); + } + }); + // Track slow API responses and alert ops this.app.use(slowResponseTracker); @@ -1550,7 +1583,7 @@ export class HTTPServer { await enrichUserWithAdmin(user); // Inject config - const configScript = getAppConfigScript(user); + const configScript = getAppConfigScript(user) + standaloneSiteScript(res); // Inject before if (html.includes('')) { @@ -1741,7 +1774,7 @@ export class HTTPServer { // Read and inject config let html = await fs.readFile(filePath, 'utf-8'); html = await this.injectHomepageMemberCount(html); - const configScript = getAppConfigScript(user); + const configScript = getAppConfigScript(user) + standaloneSiteScript(res); // Inject before if (html.includes('')) { diff --git a/server/src/standalone-sites.ts b/server/src/standalone-sites.ts new file mode 100644 index 00000000000..5efc9414c46 --- /dev/null +++ b/server/src/standalone-sites.ts @@ -0,0 +1,136 @@ +/** + * Standalone standard sites served from this app: brandjson.org (brand.json) + * and trustjson.org (trust.json). They are neutral homes for open standards + * stewarded by AgenticAdvertising.org, so they expose only their own pages, + * schemas, and the public read APIs those pages need. Everything else stays on + * agenticadvertising.org. + */ + +export type StandaloneSite = 'brandjson' | 'trustjson'; + +const APEX_HOSTS: Record = { + brandjson: 'brandjson.org', + trustjson: 'trustjson.org', +}; + +const SITE_BY_HOST = new Map([ + ['brandjson.org', 'brandjson'], + ['www.brandjson.org', 'brandjson'], + ['trustjson.org', 'trustjson'], + ['www.trustjson.org', 'trustjson'], +]); + +const AAO_ORIGIN = 'https://agenticadvertising.org'; + +export function getStandaloneSite(hostname: string | undefined): StandaloneSite | null { + if (!hostname) return null; + return SITE_BY_HOST.get(hostname.toLowerCase()) ?? null; +} + +export function standaloneSiteOrigin(site: StandaloneSite): string { + return `https://${APEX_HOSTS[site]}`; +} + +export type StandaloneDecision = + /** Serve an HTML page from server/public. */ + | { kind: 'page'; file: string } + /** Rewrite the request path and continue to the existing route. */ + | { kind: 'rewrite'; url: string } + /** Continue unchanged (assets, schemas, allowed APIs). */ + | { kind: 'pass' } + | { kind: 'redirect'; status: 301 | 302; location: string } + | { kind: 'text'; contentType: string; body: string } + | { kind: 'not_found' }; + +const STATIC_ASSET = /^\/[A-Za-z0-9._\/-]+\.(css|js|svg|png|ico|jpg|jpeg|webp|gif|woff2?|ttf)$/; + +function queryOf(url: string): string { + const i = url.indexOf('?'); + return i === -1 ? '' : url.slice(i); +} + +function robotsTxt(site: StandaloneSite): string { + const origin = standaloneSiteOrigin(site); + return `User-agent: *\nAllow: /\n\n# ${APEX_HOSTS[site]}\nSitemap: ${origin}/sitemap.xml\n`; +} + +function sitemapXml(site: StandaloneSite): string { + const origin = standaloneSiteOrigin(site); + const paths = site === 'brandjson' ? ['/', '/builder'] : ['/']; + const urls = paths.map((p) => ` ${origin}${p}`).join('\n'); + return `\n\n${urls}\n\n`; +} + +function llmsTxt(site: StandaloneSite): string { + if (site === 'brandjson') { + return [ + '# brand.json', + '', + '> An open standard for publishing brand identity at /.well-known/brand.json so AI agents can find it. Stewarded by AgenticAdvertising.org.', + '', + '- [Builder and validator](https://brandjson.org/builder): create or check a brand.json', + '- [Specification](https://docs.adcontextprotocol.org/docs/brand-protocol/brand-json)', + '- [JSON Schema](https://brandjson.org/schemas/v3/brand.json)', + '', + ].join('\n'); + } + return [ + '# trust.json', + '', + '> Draft open standard for publishing which agents an organization runs, their keys, and who may act on its behalf, at /.well-known/trust.json. Stewarded by AgenticAdvertising.org.', + '', + '- [RFC](https://github.com/adcontextprotocol/adcp/issues/7809)', + '- [Draft specification](https://github.com/adcontextprotocol/adcp/pull/7819)', + '', + ].join('\n'); +} + +/** + * Decide how to handle a request on a standalone site host. + * `path` is req.path; `url` is req.originalUrl (path + query). + */ +export function resolveStandaloneRequest( + site: StandaloneSite, + hostname: string, + method: string, + path: string, + url: string, +): StandaloneDecision { + const apex = APEX_HOSTS[site]; + if (hostname.toLowerCase() !== apex) { + return { kind: 'redirect', status: 301, location: `https://${apex}${url}` }; + } + + if (path === '/robots.txt') return { kind: 'text', contentType: 'text/plain; charset=utf-8', body: robotsTxt(site) }; + if (path === '/sitemap.xml') return { kind: 'text', contentType: 'application/xml; charset=utf-8', body: sitemapXml(site) }; + if (path === '/llms.txt' || path === '/.well-known/llms.txt') { + return { kind: 'text', contentType: 'text/plain; charset=utf-8', body: llmsTxt(site) }; + } + + if (STATIC_ASSET.test(path) && (method === 'GET' || method === 'HEAD')) return { kind: 'pass' }; + if (path.startsWith('/schemas/')) return { kind: 'pass' }; + + if (site === 'trustjson') { + if (path === '/' || path === '/index.html') return { kind: 'page', file: 'sites/trustjson/index.html' }; + return { kind: 'not_found' }; + } + + // brandjson + if (path === '/' || path === '/index.html') return { kind: 'page', file: 'sites/brandjson/index.html' }; + if (path === '/builder' || path === '/builder/') { + return { kind: 'rewrite', url: `/brand/builder${queryOf(url)}` }; + } + if (path === '/spec') { + return { kind: 'redirect', status: 302, location: 'https://docs.adcontextprotocol.org/docs/brand-protocol/brand-json' }; + } + if (path.startsWith('/view/')) { + return { kind: 'redirect', status: 302, location: `${AAO_ORIGIN}/brand${url}` }; + } + // Public, unauthenticated reads the builder uses. Writes and anything + // session-bound stay on agenticadvertising.org. + if ((method === 'GET' || method === 'HEAD') && path.startsWith('/api/brands/')) return { kind: 'pass' }; + if ((method === 'GET' || method === 'HEAD') && path === '/api/config') return { kind: 'pass' }; + if (path.startsWith('/api/')) return { kind: 'not_found' }; + + return { kind: 'redirect', status: 301, location: `${AAO_ORIGIN}${url}` }; +} diff --git a/server/tests/unit/standalone-sites-route.test.ts b/server/tests/unit/standalone-sites-route.test.ts new file mode 100644 index 00000000000..1f7e83e268a --- /dev/null +++ b/server/tests/unit/standalone-sites-route.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import { HTTPServer } from '../../src/http.js'; + +const ORIGINAL_WORKOS_ENV = vi.hoisted(() => ({ + apiKey: process.env.WORKOS_API_KEY, + clientId: process.env.WORKOS_CLIENT_ID, +})); + +vi.hoisted(() => { + process.env.WORKOS_API_KEY = process.env.WORKOS_API_KEY || 'sk_test_public_brand_json_route'; + process.env.WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID || 'client_test_public_brand_json_route'; +}); + +vi.mock('../../src/config.js', async () => { + const actual = await vi.importActual('../../src/config.js'); + return { + ...actual, + getDatabaseConfig: vi.fn().mockReturnValue({ + connectionString: 'postgresql://localhost/test', + }), + }; +}); + +vi.mock('../../src/db/client.js', () => ({ + initializeDatabase: vi.fn(), + getPool: vi.fn().mockReturnValue({ query: vi.fn() }), + isDatabaseInitialized: vi.fn().mockReturnValue(true), + closeDatabase: vi.fn(), + healthCheck: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/db/migrate.js', () => ({ + runMigrations: vi.fn().mockResolvedValue(undefined), +})); + +describe('standalone standard sites (real server)', () => { + let server: HTTPServer | undefined; + + afterEach(async () => { + await server?.stop(); + server = undefined; + if (ORIGINAL_WORKOS_ENV.apiKey === undefined) { + delete process.env.WORKOS_API_KEY; + } else { + process.env.WORKOS_API_KEY = ORIGINAL_WORKOS_ENV.apiKey; + } + if (ORIGINAL_WORKOS_ENV.clientId === undefined) { + delete process.env.WORKOS_CLIENT_ID; + } else { + process.env.WORKOS_CLIENT_ID = ORIGINAL_WORKOS_ENV.clientId; + } + }); + + function app() { + server = new HTTPServer(); + return (server as unknown as { app: unknown }).app; + } + + it('serves the brandjson.org landing page with neutral-chrome marker', async () => { + const res = await request(app()).get('/').set('Host', 'brandjson.org'); + expect(res.status).toBe(200); + expect(res.text).toContain(''); + expect(res.text).toContain('window.__ADCP_SITE__="brandjson"'); + }); + + it('serves the builder at brandjson.org/builder', async () => { + const res = await request(app()).get('/builder?domain=acme.example').set('Host', 'brandjson.org'); + expect(res.status).toBe(200); + expect(res.text).toContain('id="host-with-aao-btn"'); + expect(res.text).toContain('window.__ADCP_SITE__="brandjson"'); + }); + + it('does not expose the AAO app on brandjson.org', async () => { + const res = await request(app()).get('/dashboard').set('Host', 'brandjson.org'); + expect(res.status).toBe(301); + expect(res.headers.location).toBe('https://agenticadvertising.org/dashboard'); + }); + + it('redirects www.brandjson.org to the apex', async () => { + const res = await request(app()).get('/builder').set('Host', 'www.brandjson.org'); + expect(res.status).toBe(301); + expect(res.headers.location).toBe('https://brandjson.org/builder'); + }); + + it('serves the trustjson.org landing page and 404s app paths', async () => { + const landing = await request(app()).get('/').set('Host', 'trustjson.org'); + expect(landing.status).toBe(200); + expect(landing.text).toContain('window.__ADCP_SITE__="trustjson"'); + const other = await request(app()).get('/registry').set('Host', 'trustjson.org'); + expect(other.status).toBe(404); + }); + + it('leaves agenticadvertising.org pages without the standalone marker', async () => { + const res = await request(app()).get('/brand/builder').set('Host', 'agenticadvertising.org'); + expect(res.status).toBe(200); + expect(res.text).not.toContain('window.__ADCP_SITE__='); + }); +}); diff --git a/server/tests/unit/standalone-sites.test.ts b/server/tests/unit/standalone-sites.test.ts new file mode 100644 index 00000000000..64836fed2c9 --- /dev/null +++ b/server/tests/unit/standalone-sites.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { getStandaloneSite, resolveStandaloneRequest } from '../../src/standalone-sites.js'; + +const brand = (method: string, url: string, host = 'brandjson.org') => + resolveStandaloneRequest('brandjson', host, method, url.split('?')[0], url); +const trust = (method: string, url: string, host = 'trustjson.org') => + resolveStandaloneRequest('trustjson', host, method, url.split('?')[0], url); + +describe('getStandaloneSite', () => { + it('recognizes apex and www hosts only', () => { + expect(getStandaloneSite('brandjson.org')).toBe('brandjson'); + expect(getStandaloneSite('WWW.BRANDJSON.ORG')).toBe('brandjson'); + expect(getStandaloneSite('trustjson.org')).toBe('trustjson'); + expect(getStandaloneSite('agenticadvertising.org')).toBeNull(); + expect(getStandaloneSite('evil-brandjson.org')).toBeNull(); + expect(getStandaloneSite(undefined)).toBeNull(); + }); +}); + +describe('resolveStandaloneRequest: brandjson.org', () => { + it('redirects www to the apex, keeping the path and query', () => { + expect(brand('GET', '/builder?domain=acme.example', 'www.brandjson.org')).toEqual({ + kind: 'redirect', status: 301, location: 'https://brandjson.org/builder?domain=acme.example', + }); + }); + + it('serves the landing page and maps /builder onto the existing builder route', () => { + expect(brand('GET', '/')).toEqual({ kind: 'page', file: 'sites/brandjson/index.html' }); + expect(brand('GET', '/builder?domain=acme.example')).toEqual({ kind: 'rewrite', url: '/brand/builder?domain=acme.example' }); + }); + + it('passes schemas, static assets, and public brand reads', () => { + expect(brand('GET', '/schemas/v3/brand.json').kind).toBe('pass'); + expect(brand('GET', '/design-system.css').kind).toBe('pass'); + expect(brand('GET', '/sites/standard-site.css').kind).toBe('pass'); + expect(brand('GET', '/api/brands/resolve?domain=acme.example').kind).toBe('pass'); + }); + + it('keeps writes and other APIs off the site', () => { + expect(brand('POST', '/api/brands/setup-my-brand').kind).toBe('not_found'); + expect(brand('GET', '/api/me/portrait').kind).toBe('not_found'); + expect(brand('POST', '/design-system.css').kind).not.toBe('pass'); + }); + + it('sends the viewer and everything else to agenticadvertising.org', () => { + expect(brand('GET', '/view/acme.example')).toEqual({ + kind: 'redirect', status: 302, location: 'https://agenticadvertising.org/brand/view/acme.example', + }); + expect(brand('GET', '/registry?tab=brands')).toEqual({ + kind: 'redirect', status: 301, location: 'https://agenticadvertising.org/registry?tab=brands', + }); + }); + + it('answers robots.txt, sitemap.xml, and llms.txt for its own host', () => { + const robots = brand('GET', '/robots.txt'); + expect(robots.kind).toBe('text'); + expect(robots.kind === 'text' && robots.body).toContain('https://brandjson.org/sitemap.xml'); + const llms = brand('GET', '/llms.txt'); + expect(llms.kind === 'text' && llms.body).toContain('# brand.json'); + }); +}); + +describe('resolveStandaloneRequest: trustjson.org', () => { + it('serves only its landing page, schemas, and assets', () => { + expect(trust('GET', '/')).toEqual({ kind: 'page', file: 'sites/trustjson/index.html' }); + expect(trust('GET', '/schemas/v3/brand.json').kind).toBe('pass'); + expect(trust('GET', '/builder').kind).toBe('not_found'); + expect(trust('GET', '/api/brands/resolve').kind).toBe('not_found'); + expect(trust('GET', '/registry').kind).toBe('not_found'); + }); +});