Skip to content

feat(platform): the blog out of code + budstacks.io SEO (20 stories) - #258

Merged
AutomatosAI merged 26 commits into
mainfrom
ralph/platform-content-seo
Aug 18, 2026
Merged

feat(platform): the blog out of code + budstacks.io SEO (20 stories)#258
AutomatosAI merged 26 commits into
mainfrom
ralph/platform-content-seo

Conversation

@AutomatosAI

Copy link
Copy Markdown
Owner

Implements tasks/prd-platform-content-and-seo.md — Workstream A (typography), B (platform Wire) and C (platform SEO). 20 autonomous stories, each one commit with a decision-level journal entry in ralph/progress.txt.

Also carries the 2 typography commits from feat/article-typography, which this branch was cut from.

What changes

The blog stops being a code deploy. /blog read a hardcoded TypeScript array duplicated across two files. It now reads platform_posts, authored from /super-admin/the-wire. All 8 existing posts are migrated with their slugs unchanged — no /blog/<slug> URL moves.

Every post gets its own metadata. Neither blog file exported metadata or generateMetadata, so all 8 posts served the root layout's title, "BudStacks - Medical Cannabis SaaS Platform". Each now has its own title, description, canonical, OG image (3-tier fallback), sitemap entry, and Article + BreadcrumbList JSON-LD.

