diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index 6d1f218..311ba2a 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -1,31 +1,32 @@ -# JourneyDeck current handoff, October 12, 2026 +# JourneyDeck current handoff, October 13, 2026 ## Current objective -Ship four themed road-trip SEO landing pages in one draft PR. Do not merge or deploy. +Open a non-draft PR that adds the Launch Llama badge to the homepage footer. Do not merge. ## Checkout -- Branch `cursor/road-trip-journal-seo-pages-2843` off `main`. +- Branch `cursor/launch-llama-homepage-badge-4cdd` off `main`. ## Material changes -- New public pages: `/route-66-road-trip-journal`, `/pacific-coast-highway-road-trip-journal`, `/national-parks-road-trip-journal`, `/texas-hill-country-road-trip-journal`. -- Wired like #268/#270/#273/#276: `publicPaths`, `app.get` sendFile, sitemap, server tests, verify-live expectations, feature map, canonical/meta/OG/JSON-LD/Umami, plain App Store CTA plus `campaign.js`. -- Copy from the draft, minus unconfirmed personal lines, plus design-review FAQ and intro fixes. JSON-LD FAQ text matches the visible FAQ. No em/en dashes. Journeys start only when the user taps Start Journey. -- Cross-links from `/road-trip-journal-app`, `/private-driving-journal`, and the Apple Music soundtrack footer. +- Exact Launch Llama badge snippet in `web/beta.html` footer, centered via `.footer-badge`. +- CSP `img-src` allows `https://tools.launchllama.co` only. CSS cache-bust `beta.css?v=grand-tour-5`. +- verify-live `page:/ launch-badge` plus server and Playwright coverage. ## Verification -- `node --test --import tsx server/tests/api.test.ts` -- `node --test --import tsx server/tests/campaign.test.ts server/tests/verify-live.test.ts` -- Desktop 1280 and mobile 390 screenshots of each page in the PR body. +- `npm run check:server` +- `npm run lint:server` +- `npm run test:server` (66 pass) +- `npx playwright test tests/e2e/public-beta.spec.ts` (5 pass) +- Footer screenshots at 390 and 1280 before/after. ## Unresolved / owner decisions -- Keep the PR as a draft. Do not merge or deploy from this session. +- Keep the PR open. Do not merge or deploy from this session. ## Next steps -1. Keep the four-page SEO PR open for review. Do not merge or deploy. -2. Confirm JourneyDeck CI is green. +1. Confirm JourneyDeck CI is green. +2. Do not merge or deploy. diff --git a/server/src/app.ts b/server/src/app.ts index 09f7365..31baad6 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -26,7 +26,7 @@ const publicPaths = new Set(["/healthz", "/readyz", "/login", "/login.html", "/p const publicAuthPaths = new Set(["/api/auth/login", "/api/auth/passkey/options", "/api/auth/passkey/verify"]); const scheduledSyncPath = "/api/spotify/sync"; const securityHeaders = { - "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; script-src 'self' https://unpkg.com https://sdk.scdn.co https://open.spotify.com https://cloud.umami.is; connect-src 'self' https://tiles.openfreemap.org https://api.spotify.com https://*.spotify.com wss://*.spotify.com https://*.scdn.co https://gateway.umami.is; img-src 'self' data: blob: https://tiles.openfreemap.org https://i.scdn.co; media-src blob: https://*.scdn.co https://*.spotify.com; font-src 'self' data: https://tiles.openfreemap.org; worker-src 'self' blob:; child-src blob:; object-src 'none'; frame-src https://open.spotify.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; manifest-src 'self'", + "content-security-policy": "default-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; script-src 'self' https://unpkg.com https://sdk.scdn.co https://open.spotify.com https://cloud.umami.is; connect-src 'self' https://tiles.openfreemap.org https://api.spotify.com https://*.spotify.com wss://*.spotify.com https://*.scdn.co https://gateway.umami.is; img-src 'self' data: blob: https://tiles.openfreemap.org https://i.scdn.co https://tools.launchllama.co; media-src blob: https://*.scdn.co https://*.spotify.com; font-src 'self' data: https://tiles.openfreemap.org; worker-src 'self' blob:; child-src blob:; object-src 'none'; frame-src https://open.spotify.com; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; manifest-src 'self'", "x-content-type-options": "nosniff", "x-frame-options": "DENY", "referrer-policy": "no-referrer", "permissions-policy": "autoplay=(self \"https://open.spotify.com\"), encrypted-media=(self \"https://open.spotify.com\"), camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=()" }; diff --git a/server/tests/api.test.ts b/server/tests/api.test.ts index c756e53..d28af49 100644 --- a/server/tests/api.test.ts +++ b/server/tests/api.test.ts @@ -335,7 +335,7 @@ test("hosted root serves the Grand Touring launch page while private routes stay for (const rel of ["api-catalog", "service-desc", "service-doc", "describedby"]) assert.match(links, new RegExp(`rel="${rel}"`)); assert.match(landing.body, /GRAND TOURING/); assert.match(landing.body, /JOURNEYDECK 4\.0/); - assert.match(landing.body, /href="\/beta\.css\?v=grand-tour-4"/); + assert.match(landing.body, /href="\/beta\.css\?v=grand-tour-5"/); assert.match(landing.body, /id="medallion-theme"/); assert.match(landing.body, /data-theme="redline"/); assert.match(landing.body, /aria-label="1 of 5: Today"/); @@ -348,6 +348,14 @@ test("hosted root serves the Grand Touring launch page while private routes stay assert.match(landing.body, /APPLE WATCH \/ PAIRED IPHONE/); assert.match(landing.body, //); assert.match(String(landing.headers["content-security-policy"]), /default-src 'self'/); + assert.match(String(landing.headers["content-security-policy"]), /img-src[^;]*https:\/\/tools\.launchllama\.co/); + assert.doesNotMatch(String(landing.headers["content-security-policy"]), /(?:script-src|connect-src)[^;]*https:\/\/tools\.launchllama\.co/); + assert.match(landing.body, /href="https:\/\/tools\.launchllama\.co\/products\/journeydeck\?utm_source=badge&utm_medium=referral"/); + assert.match(landing.body, /src="https:\/\/tools\.launchllama\.co\/featured-badge-white\.jpg\?v=5"/); + assert.match(landing.body, /alt="Featured on Launch"/); + assert.match(landing.body, /width="200" height="52"/); + assert.match(landing.body, /href="https:\/\/launchmcp\.co\/\?utm_source=badge&utm_medium=referral"/); + assert.match(landing.body, />Launch MCP<\/a>/); assert.match(landing.body, /rel="canonical" href="https:\/\/journeydeck\.me\/"/); assert.match(landing.body, /property="og:url" content="https:\/\/journeydeck\.me\/"/); assert.match(landing.body, /href="\/login"/i); @@ -379,7 +387,7 @@ test("hosted root serves the Grand Touring launch page while private routes stay assert.doesNotMatch(String(googleVerification.headers["content-security-policy"]), /google-site-verification/); for (const [url, mime] of [ - ["/beta.css?v=grand-tour-4", /text\/css/], + ["/beta.css?v=grand-tour-5", /text\/css/], ["/public-page.css?v=grand-tour-3", /text\/css/], ["/public-page.css?v=hero-lcp-1", /text\/css/], ["/site-mobile-nav.css?v=1", /text\/css/], @@ -531,7 +539,7 @@ test("hosted home and Support HTML do not advertise a 3-day Plus trial", async ( test("hosted cache headers distinguish versioned assets, unversioned images, and short-lived documents", async () => { const fixture = fixtureDatabase(), runtime = await createApp({ databasePath: fixture.filename, root, allowTestAuth: true, legacyUpstream: "", mode: "web" }); try { - const versionedCss = await runtime.app.inject({ method: "GET", url: "/beta.css?v=grand-tour-4" }); + const versionedCss = await runtime.app.inject({ method: "GET", url: "/beta.css?v=grand-tour-5" }); assert.equal(versionedCss.statusCode, 200, versionedCss.body); assert.equal(versionedCss.headers["cache-control"], "public, max-age=31536000, immutable"); diff --git a/server/tests/campaign.test.ts b/server/tests/campaign.test.ts index f8a3006..1663d82 100644 --- a/server/tests/campaign.test.ts +++ b/server/tests/campaign.test.ts @@ -316,11 +316,19 @@ test("served public pages keep the plain App Store URL and load the first-party assert.match(String(page.headers["content-security-policy"]), /script-src[^;]*https:\/\/cloud\.umami\.is/, url); assert.match(String(page.headers["content-security-policy"]), /connect-src[^;]*https:\/\/gateway\.umami\.is/, url); assert.doesNotMatch(String(page.headers["content-security-policy"]), /connect-src[^;]*https:\/\/cloud\.umami\.is/, url); + assert.match(String(page.headers["content-security-policy"]), /img-src[^;]*https:\/\/tools\.launchllama\.co/, url); + assert.doesNotMatch(String(page.headers["content-security-policy"]), /(?:script-src|connect-src)[^;]*https:\/\/tools\.launchllama\.co/, url); } const home = await runtime.app.inject({ method: "GET", url: "/" }); assert.match(home.body, /
Available products, price, billing period, trial terms, and included features are shown before purchase.
+