From b43098848d7c3b2895749666497a196f5484914d Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Sat, 15 Aug 2026 17:57:03 +0100 Subject: [PATCH 1/2] fix(middleware): public pages and lead capture were behind the login wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isPublicRoute is an allowlist, and seven routes were never added to it. They render correctly in development, where you are signed in, and 307 every anonymous visitor and crawler to /auth/login. Verified against production before the fix — /terms, /privacy, /cookies, /blog, /learn, /marketplace and /contact returned 200 while all of the following returned 307: /documents and every guide beneath it 18 pages, 16 videos (#246/#249/#251) /faq /dpa, /aup, /regulatory POST /api/platform/leads Two of those carry real cost. The /documents guide hub is the largest piece of marketing content the platform has and has never been reachable logged-out or indexable by any crawler. And platform lead capture has recorded nothing since #254 deployed — every homepage CTA and Operator 101 submission hit the auth wall, so Phase 1 has not worked in production at any point. The leads endpoint is unauthenticated by design: a prospect has no account and no tenant, which is why it is not the storefront newsletter route. Consent, honeypot and IP rate-limiting are enforced inside it. Adds a CI guard so there is no fifth occurrence. It enumerates every top-level app/*/page.tsx and every path the platform sitemap advertises, and fails the build unless each is allowlisted or explicitly named private with a reason. A source check rather than an HTTP probe: CI never starts the app, so there is no origin to curl. Also lands tasks/prd-platform-content-and-seo.md, which this is US-000 of. Not fixed here: the Clerk redirect leaks the container's internal origin (redirect_url=https://0.0.0.0:8080/dpa), so an authenticating user lands on an unreachable URL. Config, not middleware. --- .github/workflows/ci.yml | 8 + nextjs_space/middleware.ts | 22 + nextjs_space/package.json | 1 + .../ci/check-public-routes-allowlisted.mjs | 146 ++++++ tasks/prd-platform-content-and-seo.md | 424 ++++++++++++++++++ 5 files changed, 601 insertions(+) create mode 100644 nextjs_space/scripts/ci/check-public-routes-allowlisted.mjs create mode 100644 tasks/prd-platform-content-and-seo.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da93e961..ee526f2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,14 @@ jobs: - name: Lockfile guard (PRD-209) run: pnpm check:lockfile + # isPublicRoute is an allowlist, and a page missing from it 307s every + # anonymous visitor and crawler to the Clerk login while looking fine in + # development. Four occurrences to date — robots/sitemap, the Automatos + # ingest route, the legal pages + lead endpoint, and the whole /documents + # guide hub. Clean on this tree after that fix, so it gates. + - name: Public route guard (allowlist vs route tree) + run: pnpm check:public-routes + # PRD-216 AC-7: PRD-200 string-aware grep gates (CSS sanitize + # error-message-leak). These already pass on this tree, so they gate # the build (blocking). diff --git a/nextjs_space/middleware.ts b/nextjs_space/middleware.ts index 371a11f2..c252c117 100644 --- a/nextjs_space/middleware.ts +++ b/nextjs_space/middleware.ts @@ -51,6 +51,28 @@ const isPublicRoute = createRouteMatcher([ "/terms", // Terms of service "/privacy", // Privacy policy "/cookies", // Cookie policy + // These three shipped as public legal pages but were never added here, so a + // signed-out visitor or crawler got a 307 to /auth/login. Confirmed against + // production before the fix: /terms and /privacy returned 200 while /dpa, + // /aup and /regulatory returned 307. /regulatory is also linked from the + // storefront footer, so the dead end was reachable from tenant sites. + "/dpa", // Data processing agreement + "/aup", // Acceptable use policy + "/regulatory", // Regulatory information + "/faq", // Public FAQ + // The BudStacks Guide (#246/#249/#251) — 18 illustrated guide pages carrying + // 16 embedded videos, built as top-of-funnel marketing and then reachable + // only by signed-in users. Both the index and every guide beneath it. + "/documents", + "/documents/(.*)", + // Public lead capture for the homepage CTA and the Operator 101 download. + // UNAUTHENTICATED BY DESIGN — a prospect has no account and no tenant, which + // is why it is not the storefront newsletter endpoint (see the route's own + // header). Consent, honeypot and IP rate-limiting are enforced inside it. + // Without this entry the endpoint answered every submission with a 307 to + // Clerk, so PRD Phase 1 lead capture recorded nothing from the moment #254 + // deployed until this landed. + "/api/platform/leads", "/accept-invite(.*)", // PRD-301 team invitation acceptance (logged-out invitees) "/api/team/invitation(.*)", // PRD-301 public token-gated invitation preview ]); diff --git a/nextjs_space/package.json b/nextjs_space/package.json index bfb0919a..eae85f8a 100644 --- a/nextjs_space/package.json +++ b/nextjs_space/package.json @@ -23,6 +23,7 @@ "check:grep-gates:report": "node scripts/ci/check-no-tenant-context-escapes.mjs || true; node scripts/ci/check-no-pii-in-logs.mjs || true", "check:docs-lint": "node scripts/ci/check-docs-lint.mjs", "check:lockfile": "node scripts/ci/check-single-lockfile.mjs", + "check:public-routes": "node scripts/ci/check-public-routes-allowlisted.mjs", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", diff --git a/nextjs_space/scripts/ci/check-public-routes-allowlisted.mjs b/nextjs_space/scripts/ci/check-public-routes-allowlisted.mjs new file mode 100644 index 00000000..9b46d5c7 --- /dev/null +++ b/nextjs_space/scripts/ci/check-public-routes-allowlisted.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * Public-route guard: a public page must be in `isPublicRoute`, or it answers + * anonymous traffic with a redirect to the Clerk login. + * + * `isPublicRoute` in middleware.ts is an ALLOWLIST. A new marketing or legal + * page renders correctly in development (where you are signed in) and 307s for + * every real visitor and every crawler. Nothing in the build catches it. + * + * This has now happened four times: + * 1. /robots.txt and /sitemap.xml (SEO US-006) + * 2. /api/integrations/automatos/posts (fix f59ac74) + * 3. /dpa, /aup, /regulatory, /faq (found by PRD review, 2026-08-15) + * 4. /documents + every guide beneath it (found while fixing 3 — the whole + * 18-page guide hub, built as top-of-funnel marketing, had never been + * reachable logged-out) + * + * …and #3 also took POST /api/platform/leads with it, so platform lead capture + * recorded nothing from the moment it shipped. + * + * WHY THIS IS A SOURCE CHECK AND NOT AN HTTP PROBE. CI never starts the app + * (typecheck, lint and build only), so there is no origin to curl. Comparing + * the route tree against the allowlist needs no server and fails at PR time + * rather than after deploy. + * + * Exits non-zero (CI failure) if a public-looking page is not allowlisted. + */ +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const appRoot = join(here, "..", ".."); + +/** + * Top-level route segments that are NOT public, with the reason. Anything else + * carrying a page.tsx is treated as public and must be allowlisted. + * + * Deliberately an explicit list: a new segment defaults to "must be public", + * so forgetting to classify it fails the build instead of silently shipping + * behind a login wall. If you add a genuinely private area, name it here. + */ +const INTENTIONALLY_PRIVATE = new Map([ + ["tenant-admin", "tenant admin console — auth required"], + ["super-admin", "platform admin console — auth required"], +]); + +/** Extract the string literals passed to createRouteMatcher([...]). */ +function readPublicPatterns(middlewareSource) { + const start = middlewareSource.indexOf("const isPublicRoute = createRouteMatcher(["); + if (start === -1) { + console.error("✗ Could not find isPublicRoute in middleware.ts."); + process.exit(1); + } + const end = middlewareSource.indexOf("]);", start); + const block = middlewareSource.slice(start, end); + + return [...block.matchAll(/"([^"]+)"/g)].map((m) => m[1]); +} + +/** + * Clerk's matcher accepts path-to-regexp style patterns. Only `(.*)` appears in + * this file, so translating that one form is enough — and being narrow here is + * deliberate: a pattern this does not understand should fail loudly rather than + * quietly match everything. + */ +function patternToRegExp(pattern) { + const WILDCARD = "(.*)"; + // Split on the wildcard FIRST, escape each literal piece completely, then + // rejoin with a real `.*`. Escaping the whole string and un-escaping after + // is the obvious approach and is wrong: `*` has to be in the escape class to + // handle a literal asterisk, which leaves `(\.\*)` to detect — and getting + // that dance slightly wrong yields `(\.*)`, "zero or more literal dots", + // which still matches the bare path and so passes its own test. + const escaped = pattern + .split(WILDCARD) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*"); + return new RegExp(`^${escaped}$`); +} + +const middlewarePath = join(appRoot, "middleware.ts"); +const patterns = readPublicPatterns(readFileSync(middlewarePath, "utf8")); +const matchers = patterns.map(patternToRegExp); +const isPublic = (path) => matchers.some((re) => re.test(path)); + +/** Top-level segments under app/ that render a page. */ +const segments = readdirSync(join(appRoot, "app"), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + // Route groups `(name)`, private folders `_name`, and dynamic segments are + // not addressable top-level paths. + .filter((n) => !n.startsWith("(") && !n.startsWith("_") && !n.startsWith("[")) + .filter((n) => existsSync(join(appRoot, "app", n, "page.tsx"))) + .sort(); + +const violations = []; + +for (const segment of segments) { + if (INTENTIONALLY_PRIVATE.has(segment)) continue; + const path = `/${segment}`; + if (!isPublic(path)) { + violations.push({ + path, + hint: `app/${segment}/page.tsx renders a public page but /${segment} is not in isPublicRoute`, + }); + } +} + +/** + * Every URL the platform sitemap advertises must also be fetchable. Listing a + * path for crawlers that answers them with a login redirect is worse than not + * listing it. + */ +const sitemapPath = join(appRoot, "app", "sitemap.ts"); +if (existsSync(sitemapPath)) { + const sitemapSource = readFileSync(sitemapPath, "utf8"); + const block = sitemapSource.slice( + sitemapSource.indexOf("MARKETING_PATHS"), + sitemapSource.indexOf("];", sitemapSource.indexOf("MARKETING_PATHS")), + ); + for (const [, path] of block.matchAll(/path:\s*"([^"]*)"/g)) { + const url = path === "" ? "/" : path; + if (!isPublic(url)) { + violations.push({ + path: url, + hint: `app/sitemap.ts advertises ${url} to crawlers but it is not in isPublicRoute`, + }); + } + } +} + +if (violations.length > 0) { + console.error("✗ Public-route guard failed — these answer anonymous requests with a login redirect:"); + for (const v of violations) { + console.error(` - ${v.path}`); + console.error(` ${v.hint}`); + } + console.error("\n Add them to isPublicRoute in middleware.ts, or — if the page really is"); + console.error(" private — name it in INTENTIONALLY_PRIVATE in this script with a reason."); + process.exit(1); +} + +console.log( + `✓ Public-route guard: ${segments.length} top-level pages and every sitemap path are reachable logged-out.`, +); diff --git a/tasks/prd-platform-content-and-seo.md b/tasks/prd-platform-content-and-seo.md new file mode 100644 index 00000000..7f64150a --- /dev/null +++ b/tasks/prd-platform-content-and-seo.md @@ -0,0 +1,424 @@ +# PRD: Platform content and SEO — article typography, platform Wire, budstacks.io SEO + +- **Status:** Draft, not started +- **Owner:** Gerard +- **Created:** 2026-08-15 +- **Predecessors:** #249 (video slots) · #251 (/documents chrome) · #253 (editorial posts + `lib/blog/posts.ts`) · #254 (platform leads, Phase 1) · #255 (`.bs-article`) +- **Related:** `tasks/prd-seo-supercharge.md` (tenant SEO — the components this reuses) · `tasks/prd-automatos-core-integration.md` (assisted-Wire drafts) + +--- + +## 1. Introduction + +BudStacks sells storefronts, content tooling and SEO to cannabis operators. Its own marketing site does none of those things for itself. + +Four concrete gaps, all verified in the live tree — and for the first, against production — on 2026-08-15: + +0. **Much of it is behind a login wall.** `/documents` (all 18 guide pages), `/faq`, `/dpa`, `/aup`, `/regulatory` and `POST /api/platform/leads` are missing from the `isPublicRoute` allowlist, so anonymous visitors and crawlers get a 307 to Clerk. The guide hub has never been indexable, and platform lead capture — the whole of Phase 1 — has recorded nothing since it shipped. Fixed first, in Workstream 0. + + +1. **Article text is unstyled.** `@tailwindcss/typography` is not installed — `tailwind.config.ts:255` loads only `tailwindcss-animate`. Every `prose-*` class in the repo is inert: preflight strips heading sizes and paragraph margins and nothing puts them back. PR #255 fixed this for the platform blog with a hand-rolled `.bs-article`. **Eight surfaces still render long-form text with no typography at all**, including `/store/[slug]/the-wire/[postSlug]` — live on every tenant storefront — and the TipTap editor, so authors cannot see formatting while writing it. + +2. **Every blog post is a code deploy.** `/blog` reads a hardcoded TypeScript array. Six sample posts are duplicated across `app/blog/page.tsx:9` and `app/blog/[slug]/page.tsx:11`; PR #253 added `lib/blog/posts.ts` as a single source but only the two editorial posts live there. Publishing throttled to the deploy cadence is the reason the blog has eight posts. + +3. **budstacks.io's own SEO is unmanaged.** `app/sitemap.ts` and `app/robots.ts` exist (SEO Supercharge US-006) but neither `app/blog/page.tsx` nor `app/blog/[slug]/page.tsx` exports `metadata` or `generateMetadata`, so **every blog post serves the root layout's title** — "BudStacks - Medical Cannabis SaaS Platform" — as its own. Individual posts are absent from the sitemap. Meanwhile `app/tenant-admin/seo/` ships 22 components that do exactly this job for tenants. + +This PRD closes all three so marketing runs without an engineer, and does it mostly by pointing components that already work at platform-scoped data. + +## 2. Goals + +- Long-form text renders correctly on all eight remaining surfaces, with the tenant storefront respecting **tenant** branding rather than BudStacks branding. +- A new blog post is published from super-admin in minutes, with no deploy, no PR and no engineer. +- The eight existing posts survive the migration at their current URLs, with no 404 and no lost content. +- Every platform blog post carries its own title, description, canonical, OG image and sitemap entry. +- budstacks.io's marketing metadata is editable in super-admin, not hardcoded in `app/layout.tsx`. +- No regression to tenant Wire, tenant SEO or the storefront — the tenant paths are the proven ones being reused, not rewritten. + +## 3. User Stories + +### Workstream 0 — Routing (ship FIRST, alone, before anything else) + +Found during PRD review and **confirmed live against budstacks.io on 2026-08-15**, not inferred from source: + +``` +GET /terms -> 200 GET /dpa -> 307 → /auth/login +GET /privacy -> 200 GET /aup -> 307 → /auth/login +GET /cookies -> 200 GET /regulatory -> 307 → /auth/login +GET /blog -> 200 GET /faq -> 307 → /auth/login +GET /learn -> 200 GET /documents -> 307 → /auth/login +GET /marketplace -> 200 GET /documents/getting-started -> 307 → /auth/login +GET /contact -> 200 POST /api/platform/leads -> 307 → /auth/login +``` + +The routes exist and work. They are simply missing from `isPublicRoute` in `middleware.ts:9-56`, which lists `/terms`, `/privacy` and `/cookies` but not these seven. + +Three consequences, in order of cost: + +- **The entire `/documents` guide hub is invisible.** 18 illustrated guide pages carrying 16 videos (#246, #249, #251), built as top-of-funnel marketing, have never been reachable by a signed-out visitor or indexed by any crawler. The index and every guide beneath it 307. This is the largest single piece of marketing content the platform has. +- **Platform lead capture has been dead since #254 deployed.** Every homepage CTA and Operator-101 submission has hit Clerk's auth wall and recorded nothing. The whole of Phase 1 does not work in production. +- **Four of the pages this PRD styles are invisible** to anonymous visitors and crawlers. Styling `/regulatory` (US-004) while nobody can load it is wasted work. + +This is the **fourth occurrence of the same class** — `/robots.txt` and `/sitemap.xml` needed it (SEO US-006), then the Automatos ingest route (fix `f59ac74`), then the legal pages and the lead endpoint, then `/documents` and `/faq` found while fixing those. Four occurrences is what makes this worth a guard rather than a one-line patch. + +#### US-000: Public routes reach the public +**Description:** As an anonymous visitor or crawler, I want the public marketing pages and the lead endpoint to load, so the site's own funnel is not behind a login wall. + +**Acceptance Criteria:** +- [ ] `/dpa`, `/aup`, `/regulatory`, `/faq`, `/documents`, `/documents/(.*)` and `/api/platform/leads` added to `isPublicRoute` (`middleware.ts:9-56`) +- [ ] Each verified anonymously with curl after deploy: the six page routes return 200, and `POST /api/platform/leads` with an invalid body returns a 400 validation error rather than a 307 +- [ ] At least one `/documents/` sub-page verified, not just the index +- [ ] One real lead submitted end-to-end through the homepage CTA and confirmed present in `platform_leads` +- [ ] **A CI guard compares the route tree against the allowlist** — every top-level `app/*/page.tsx` must be allowlisted or explicitly named as private with a reason, and every path the platform sitemap advertises must be allowlisted. A source check, not an HTTP probe: CI never starts the app, so there is no origin to curl. Pairs with the US-005 guard as the other half of "the sweep cannot silently regress" +- [ ] Shipped as its own PR, merged and deployed before any other story in this PRD +- [ ] Separately raised (not fixed here): the Clerk redirect leaks the container's internal origin — `redirect_url=https%3A%2F%2F0.0.0.0%3A8080%2Fdpa` — so even an authenticating user lands on an unreachable URL. Config, not middleware, and out of scope for this story + +--- + +### Workstream A — Article typography (retire the inert `prose-*`) + +Eight surfaces. Seven are the straightforward swap; **US-001 is not**, for a reason worth stating up front. + +`.bs-article` (`app/globals.css:1377`) is the **BudStacks brand** voice: `text-bs-fg-body`, `--bs-font-display` (Cormorant Garamond) headings, `text-bs-green-soft` links, `text-bs-green` list markers. Applying it to a tenant's blog would paint every operator's articles in BudStacks green and a serif they never chose. + +**The tenant side already has the machinery for this, and it is not `globals.css`.** `components/tenant-theme-provider.tsx` injects `TENANT_SCOPED_CSS` (`:133`) — static rules scoped to `.tenant-theme-container` that consume the per-tenant variables, described in its own comment as "the bridge between 'variables are set' and 'components actually use them'". The Wire post page is inside it (`app/store/[slug]/layout.tsx:378`). That bridge is where the article rules belong: it is already the tenant design system, and its scoping means the rules cannot leak into admin or editor chrome. + +Two consequences that shrink US-001 considerably: + +- **Headings are already handled.** `TENANT_SCOPED_CSS` sets `font-family` and `font-size` on `h1`–`h6` inside the container from `--tenant-font-heading` and the heading scale (`:141-155`). The article rules must **not** restate them — only supply the vertical margins that preflight stripped. +- **shadcn tokens are already tenant-aware inside the container.** `applyThemeToContainer` remaps `--primary` (`:285`), `--secondary` (`:287`), `--accent` (`:289`) and `--foreground` (`:297`) to the tenant's colours. So `hsl(var(--primary))` for links and list markers *is* the tenant's brand colour — no new variable plumbing needed, and `text-muted-foreground` already on the page is already correct. + +`.bs-article` also styles only `h2, h3, p, strong, em, a, ul, ol, li, blockquote, code, hr` — no rule for `h1`, `h4`–`h6`, `img`, `figure`, `table` or `pre`. Fine for hand-authored editorial, **not** fine for arbitrary TipTap output, which is what the tenant surface renders. + +#### US-001: Tenant Wire article typography, on tenant branding +**Description:** As a storefront visitor, I want a tenant's blog post to be readable and to look like that tenant's brand, so the article does not render as unstyled flow wearing BudStacks colours. + +**Acceptance Criteria:** +- [ ] `.tenant-article` rules added to `TENANT_SCOPED_CSS` in `components/tenant-theme-provider.tsx` — **not** to `globals.css`, and never using `bs-*` tokens +- [ ] Colours come from the shadcn tokens the provider already remaps per tenant (`hsl(var(--primary))`, `hsl(var(--foreground))`, `hsl(var(--border))`, `hsl(var(--muted-foreground))`) +- [ ] Heading `font-family` and `font-size` are **not** restated — `.tenant-theme-container h1`–`h6` already set them. Only `margin-top`/`margin-bottom` are added +- [ ] Covers every tag TipTap can emit: `p`, `strong`, `em`, `a`, `ul`, `ol`, `li`, `blockquote`, `code`, `pre`, `hr`, `img`, `table`, plus heading margins +- [ ] `img` is `max-width: 100%; height: auto` — a wide tenant image must not force horizontal page scroll +- [ ] `pre` scrolls inside itself (`overflow-x: auto`) so a long code line cannot scroll the page +- [ ] `table` is `width: 100%` inside an `overflow-x: auto` wrapper, or `display: block; overflow-x: auto` — same reason +- [ ] `app/store/[slug]/the-wire/[postSlug]/page.tsx:205` applies `.tenant-article` to the rendered-HTML container only, not the whole `
` +- [ ] The h1, the meta row (`:210`) and the cover image (`:222`) move **outside** the styled container, replacing the two `not-prose` escapes +- [ ] Both `not-prose` occurrences removed; no `prose*` class remains in the file +- [ ] Verify in browser using dev-browser skill, on a tenant with non-default branding — headings, lists and links pick up that tenant's colours +- [ ] Typecheck/lint passes + +#### US-002: TipTap authoring view matches the published article +**Description:** As an author, I want the editor to show real formatting so I can see what I am publishing without saving and previewing. + +**Acceptance Criteria:** +- [ ] `components/editor/tiptap.tsx:137` drops `prose prose-sm sm:prose lg:prose-lg xl:prose-2xl` for the article class +- [ ] Headings, lists, blockquotes and code render visibly distinct inside the editor +- [ ] Editor chrome (border, min-height, padding, focus ring) unchanged +- [ ] Verify in browser using dev-browser skill — type an h2, a bulleted list and a blockquote; each is visibly styled +- [ ] Typecheck/lint passes + +#### US-003: Legal pages get real typography +**Description:** As a visitor reading the terms, I want headings and lists to be distinguishable so a long legal document is navigable. + +**Acceptance Criteria:** +- [ ] `.bs-article` replaces the inert prose string in all four: `app/terms/page.tsx:362`, `app/privacy/page.tsx:323`, `app/dpa/page.tsx:294`, `app/aup/page.tsx:204` +- [ ] All four carry an identical class string today — the replacement is identical in all four +- [ ] `max-width: none` retained where the page intends full width (the current strings all carry `max-w-none`) +- [ ] Verify in browser using dev-browser skill on all four pages +- [ ] Typecheck/lint passes + +#### US-004: Regulatory and Learning Center article typography +**Description:** As a visitor, I want `/regulatory` and Learning Center articles to read as articles. + +**Acceptance Criteria:** +- [ ] `app/learn/[slug]/markdown-content.tsx:24` — the long prose chain replaced with `.bs-article max-w-none` +- [ ] `.bs-article` extended for the tags that renderer emits and it did not cover: `h1`, `img`, `pre`. `h4`, `figure`/`figcaption` and `table` added at the same time so the class is complete for arbitrary article HTML +- [ ] `pre` and `table` scroll inside themselves; `img` is `max-width: 100%` +- [ ] `app/regulatory/page.tsx:13` — inert classes **deleted with no replacement**. Unlike the other legal pages it does not wrap itself in `.budstacks-theme`, so it renders on the light `:root` palette and `.bs-article`'s light body colour would be grey-on-white. Every element on that page already carries explicit utility classes, so the removed chain was styling nothing it needed +- [ ] A comment on that page records why it is the exception, so the next sweep does not "fix" it +- [ ] Verify in browser using dev-browser skill on `/regulatory` and one `/learn/[slug]` +- [ ] Typecheck/lint passes + +#### US-005: A guard so `prose-*` cannot come back +**Description:** As a developer, I want a check that fails when someone adds a `prose-*` class, so an inert class never ships again while the plugin is absent. + +**Acceptance Criteria:** +- [ ] A CI check fails on any new `prose-`, `prose "`, or `not-prose` occurrence under `app/`, `components/` and `lib/` +- [ ] The failure message names the file and says to use `.bs-article` or `.tenant-article` +- [ ] Passes cleanly on the tree once US-001 to US-004 have landed +- [ ] If `@tailwindcss/typography` is ever intentionally installed, the guard is the one place to remove — documented in a comment + +--- + +### Workstream B — Platform Wire (Phase 2): blog out of code + +Reuse targets, all proven in production: `posts` model (`prisma/schema.prisma:680`), `app/tenant-admin/the-wire/post-form.tsx` (473 lines), `posts-list.tsx` (224), `app/api/tenant-admin/posts/route.ts` (132) and `[id]/route.ts` (263). + +#### US-006: `platform_posts` model, SQL applied before any code +**Description:** As a developer, I need platform blog posts stored in the database so publishing stops being a deploy. + +**Acceptance Criteria:** +- [ ] `platform_posts` added to `prisma/schema.prisma`: `id`, `slug` (`@unique`), `title`, `excerpt`, `content`, `coverImage`, `coverImageAlt`, `authorName`, `authorRole`, `published` (default `false`), `publishedAt`, `seo Json?`, `createdAt`, `updatedAt` +- [ ] **Author is denormalised strings, not a `users` FK** — `users` is tenant-scoped (`lib/db.ts:73`), so an FK join would hit tenant scoping on the apex where no tenant context exists. It also sidesteps the Clerk-id-vs-`users.id` P2003 class that broke the lekkerweed blog (PR #226). `learning_resources` sets the same precedent by carrying no author relation at all +- [ ] Indexes on `[published, publishedAt]` and `[slug]` +- [ ] **`platform_posts` is NOT added to `tenantScopedModels` (`lib/db.ts:49`)** — that Set is opt-in, so a platform model is unscoped by default. Adding it would break every apex query +- [ ] Hand-written `.sql` in `prisma/migrations/` — the build runs `prisma generate` only, there is no `prisma migrate` step +- [ ] **The SQL is applied to Railway prod and verified in psql BEFORE the code that queries it deploys**, or every `/blog` request 500s +- [ ] `prisma generate` succeeds; typecheck passes + +#### US-007: Platform posts write API +**Description:** As a super-admin, I need endpoints to create, edit, publish and delete platform posts. + +**Acceptance Criteria:** +- [ ] `POST`/`GET` at `app/api/platform/posts/route.ts`; `GET`/`PATCH`/`DELETE` at `app/api/platform/posts/[id]/route.ts` +- [ ] Every route is super-admin only and returns 403 to a tenant admin, using **`withSuperAdmin` / `withSuperAdminParams` from `lib/api-auth.ts:150`** +- [ ] ⚠️ **Do NOT model these on `app/api/platform/leads`.** It is the only existing route under `app/api/platform/` and it is deliberately **unauthenticated** — its own header reads "Unauthenticated and platform-level — there is no tenant here by design", because a prospect filling in the homepage CTA has no account. Copying its shape would ship an unauthenticated write API for platform blog content. These will be the first `/api/platform/*` routes to use `withSuperAdmin` +- [ ] Zod validation on all input +- [ ] `content` sanitised on write. The tenant Wire's `cleanContent` is a **local `const` inside the page component** (`app/store/[slug]/the-wire/[postSlug]/page.tsx:164`), not an importable helper — **extract it to `lib/` first and call it from both** the Wire page and these routes, so the two paths cannot drift +- [ ] Slug validated against **`POST_SLUG_PATTERN`** and `POST_SLUG_MAX_LENGTH` (`lib/seo/post-slug.ts`). Note the tenant routes do **not** enforce the pattern server-side — they apply `normalizePostSlug` and a max length only, leaving the regex to the client form and tests. **Enforce the regex server-side here**, and treat the tenant-side gap as known debt rather than the example to follow +- [ ] Duplicate slug returns a 409 with a usable message, not a Prisma error +- [ ] Setting `published: true` stamps `publishedAt` once and does not overwrite it on later edits +- [ ] Row types declared explicitly on every query — the any-widened Prisma client throws TS7006 on implicit-`any` map callbacks +- [ ] Typecheck/lint passes + +#### US-008: Super-admin Wire list +**Description:** As a super-admin, I want a list of platform posts with their status so I can see what is published. + +**Acceptance Criteria:** +- [ ] `/super-admin/the-wire` lists posts: title, slug, author, published state, `publishedAt` +- [ ] Adapted from `app/tenant-admin/the-wire/posts-list.tsx`, pointed at the platform endpoints +- [ ] Publish/unpublish toggle and delete, both with confirmation +- [ ] "New post" links to the editor +- [ ] Nav entry added to `components/admin/SuperAdminSidebar.tsx`, alongside Leads (`:58`) +- [ ] Empty state before any post exists +- [ ] Verify in browser using dev-browser skill +- [ ] Typecheck/lint passes + +#### US-009: Super-admin post editor +**Description:** As a super-admin, I want to write and edit a post in the browser, so publishing needs no PR. + +**Acceptance Criteria:** +- [ ] `/super-admin/the-wire/new` and `/super-admin/the-wire/[id]` +- [ ] Adapted from `app/tenant-admin/the-wire/post-form.tsx` — TipTap body, title, slug, excerpt, cover image + alt, published toggle +- [ ] **A new `POST /api/platform/upload` route, guarded by `withSuperAdmin`, reusing the existing S3 client.** The tenant form posts to `/api/tenant-admin/upload` (`post-form.tsx:89`), which is `withTenantAuth` and derives `tenantId` from the authenticated user — a super-admin has no tenant, so that route either rejects the upload or stamps it against the wrong tenant. Do not reuse it +- [ ] The tenant form's SEO-Pro entitlement gating (`post-form.tsx:52-78`) and `AiAssistButton` (`:384`) are **removed** — entitlements are a tenant concept and the platform is not on a plan +- [ ] Slug auto-derives from the title on create and is editable; editing an existing slug warns that the old URL will break (auto-301 is US-020) +- [ ] Verify in browser using dev-browser skill — create, save as draft, publish, edit, confirm it appears on `/blog` +- [ ] Typecheck/lint passes + +#### US-010: `/blog` index reads the database +**Description:** As a visitor, I want the blog index to list published posts from the database. + +**Acceptance Criteria:** +- [ ] `app/blog/page.tsx` queries `platform_posts` where `published: true`, newest `publishedAt` first +- [ ] Card layout, spacing and styling unchanged from the current page +- [ ] `export const dynamic = "force-dynamic"` — at build time `DATABASE_URL` is a dummy and the mock client in `lib/db.ts` returns `[]`, which would bake an empty blog into the static output (the same reason `app/sitemap.ts` sets it) +- [ ] A database error renders an empty state, never a 500 +- [ ] Explicit row type on the query result +- [ ] Verify in browser using dev-browser skill +- [ ] Typecheck/lint passes + +#### US-011: `/blog/[slug]` reads the database, with its own metadata +**Description:** As a visitor and as a crawler, I want a post page served from the database that carries that post's own title and description. + +**Acceptance Criteria:** +- [ ] `app/blog/[slug]/page.tsx` loads one post by slug; unpublished or missing returns `notFound()` +- [ ] `generateMetadata` exports per-post title, description (from `excerpt`), canonical URL from `platformBaseUrl()`, and OG image from `coverImage` — **this page has no metadata export at all today**, so every post currently serves the root layout's title +- [ ] Body renders through `.bs-article` (from #255) with the content sanitised +- [ ] Related-posts block preserved, now sourced from the database +- [ ] `generateStaticParams` (`:344`) removed or reconciled with `force-dynamic` +- [ ] Verify in browser using dev-browser skill; confirm `` and `og:image` differ per post via page source +- [ ] Typecheck/lint passes + +#### US-012: Migrate the eight existing posts +**Description:** As a visitor following an existing link, I want every current post to keep working at its current URL. + +**Acceptance Criteria:** +- [ ] All 8 posts inserted into `platform_posts` — 2 from `lib/blog/posts.ts` plus the 6 `samplePosts` +- [ ] **Slugs identical to today's** — no existing `/blog/[slug]` URL changes +- [ ] `published: true`; `publishedAt` set from each post's existing `date` string +- [ ] `author` / `role` map to `authorName` / `authorRole` +- [ ] Applied as SQL, verified by row count and by loading each of the 8 URLs +- [ ] Runs before US-013 removes the arrays — content must exist in the database before the code that held it is deleted + +#### US-013: Delete the inline post arrays +**Description:** As a developer, I want one source of truth for blog content so the drift that produced 6-vs-8 entries cannot recur. + +**Acceptance Criteria:** +- [ ] `samplePosts` removed from `app/blog/page.tsx:9` and `app/blog/[slug]/page.tsx:11` +- [ ] `lib/blog/posts.ts` deleted, and its imports with it +- [ ] `grep -rn "samplePosts\|BLOG_POSTS"` returns nothing +- [ ] All 8 post URLs plus `/blog` still render, verified after removal +- [ ] Typecheck/lint passes + +--- + +### Workstream C — Platform SEO (Phase 3): budstacks.io manages its own + +Reuse targets: `components/admin/seo/` (22 files) and `app/tenant-admin/seo/seo-page-client.tsx` (796 lines). + +#### US-014: `platform_seo_settings` + super-admin SEO page +**Description:** As a super-admin, I want to edit budstacks.io's own metadata without touching code. + +**Acceptance Criteria:** +- [ ] Model keyed by marketing route (`/`, `/marketplace`, `/learn`, `/blog`, `/contact`) holding title, description, OG image, `noindex` +- [ ] SQL applied to prod before the reading code deploys +- [ ] `/super-admin/seo` page reusing `SeoEditorModal` and `GooglePreview` from `components/admin/seo/` +- [ ] Not added to `tenantScopedModels` +- [ ] Explicit row types +- [ ] Verify in browser using dev-browser skill +- [ ] Typecheck/lint passes + +#### US-015: Marketing pages consume the settings +**Description:** As a crawler, I want each marketing page to serve its own configured title and description. + +**Acceptance Criteria:** +- [ ] Each route's `generateMetadata` reads `platform_seo_settings`, falling back to the current hardcoded values in `app/layout.tsx:32` when no row exists +- [ ] The hardcoded strings remain as the documented fallback — an empty table must not blank the site's metadata +- [ ] `noindex` emits a real robots meta tag +- [ ] Verify via page source that an edit in super-admin changes the served `<title>` +- [ ] Typecheck/lint passes + +#### US-016: Blog posts enter the sitemap +**Description:** As a crawler, I want to discover every published post from the sitemap. + +**Acceptance Criteria:** +- [ ] `app/sitemap.ts` enumerates published `platform_posts`, mirroring the existing `learning_resources` block +- [ ] `lastModified` from `updatedAt`; priority 0.6 +- [ ] Wrapped in the same try/catch — a sitemap that 500s teaches a crawler the whole site is broken +- [ ] Unpublished posts absent +- [ ] Verify `/sitemap.xml` lists all 8 post URLs + +#### US-017: Canonicals across platform marketing routes +**Description:** As a crawler, I want one canonical URL per page so apex and `www` do not compete. + +**Acceptance Criteria:** +- [ ] Every marketing route emits `<link rel="canonical">` from `platformBaseUrl()` +- [ ] Blog posts canonical to `/blog/{slug}` +- [ ] Cross-check against the `www.*` handling noted in the SEO Supercharge PRD before choosing the canonical host +- [ ] Verify in page source on three routes + +#### US-018: Article and BreadcrumbList JSON-LD for the platform blog +**Description:** As a crawler, I want structured data on platform posts, the same as tenant posts get. + +**Acceptance Criteria:** +- [ ] Reuses the tenant JSON-LD engine (SEO Supercharge US-016) rather than a second implementation +- [ ] `Article` carries headline, image, `datePublished`, `dateModified`, author, publisher +- [ ] `BreadcrumbList`: Home → Blog → post +- [ ] Validates clean in Google's Rich Results Test +- [ ] Typecheck/lint passes + +#### US-019: Platform SEO audit panel +**Description:** As a super-admin, I want the audit we sell to tenants pointed at our own site. + +**Acceptance Criteria:** +- [ ] `SeoAuditTab` reused on `/super-admin/seo` against platform routes +- [ ] Flags missing titles, missing descriptions, missing OG images, absent canonicals +- [ ] Verify in browser using dev-browser skill +- [ ] Typecheck/lint passes + +#### US-020: Slug changes issue a 301 +**Description:** As a visitor with an old link, I want a changed post slug to redirect rather than 404. + +**Acceptance Criteria:** +- [ ] Editing a published post's slug writes a redirect from old to new +- [ ] Reuses the tenant redirects mechanism (`seo_redirects`, SEO Supercharge US-021) with platform scope +- [ ] Old URL returns 301 to the new one +- [ ] Redirect chains collapse rather than nest + +--- + +### Workstream D — Content + +#### US-021: Rewrite or retire the six legacy sample posts +**Description:** As a visitor, I want every published post to be real editorial, not placeholder text. + +**Acceptance Criteria:** +- [ ] Each of the 6 is either rewritten to publishable quality or unpublished (`published: false`, not deleted — the URL history stays intact) +- [ ] Rewrites follow the framing rules in §6: no revenue promises, no "passive income" +- [ ] Each rewritten post has a real excerpt, cover image and alt text +- [ ] Decision recorded per post so the reasoning survives + +## 4. Functional Requirements + +- **FR-1:** No `prose-*` class remains in `app/`, `components/` or `lib/`; a CI check enforces this. +- **FR-2:** Tenant storefront articles are styled from `--tenant-*` variables; platform articles from `bs-*`. Neither borrows the other's tokens. +- **FR-3:** Platform blog content is stored in `platform_posts` and edited only through super-admin. +- **FR-4:** Publishing or editing a post requires no deploy and takes effect on the next request. +- **FR-5:** All 8 existing posts retain their current URLs. +- **FR-6:** Every published post emits its own title, description, canonical, OG image and sitemap entry. +- **FR-7:** Every platform write endpoint is super-admin only and 403s a tenant admin. +- **FR-8:** All post HTML is sanitised on write, using the sanitiser already applied on the tenant read path. +- **FR-9:** No platform model is added to `tenantScopedModels`. +- **FR-10:** Every schema change is applied as SQL to prod and verified before the code that reads it deploys. +- **FR-11:** Every public page and unauthenticated endpoint is present in `isPublicRoute`, and CI proves it by fetching them logged-out. + +## 5. Non-Goals (Out of Scope) + +- Installing `@tailwindcss/typography` — deliberately avoided; the hand-rolled classes stay in the `bs-*`/`--tenant-*` systems with no new dependency. +- Restyling tenant storefronts beyond the article body. +- Migrating Learning Center content into `platform_posts` — `learning_resources` stays as it is. +- Categories, tags, scheduled publishing, drafts-with-preview-links, or multi-author workflow on the platform blog. Publish/unpublish only. +- Comments, reactions or any social feature. +- Changing tenant Wire behaviour or the tenant SEO Manager — they are reuse sources, not targets. +- Automatos assisted-Wire drafting for platform posts — that is `prd-automatos-core-integration.md`. +- Paid distribution or ad tooling. Paid cannabis advertising is banned on the major networks; distribution stays organic per the GTM playbook. + +## 6. Design Considerations + +- **Two article voices, deliberately.** `.bs-article` is BudStacks-branded and belongs on `budstacks.io`, `/learn`, `/documents` and the legal pages. `.tenant-article` lives in `TENANT_SCOPED_CSS` and derives from the tenant-remapped shadcn tokens, so an operator's blog looks like their store. Mixing them is the failure mode this workstream exists to prevent. +- **Extend the existing bridge, do not build a second one.** `TENANT_SCOPED_CSS` is already the declared seam between tenant variables and rendered components. A parallel tenant-styling mechanism in `globals.css` would be a second place to look when a tenant's blog renders wrong. +- **`.bs-article`'s `max-width: 68ch`** is a text-column constraint. It must wrap only the prose, never a cover image or a page header — this is precisely why the Wire swap needs a DOM change rather than a class swap. +- **Editorial framing rules,** carried forward from PR #253 and unchanged: frame the economics, never "passive income"; no revenue promises anywhere; a passive-income claim contradicts the guides and is the highest-risk framing for cannabis plus earnings. +- Super-admin Wire and SEO pages follow the existing super-admin chrome; the sidebar is the only nav change. + +## 7. Technical Considerations + +**Traps that have already cost time in this repo — treat each as a hard constraint:** + +- **Migrations are loose `.sql` files.** `postinstall` runs `prisma generate` only; there is no `prisma migrate` in the build. Schema changes are hand-applied, and **the SQL must land in prod before the code that queries it deploys** or every affected request 500s. +- **TS7006 on the any-widened Prisma client.** `findMany`/`groupBy` results make map callbacks implicit `any`. Declare row types explicitly on every new query. This recurs on every new query page. +- **Clerk id ≠ `users.id`.** `getCurrentUser().id` returns the Clerk id; using it as a UUID FK throws P2003. This broke the lekkerweed blog (PR #226). US-006 avoids the class entirely with denormalised author strings. +- **`tenantScopedModels` is opt-in** (`lib/db.ts:49`). Platform models must stay out of it. Note `users` and `posts` are both in it — which is why platform posts are a separate table rather than tenant `posts` with a null `tenantId`. +- **`force-dynamic` on anything reading the database at request time.** At build time `DATABASE_URL` is a dummy and the mock client returns `[]`; without it an empty blog is baked into the static output. +- **Middleware routing.** `/sitemap.xml` and `/robots.txt` reach the apex handlers only because they are in the `isPublicRoute` allowlist (`middleware.ts:8-46`); tenant hosts are rewritten to the store handlers first. Any new public platform route needs the same treatment. +- **PRs merge before CI finishes** on this repo, and `main` auto-deploys. A TypeScript error is a failed deploy. Merging while work is in flight stranded commits twice on 2026-08-15 — land stories in dependency order. + +- **`isPublicRoute` is an allowlist, and forgetting it is a repeat failure.** Any new public page or unauthenticated endpoint must be added to `middleware.ts:9-56` or it answers anonymous traffic with a Clerk redirect. This has now bitten three times (robots/sitemap, the Automatos ingest route, and the four routes in US-000). The CI probe in US-000 exists to make the fourth time impossible. + +**Dependency order:** **US-000 ships first, alone, before everything** — it is a live prod defect and it makes US-004's work observable. Then A (independent). B is strictly ordered US-006 → 007 → 008/009 → 010/011 → 012 → 013. C depends on B for anything touching posts (US-016, US-018, US-020); US-014/015/017/019 do not. D depends on B. + +## 8. Success Metrics + +- All 18 `/documents` guides, `/faq` and the three legal pages reachable logged-out and eligible for indexing; leads recording again after having silently recorded nothing since #254 deployed. +- Zero `prose-*` occurrences in `app/`, `components/`, `lib/`; CI enforces it. +- A new post goes from blank to live in under 10 minutes with no engineer and no deploy. +- All 8 existing post URLs return 200 after migration; zero 404s. +- 100% of published posts have a unique `<title>`, a description, a canonical and a sitemap entry — currently 0%. +- Publishing cadence is limited by writing, not by deploys. +- No increase in tenant storefront error rate across the Workstream A rollout. + +## 9. Resolved Questions + +Closed during PRD review on 2026-08-15, each verified against the tree or prod rather than assumed. + +1. **`.tenant-article` scope — RESOLVED: tenant variables.** Built on `--tenant-color-*` with fallbacks to the tenant-remapped shadcn tokens, following the `.legal-document` precedent (`app/globals.css:1582`). Not `.bs-article`, which would put BudStacks green on every operator's blog. +2. **Legacy posts during migration — RESOLVED: stay published as-is.** Live URLs outrank placeholder prose; US-021 rewrites them afterwards. This is what US-012 already specifies. +3. **Canonical host — RESOLVED: apex.** The `www` → apex 301 shipped in SEO US-008 and is live in `middleware.ts` (the `wwwRedirectHost` branch). The remaining `www` issue is Cloudflare-for-SaaS provisioning per domain — an ops item, not a code question. US-017 canonicalises to the apex. +4. **OG images — RESOLVED: one platform default for launch.** Per-route upload is a later addition; US-014 ships a single default. +5. **Ralph or hand-built — RESOLVED, split.** US-000 is hand-landed today. The Workstream A remainder and the mechanical stories in B and C are Ralph-able **once US-007's auth reference is the corrected one**. The prod-SQL-before-deploy steps (US-006, US-014) and all of Workstream D stay human. +6. **Sequencing against the SEO Supercharge run — RESOLVED: nothing in flight.** Both runs completed and merged (#242, hotfix #243, #244 GEO, live-verified 2026-08-14). `seo_redirects` exists (`prisma/schema.prisma:267`) and the JSON-LD engine exists (`lib/seo/article-json-ld.ts`, `lib/seo/breadcrumb-json-ld.ts`) already wired into the tenant Wire page. **US-016, US-018 and US-020 are pure reuse** — the earlier concern about building them from scratch was unfounded. + +### Still open + +- **Authoring fidelity (US-002).** The editor now wears `.bs-article` while tenant posts publish under `.tenant-article`, so "the authoring view matches the published article" holds for **platform** posts and only structurally for tenant posts — a tenant author sees correct heading rhythm, lists and quotes, but BudStacks' colours rather than their own. Accepted for v1; revisit if operators find it confusing. Recorded here so it is a decision rather than a discovery. + +## 10. Review log + +**2026-08-15 — independent review against the live tree and prod.** Four findings accepted and folded in above: US-000 added (middleware), US-007's auth reference corrected, US-009's upload gap closed, and six open questions resolved. + +Acting on finding 1 turned up **two more routes the review had not reached**: `/faq` and — more costly — `/documents` and every guide beneath it, so US-000 covers seven entries rather than four. Enumerating `app/*/page.tsx` against the allowlist, rather than checking only the routes already suspected, is what surfaced them; that enumeration is now the CI guard. + +Two corrections to the review itself, verified with `git show origin/main`: + +- **US-001 and US-002 are NOT already shipped.** The review reported `.tenant-article` at `tenant-theme-provider.tsx:257-360` and `bs-article` at `tiptap.tsx:147` as present at HEAD. `origin/main` (`4e65686`) contains **zero** occurrences of `tenant-article`, and still carries `prose prose-lg mx-auto dark:prose-invert` at `the-wire/[postSlug]/page.tsx:205`, `not-prose` at `:210` and `:222`, and the full prose chain at `tiptap.tsx:137`. That work exists only in the uncommitted branch `feat/article-typography`; the review read the working tree and attributed it to HEAD. Workstream A does **not** shrink — US-001 and US-002 are written but unmerged and unverified. +- **US-005's guard does not trip on the `not-prose` comment.** The implemented check strips block and line comments before matching, precisely because this codebase writes block comments without leading asterisks. All seven surviving `prose` mentions were traced individually and are stripped. The concern applied to a naive grep, not to `scripts/ci/check-no-inert-prose-classes.mjs`. From da72afe6774f47f99cf9b6ddd5f0c7180c6ba4d1 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh <gerard161@gmail.com> Date: Sat, 15 Aug 2026 18:20:47 +0100 Subject: [PATCH 2/2] fix(middleware): account recovery was behind the login wall too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated review of #256 found the guard itself was too narrow: it discovered only top-level app/<segment>/page.tsx, so nested and dynamic pages (app/documents/[slug]/page.tsx — 18 guide pages) were invisible to it, and it never checked the leads route.ts at all. A later deletion of "/documents/(.*)" or "/api/platform/leads" from the allowlist would have passed CI. Rewriting it to walk the App Router recursively immediately turned up five more broken routes, and one is worse than anything in the original commit: /auth/forgot-password account recovery — a user who has forgotten /auth/reset-password/<t> their password is redirected to the login they /auth/callback cannot complete. Locked out, not just hidden. /legal/changelog public compliance pages; /legal/subprocessors is /legal/subprocessors a GDPR transparency obligation linked from the DPA All five confirmed 307 against production, same as the original seven. The guard now walks the tree recursively — route groups collapse, an optional catch-all [[...rest]] yields its parent (which is the only reason /auth/login resolves, since there is no app/auth/login/page.tsx), and other dynamic segments become a probe value so the matcher test means something. Public API handlers are a declared manifest rather than discovered, because a route.ts gives no signal about whether it expects a session. Verified against the fixed tree: 104 routes discovered, zero unmatched. Every round of this bug was found by widening the search, never by looking harder at the routes already suspected. That is what the guard automates. Also folds the review's remaining findings into their PRD stories: block slug edits on published posts until auto-301 exists rather than warning and allowing; distinguish an empty blog from a database outage; fall back to the platform OG image when a post has no cover; and give every newly-public route a metadata owner rather than the five originally listed. --- nextjs_space/middleware.ts | 12 +++ .../ci/check-public-routes-allowlisted.mjs | 95 +++++++++++++++---- tasks/prd-platform-content-and-seo.md | 61 ++++++++---- 3 files changed, 131 insertions(+), 37 deletions(-) diff --git a/nextjs_space/middleware.ts b/nextjs_space/middleware.ts index c252c117..381af857 100644 --- a/nextjs_space/middleware.ts +++ b/nextjs_space/middleware.ts @@ -18,6 +18,13 @@ const isPublicRoute = createRouteMatcher([ "/sitemap.xml", "/auth/login(.*)", "/auth/signup(.*)", + // Account recovery, and the only routes here whose absence LOCKED USERS OUT + // rather than merely hiding a page: a visitor who has forgotten their + // password was redirected to the login they cannot complete. Every one of + // these is reached with no session by definition. + "/auth/forgot-password", + "/auth/reset-password(.*)", + "/auth/callback", "/store/(.*)", // Storefronts are public "/api/webhooks(.*)", "/api/uploadthing(.*)", @@ -60,6 +67,11 @@ const isPublicRoute = createRouteMatcher([ "/aup", // Acceptable use policy "/regulatory", // Regulatory information "/faq", // Public FAQ + // Public compliance pages. /legal/subprocessors in particular is a GDPR + // transparency obligation and is linked from the DPA — a login wall in front + // of it is a compliance problem, not just a broken link. + "/legal/changelog", + "/legal/subprocessors", // The BudStacks Guide (#246/#249/#251) — 18 illustrated guide pages carrying // 16 embedded videos, built as top-of-funnel marketing and then reachable // only by signed-in users. Both the index and every guide beneath it. diff --git a/nextjs_space/scripts/ci/check-public-routes-allowlisted.mjs b/nextjs_space/scripts/ci/check-public-routes-allowlisted.mjs index 9b46d5c7..c1a7e15e 100644 --- a/nextjs_space/scripts/ci/check-public-routes-allowlisted.mjs +++ b/nextjs_space/scripts/ci/check-public-routes-allowlisted.mjs @@ -84,25 +84,88 @@ const patterns = readPublicPatterns(readFileSync(middlewarePath, "utf8")); const matchers = patterns.map(patternToRegExp); const isPublic = (path) => matchers.some((re) => re.test(path)); -/** Top-level segments under app/ that render a page. */ -const segments = readdirSync(join(appRoot, "app"), { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name) - // Route groups `(name)`, private folders `_name`, and dynamic segments are - // not addressable top-level paths. - .filter((n) => !n.startsWith("(") && !n.startsWith("_") && !n.startsWith("[")) - .filter((n) => existsSync(join(appRoot, "app", n, "page.tsx"))) - .sort(); +/** + * Unauthenticated API handlers that must STAY unauthenticated. A route.ts gives + * no signal about whether it expects a session, so these are declared rather + * than discovered — the point is that dropping one from the allowlist fails CI. + * `/api/platform/leads` is here because that is precisely what happened: it + * shipped in #254 and answered every submission with a redirect to Clerk. + */ +const PUBLIC_API_HANDLERS = [ + { + path: "/api/platform/leads", + file: "app/api/platform/leads/route.ts", + why: "homepage CTA + Operator 101 download — a prospect has no account and no tenant", + }, +]; + +/** + * Every page in the App Router tree, as an addressable path. + * + * Recursive, not top-level only: `app/documents/[slug]/page.tsx` is 18 guide + * pages, and a top-level scan cannot see that dropping `/documents/(.*)` puts + * all of them back behind the login wall. Walking the whole tree is also what + * surfaced /auth/forgot-password, /auth/callback, /legal/changelog and + * /legal/subprocessors as already broken. + * + * Dynamic segments become a probe value so the matcher test means something. + * An OPTIONAL catch-all `[[...rest]]` also matches its parent, so it yields the + * parent path — that is the only reason /auth/login resolves at all, since + * there is no app/auth/login/page.tsx, just [[...rest]]. + */ +function collectRoutes(dir, segments = []) { + const routes = []; + + if (existsSync(join(dir, "page.tsx"))) { + routes.push(`/${segments.join("/")}`.replace(/\/+$/, "") || "/"); + } + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const name = entry.name; + if (name.startsWith("_") || name === "node_modules") continue; + + let next; + if (name.startsWith("(") && name.endsWith(")")) { + next = segments; // route group — organisational, not addressable + } else if (name.startsWith("[[") && name.endsWith("]]")) { + next = segments; // optional catch-all — also matches the parent + } else if (name.startsWith("[")) { + next = [...segments, "__probe__"]; + } else { + next = [...segments, name]; + } + + routes.push(...collectRoutes(join(dir, name), next)); + } + + return routes; +} const violations = []; -for (const segment of segments) { - if (INTENTIONALLY_PRIVATE.has(segment)) continue; - const path = `/${segment}`; - if (!isPublic(path)) { +const routes = [...new Set(collectRoutes(join(appRoot, "app")))].sort(); + +for (const route of routes) { + if (route === "/") continue; // the root page, allowlisted as "/" + const topSegment = route.split("/")[1]; + if (INTENTIONALLY_PRIVATE.has(topSegment)) continue; + if (!isPublic(route)) { + violations.push({ + path: route, + hint: `a page.tsx renders ${route} but no isPublicRoute pattern matches it`, + }); + } +} + +for (const handler of PUBLIC_API_HANDLERS) { + // A removed route is not this guard's business — only a still-present + // unauthenticated handler that has lost its allowlist entry. + if (!existsSync(join(appRoot, handler.file))) continue; + if (!isPublic(handler.path)) { violations.push({ - path, - hint: `app/${segment}/page.tsx renders a public page but /${segment} is not in isPublicRoute`, + path: handler.path, + hint: `${handler.file} is unauthenticated by design (${handler.why}) but is not in isPublicRoute`, }); } } @@ -142,5 +205,5 @@ if (violations.length > 0) { } console.log( - `✓ Public-route guard: ${segments.length} top-level pages and every sitemap path are reachable logged-out.`, + `✓ Public-route guard: ${routes.length} pages, ${PUBLIC_API_HANDLERS.length} public API handler(s) and every sitemap path are allowlisted.`, ); diff --git a/tasks/prd-platform-content-and-seo.md b/tasks/prd-platform-content-and-seo.md index 7f64150a..ab4b7830 100644 --- a/tasks/prd-platform-content-and-seo.md +++ b/tasks/prd-platform-content-and-seo.md @@ -14,7 +14,7 @@ BudStacks sells storefronts, content tooling and SEO to cannabis operators. Its Four concrete gaps, all verified in the live tree — and for the first, against production — on 2026-08-15: -0. **Much of it is behind a login wall.** `/documents` (all 18 guide pages), `/faq`, `/dpa`, `/aup`, `/regulatory` and `POST /api/platform/leads` are missing from the `isPublicRoute` allowlist, so anonymous visitors and crawlers get a 307 to Clerk. The guide hub has never been indexable, and platform lead capture — the whole of Phase 1 — has recorded nothing since it shipped. Fixed first, in Workstream 0. +0. **Much of it is behind a login wall — including password reset.** Twelve routes are missing from the `isPublicRoute` allowlist, so anonymous visitors and crawlers get a 307 to Clerk: `/auth/forgot-password` and the rest of account recovery, `/documents` (all 18 guide pages), `/faq`, the legal and compliance pages, and `POST /api/platform/leads`. A locked-out user cannot reset their password, the guide hub has never been indexable, and platform lead capture — the whole of Phase 1 — has recorded nothing since it shipped. Fixed first, in Workstream 0. 1. **Article text is unstyled.** `@tailwindcss/typography` is not installed — `tailwind.config.ts:255` loads only `tailwindcss-animate`. Every `prose-*` class in the repo is inert: preflight strips heading sizes and paragraph margins and nothing puts them back. PR #255 fixed this for the platform blog with a hand-rolled `.bs-article`. **Eight surfaces still render long-form text with no typography at all**, including `/store/[slug]/the-wire/[postSlug]` — live on every tenant storefront — and the TipTap editor, so authors cannot see formatting while writing it. @@ -41,34 +41,43 @@ This PRD closes all three so marketing runs without an engineer, and does it mos Found during PRD review and **confirmed live against budstacks.io on 2026-08-15**, not inferred from source: ``` -GET /terms -> 200 GET /dpa -> 307 → /auth/login -GET /privacy -> 200 GET /aup -> 307 → /auth/login -GET /cookies -> 200 GET /regulatory -> 307 → /auth/login -GET /blog -> 200 GET /faq -> 307 → /auth/login -GET /learn -> 200 GET /documents -> 307 → /auth/login -GET /marketplace -> 200 GET /documents/getting-started -> 307 → /auth/login -GET /contact -> 200 POST /api/platform/leads -> 307 → /auth/login +WORKING BROKEN — 307 → /auth/login +GET /terms -> 200 GET /documents (index + all 18 guides) +GET /privacy -> 200 GET /documents/getting-started +GET /cookies -> 200 GET /faq +GET /blog -> 200 GET /dpa +GET /blog/<post> -> 200 GET /aup +GET /learn -> 200 GET /regulatory +GET /marketplace -> 200 GET /legal/changelog +GET /contact -> 200 GET /legal/subprocessors +GET /auth/login -> 200 GET /auth/forgot-password + GET /auth/callback + GET /auth/reset-password/<token> + POST /api/platform/leads ``` -The routes exist and work. They are simply missing from `isPublicRoute` in `middleware.ts:9-56`, which lists `/terms`, `/privacy` and `/cookies` but not these seven. +The routes exist and work. They are simply missing from `isPublicRoute` in `middleware.ts:9-56`. -Three consequences, in order of cost: +Four consequences, in order of cost: -- **The entire `/documents` guide hub is invisible.** 18 illustrated guide pages carrying 16 videos (#246, #249, #251), built as top-of-funnel marketing, have never been reachable by a signed-out visitor or indexed by any crawler. The index and every guide beneath it 307. This is the largest single piece of marketing content the platform has. +- **Account recovery is broken.** `/auth/forgot-password` redirects to the login page — a user who has forgotten their password is sent to the one screen they cannot complete. `/auth/reset-password/<token>` and `/auth/callback` are behind the same wall. This is the only item here that locks users out rather than merely hiding content, and it is the reason US-000 ships immediately. +- **The entire `/documents` guide hub is invisible.** 18 illustrated guide pages carrying 16 videos (#246, #249, #251), built as top-of-funnel marketing, have never been reachable by a signed-out visitor or indexed by any crawler. - **Platform lead capture has been dead since #254 deployed.** Every homepage CTA and Operator-101 submission has hit Clerk's auth wall and recorded nothing. The whole of Phase 1 does not work in production. -- **Four of the pages this PRD styles are invisible** to anonymous visitors and crawlers. Styling `/regulatory` (US-004) while nobody can load it is wasted work. +- **Public compliance pages are gated.** `/legal/subprocessors` is a GDPR transparency obligation linked from the DPA; a login wall in front of it is a compliance problem, not just a broken link. `/dpa`, `/aup` and `/regulatory` — four of the pages this PRD styles — are equally unreachable, which makes US-004 unobservable until this lands. -This is the **fourth occurrence of the same class** — `/robots.txt` and `/sitemap.xml` needed it (SEO US-006), then the Automatos ingest route (fix `f59ac74`), then the legal pages and the lead endpoint, then `/documents` and `/faq` found while fixing those. Four occurrences is what makes this worth a guard rather than a one-line patch. +This is the **fourth occurrence of the same class** — `/robots.txt` and `/sitemap.xml` (SEO US-006), then the Automatos ingest route (fix `f59ac74`), then the legal pages and the lead endpoint, then everything else above. Each round was found by looking only at the routes already suspected. **Enumerating the whole App Router tree against the allowlist is what found the rest, and that enumeration is now the CI guard** — which is the actual lesson, and why this is not a one-line patch. #### US-000: Public routes reach the public **Description:** As an anonymous visitor or crawler, I want the public marketing pages and the lead endpoint to load, so the site's own funnel is not behind a login wall. **Acceptance Criteria:** -- [ ] `/dpa`, `/aup`, `/regulatory`, `/faq`, `/documents`, `/documents/(.*)` and `/api/platform/leads` added to `isPublicRoute` (`middleware.ts:9-56`) -- [ ] Each verified anonymously with curl after deploy: the six page routes return 200, and `POST /api/platform/leads` with an invalid body returns a 400 validation error rather than a 307 +- [ ] Twelve entries added to `isPublicRoute` (`middleware.ts:9-56`): `/auth/forgot-password`, `/auth/reset-password(.*)`, `/auth/callback`, `/documents`, `/documents/(.*)`, `/faq`, `/dpa`, `/aup`, `/regulatory`, `/legal/changelog`, `/legal/subprocessors`, `/api/platform/leads` +- [ ] Each verified anonymously with curl after deploy: every page route returns 200, and `POST /api/platform/leads` with an invalid body returns a 400 validation error rather than a 307 - [ ] At least one `/documents/<guide>` sub-page verified, not just the index +- [ ] **Password reset walked end-to-end signed-out** — request the email, follow the link, set a new password. This is the one path where the bug locked people out - [ ] One real lead submitted end-to-end through the homepage CTA and confirmed present in `platform_leads` -- [ ] **A CI guard compares the route tree against the allowlist** — every top-level `app/*/page.tsx` must be allowlisted or explicitly named as private with a reason, and every path the platform sitemap advertises must be allowlisted. A source check, not an HTTP probe: CI never starts the app, so there is no origin to curl. Pairs with the US-005 guard as the other half of "the sweep cannot silently regress" +- [ ] **A CI guard compares the route tree against the allowlist.** It walks the App Router **recursively** — nested and dynamic pages included, since `/documents/[slug]` is 18 pages a top-level scan cannot see — and checks a declared manifest of unauthenticated API handlers, since a `route.ts` gives no signal about whether it expects a session. Every discovered page must be allowlisted or sit under a top segment explicitly named private with a reason. A source check, not an HTTP probe: CI never starts the app, so there is no origin to curl +- [ ] Guard verified to pass on the fixed tree: 104 routes discovered, zero unmatched - [ ] Shipped as its own PR, merged and deployed before any other story in this PRD - [ ] Separately raised (not fixed here): the Clerk redirect leaks the container's internal origin — `redirect_url=https%3A%2F%2F0.0.0.0%3A8080%2Fdpa` — so even an authenticating user lands on an unreachable URL. Config, not middleware, and out of scope for this story @@ -201,7 +210,8 @@ Reuse targets, all proven in production: `posts` model (`prisma/schema.prisma:68 - [ ] Adapted from `app/tenant-admin/the-wire/post-form.tsx` — TipTap body, title, slug, excerpt, cover image + alt, published toggle - [ ] **A new `POST /api/platform/upload` route, guarded by `withSuperAdmin`, reusing the existing S3 client.** The tenant form posts to `/api/tenant-admin/upload` (`post-form.tsx:89`), which is `withTenantAuth` and derives `tenantId` from the authenticated user — a super-admin has no tenant, so that route either rejects the upload or stamps it against the wrong tenant. Do not reuse it - [ ] The tenant form's SEO-Pro entitlement gating (`post-form.tsx:52-78`) and `AiAssistButton` (`:384`) are **removed** — entitlements are a tenant concept and the platform is not on a plan -- [ ] Slug auto-derives from the title on create and is editable; editing an existing slug warns that the old URL will break (auto-301 is US-020) +- [ ] Slug auto-derives from the title on create, and is freely editable **while the post is a draft** +- [ ] **Editing the slug of a PUBLISHED post is blocked until US-020 (auto-301) is deployed**, and the UI says why. A warning is not enough: between this story and US-020 a slug change silently 404s a live URL and discards its inbound links. Either US-020 moves ahead of this story or the field is disabled for published posts — do not ship "warn and allow" - [ ] Verify in browser using dev-browser skill — create, save as draft, publish, edit, confirm it appears on `/blog` - [ ] Typecheck/lint passes @@ -212,7 +222,7 @@ Reuse targets, all proven in production: `posts` model (`prisma/schema.prisma:68 - [ ] `app/blog/page.tsx` queries `platform_posts` where `published: true`, newest `publishedAt` first - [ ] Card layout, spacing and styling unchanged from the current page - [ ] `export const dynamic = "force-dynamic"` — at build time `DATABASE_URL` is a dummy and the mock client in `lib/db.ts` returns `[]`, which would bake an empty blog into the static output (the same reason `app/sitemap.ts` sets it) -- [ ] A database error renders an empty state, never a 500 +- [ ] **An empty blog and a broken database must not look the same.** A successful zero-row query renders the empty state; a query that throws logs the error and returns a controlled failure, never a 200 with no posts. Serving "no articles" during an outage hides it from users, crawlers and monitoring at once — and teaches Google the blog is empty - [ ] Explicit row type on the query result - [ ] Verify in browser using dev-browser skill - [ ] Typecheck/lint passes @@ -222,7 +232,8 @@ Reuse targets, all proven in production: `posts` model (`prisma/schema.prisma:68 **Acceptance Criteria:** - [ ] `app/blog/[slug]/page.tsx` loads one post by slug; unpublished or missing returns `notFound()` -- [ ] `generateMetadata` exports per-post title, description (from `excerpt`), canonical URL from `platformBaseUrl()`, and OG image from `coverImage` — **this page has no metadata export at all today**, so every post currently serves the root layout's title +- [ ] `generateMetadata` exports per-post title, description (from `excerpt`), canonical URL from `platformBaseUrl()`, and an OG image — **this page has no metadata export at all today**, so every post currently serves the root layout's title +- [ ] **Every published post resolves an OG image**, falling back to the platform default from US-014 when `coverImage` is null. Neither the editor nor the migration makes a cover mandatory, so `coverImage` alone would leave posts with no share image at all - [ ] Body renders through `.bs-article` (from #255) with the content sanitised - [ ] Related-posts block preserved, now sourced from the database - [ ] `generateStaticParams` (`:344`) removed or reconciled with `force-dynamic` @@ -260,7 +271,9 @@ Reuse targets: `components/admin/seo/` (22 files) and `app/tenant-admin/seo/seo- **Description:** As a super-admin, I want to edit budstacks.io's own metadata without touching code. **Acceptance Criteria:** -- [ ] Model keyed by marketing route (`/`, `/marketplace`, `/learn`, `/blog`, `/contact`) holding title, description, OG image, `noindex` +- [ ] Model keyed by marketing route, holding title, description, OG image, `noindex` +- [ ] **Covers every public marketing route, not a subset** — `/`, `/marketplace`, `/learn`, `/blog`, `/contact`, plus `/documents` and its guides, `/faq`, `/regulatory`, `/terms`, `/privacy`, `/cookies`, `/dpa`, `/aup`, `/legal/*`. US-000 makes all of these publicly indexable; any route without an owner here inherits the root layout's generic title, which is the defect US-011 fixes for posts +- [ ] One platform default OG image seeds the table so no route is left without one - [ ] SQL applied to prod before the reading code deploys - [ ] `/super-admin/seo` page reusing `SeoEditorModal` and `GooglePreview` from `components/admin/seo/` - [ ] Not added to `tenantScopedModels` @@ -416,7 +429,13 @@ Closed during PRD review on 2026-08-15, each verified against the tree or prod r **2026-08-15 — independent review against the live tree and prod.** Four findings accepted and folded in above: US-000 added (middleware), US-007's auth reference corrected, US-009's upload gap closed, and six open questions resolved. -Acting on finding 1 turned up **two more routes the review had not reached**: `/faq` and — more costly — `/documents` and every guide beneath it, so US-000 covers seven entries rather than four. Enumerating `app/*/page.tsx` against the allowlist, rather than checking only the routes already suspected, is what surfaced them; that enumeration is now the CI guard. +Acting on finding 1 turned up **two more routes the review had not reached**: `/faq` and — more costly — `/documents` and every guide beneath it. Enumerating `app/*/page.tsx` against the allowlist, rather than checking only the routes already suspected, is what surfaced them. + +**2026-08-15 — automated review of PR #256.** One finding on the guard itself, accepted and acted on: it discovered only top-level `app/<segment>/page.tsx`, so it could not see nested or dynamic pages (`app/documents/[slug]/page.tsx`) and never checked the leads `route.ts` — meaning a future deletion of `/documents/(.*)` or `/api/platform/leads` from the allowlist would have passed CI. Rewriting it to walk the tree recursively **immediately exposed five more broken routes**, one of them worse than anything found so far: `/auth/forgot-password`, `/auth/reset-password/<token>` and `/auth/callback` (account recovery — users locked out, not merely content hidden), plus `/legal/changelog` and `/legal/subprocessors`. US-000 went from seven entries to twelve. + +The lesson is the one already stated above and now demonstrated twice in a row: **every round of this bug was found by widening the search, never by looking harder at the routes already suspected.** The guard's value is that it does the widening automatically. + +The remaining seven PR-review comments are all against future stories rather than shipped code (slug edits before redirects exist, empty-blog versus database outage, OG-image fallback, metadata coverage for the newly-public routes). They are folded into their stories. Two corrections to the review itself, verified with `git show origin/main`: