Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .ai/manifests/governance.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"aiEntrypoints": [
{
"bytes": 23170,
"bytes": 24426,
"file": "CLAUDE.md"
},
{
Expand Down Expand Up @@ -54,7 +54,7 @@
"looseDocs": 23,
"rules": [
{
"bytes": 19573,
"bytes": 20001,
"file": "rules/00-master-rules.md"
},
{
Expand Down Expand Up @@ -245,14 +245,22 @@
"bytes": 23088,
"file": "rules/37-payg-credit-integrity.md"
},
{
"bytes": 6068,
"file": "rules/38-adsense-eligibility-and-low-value-content.md"
},
{
"bytes": 3435,
"file": "rules/39-worktree-branch-completion-pr-and-release-notes.md"
},
{
"bytes": 5156,
"file": "rules/README.md"
}
],
"skills": [
{
"bytes": 20103,
"bytes": 20338,
"file": "skills/00-index.md"
},
{
Expand Down Expand Up @@ -431,6 +439,10 @@
"bytes": 4194,
"file": "skills/find-canonical-owner.md"
},
{
"bytes": 3539,
"file": "skills/finish-worktree-branch-with-pr.md"
},
{
"bytes": 4302,
"file": "skills/frontend-architecture-review.md"
Expand Down
4 changes: 2 additions & 2 deletions .ai/manifests/hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
".ai/manifests/environment-variables.json": "33602cbe",
".ai/manifests/event-graph.json": "c1cd05bc",
".ai/manifests/frontend-routes.json": "a52b7ece",
".ai/manifests/governance.json": "f71a269b",
".ai/manifests/governance.json": "e4492ebd",
".ai/manifests/i18n.json": "3befb756",
".ai/manifests/nginx-routes.json": "53c65be1",
".ai/manifests/packages.json": "cc928a4a",
Expand All @@ -18,7 +18,7 @@
".ai/manifests/rabbitmq-events.json": "bcaeb411",
".ai/manifests/repository.json": "047d93d0",
".ai/manifests/services.json": "9c92d2c4",
".ai/manifests/tests.json": "4b6be224",
".ai/manifests/tests.json": "4044f562",
".ai/manifests/workspace-dependency-graph.json": "80e5438b",
".ai/manifests/workspaces.json": "b5133e99",
".ai/packs/README.md": "2e64753b",
Expand Down
4 changes: 2 additions & 2 deletions .ai/manifests/tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
},
"claw-frontend": {
"runner": "vitest",
"testFiles": 407
"testFiles": 410
},
"claw-health-service": {
"runner": "jest",
Expand Down Expand Up @@ -102,5 +102,5 @@
}
},
"generated": true,
"total": 1119
"total": 1122
}
88 changes: 45 additions & 43 deletions CLAUDE.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions apps/claw-frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,52 @@ that 403s on every call.
- Publishing mints a new immutable version and pins the model as an admin
override; the help text says so, because automated sync will then never
refresh it.

## AdSense script vs verification vs ad units (2026-09-01)

An AdSense "low value content" rejection traced back to `AdSenseHead` being
mounted in the ROOT layout (`app/layout.tsx`), with no pathname check —
`(auth)`, `(portal)`, and `(payment)` all render through the root layout, so
the ad loader script executed on login, chat, billing and settings. A
pathname-aware hook (`useAdSenseScript` / `shouldLoadAdSenseScript`) already
existed but was never wired into anything actually mounted — dead code
guarding nothing. Comments across the codebase claimed "the script only ever
lives in the marketing layout," which was aspirational, not true.

Fixed by splitting into three independently-gated pieces (see
`docs/03-architecture/adsense-eligibility.md` and
`rules/38-adsense-eligibility-and-low-value-content.md`):

- **Verification** (`<meta name="google-adsense-account">`) stays in
`AdSenseHead`, now mounted only in `(marketing)/layout.tsx`.
- **The loader script** moved to `AdSenseScriptLoader`, a client component
that re-derives pathname eligibility via the (previously-dead)
`useAdSenseScript` hook. `reviewMode` no longer bypasses eligibility — a
page the reviewer should never see monetized on must not carry the loader
either, verification or not.
- **Manual ad units** (`AdUnit`) were already correctly gated; unchanged.

`app/__tests__/adsense-route-boundary.test.ts` asserts the route boundary
structurally (reads each layout's source, fails if AdSense is referenced
outside `(marketing)`) — a regression here fails on exactly the bug that
caused the rejection, not on a behavioral edge case a future refactor might
miss.

**A raw `<script src>` rendered from a client component is hoisted to the
real `document.head` by React regardless of where in the tree it renders**,
and is NOT removed on unmount. This broke a first draft of
`adsense-script-loader.test.tsx`: `@testing-library/react`'s `render()`
mounts into real jsdom, so a script asserted-absent in one test could still
be the leftover DOM node from an earlier test in the same file (jsdom is not
reset between `it()` blocks). Use `renderToStaticMarkup` for this component's
tests instead — it never touches the real document.

Public chat shares also got a **temporary, blanket kill switch**
(`CHAT_SHARE_REVIEW_LOCKDOWN_ENABLED`,
`constants/chat-share-review-lockdown.constants.ts`) that overrides the
existing per-snapshot `adsEligible`/`indexEligible` system to `false`/excluded
everywhere (ads, indexing, sitemap, RSS) for the AdSense review window —
`/rss.xml` in particular is served by a SEPARATE implementation
(`global-rss.service.ts`) from the per-locale feeds (`rss.service.ts`); both
needed the same guard, and missing the second one was caught only by running
the full test suite, not by reasoning about the call graph.
14 changes: 11 additions & 3 deletions apps/claw-frontend/src/app/(marketing)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AdSenseHead } from '@/components/adsense/adsense-head';
import { SkipToContent } from '@/components/layout/skip-to-content';
import { MarketingFooter } from '@/components/marketing/marketing-footer';
import { MarketingHeader } from '@/components/marketing/marketing-header';
Expand All @@ -9,9 +10,16 @@ export default function MarketingLayout({
}): React.ReactElement {
return (
<div className="flex min-h-dvh flex-col">
{/* AdSense script lives ONLY here in the marketing layout — it can
* never appear in the (portal) or (auth) trees. It self-gates on
* configuration + eligibility + review/serving flags. */}
{/* AdSense is mounted ONLY here — it can never appear in the (portal),
* (auth) or (payment) trees, because they render through the root
* layout without this component at all. Even inside this tree the
* verification meta tag is the only thing unconditional: the loader
* script additionally self-gates per-pathname (AdSenseScriptLoader),
* because this group also contains non-eligible pages such as
* /share/chat, /terms and /privacy. Both elements are hoisted to the
* real document <head> by React even though this layout renders inside
* <body> — see rules/38-adsense-eligibility-and-low-value-content.md. */}
<AdSenseHead />
<SkipToContent />
<MarketingHeader />
<main id="main-content" tabIndex={-1} className="flex-1 focus-visible:outline-none">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { describe, expect, it } from 'vitest';

const APP_DIR = join(__dirname, '..');

function readLayout(relativePath: string): string {
return readFileSync(join(APP_DIR, relativePath), 'utf8');
}

const ADSENSE_REFERENCE_PATTERN = /AdSenseHead|AdSenseScriptLoader|adsbygoogle/u;

/**
* Regression guard for the AdSense "low value content" fix: the loader must
* be reachable ONLY through the (marketing) layout. Reading source text
* rather than rendering the tree avoids fighting next/font mocking for a
* check that is purely about which files reference which component — see
* rules/38-adsense-eligibility-and-low-value-content.md.
*/
describe('AdSense script route boundary', () => {
it('is never referenced by the root layout', () => {
expect(readLayout('layout.tsx')).not.toMatch(ADSENSE_REFERENCE_PATTERN);
});

it('is never referenced by the (auth) layout', () => {
expect(readLayout('(auth)/layout.tsx')).not.toMatch(ADSENSE_REFERENCE_PATTERN);
});

it('is never referenced by the (portal) layout', () => {
expect(readLayout('(portal)/layout.tsx')).not.toMatch(ADSENSE_REFERENCE_PATTERN);
});

it('is mounted by the (marketing) layout', () => {
expect(readLayout('(marketing)/layout.tsx')).toMatch(/AdSenseHead/u);
});

it('the (payment) route group has no layout of its own to mount it from', () => {
// If this ever starts failing because a layout.tsx was added under
// (payment), that new file needs the same "no AdSense" assertion above.
expect(() => readLayout('(payment)/layout.tsx')).toThrow();
});
});
68 changes: 66 additions & 2 deletions apps/claw-frontend/src/app/__tests__/rss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('global rss feed', () => {
);

it(
'includes public chat shares alongside the registry pages',
'excludes public chat shares while the AdSense review lockdown is on, without calling the chat feed at all',
async () => {
mockListPublicChatRssEntries.mockImplementation((locale: string) =>
Promise.resolve(
Expand All @@ -96,17 +96,56 @@ describe('global rss feed', () => {

const xml = await (await GET(new Request('https://claw.example/rss.xml'))).text();

expect(xml).not.toContain('/share/chat/');
expect(xml).not.toContain('<category>public-chat</category>');
expect(mockListPublicChatRssEntries).not.toHaveBeenCalled();
},
DYNAMIC_IMPORT_TIMEOUT_MS,
);

it(
'resumes including public chat shares once the review lockdown is lifted',
async () => {
vi.doMock('@/constants/chat-share-review-lockdown.constants', () => ({
CHAT_SHARE_REVIEW_LOCKDOWN_ENABLED: false,
}));
mockListPublicChatRssEntries.mockImplementation((locale: string) =>
Promise.resolve(
locale === 'de'
? [
{
publicShareId: 'share-abc',
contentLocale: 'de',
title: 'Ein geteilter Chat',
description: 'Zusammenfassung',
publishedAt: '2026-08-20T10:00:00.000Z',
},
]
: [],
),
);
const { GET } = await import('../rss.xml/route');

const xml = await (await GET(new Request('https://claw.example/rss.xml'))).text();

expect(xml).toContain('https://claw.example/de/share/chat/share-abc');
expect(xml).toContain('<category>public-chat</category>');

vi.doUnmock('@/constants/chat-share-review-lockdown.constants');
},
DYNAMIC_IMPORT_TIMEOUT_MS,
);

// One locale's chat feed failing must not cost the other twelve, and must
// never cost the registry pages, which need no upstream at all.
// never cost the registry pages, which need no upstream at all. Verified
// with the AdSense review lockdown lifted: while it is on, the chat feed is
// never called at all, so it cannot degrade — see the lockdown test below.
it(
'still serves every page when a locale chat feed is unavailable',
async () => {
vi.doMock('@/constants/chat-share-review-lockdown.constants', () => ({
CHAT_SHARE_REVIEW_LOCKDOWN_ENABLED: false,
}));
mockListPublicChatRssEntries.mockImplementation((locale: string) =>
Promise.resolve(locale === 'ja' ? null : []),
);
Expand All @@ -118,6 +157,23 @@ describe('global rss feed', () => {
expect(response.status).toBe(200);
expect(response.headers.get('X-Claw-Discovery-Degraded')).toBe('chat-feed-unavailable');
expect(xml).toContain('https://claw.example/ja/about');

vi.doUnmock('@/constants/chat-share-review-lockdown.constants');
},
DYNAMIC_IMPORT_TIMEOUT_MS,
);

it(
'cannot be degraded by the chat feed while the AdSense review lockdown is on, since it is never called',
async () => {
mockListPublicChatRssEntries.mockImplementation(() => Promise.resolve(null));
const { GET } = await import('../rss.xml/route');

const response = await GET(new Request('https://claw.example/rss.xml'));

expect(response.status).toBe(200);
expect(response.headers.get('X-Claw-Discovery-Degraded')).toBeNull();
expect(mockListPublicChatRssEntries).not.toHaveBeenCalled();
},
DYNAMIC_IMPORT_TIMEOUT_MS,
);
Expand All @@ -134,6 +190,12 @@ describe('global rss feed', () => {
it(
'keeps every registry page ahead of chats in document order, even when chats are dated later',
async () => {
// Ordering is a property of the merge logic, independent of the AdSense
// review lockdown — verified here with the lockdown lifted so chat
// entries actually reach the merge to be ordered.
vi.doMock('@/constants/chat-share-review-lockdown.constants', () => ({
CHAT_SHARE_REVIEW_LOCKDOWN_ENABLED: false,
}));
const FAR_FUTURE_PUBLISHED_AT = '2099-01-01T00:00:00.000Z';
mockListPublicChatRssEntries.mockImplementation((locale: string) =>
Promise.resolve([
Expand Down Expand Up @@ -170,6 +232,8 @@ describe('global rss feed', () => {
expect(pageIndex).toBeGreaterThan(-1);
expect(pageIndex).toBeLessThan(firstChatIndex);
}

vi.doUnmock('@/constants/chat-share-review-lockdown.constants');
},
DYNAMIC_IMPORT_TIMEOUT_MS,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,14 @@ describe('sitemaps are readable by Google', () => {
// reach Google through the same documents and carry a different lastmod
// source (`updatedAt`, a full timestamp) and a different URL shape.
it(
'holds the chat half to the same rules as the pages half',
'holds the chat half to the same rules as the pages half, once the AdSense review lockdown is lifted',
async () => {
// CHAT_SHARE_REVIEW_LOCKDOWN_ENABLED excludes chats-*.xml entirely today
// (asserted in sitemap.test.ts); this test exercises the URL/lastmod
// shape the chat half must have once that lockdown is lifted.
vi.doMock('@/constants/chat-share-review-lockdown.constants', () => ({
CHAT_SHARE_REVIEW_LOCKDOWN_ENABLED: false,
}));
mockChatSharePage.mockResolvedValue({
items: [
{
Expand All @@ -218,6 +224,8 @@ describe('sitemaps are readable by Google', () => {
expect(location).toBe(`${SITE}/en/share/chat/share-abc`);
expect(new URL(location).host).toBe(new URL(SITE).host);
expect(isW3cDatetime(stamp)).toBe(true);

vi.doUnmock('@/constants/chat-share-review-lockdown.constants');
},
DYNAMIC_IMPORT_TIMEOUT_MS,
);
Expand Down
31 changes: 29 additions & 2 deletions apps/claw-frontend/src/app/__tests__/sitemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('sitemap index route', () => {
});

it(
'publishes localized page and bounded chat child documents',
'publishes localized page documents and excludes chat shares while the AdSense review lockdown is on',
async () => {
vi.stubEnv('NODE_ENV', 'production');
process.env['SITE_URL'] = 'https://claw.example';
Expand All @@ -43,9 +43,36 @@ describe('sitemap index route', () => {
expect(xml).toContain('https://claw.example/sitemaps/en/pages-1.xml');
expect(xml).toContain('https://claw.example/sitemaps/ar/pages-1.xml');
expect(xml).toContain('https://claw.example/sitemaps/zh/pages-1.xml');
// CHAT_SHARE_REVIEW_LOCKDOWN_ENABLED excludes chat shares from the
// sitemap entirely for the duration of the AdSense review — no
// chats-*.xml child document is referenced, and the chat-service
// count lookup is skipped rather than fetched and discarded.
expect(xml).not.toContain('/chats-');
expect(xml).not.toContain('/share/chat/');
expect(mockCountIndexableChatShares).not.toHaveBeenCalled();
},
DYNAMIC_IMPORT_TIMEOUT_MS,
);

it(
'resumes publishing bounded chat child documents once the review lockdown is lifted',
async () => {
vi.doMock('@/constants/chat-share-review-lockdown.constants', () => ({
CHAT_SHARE_REVIEW_LOCKDOWN_ENABLED: false,
}));
vi.stubEnv('NODE_ENV', 'production');
process.env['SITE_URL'] = 'https://claw.example';
mockCountIndexableChatShares.mockImplementation((locale: string) =>
Promise.resolve({ locale, count: locale === 'ja' ? 40_001 : 0 }),
);
const { GET } = await import('../sitemap.xml/route');

const xml = await (await GET()).text();

expect(xml).toContain('https://claw.example/sitemaps/ja/chats-1.xml');
expect(xml).toContain('https://claw.example/sitemaps/ja/chats-2.xml');
expect(xml).not.toContain('/share/chat/');

vi.doUnmock('@/constants/chat-share-review-lockdown.constants');
},
DYNAMIC_IMPORT_TIMEOUT_MS,
);
Expand Down
Loading
Loading