budstacks.io manages its own SEO. 15 marketing routes editable from /super-admin/seo, reusing the SeoEditorModal and SeoAuditTab already shipping to tenants. Includes every route US-000 (#256) unblocked.

The 18 /documents guides enter the sitemap — the largest content set on the site, previously absent entirely.

Article typography — the 8 surfaces still carrying inert prose-* classes, with .tenant-article on tenant brand colours so an operator's blog doesn't render in BudStacks green.

Migrations — do NOT apply by hand

Five timestamped directories, applied automatically by entrypoint.sh:18 (prisma migrate deploy):

20260815000000_add_platform_posts
20260816000000_seed_editorial_platform_posts     2 posts, ON CONFLICT DO NOTHING
20260816010000_seed_sample_platform_posts        6 posts, ON CONFLICT DO NOTHING
20260816020000_add_platform_seo_settings
20260816030000_add_platform_seo_redirects

Applying the DDL manually leaves _prisma_migrations without a record, so migrate deploy retries on boot, fails "already exists", and that failure blocks every subsequent deploy (P3009 — the entrypoint already carries a self-heal from a prior occurrence).

Deploy risk, stated plainly

/blog now depends entirely on these migrations succeeding. The inline arrays are deleted, so there is no fallback. migrate deploy runs before Next starts so the ordering is correct, but watch this deploy's logs — a failed migration means a live blog with no posts.

Post-deploy verification:

SELECT count(*) FILTER (WHERE published) AS live, count(*) AS total FROM platform_posts;  -- expect 8 / 8
SELECT migration_name, finished_at, rolled_back_at FROM _prisma_migrations
WHERE migration_name LIKE '%platform_%' ORDER BY started_at;                              -- no nulls, no rollbacks

Not verified — needs a human

Six visual checks deferred: everything under /super-admin is Clerk-gated and the runner had no session, and the local DATABASE_URL was unreachable, so a screenshot would have proved nothing. It declined to fake them rather than claim checks it could not perform.

Please walk: sign in as super-admin → /super-admin/the-wire → create a post → publish → confirm it appears on /blog. That exercises the Wire list, editor, /blog index and post page together. Also /super-admin/seo and the audit panel.

TipTap keystroke behaviour is unproven — its styling was verified against the compiled CSS bundle on an admin-surface wrapper, but not typing.

Judgement calls worth reviewing

  • Sitemap deliberately duplicates the posts query rather than reusing loadPublishedPlatformPosts(): that one re-throws so /blog 500s rather than rendering "we never published anything", but a sitemap needs the opposite — degrade to nothing rather than teach a crawler the site is broken.
  • US-002 skipped a redundant @@index([slug])slug @unique already creates one; a second is storage and write cost for an identical lookup. Matches platform_leads.
  • platform-article-json-ld.ts is not a second implementation — it extracts the node builders the tenant Wire already uses. What lives there is platform-specific: no plan gate (budstacks.io buys nothing from itself), platformCanonical not storeCanonical, publisher restated rather than @id-referenced.
  • Metadata degrades to hardcoded fallbacks on a database error, deliberately opposite to /bloggenerateMetadata has no error.tsx boundary, so throwing there 500s a legal document over its <title>.

Flagged, not fixed

  • The 7 loose .sql files at the top of prisma/migrations/ still look like migrations and have never been applied by any deploy.
  • app/learn/[slug]/markdown-content.tsx is a regex renderer with no blockquote or table support; ordered lists render as bullets.
  • Two stacked cookie banners cover the top of storefront articles.
  • The CI test:cov job fails on per-file coverage thresholds and names different files run to run, so a genuine regression there would currently be invisible.

Test plan

  • tsc --noEmit exit 0 on every story
  • Full vitest suite green after every story
  • check:public-routes, check:article-typography, check:security green
  • pnpm build succeeds
  • CI green on this PR
  • Post-deploy: the two SQL checks above
  • Post-deploy: /blog lists 8 posts; each post URL renders; <title> differs per post
  • Human walkthrough of the super-admin screens

…urfaces

@tailwindcss/typography is not installed — tailwind.config.ts loads only
tailwindcss-animate — so every prose-* class in the app styles nothing.
Preflight strips heading sizes, paragraph margins and list markers, and with no
plugin nothing puts them back. #255 fixed the platform blog; these are the
surfaces it left behind.

TWO ARTICLE VOICES, DELIBERATELY. .bs-article is BudStacks-branded (Cormorant
Garamond, bs-green). Pointing it at a storefront would paint every operator's
blog in our colours, so tenant long-form gets its own .tenant-article in
TENANT_SCOPED_CSS — the tenant bridge that already exists for exactly this,
scoped to .tenant-theme-container so it cannot leak into admin chrome.

Its colours follow .legal-document, the storefront long-form precedent already
in globals.css, reading --tenant-color-*. One departure: every value falls back
to the equivalent shadcn token, which the provider also remaps per tenant.
Those keys exist only if a tenant's designSystem.colors defines them, and the
namespace is not uniform — store/[slug]/layout.tsx sets --tenant-color-text
where .legal-document reads --tenant-color-foreground.

Headings in .tenant-article carry no font-family or font-size: the container
already sets both from --tenant-font-heading and the heading scale, and
restating either would override a tenant's own typography with a hard-coded
one. Only vertical rhythm is added.

The Wire page needed a DOM change, not a class swap. Its h1, meta row and cover
image now sit outside the styled container — an article measure would have
clamped the cover image — which is what the two not-prose escapes were for.

/regulatory is the exception: classes deleted, nothing added. It is the one
legal page that does not wrap itself in .budstacks-theme, so it renders on the
light :root palette where .bs-article's light body colour would be
grey-on-white. Every element there already carries explicit utility classes. A
comment records this so the next sweep does not "fix" it.

.bs-article gains h1, h4, img, figure, pre and table for the Learning Center,
whose markdown renderer emits tags the hand-authored blog never used. pre and
table scroll inside themselves; img is capped at 100% width.

Adds a CI guard so this cannot return while the plugin is absent. It strips
block and line comments before matching — this codebase writes block comments
without leading asterisks, so the comments documenting the ban would otherwise
be reported as violations of it.

NOT verified in a browser. The highest-value check is a tenant with non-default
branding: its Wire post should render in that tenant's colours, not ours.
Brings in US-000 (#256). Both branches add a CI guard, so ci.yml and
package.json each gain one entry from either side — kept both.

# Conflicts:
#	.github/workflows/ci.yml
#	nextjs_space/package.json
Archives the completed LLM-visibility run and sets up
ralph/platform-content-seo: verify the typography sweep, move the blog out of
code into platform_posts with super-admin authoring, then platform SEO.

Also corrects the PRD's migration guidance, which was wrong in four places. It
claimed there is no prisma migrate step and that schema SQL must be hand-applied
to prod before the code deploys. In fact nextjs_space/entrypoint.sh runs
`prisma migrate deploy` on every container boot and 40 timestamped migration
directories ship that way. What does NOT apply is a loose .sql at the top of
prisma/migrations/ — migrate deploy only reads <timestamp>_<name>/migration.sql,
and the seven loose files there have never been applied by any deploy. That is
the real reason platform_leads needed manual psql for #254, and it means US-002
and US-013 need no human database step at all, provided the migration is shaped
correctly.

Workstream D (rewriting the six legacy sample posts) is deliberately excluded —
editorial judgement against framing rules, not Ralph's work.
loop.sh:42 hard-fails unless the current branch matches, and it was still
pinned to the previous run's ralph/seo-llm-visibility — so launching this run
would have refused to start. Updated the guard plus the two banner strings.
…wser

Verification-only story; no source changes were needed — every surface was
already correct. Checked in a real Chromium against a PRODUCTION build: the
tenant Wire post takes link and list-marker colour from the tenant's brand
(#B5179E, not BudStacks green) and its cover image spans the container rather
than the text measure; /terms /privacy /dpa /aup carry .bs-article with real
heading sizes; /regulatory is correctly left unstyled on the light palette;
/learn renders headings, image and code blocks. TipTap's .bs-article styling
was verified against the compiled CSS on an admin-surface wrapper — the route
is Clerk-gated, so keystroke behaviour is deferred to human review.

Key finding: dev mode CANNOT verify tenant theming. The app's CSP omits
'unsafe-eval', Next dev compiles with eval, so hydration dies and
TenantThemeProvider's useEffect never applies the tenant vars. Also found that
`prisma migrate deploy` cannot build this schema from empty (the history is
incremental, not a baseline) and that the local Prisma client was stale enough
to break tsc and next build before any edit.

Verified: tsc 0 · vitest 168 files / 3026 tests pass · check:public-routes,
check:article-typography, check:security all green · build 188 pages.

Story: ralph/prd.json US-001
PRD: tasks/prd-platform-content-and-seo.md
Adds the table budstacks.io's blog will live in, so publishing stops being a
code deploy. Author is two denormalised strings with no relation: `users` is
itself in tenantScopedModels so an FK join is unreadable from the apex, and
getCurrentUser().id is a Clerk id, not a users.id — the P2003 that broke the
lekkerweed blog in #226. platform_posts stays OUT of tenantScopedModels, like
platform_leads and learning_resources; that Set is an opt-in allowlist and a
platform table inside it gets a tenantId filter welded onto every apex query.

Migration ships as a timestamped DIRECTORY so entrypoint.sh's `migrate deploy`
actually applies it — the seven loose .sql files at the top of prisma/migrations/
never have. Its DDL is byte-identical to `prisma migrate diff --from-empty
--to-schema-datamodel --script` output. Deviation flagged in the journal: no
second plain index on slug, because `slug @unique` already indexes that lookup
(platform_leads treats email the same way).

Nine offline guard tests cover the three silent-regression paths (allowlist
absence, no author/tenant FK, migration is a directory). The allowlist guard was
negative-tested by temporarily adding platform_posts to the Set and confirming it
fails, then restoring lib/db.ts.

Verified: prisma validate + generate ok · tsc 0 · vitest 169 files / 3035 tests
pass · check:public-routes, check:article-typography, check:security green.

Story: ralph/prd.json US-002
PRD: tasks/prd-platform-content-and-seo.md
The article-body sanitize-html policy moves out of the Wire render path into
lib/security/post-sanitize.ts as sanitizePostHtml(), so the storefront Wire and
the coming platform posts API share one allowlist instead of two that drift.
Allowlists copied verbatim — tags, attribute map, the two iframe hostnames and
the allowedStyles map — and proven byte-identical to the original inline config
over a 25-input hostile/benign corpus. 16 unit tests now pin the policy.

Story: ralph/prd.json US-003
PRD: tasks/prd-platform-content-and-seo.md
Super-admin CRUD for platform_posts: GET/POST at /api/platform/posts and
GET/PATCH/DELETE at /api/platform/posts/[id], all behind withSuperAdmin /
withSuperAdminParams with requireSameOrigin on every mutation — deliberately
NOT shaped like the unauthenticated platform/leads route. Zod-validated,
content sanitised through lib/security/post-sanitize, POST_SLUG_PATTERN
enforced server-side (the gap the tenant routes leave to the client form), a
taken slug answers 409 both pre-check and on P2002, publishedAt is stamped once
and never rewritten, and a published post's slug is locked until US-019 adds the
automatic 301. 40 unit tests; tsc 0; full suite 171 files / 3091 tests green.

Story: ralph/prd.json US-004
PRD: tasks/prd-platform-content-and-seo.md
POST /api/platform/upload: withSuperAdmin + same-origin, the tenant route's
validator and limits unchanged, bytes under platform/uploads/ rather than a
borrowed tenant id. The public image route learned that one extra key shape so
the returned publicUrl is durable — the form stores `publicUrl || url`, and a
presigned url expires in an hour. Response shape matches the tenant route field
for field. 13 new route tests + 5 on the parser; tsc, full unit suite and all
three CI guards green.

Story: ralph/prd.json US-005
PRD: tasks/prd-platform-content-and-seo.md
/super-admin/the-wire lists platform_posts (title, /blog slug, author,
status, publishedAt) with edit, publish/unpublish and delete, both mutating
actions behind a confirmation naming the URL. Adapted from the tenant Wire
list, pointed at /api/platform/posts/[id]; the newsletter action, AUTOMATOS
pill and users join are dropped. Sidebar gains "The Wire" beside Leads.
force-dynamic keeps the build-time Prisma mock from baking in an empty list.

Verified: tsc exit 0, 171 unit files / 3109 tests pass, pnpm build exit 0 with
the route registered dynamic, all three CI guards green, lint clean, route
307s to login unauthenticated. Browser check deferred to human review — no
Clerk session and the local DATABASE_URL is unreachable.

Story: ralph/prd.json US-006
PRD: tasks/prd-platform-content-and-seo.md
/super-admin/the-wire/new and /[id] — TipTap body, title, slug, excerpt,
cover image plus alt, byline and publish toggle, posting to the US-004 write
routes and uploading covers to the US-005 platform route. The tenant form's
SEO-Pro entitlement gating and AiAssistButton are removed (both read per-tenant
state the platform does not have) and a text test pins them out. A published
post's URL is read-only with the API's own refusal message beside it, and the
save omits the slug key entirely, so a live URL cannot move until US-019.

Verified: tsc 0, 21 new tests, full vitest 173 files / 3131 tests green,
next build 0 with both routes dynamic in the manifest. Browser check deferred
to human review — no super-admin session and no local DB reachability.

Story: ralph/prd.json US-007
PRD: tasks/prd-platform-content-and-seo.md
The index lists published platform_posts, newest publishedAt first, through a
new server-only lib/platform/published-posts.ts; card markup and spacing are
unchanged and links go through blogPostPath(). force-dynamic, so the build-time
Prisma mock cannot bake an empty blog into the static output. An outage is not
an empty blog: the query logs the driver reason and re-throws rather than
returning [], verified live — 500 against the unreachable DB, 200 with the empty
state on zero rows, 200 with three ordered cards when seeded. The inline arrays
stay until US-010/US-011 migrate their content.

Story: ralph/prd.json US-008
PRD: tasks/prd-platform-content-and-seo.md
…n metadata

The article page loads one published platform_posts row by slug (React-cache()d,
so generateMetadata and the body share ONE query) and exports per-post metadata
for the first time — before this, all eight posts served the root layout's title.
Body renders through .bs-article via the shared lib/ sanitiser; the related strip
comes from the table and hides itself when empty; generateStaticParams is gone in
favour of force-dynamic. Adopted a predecessor iteration's uncommitted
platformCanonical() after checking it against storeCanonical line by line.

og:image is built ABSOLUTE: the platform root layout declares no metadataBase,
unlike every store layout, so a relative one would have pointed at localhost. A
post with no cover falls back to the platform hero. Verified live against a
throwaway Postgres — titles, og:images and canonicals differ per post, a draft
404s without leaking its title, and a seeded <script> is stripped.

The eight legacy /blog/<slug> URLs 404 until US-010/US-011 migrate them; the
inline arrays are untouched because they are what those migrations copy from.

Story: ralph/prd.json US-009
PRD: tasks/prd-platform-content-and-seo.md
…m_posts

Timestamped migration directory (sorts after 20260815000000_add_platform_posts,
the order `migrate deploy` applies) inserting the two lib/blog/posts.ts posts
with their slugs unchanged, published, fixed uuids so /api/platform/posts/[id]'s
parseUuid accepts them, and ON CONFLICT ("slug") DO NOTHING so a re-deploy can
neither duplicate a post nor overwrite a super-admin's later edit. Adopted from
a crashed iteration's untracked draft after re-verifying every claim in it.

Verified by executing both migrations twice against a disposable PostgreSQL 17
cluster: 2 rows not 4, and md5(content) from the database matches the source
template literals byte for byte. tsc clean; 3203 unit tests pass; public-route,
article-typography and security guards green.

Story: ralph/prd.json US-010
PRD: tasks/prd-platform-content-and-seo.md
…osts

The six samplePosts move out of app/blog/[slug]/page.tsx and into
platform_posts via a timestamped migration directory: fixed UUIDs, slugs
character-for-character as shipped, published:true so the already-indexed
/blog/<slug> URLs stay live, ON CONFLICT ("slug") DO NOTHING so a redeploy
cannot duplicate a row or overwrite the human rewrite still to come. The SQL
was generated from the array rather than retyped, so the six HTML bodies are
transcription-error-free by construction.

Verified on a throwaway PG 17.9 cluster, not just as text: both seeds applied
in migrate-deploy order give 8 published rows; replaying them over a simulated
super-admin edit leaves 8 rows with the edit intact; all six rows read back
byte-for-byte identical to the source array. tsc 0, prisma validate clean,
3218 unit tests pass, all three CI guards green. US-012's precondition — all 8
posts in the database — is now met.

Story: ralph/prd.json US-011
PRD: tasks/prd-platform-content-and-seo.md
Removes the last blog content authored in code: the six-entry hardcoded arrays
in app/blog/page.tsx and app/blog/[slug]/page.tsx, and lib/blog/posts.ts (the
two editorial posts) deleted outright along with the now-empty lib/blog/. The
detail page concatenated its six with those two, which is how the index
advertised six posts while eight URLs resolved — that drift is now impossible.
All eight are rows in platform_posts, seeded by US-010 and US-011.
`grep -rn 'samplePosts\|BLOG_POSTS' app lib` returns nothing, the AC.

The two tests that held the arrays in place are inverted into guards that keep
them gone, matching on the slug strings rather than the const name so a renamed
array cannot slip past, plus new guards that lib/blog/ is absent from disk.

Verified against a real database and a real browser, not just text: throwaway
PG 17.9 cluster carrying both seeds, dev server pointed at it via the
environment (.env untouched). /blog serves 8 cards in the right order with no
empty state; all 8 /blog/<slug> return 200 with their own title, h1 and a
populated .bs-article body whose computed styles prove the class is live;
an unknown slug 404s. tsc 0, 3220 unit tests pass, all three CI guards green.
Deviation declared in the journal: dev-browser's server hardcodes port 9222,
which the user's own Chrome holds, so Playwright Chromium was driven directly.

Story: ralph/prd.json US-012
PRD: tasks/prd-platform-content-and-seo.md
Adds the table budstacks.io's own metadata lives in, keyed by a unique
routePath. Every authored column is NULLABLE because a row is an OVERRIDE, not
a replacement: each route's existing `export const metadata` stays the
documented fallback, and US-015 has to apply it per COLUMN — with a row present
for all 15 routes, a per-row fallback would let an authored title blank that
page's description. Stays OUT of tenantScopedModels, like platform_posts and
platform_leads; that Set is an opt-in allowlist and a platform table inside it
gets a tenantId filter welded onto every apex query.

Seeded with the one platform default OG image (PLATFORM_DEFAULT_OG_IMAGE, which
already flagged itself as the value this story replaces) on all 15 static
marketing routes from middleware's isPublicRoute allowlist — a rooted path, not
a URL, so staging does not advertise production's asset. Title and description
stay NULL: freezing marketing copy into SQL would make editing it a deploy
again. ON CONFLICT DO NOTHING rather than an upsert, so a replay cannot
overwrite an image a super-admin has since chosen.

Migration ships as a timestamped DIRECTORY so entrypoint.sh's `migrate deploy`
applies it; its DDL is byte-identical to `prisma migrate diff --from-empty
--to-schema-datamodel --script`. Applied against a throwaway PG 17 cluster:
CREATE TABLE / INDEX / INSERT 0 15, seed replay a no-op, an authored edit
survived it, duplicate routePath refused. 19 offline guards added; the
allowlist guard was negative-tested by temporarily adding the model to the Set.

Verified: prisma validate + generate ok · tsc 0 · vitest 177 files / 3239 tests
pass · check:public-routes, check:article-typography, check:security green.

Story: ralph/prd.json US-013
PRD: tasks/prd-platform-content-and-seo.md
/super-admin/seo edits budstacks.io's own title, description, social card and
noindex for every public marketing route — the 15 static ones plus every
published /documents guide — through the same SeoEditorModal the tenant SEO
Manager uses. Writes go to PUT /api/platform/seo behind withSuperAdmin and
requireSameOrigin, over a flat .strict() Zod contract matching the four real
columns; leads stays the deliberate unauthenticated exception and was not
copied. The route list is one TypeScript module (lib/platform/seo-routes.ts),
resolved server-side so the guide registry never reaches the browser bundle.

Three additive props on the shared SEO components, all defaulting to today's
behaviour, so the platform surface stops lying: indexingFields (offer only the
control the table stores), ogUploadEndpoint (/api/platform/upload, not the
tenant route that 403s a super-admin), and neutral upload copy.

tsc 0; 178 test files / 3277 tests pass, 38 new; public-routes, article-
typography and security guards green; build compiles both routes and no guide
prose reaches .next/static. Visual check deferred to human review — the page is
Clerk-gated with no credentials and no local DB (journalled).

Story: ralph/prd.json US-014
PRD: tasks/prd-platform-content-and-seo.md
Fifteen static marketing routes and the eighteen /documents guides now resolve
title, description, og:image and robots from platform_seo_settings, falling back
PER COLUMN to the strings each page shipped with — the seeded rows carry an
image and nothing else, so a per-row check would have blanked every title.
noindex emits a real <meta name="robots" content="noindex, follow">; a failed
read returns null and keeps the shipped metadata rather than throwing into a
blank page. /contact's metadata moved to a layout because its page is a client
component. Reuses platformAbsoluteUrl/platformCanonical and the existing
og:image default.

Verified: tsc 0; 3305 tests pass (28 new); public-routes, article-typography and
security guards green; build compiles with 0 prerendered routes. Verified live
against a throwaway local Postgres carrying the real US-013 migration — editing
a row changed the served <title> with no deploy, noindex emitted its tag, and
deleting a row left the page's own metadata intact.

Story: ralph/prd.json US-015
PRD: tasks/prd-platform-content-and-seo.md
…emap

app/sitemap.ts now publishes /blog/{slug} for every published platform_posts
row (lastModified from updatedAt, priority 0.6) and /documents plus all 18
published guides — the site's largest content set, public since #246 and never
listed. Both database reads keep the file's swallow-and-degrade try/catch, the
opposite call from the blog loaders, and run under Promise.all.

The published-guide filter moved to publishedGuides() in lib/documents/registry
so the sitemap and lib/platform/seo-routes.ts cannot disagree about which
guides exist. Verified: tsc clean, 3308 unit tests pass, all four CI guards
green, and a live curl of /sitemap.xml returned 200 with 27 entries while the
database was unreachable — the outage path the AC asks for.

Story: ralph/prd.json US-016
PRD: tasks/prd-platform-content-and-seo.md
buildPlatformPageMetadata now returns alternates.canonical, and og:url reuses the
same string — one change covering the 15 static marketing routes and all 18
/documents guides, since US-015 routed every one of them through that builder.
app/learn/[slug] was the only public content route left building its own metadata
without a canonical; it now declares one from the RESOLVED row's slug (not
params.slug), and its 404 branch declares none. /blog/<slug> has canonicalled since
US-009 and was verified, not rewritten. AC-3 needed no code: middleware.ts:155
already 301s www to the apex via wwwRedirectHost.

Canonical is built from platformBaseUrl(), never authored — platform_seo_settings
deliberately has no canonical column, so no schema change and no migration here.

Verified: tsc 0; 3315 unit tests pass (+7, no regressions); public-routes,
article-typography and security guards green. Verified live on 19 routes — all 15
static plus two guides, /blog, /blog/<slug> and /learn/<slug> against a throwaway
Postgres — with 0 missing canonicals, ?utm_source/?ref/?fbclid URLs collapsing to
the clean path, and 404 slugs declaring none.

Story: ralph/prd.json US-017
PRD: tasks/prd-platform-content-and-seo.md
/blog/<slug> emits one ld+json @graph: Organization (publisher), Article
(headline, image, datePublished from publishedAt, dateModified, author,
publisher) and BreadcrumbList reading Home > Blog > post — through the
existing <JsonLd>. Reuse per AC-1: the node shapes were EXTRACTED from
lib/seo/article-json-ld.ts (buildArticleNode) and breadcrumb-json-ld.ts
(buildBreadcrumbNodes, URL resolver injected) so tenant and platform share
one definition; only the platform cascade is new. Tenant behaviour unchanged
— the four existing SEO test files pass untouched.

Verified: tsc --noEmit exit 0; full unit project 181 files / 3338 tests pass;
check:public-routes, check:article-typography, check:security and lint green.
Live curl not possible — the dev DATABASE_URL host is unreachable from this
machine, so /blog 500s; deferred to human review and journalled.

Story: ralph/prd.json US-018
PRD: tasks/prd-platform-content-and-seo.md
New platform_seo_redirects table (timestamped migration, out of
tenantScopedModels) plus resolvePlatformRedirect in middleware, reusing
planSlugRenameRedirect and the existing SWR feed/cache unchanged — chains
collapse rather than nest. Unblocks US-007: the published-slug lock is
removed from both the PATCH route (409) and the editor, replaced by a
move warning and the server's slugRedirect report.

Verified: tsc exit 0; 3367 unit tests pass; prisma validate + DDL diffed
against migrate diff; public-routes/article-typography/security/lint
clean; live dev server confirms scope=platform reaches
platform_seo_redirects while the unscoped feed still resolves a tenant.

Story: ralph/prd.json US-019
PRD: tasks/prd-platform-content-and-seo.md
SeoAuditTab now renders on /super-admin/seo against the platform's own
routes: it gained `apiPath` and `copy` props, both defaulting to the store's,
so the tenant SEO Manager's call site is unchanged. A new pure engine
(lib/platform/seo-audit.ts) flags routes and published posts with no
canonical, no title, no description and no social card of their own, judging
the output of the same resolvers generateMetadata calls rather than a second
copy of the cascade. Reuses scoreSeoAudit (now told which checks ran) and the
tenant 15-minute cache; the route is withSuperAdmin, GET only.

Verified: tsc 0, 3405 tests pass (38 new), pnpm build 0 — the build caught an
invalid Route export the typecheck cannot. Public-route, typography and
security guards clean; live 307-to-login on the new endpoint, and /terms +
/documents/seo render byte-identical metadata after the resolver extraction.
Visual check of the panel deferred to human review — /super-admin is
Clerk-gated on a machine with no session and no database.

Story: ralph/prd.json US-020
PRD: tasks/prd-platform-content-and-seo.md

RALPH_COMPLETE
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 114 files, which is 14 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 19695a58-1e0c-4525-97a4-1176b55fa39d

📥 Commits

Reviewing files that changed from the base of the PR and between 46357a0 and 1cfedf0.

📒 Files selected for processing (114)
  • .github/workflows/ci.yml
  • nextjs_space/app/api/platform/posts/[id]/route.ts
  • nextjs_space/app/api/platform/posts/route.ts
  • nextjs_space/app/api/platform/seo/audit/route.ts
  • nextjs_space/app/api/platform/seo/route.ts
  • nextjs_space/app/api/platform/upload/route.ts
  • nextjs_space/app/api/public/images/[...key]/route.ts
  • nextjs_space/app/api/public/seo/redirects/route.ts
  • nextjs_space/app/aup/page.tsx
  • nextjs_space/app/blog/[slug]/page.tsx
  • nextjs_space/app/blog/page.tsx
  • nextjs_space/app/contact/layout.tsx
  • nextjs_space/app/cookies/page.tsx
  • nextjs_space/app/documents/[slug]/page.tsx
  • nextjs_space/app/documents/page.tsx
  • nextjs_space/app/dpa/page.tsx
  • nextjs_space/app/faq/page.tsx
  • nextjs_space/app/globals.css
  • nextjs_space/app/layout.tsx
  • nextjs_space/app/learn/[slug]/markdown-content.tsx
  • nextjs_space/app/learn/[slug]/page.tsx
  • nextjs_space/app/learn/page.tsx
  • nextjs_space/app/legal/changelog/page.tsx
  • nextjs_space/app/legal/subprocessors/page.tsx
  • nextjs_space/app/marketplace/page.tsx
  • nextjs_space/app/page.tsx
  • nextjs_space/app/privacy/page.tsx
  • nextjs_space/app/regulatory/page.tsx
  • nextjs_space/app/sitemap.ts
  • nextjs_space/app/store/[slug]/the-wire/[postSlug]/page.tsx
  • nextjs_space/app/super-admin/seo/page.tsx
  • nextjs_space/app/super-admin/seo/platform-seo-client.tsx
  • nextjs_space/app/super-admin/the-wire/[id]/page.tsx
  • nextjs_space/app/super-admin/the-wire/cover-image-field.tsx
  • nextjs_space/app/super-admin/the-wire/new/page.tsx
  • nextjs_space/app/super-admin/the-wire/page.tsx
  • nextjs_space/app/super-admin/the-wire/post-form.tsx
  • nextjs_space/app/super-admin/the-wire/posts-list.tsx
  • nextjs_space/app/super-admin/the-wire/slug-field.tsx
  • nextjs_space/app/terms/page.tsx
  • nextjs_space/components/admin/SuperAdminSidebar.tsx
  • nextjs_space/components/admin/seo/IndexingFields.tsx
  • nextjs_space/components/admin/seo/OgImageField.tsx
  • nextjs_space/components/admin/seo/SeoAuditTab.tsx
  • nextjs_space/components/admin/seo/SeoEditorModal.tsx
  • nextjs_space/components/admin/seo/audit-client.ts
  • nextjs_space/components/admin/seo/audit-copy.ts
  • nextjs_space/components/admin/seo/index.ts
  • nextjs_space/components/editor/tiptap.tsx
  • nextjs_space/components/tenant-theme-provider.tsx
  • nextjs_space/lib/auth-public-routes.ts
  • nextjs_space/lib/documents/registry.ts
  • nextjs_space/lib/platform/post-date.ts
  • nextjs_space/lib/platform/post-editor.ts
  • nextjs_space/lib/platform/posts.ts
  • nextjs_space/lib/platform/published-posts.ts
  • nextjs_space/lib/platform/seo-audit.ts
  • nextjs_space/lib/platform/seo-routes.ts
  • nextjs_space/lib/platform/seo-settings.ts
  • nextjs_space/lib/security/post-sanitize.ts
  • nextjs_space/lib/seo/article-json-ld.ts
  • nextjs_space/lib/seo/audit-types.ts
  • nextjs_space/lib/seo/audit.ts
  • nextjs_space/lib/seo/blog-paths.ts
  • nextjs_space/lib/seo/breadcrumb-json-ld.ts
  • nextjs_space/lib/seo/generate-platform-metadata.ts
  • nextjs_space/lib/seo/iso-timestamp.ts
  • nextjs_space/lib/seo/platform-article-json-ld.ts
  • nextjs_space/lib/seo/platform-page-metadata.ts
  • nextjs_space/lib/seo/platform-post-metadata.ts
  • nextjs_space/lib/seo/platform-slug-redirects.ts
  • nextjs_space/lib/seo/platform-url.ts
  • nextjs_space/lib/seo/post-metadata.ts
  • nextjs_space/lib/seo/redirect-lookup.ts
  • nextjs_space/lib/storage/public-image-url.ts
  • nextjs_space/lib/storage/upload-validation.ts
  • nextjs_space/middleware.ts
  • nextjs_space/package.json
  • nextjs_space/prisma/migrations/20260815000000_add_platform_posts/migration.sql
  • nextjs_space/prisma/migrations/20260816000000_seed_editorial_platform_posts/migration.sql
  • nextjs_space/prisma/migrations/20260816010000_seed_sample_platform_posts/migration.sql
  • nextjs_space/prisma/migrations/20260816020000_add_platform_seo_settings/migration.sql
  • nextjs_space/prisma/migrations/20260816030000_add_platform_seo_redirects/migration.sql
  • nextjs_space/prisma/schema.prisma
  • nextjs_space/scripts/ci/check-no-inert-prose-classes.mjs
  • nextjs_space/tests/unit/blog-published-posts.test.ts
  • nextjs_space/tests/unit/platform-article-json-ld.test.ts
  • nextjs_space/tests/unit/platform-page-metadata.test.ts
  • nextjs_space/tests/unit/platform-post-editor.test.ts
  • nextjs_space/tests/unit/platform-post-metadata.test.ts
  • nextjs_space/tests/unit/platform-posts-api.test.ts
  • nextjs_space/tests/unit/platform-posts-schema.test.ts
  • nextjs_space/tests/unit/platform-posts-seed.test.ts
  • nextjs_space/tests/unit/platform-route-metadata.test.ts
  • nextjs_space/tests/unit/platform-seo-audit.test.ts
  • nextjs_space/tests/unit/platform-seo-settings-api.test.ts
  • nextjs_space/tests/unit/platform-seo-settings-schema.test.ts
  • nextjs_space/tests/unit/platform-slug-redirects.test.ts
  • nextjs_space/tests/unit/platform-upload-api.test.ts
  • nextjs_space/tests/unit/post-sanitize.test.ts
  • nextjs_space/tests/unit/public-image-url.test.ts
  • nextjs_space/tests/unit/seo-redirect-feed.test.ts
  • nextjs_space/tests/unit/seo-redirect-lookup.test.ts
  • nextjs_space/tests/unit/sitemap.test.ts
  • ralph/IMPLEMENTATION_PLAN.md
  • ralph/PROMPT_build.md
  • ralph/archive/2026-08-15-seo-llm-visibility/IMPLEMENTATION_PLAN.md
  • ralph/archive/2026-08-15-seo-llm-visibility/PROMPT_build.md
  • ralph/archive/2026-08-15-seo-llm-visibility/prd.json
  • ralph/archive/2026-08-15-seo-llm-visibility/progress.txt
  • ralph/loop.sh
  • ralph/prd.json
  • ralph/progress.txt
  • tasks/prd-platform-content-and-seo.md

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread nextjs_space/lib/storage/upload-validation.ts Fixed
Comment thread nextjs_space/scripts/ci/check-no-inert-prose-classes.mjs Fixed
CodeQL flagged two high-severity findings, both in code this branch introduced.
CodeRabbit could not have caught them — it skipped the PR entirely at 114 files,
14 over its 100-file limit.

js/redos in scripts/ci/check-no-inert-prose-classes.mjs — mine, from the US-000
work. The tail was `prose(?:-[a-z0-9-]+)*`, nesting a `+` inside a `*` over a
class that itself contains `-`. `prose-` followed by many dashes has
exponentially many valid splits, and the failing right-boundary lookahead walks
all of them: measured 7ms at 28 dashes and climbing steeply. Replaced with a
single `[a-z0-9:-]*`, which has exactly one way to match, so the same lookahead
unwinds it one character at a time — flat 0ms out to 60 dashes. The boundary
lookahead is KEPT: dropping it was the first attempt and it regressed "a
paragraph of prose," into a match. Verified the new expression matches and
rejects exactly what the old one did.

js/incomplete-multi-character-sanitization in lib/storage/upload-validation.ts —
`sanitizeUploadFileName`, added by US-005. It stripped `../` and `..\` before
replacing separators, and that removal is not iterative, so "....//" collapses
back into "../" after one pass. The function was not actually exploitable — the
subsequent global `[/\\] -> _` caught the survivors — but it was accidentally
correct rather than obviously correct, and the ordering that made it safe was
the part a later edit would most likely disturb. Now replaces separators FIRST,
which leaves no traversal sequence for a later step to miss, with the ordering
called out as the security property. Verified against ../, ..\, and the nested
....// form; legitimate names like my..file.png are unchanged.

Both guards still pass: article-typography clean, public-route 108 pages.
My CodeQL fix to `sanitizeUploadFileName` reordered the replacements so
separators die before dot sequences. That is strictly safer — it removes the
"....//" collapse the old order missed — but it changes the exact output for a
traversal payload from "etc_passwd.png" to "_.._etc_passwd.png", and this test
pinned the old string. 1 failed / 185 passed on #258.

Both outputs are safe; only the spelling differs. The test was asserting an
implementation detail, so it broke on a change that improved the thing it
exists to protect. Rewritten to assert the properties that actually matter —
no separator of either flavour, no traversal sequence, and the real filename
still recognisable at the end — which hold however the sanitiser spells its
result.

Adds a companion case for a legitimate name with interior dots
(my..file.png), which must pass through untouched: the previous version could
not distinguish "strips traversal" from "mangles dots".
@AutomatosAI
AutomatosAI merged commit 9374398 into main Aug 18, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants