From 2a4ad2b4594d232388eff56b1bc3e3887fc7ee5c Mon Sep 17 00:00:00 2001 From: legendko Date: Thu, 1 Oct 2026 13:27:20 +0200 Subject: [PATCH 01/12] Add the publisher console address as a build setting The onboarding page is about to send publishers to the publisher console, and the publisher guide will link to it too. Both read one setting, FORA_PUBLISHER_CONSOLE_URL, so moving the console is a build value rather than an edit in two places. The default is the console's address on fora-protocol.org, which redirects to wherever the console runs. Astro checks a value set in the build environment against url and startsWith https, but never checks the default, so a malformed or non-https override fails the build while the default stays a literal https URL. --- website/astro.config.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 2c440bd2..50081928 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -34,6 +34,20 @@ export default defineConfig({ access: 'public', default: 'https://pub-onboarding.demo.fora-protocol.org/onboarding', }), + // Where the onboarding page's register button and the publisher guide + // send a publisher: the console root, which signs a visitor in, or lets + // them create an account, before anything else. The default is the + // console's address on fora-protocol.org, which redirects to wherever + // the console runs, so moving the console changes that redirect rather + // than this site. Astro validates a value set in the build environment + // but never the default, so the default must itself be an https URL. + FORA_PUBLISHER_CONSOLE_URL: envField.string({ + context: 'client', + access: 'public', + url: true, + startsWith: 'https://', + default: 'https://console.fora-protocol.org/', + }), // Where a publisher behind an unsupported CDN reaches us. FORA_PUBLISHER_CONTACT_URL: envField.string({ context: 'client', From b5126bad3dd0c0db94549bf8607a5387a661ae6c Mon Sep 17 00:00:00 2001 From: legendko Date: Thu, 1 Oct 2026 13:33:01 +0200 Subject: [PATCH 02/12] Send publishers from the agent preview to the publisher console The page ended with an email form that posted the visitor's address, the previewed page and their terms to the preview service's contact route. It is replaced by a register block: a button to the publisher console, read from FORA_PUBLISHER_CONSOLE_URL, and the existing link to the publisher guide. Both open in a new tab with noopener and noreferrer, so the preview stays open and its address, which carries the previewed page, is not handed on. The form's client and its tests, the copy of the request kept only for the enquiry, and the submit handler go with it. They have to go together: the handler bound to the form's markup when the script loaded, so removing either one alone throws before a shared preview link gets to run. The guide link also never showed its outline. The page reset every .btn border to transparent after brand.css had given the ghost variant its border, then restored only the width and style. The reset is gone; brand.css already gives each variant its full border. --- website/src/lib/onboarding/contact.mjs | 113 --------------- website/src/lib/onboarding/contact.test.mjs | 111 --------------- website/src/lib/onboarding/preview-client.mjs | 2 +- website/src/pages/publishers/onboarding.astro | 129 +++--------------- 4 files changed, 20 insertions(+), 335 deletions(-) delete mode 100644 website/src/lib/onboarding/contact.mjs delete mode 100644 website/src/lib/onboarding/contact.test.mjs diff --git a/website/src/lib/onboarding/contact.mjs b/website/src/lib/onboarding/contact.mjs deleted file mode 100644 index 7163af22..00000000 --- a/website/src/lib/onboarding/contact.mjs +++ /dev/null @@ -1,113 +0,0 @@ -// Email-only interest enquiry. The service accepts an omitted name. The page -// adds what the visitor previewed: the domain, the page URL and any terms -// they changed, all optional on the service side. - -/** The path the enquiry posts to, relative to the configured service base. */ -export const CONTACT_PATH = '/v1/contact'; - -/** The service's own bounds, so the page refuses what it would refuse. */ -export const MAX_EMAIL_LENGTH = 254; - -/** Refuse header line breaks before trimming input. */ -export function hasLineBreak(value) { - return /[\r\n]/.test(String(value ?? '')); -} - -/** - * A plain check that an address could be one. - * - * Deliberately loose: the service parses it properly and is the authority. This - * exists to catch the obvious miss before a round trip, not to re-implement - * RFC 5322 in a page. - */ -export function looksLikeEmail(value) { - if (hasLineBreak(value)) return false; - const text = String(value ?? '').trim(); - if (text.length === 0 || text.length > MAX_EMAIL_LENGTH) return false; - if (/\s/.test(text)) return false; - return /^[^@]+@[^@.]+(\.[^@.]+)+$/.test(text); -} - -/** - * Build the enquiry body. - * - * `preview` is the body of the last preview request the service answered, so - * the enquiry names the same domain, page and terms the visitor just looked at. - * - * Returns { ok: true, body } or { ok: false, field, message }, where field is - * the id of the input the message belongs under. - */ -export function buildContactRequest({ email, preview } = {}) { - const address = String(email ?? '').trim(); - if (address === '') { - return { ok: false, field: 'contact-email', message: 'Tell us where to reply.' }; - } - if (!looksLikeEmail(email)) { - return { ok: false, field: 'contact-email', message: 'That does not look like an email address we can reply to.' }; - } - - const body = { email: address }; - for (const key of ['domain', 'article_url', 'terms']) { - if (preview?.[key]) body[key] = preview[key]; - } - return { ok: true, body }; -} - -/** - * What the visitor reads when an enquiry does not go. - * - * The service's own message is used where it has one, because it names what to - * change. The rest is this page's wording for cases where it does not. - */ -export function describeContactFailure({ status, body, networkError } = {}) { - if (networkError) { - return { headline: 'We couldn’t confirm your request', detail: 'Check your connection and try again.' }; - } - const said = typeof body?.error === 'string' && body.error !== '' ? body.error : ''; - if (status === 429) { - return { headline: 'Too many messages from your network', detail: 'Wait about a minute, then try again.' }; - } - if (status === 503) { - return { headline: 'This page cannot send right now', detail: said || 'Write to us at the address on the site instead.' }; - } - if (status === 400) { - return { headline: 'We could not send that', detail: said || 'Check the email address, then try again.' }; - } - return { headline: 'The message did not go', detail: said || 'Try again in a moment.' }; -} - -/** - * Send one enquiry. - * - * fetch is injected, the same way the preview client takes it, so this is - * testable without a network. - */ -export async function sendContact({ fetchImpl, baseUrl, body, signal } = {}) { - const url = `${String(baseUrl ?? '').replace(/\/+$/, '')}${CONTACT_PATH}`; - let response; - try { - response = await fetchImpl(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal, - }); - } catch (error) { - if (signal?.aborted === true || error?.name === 'AbortError') return { aborted: true }; - return { ok: false, failure: describeContactFailure({ networkError: error }) }; - } - - let parsed = null; - try { - parsed = await response.json(); - } catch { - parsed = null; - } - if (!response.ok) { - return { ok: false, failure: describeContactFailure({ status: response.status, body: parsed }) }; - } - if (response.status !== 202 || parsed?.status !== 'sent') { - return { ok: false, failure: { headline: 'We couldn’t confirm your request', detail: 'Please try again.' } }; - } - return { ok: true }; -} diff --git a/website/src/lib/onboarding/contact.test.mjs b/website/src/lib/onboarding/contact.test.mjs deleted file mode 100644 index f0eaf8b4..00000000 --- a/website/src/lib/onboarding/contact.test.mjs +++ /dev/null @@ -1,111 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - CONTACT_PATH, - MAX_EMAIL_LENGTH, - buildContactRequest, - describeContactFailure, - looksLikeEmail, - sendContact, -} from './contact.mjs'; - -const interest = (overrides = {}) => ({ email: 'olena@x.example', ...overrides }); -const terms = { pricing_model: 'per_unit', rate: '0.02', permitted_functions: ['search'], prohibited_functions: ['ai-train'], attribution_required: true }; -const previewed = { domain: 'x.example', article_url: 'https://x.example/news/story', terms }; -const response = (status = 202, body = { status: 'sent' }) => - new Response(JSON.stringify(body), { status }); -const send = (fetchImpl, options = {}) => sendContact({ - fetchImpl, baseUrl: 'https://host.example/onboarding/', body: interest(), ...options, -}); -const assertFailure = (outcome) => { - assert.equal(outcome.ok, false); - assert.ok(outcome.failure.headline.length > 0); - assert.ok(outcome.failure.detail.length > 0); -}; - -test('interest carries the previewed domain, page and terms, omits name, and sends only what the preview had', () => { - for (const name of [undefined, 'Olena', 'ignored\r\nname']) { - assert.deepEqual(buildContactRequest(interest({ name, preview: previewed })), { - ok: true, body: interest(previewed), - }); - } - assert.deepEqual(buildContactRequest(interest({ preview: { domain: 'x.example', article_url: previewed.article_url } })), { - ok: true, body: interest({ domain: 'x.example', article_url: previewed.article_url }), - }); - for (const preview of [undefined, null, {}, { domain: '', article_url: '', terms: null }]) { - assert.deepEqual(buildContactRequest(interest({ email: ' olena@x.example ', preview })), { - ok: true, body: interest(), - }); - } -}); - -test('email validation reports missing, malformed and overlong addresses against the email input', () => { - for (const email of [undefined, null, '', ' ', 'olena-at-x', 'olena@x', 'a b@c.example', 'a@@b.example', `${'a'.repeat(MAX_EMAIL_LENGTH)}@b.example`]) { - assert.equal(looksLikeEmail(email), false, JSON.stringify(email)); - const built = buildContactRequest(interest({ email })); - assert.equal(built.ok, false, JSON.stringify(email)); - assert.equal(built.field, 'contact-email'); - assert.ok(built.message.length > 0); - } - for (const email of ['a@b.example', "o'brien@x.example", `${'a'.repeat(MAX_EMAIL_LENGTH - 10)}@b.example`]) { - assert.equal(looksLikeEmail(email), true, email); - assert.equal(buildContactRequest(interest({ email })).ok, true); - } -}); - -test('raw email CR and LF are refused before trimming', () => { - for (const email of ['\rolena@x.example', 'olena@x.example\n', '\r\nolena@x.example\r\n', 'olena\n@x.example']) { - assert.equal(looksLikeEmail(email), false, JSON.stringify(email)); - const built = buildContactRequest(interest({ email })); - assert.equal(built.ok, false, JSON.stringify(email)); - assert.equal(built.field, 'contact-email'); - } -}); - -test('interest posts the exact JSON body and signal to the contact path', async () => { - const controller = new AbortController(); - const built = buildContactRequest(interest({ name: 'ignored', preview: previewed })); - assert.equal(built.ok, true); - let request; - const outcome = await send(async (url, init) => { - request = { url, init }; - return response(); - }, { body: built.body, signal: controller.signal }); - assert.deepEqual(outcome, { ok: true }); - assert.equal(request.url, `https://host.example/onboarding${CONTACT_PATH}`); - assert.equal(request.init.method, 'POST'); - assert.equal(request.init.headers['Content-Type'], 'application/json'); - assert.deepEqual(JSON.parse(request.init.body), interest(previewed)); - assert.equal(request.init.signal, controller.signal); -}); - -test('only HTTP 202 with status sent confirms relay acceptance', async () => { - assert.deepEqual(await send(async () => response()), { ok: true }); - for (const invalid of [response(200), response(202, { status: 'queued' }), response(202, {}), response(202, null), new Response('', { status: 202 }), new Response('{', { status: 202 })]) { - assertFailure(await send(async () => invalid)); - } -}); - -test('rejected requests and network errors return readable failures', async () => { - for (const status of [400, 429, 500, 503]) { - assertFailure(await send(async () => response(status, { error: 'Request rejected.' }))); - } - const outcome = await send(async () => { throw new TypeError('offline'); }); - assertFailure(outcome); - assert.match(outcome.failure.detail, /try again/i); - assert.match(describeContactFailure({ status: 429 }).headline, /Too many messages/); - assert.equal(describeContactFailure({ status: 400, body: { error: 'Invalid email.' } }).detail, 'Invalid email.'); -}); - -test('a cancelled send is reported as aborted', async () => { - const controller = new AbortController(); - assert.deepEqual(await send(async (_url, init) => { - assert.equal(init.signal, controller.signal); - controller.abort(); - throw new DOMException('aborted', 'AbortError'); - }, { signal: controller.signal }), { aborted: true }); - assert.deepEqual(await send(async () => { - throw new DOMException('aborted', 'AbortError'); - }), { aborted: true }); -}); diff --git a/website/src/lib/onboarding/preview-client.mjs b/website/src/lib/onboarding/preview-client.mjs index 228e5fee..4953aec4 100644 --- a/website/src/lib/onboarding/preview-client.mjs +++ b/website/src/lib/onboarding/preview-client.mjs @@ -1,4 +1,4 @@ -// The preview request; the contact form uses its own client. +// The preview request. // // fetch is injected rather than captured so the module can be tested without a // network and without a DOM. The caller owns the AbortController. diff --git a/website/src/pages/publishers/onboarding.astro b/website/src/pages/publishers/onboarding.astro index 5737981d..580a9c4f 100644 --- a/website/src/pages/publishers/onboarding.astro +++ b/website/src/pages/publishers/onboarding.astro @@ -2,6 +2,7 @@ // Public agent preview: one page URL, its service-generated offer and configuration. import SiteNav from '../../components/SiteNav.astro'; import SiteFooter from '../../components/SiteFooter.astro'; +import { FORA_PUBLISHER_CONSOLE_URL } from 'astro:env/client'; import '../../styles/brand.css'; --- @@ -104,10 +105,10 @@ import '../../styles/brand.css'; .field-error:empty { display: none; } .hint { font-size: .75rem; color: var(--gray); margin-top: .5rem; line-height: 1.5; } - /* The shared .btn family is in brand.css; these are the additions this - design needs. */ - .btn { padding: .75rem 1.5rem; border: 1px solid transparent; cursor: pointer; } - .btn-primary, .btn-ghost { border-width: 1px; border-style: solid; } + /* The shared .btn family is in brand.css, each variant's border included, + so nothing here may reset a border; these are the additions this design + needs. */ + .btn { padding: .75rem 1.5rem; cursor: pointer; } .btn-block { width: 100%; justify-content: center; } .btn[disabled] { opacity: .6; cursor: progress; } @@ -295,38 +296,13 @@ import '../../styles/brand.css'; .offer .lbl:after { content: ":"; color: var(--brace); } .offer .val { color: var(--str); } - /* contact */ + /* register */ .cta { background: var(--near-black); border: 1px solid var(--mid); border-radius: 4px; padding: 2.5rem 2.5rem 2rem; max-width: 44rem; } .cta h2 { font-size: clamp(1.5rem, 3vw, 2rem); margin-bottom: .75rem; } .cta p { color: var(--gray); margin-bottom: 1.5rem; } - .contact-form { - display: grid; - grid-template-columns: 1fr; - gap: 0 1.25rem; - } - .contact-form .field { margin-bottom: .75rem; } - .contact-actions { - grid-column: 1 / -1; - display: flex; - gap: .75rem; - flex-wrap: wrap; - align-items: center; - margin-top: .5rem; - } - .contact-actions button { border: 0; } - .contact-note { - grid-column: 1 / -1; - font-size: .875rem; - margin-top: .75rem; - overflow-wrap: anywhere; - } - .contact-note:empty { display: none; } - .contact-note.sent { color: var(--str); } - .contact-note.failed { color: #f0a08c; } - .contact-form .hint { grid-column: 1 / -1; margin-bottom: 0; } - @media (max-width: 560px) { - .contact-actions .btn { width: 100%; justify-content: center; } - } + .cta-actions { display: flex; flex-wrap: wrap; gap: .75rem; } + /* On a narrow screen the longer label wraps; keep both lines centred. */ + .cta-actions .btn { text-align: center; text-wrap: balance; } .note { font-size: .8125rem; color: var(--gray); border-left: 2px solid var(--mid); padding-left: .75rem; margin-top: 1rem; } .notes { list-style: none; padding: 0; margin: 1.25rem 0 0; display: grid; gap: .5rem; } @@ -358,6 +334,7 @@ import '../../styles/brand.css'; @media (max-width: 560px) { .controls { grid-template-columns: 1fr; } .cta { padding: 1.5rem; } + .cta-actions .btn { width: 100%; justify-content: center; } } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } * { transition: none !important; } } @@ -455,26 +432,16 @@ import '../../styles/brand.css'; -
+
-
Interested?
-

Let’s connect your site

-

Leave your email and we’ll get in touch about the next steps.

- -
-
- - -

-
-
- - Read the publisher guide -
-

-

We send your email, the page you previewed and any terms you changed to our team.

-
+
Liked the preview?
+

Register to start step 1.

+

The publisher console generates your manifest and your edge package from what you enter, and ingests your catalog once your terms are set. You place one file and one function. Nothing is charged for registering.

+
@@ -499,11 +466,10 @@ import '../../styles/brand.css'; import { previewDetails, unreadableNote } from '../../lib/onboarding/default-preview.mjs'; import { escapeHtml as esc, highlightJson } from '../../lib/onboarding/record.mjs'; import { offerOutline, resolvedControls } from '../../lib/onboarding/offer-outline.mjs'; - import { buildContactRequest, sendContact } from '../../lib/onboarding/contact.mjs'; const $ = (id) => document.getElementById(id); - const state = { domain: '', url: '', request: null, preview: null, inFlight: null, requestId: 0 }; + const state = { domain: '', url: '', preview: null, inFlight: null, requestId: 0 }; // ---- the preview API ----------------------------------------------------- // The single seam where the preview service is called. It answers with its @@ -748,7 +714,6 @@ import '../../styles/brand.css'; state.domain = preview.domain; state.url = built.body.article_url; - state.request = built.body; state.preview = preview; renderAll(); if (explicit) { @@ -803,62 +768,6 @@ import '../../styles/brand.css'; .forEach((id) => $(id).addEventListener('input', scheduleTermsRefresh)); $('url').addEventListener('input', () => setFieldError('url', '')); - // ---- the enquiry form --------------------------------------------------- - let contactInFlight = null; - - $('contact-form').addEventListener('submit', async (event) => { - event.preventDefault(); - setFieldError('contact-email', ''); - - const built = buildContactRequest({ - email: $('contact-email').value, - // The preview they just read, which is what makes the enquiry worth - // answering. It is the answered request, not whatever is being typed in - // the form above. - preview: state.request, - }); - if (!built.ok) { - setFieldError(built.field, built.message); - $(built.field)?.focus(); - return; - } - - const note = $('contact-note'); - const button = $('contact-send'); - note.className = 'contact-note'; - note.textContent = ''; - button.disabled = true; - button.textContent = 'Sending…'; - - if (contactInFlight) contactInFlight.abort(); - const controller = new AbortController(); - contactInFlight = controller; - - const outcome = await sendContact({ - fetchImpl: window.fetch.bind(window), - baseUrl: FORA_ONBOARDING_API_BASE, - body: built.body, - signal: controller.signal, - }); - if (contactInFlight !== controller) return; - contactInFlight = null; - - button.disabled = false; - button.textContent = 'Get in touch'; - if (outcome.aborted) return; - - if (outcome.ok) { - note.className = 'contact-note sent'; - note.textContent = 'Thanks — your interest has been sent to our team.'; - $('contact-form').reset(); - return; - } - note.className = 'contact-note failed'; - note.textContent = `${outcome.failure.headline}. ${outcome.failure.detail}`; - }); - - $('contact-email').addEventListener('input', () => setFieldError('contact-email', '')); - const sharedUrl = previewPageUrl(window.location.href); if (sharedUrl !== null) { $('url').value = sharedUrl; From 9597a82b8bff1cac878c369aff6d774f1b5d83ce Mon Sep 17 00:00:00 2001 From: legendko Date: Thu, 1 Oct 2026 13:35:43 +0200 Subject: [PATCH 03/12] Point the preview's dead ends at the publisher contact address With the form gone, two messages pointed at nothing. The card for a CDN the preview does not recognize told the visitor to leave their email below, and the note for a domain with no generated discovery document said "Ask us". Both now link to FORA_PUBLISHER_CONTACT_URL, which was declared for exactly this and had gone unused. cdnPresentation stays a pure mapping: it only labels the link, and the page supplies the address, escaped into the card's markup. The discovery-document note is built from nodes. A test pins that only the unrecognized outcome offers the link and that the mapping never carries an address. --- website/astro.config.mjs | 4 +++- .../src/lib/onboarding/cdn-integrations.mjs | 11 ++++++++-- .../src/lib/onboarding/page-fallback.test.mjs | 20 ++++++++++++++++--- website/src/pages/publishers/onboarding.astro | 18 ++++++++++++----- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 50081928..5aaef3fc 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -48,7 +48,9 @@ export default defineConfig({ startsWith: 'https://', default: 'https://console.fora-protocol.org/', }), - // Where a publisher behind an unsupported CDN reaches us. + // Where a publisher reaches us when the preview cannot take them further: + // a CDN the preview does not recognize, or a domain it could not + // generate a discovery document for. FORA_PUBLISHER_CONTACT_URL: envField.string({ context: 'client', access: 'public', diff --git a/website/src/lib/onboarding/cdn-integrations.mjs b/website/src/lib/onboarding/cdn-integrations.mjs index 247ae913..4a165ce6 100644 --- a/website/src/lib/onboarding/cdn-integrations.mjs +++ b/website/src/lib/onboarding/cdn-integrations.mjs @@ -24,7 +24,11 @@ export function cdnName(provider) { return provider === 'akamai' ? 'Akamai' : ''; } -/** The same provider interpretation drives the result badge and configuration. */ +/** + * The same provider interpretation drives the result badge and configuration. + * `contact` labels a link to the publisher contact address, or is '' when the + * outcome needs none; the page owns the address, so this mapping stays pure. + */ export function cdnPresentation(provider) { const integration = Object.hasOwn(CDN_INTEGRATIONS, provider) ? CDN_INTEGRATIONS[provider] : null; if (integration) return { @@ -33,6 +37,7 @@ export function cdnPresentation(provider) { title: `Integration path: ${integration.scheme} signed URLs`, description: integration.path, guidance: integration.guidance ?? 'We’ll provide a CDN package and setup instructions tailored to your site. Setup includes choosing the content paths to license and checking that ordinary visitors can still browse normally.', + contact: '', }; if (provider === 'akamai') return { badge: 'Akamai detected', tone: 'ok', @@ -40,12 +45,14 @@ export function cdnPresentation(provider) { title: 'Integration path: Signed delivery URLs', description: 'We’ll configure your Akamai integration to check the Exchange’s signed URLs before allowing access to your licensed content.', guidance: 'We’ll provide the package and guide you through connecting it to your Akamai service, choosing which paths to protect, and testing access before going live.', + contact: '', }; return { badge: 'We couldn’t identify your CDN', tone: 'none', evidenceIntro: 'We found these network signals:', title: 'We’ll help you connect your website', - description: 'CloudFront, Cloudflare, and Fastly are supported today. If you use another CDN — or no CDN — leave your email below and we’ll help you find the right setup.', + description: 'CloudFront, Cloudflare, and Fastly are supported today. If you use another CDN — or no CDN — write to us and we’ll help you find the right setup.', guidance: '', + contact: 'Write to us', }; } diff --git a/website/src/lib/onboarding/page-fallback.test.mjs b/website/src/lib/onboarding/page-fallback.test.mjs index 3b9f952d..30ffd40b 100644 --- a/website/src/lib/onboarding/page-fallback.test.mjs +++ b/website/src/lib/onboarding/page-fallback.test.mjs @@ -2,12 +2,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as integrations from './cdn-integrations.mjs'; +const MAY_BE_EMPTY = new Set(['guidance', 'contact']); + function presentation(provider) { assert.equal(typeof integrations.cdnPresentation, 'function', 'CDN presentation must expose a pure provider mapping'); const result = integrations.cdnPresentation(provider); - for (const field of ['badge', 'tone', 'title', 'description', 'guidance']) { + for (const field of ['badge', 'tone', 'title', 'description', 'guidance', 'contact']) { assert.equal(typeof result[field], 'string', `${field} must be text`); - if (field !== 'guidance') assert.ok(result[field].trim(), `${field} must not be empty`); + if (!MAY_BE_EMPTY.has(field)) assert.ok(result[field].trim(), `${field} must not be empty`); } return result; } @@ -52,7 +54,8 @@ test('Akamai uses the same detected styling and promises setup guidance', () => test('absent, unexpected, prototype and hostile provider tokens share an honest unknown outcome', () => { const unknown = presentation('none'); assert.equal(unknown.badge, 'We couldn’t identify your CDN'); - assert.match(unknown.description, /leave your email below/i); + assert.match(unknown.description, /write to us/i); + assert.doesNotMatch(unknown.description, /\bbelow\b|leave your email/i, 'The page has no form below to point at'); assert.equal(unknown.guidance, '', 'Unknown CDN guidance is included in the main paragraph'); assert.doesNotMatch(`${unknown.title} ${unknown.description} ${unknown.guidance}`, /start registration/i); for (const provider of [undefined, null, '', 'unknown', 'unexpected-provider', 'constructor', 'toString', '__proto__', '']) { @@ -60,6 +63,17 @@ test('absent, unexpected, prototype and hostile provider tokens share an honest } }); +test('only an unidentified CDN offers the contact link, and the mapping carries no address', () => { + for (const provider of ['cloudfront', 'cloudflare', 'fastly', 'akamai']) { + assert.equal(presentation(provider).contact, '', `${provider} needs no contact link`); + } + const unknown = presentation('none'); + assert.equal(unknown.contact, 'Write to us'); + for (const value of Object.values(unknown)) { + assert.doesNotMatch(value, /mailto:|https?:\/\//, 'The page supplies the contact address, never the mapping'); + } +}); + test('cdnName gives the visitor-facing name for recognized providers and nothing for the rest', () => { assert.equal(integrations.cdnName('cloudflare'), 'Cloudflare'); assert.equal(integrations.cdnName('akamai'), 'Akamai'); diff --git a/website/src/pages/publishers/onboarding.astro b/website/src/pages/publishers/onboarding.astro index 580a9c4f..ac600789 100644 --- a/website/src/pages/publishers/onboarding.astro +++ b/website/src/pages/publishers/onboarding.astro @@ -156,6 +156,7 @@ import '../../styles/brand.css'; .pill.ok { color: var(--str); border-color: #7ec8a066; background: #7ec8a014; } .pill.none { color: var(--warn); border-color: #e0a45866; background: #e0a45814; } .card p { color: var(--gray); font-size: .9375rem; } + .cdn-contact { margin-top: 1rem; } .evidence { margin-top: 1rem; border-top: 1px solid var(--mid); padding-top: .875rem; } .evidence p { margin-bottom: .75rem; } .evidence .k { @@ -452,12 +453,13 @@ import '../../styles/brand.css';