fix: enforce tenant-scoped manuals and deployment gates
This commit is contained in:
parent
c5e40c5caf
commit
f077966bb2
12 changed files with 636 additions and 18 deletions
|
|
@ -1,8 +1,10 @@
|
|||
export const dynamic = "force-dynamic"
|
||||
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { Metadata } from "next"
|
||||
import { headers } from "next/headers"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { ManualsPageExperience } from "@/components/manuals-page-experience"
|
||||
import { listConvexManuals } from "@/lib/convex-service"
|
||||
|
|
@ -10,6 +12,7 @@ import { scanManuals } from "@/lib/manuals"
|
|||
import { selectManualsForSite } from "@/lib/manuals-site-selection"
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
|
||||
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
||||
|
||||
export const metadata: Metadata = generateSEOMetadata({
|
||||
title: "Vending Machine Manuals | Rocky Mountain Vending",
|
||||
|
|
@ -31,14 +34,54 @@ export const metadata: Metadata = generateSEOMetadata({
|
|||
})
|
||||
|
||||
export default async function ManualsPage() {
|
||||
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
|
||||
const convexManuals = await listConvexManuals()
|
||||
const requestHeaders = await headers()
|
||||
const requestHost =
|
||||
requestHeaders.get("x-forwarded-host") || requestHeaders.get("host")
|
||||
const manualsDomain = resolveManualsTenantDomain({
|
||||
requestHost,
|
||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
||||
})
|
||||
|
||||
const convexManuals = manualsDomain
|
||||
? await listConvexManuals(manualsDomain)
|
||||
: []
|
||||
|
||||
const isLocalDevelopment = process.env.NODE_ENV === "development"
|
||||
const shouldUseFilesystemFallback = isLocalDevelopment
|
||||
|
||||
const allManuals =
|
||||
convexManuals.length > 0 ? convexManuals : await scanManuals()
|
||||
convexManuals.length > 0 || !shouldUseFilesystemFallback
|
||||
? convexManuals
|
||||
: await scanManuals()
|
||||
let manuals =
|
||||
convexManuals.length > 0
|
||||
? convexManuals
|
||||
: selectManualsForSite(allManuals).manuals
|
||||
: shouldUseFilesystemFallback
|
||||
? selectManualsForSite(allManuals, manualsDomain).manuals
|
||||
: []
|
||||
|
||||
const shouldShowDegradedState =
|
||||
!shouldUseFilesystemFallback && manuals.length === 0
|
||||
|
||||
if (shouldShowDegradedState) {
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || ""
|
||||
const convexUrlHash = convexUrl
|
||||
? createHash("sha256").update(convexUrl).digest("hex").slice(0, 12)
|
||||
: "missing"
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "manuals.degraded_empty_tenant",
|
||||
severity: "error",
|
||||
domain: manualsDomain || "missing",
|
||||
host: requestHost || "missing",
|
||||
manualCount: manuals.length,
|
||||
convexManualCount: convexManuals.length,
|
||||
convexUrlHash,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
|
||||
const thumbnailsRoot = getManualsThumbnailsRoot()
|
||||
|
|
@ -99,8 +142,22 @@ export default async function ManualsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
||||
/>
|
||||
<div className="public-page">
|
||||
<div className="public-page" data-manuals-domain={manualsDomain}>
|
||||
{shouldShowDegradedState ? (
|
||||
<div className="mx-auto max-w-[var(--public-shell-max)] px-4 py-10 sm:px-5 lg:px-6">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-6">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Manuals Library Temporarily Unavailable
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
We are restoring tenant catalog data for this domain. Please
|
||||
refresh shortly or contact support if this persists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ManualsPageExperience initialManuals={manuals} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
// @ts-nocheck
|
||||
import { mutation, query } from "./_generated/server"
|
||||
import { v } from "convex/values"
|
||||
import {
|
||||
canonicalizeTenantDomain,
|
||||
manualVisibleForTenant,
|
||||
tenantDomainVariants,
|
||||
} from "../lib/manuals-tenant"
|
||||
|
||||
const manualInput = v.object({
|
||||
filename: v.string(),
|
||||
|
|
@ -23,10 +28,19 @@ const manualInput = v.object({
|
|||
})
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
args: {
|
||||
domain: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const tenantDomain = canonicalizeTenantDomain(args.domain)
|
||||
if (!tenantDomain) {
|
||||
return []
|
||||
}
|
||||
|
||||
const manuals = await ctx.db.query("manuals").collect()
|
||||
return manuals.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
return manuals
|
||||
.filter((manual) => manualVisibleForTenant(manual, tenantDomain))
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -106,3 +120,64 @@ export const upsertMany = mutation({
|
|||
return results
|
||||
},
|
||||
})
|
||||
|
||||
export const backfillTenantVisibility = mutation({
|
||||
args: {
|
||||
domain: v.string(),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const tenantDomain = canonicalizeTenantDomain(args.domain)
|
||||
if (!tenantDomain) {
|
||||
throw new Error("A valid tenant domain is required.")
|
||||
}
|
||||
|
||||
const aliases = tenantDomainVariants(tenantDomain)
|
||||
const dryRun = Boolean(args.dryRun)
|
||||
const now = Date.now()
|
||||
const manuals = await ctx.db.query("manuals").collect()
|
||||
|
||||
let patched = 0
|
||||
let alreadyCovered = 0
|
||||
|
||||
for (const manual of manuals) {
|
||||
const visibilitySet = new Set(
|
||||
(manual.siteVisibility || [])
|
||||
.map((entry) => canonicalizeTenantDomain(entry))
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain)
|
||||
const hasDomain =
|
||||
aliases.some((alias) => visibilitySet.has(alias)) ||
|
||||
(sourceDomain ? aliases.includes(sourceDomain) : false)
|
||||
|
||||
if (hasDomain) {
|
||||
alreadyCovered += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nextVisibility = Array.from(
|
||||
new Set([...visibilitySet, ...aliases])
|
||||
).sort()
|
||||
|
||||
if (!dryRun) {
|
||||
await ctx.db.patch(manual._id, {
|
||||
sourceDomain: sourceDomain || tenantDomain,
|
||||
siteVisibility: nextVisibility,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
patched += 1
|
||||
}
|
||||
|
||||
return {
|
||||
domain: tenantDomain,
|
||||
total: manuals.length,
|
||||
patched,
|
||||
alreadyCovered,
|
||||
dryRun,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
45
docs/manuals-tenant-recovery.md
Normal file
45
docs/manuals-tenant-recovery.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Manuals Tenant Recovery Runbook
|
||||
|
||||
## 1) Verify runtime env on active app
|
||||
|
||||
Confirm these variables on the live Coolify app/container:
|
||||
|
||||
- `NEXT_PUBLIC_CONVEX_URL` (full `https://...` URL)
|
||||
- `NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app`
|
||||
- Optional override: `MANUALS_TENANT_DOMAIN=rmv.abundancepartners.app`
|
||||
|
||||
## 2) Verify Convex tenant data gate
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm deploy:staging:convex-gate
|
||||
```
|
||||
|
||||
This fails if Convex returns fewer than one manual for the active domain.
|
||||
|
||||
## 3) Backfill existing manuals rows for tenant visibility
|
||||
|
||||
Dry run first:
|
||||
|
||||
```bash
|
||||
pnpm manuals:backfill:tenant -- --domain rmv.abundancepartners.app --dry-run
|
||||
```
|
||||
|
||||
Apply:
|
||||
|
||||
```bash
|
||||
pnpm manuals:backfill:tenant -- --domain rmv.abundancepartners.app
|
||||
```
|
||||
|
||||
## 4) Re-run smoke checks
|
||||
|
||||
```bash
|
||||
pnpm deploy:staging:smoke -- --base-url https://rmv.abundancepartners.app --skip-browser
|
||||
```
|
||||
|
||||
Manuals checks will fail if:
|
||||
|
||||
- `/manuals` renders with `initialManuals: []`
|
||||
- tenant domain marker mismatches the host
|
||||
- degraded manuals state is shown
|
||||
|
|
@ -3,6 +3,7 @@ import { fetchQuery } from "convex/nextjs"
|
|||
import { makeFunctionReference } from "convex/server"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
||||
import type { Product } from "@/lib/products/types"
|
||||
import type { Manual } from "@/lib/manuals-types"
|
||||
|
||||
|
|
@ -80,14 +81,15 @@ function getServerConvexClient(useAdminAuth: boolean) {
|
|||
async function queryManualsWithAuthFallback<TData>(
|
||||
label: string,
|
||||
queryRef: ReturnType<typeof makeFunctionReference<"query">>,
|
||||
fallback: TData
|
||||
fallback: TData,
|
||||
args: Record<string, unknown> = {}
|
||||
): Promise<TData> {
|
||||
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY
|
||||
const adminClient = getServerConvexClient(true)
|
||||
|
||||
if (adminClient) {
|
||||
try {
|
||||
return (await adminClient.query(queryRef, {})) as TData
|
||||
return (await adminClient.query(queryRef, args)) as TData
|
||||
} catch (error) {
|
||||
console.error(`[convex-service] ${label} admin query failed`, error)
|
||||
if (!adminKey) {
|
||||
|
|
@ -103,7 +105,7 @@ async function queryManualsWithAuthFallback<TData>(
|
|||
|
||||
return await safeFetchQuery(
|
||||
`${label}.public`,
|
||||
publicClient.query(queryRef, {}),
|
||||
publicClient.query(queryRef, args),
|
||||
fallback
|
||||
)
|
||||
}
|
||||
|
|
@ -227,15 +229,26 @@ export async function getConvexProduct(id: string): Promise<Product | null> {
|
|||
return match ? mapConvexProduct(match) : null
|
||||
}
|
||||
|
||||
export async function listConvexManuals(): Promise<Manual[]> {
|
||||
export async function listConvexManuals(domain?: string): Promise<Manual[]> {
|
||||
if (!hasConvexUrl()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const tenantDomain = resolveManualsTenantDomain({
|
||||
requestHost: domain,
|
||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
||||
})
|
||||
|
||||
if (!tenantDomain) {
|
||||
return []
|
||||
}
|
||||
|
||||
const manuals = await queryManualsWithAuthFallback(
|
||||
"manuals.list",
|
||||
LIST_MANUALS,
|
||||
[] as ConvexManualDoc[]
|
||||
[] as ConvexManualDoc[],
|
||||
{ domain: tenantDomain }
|
||||
)
|
||||
return (manuals as ConvexManualDoc[]).map(mapConvexManual)
|
||||
}
|
||||
|
|
|
|||
90
lib/manuals-tenant.test.ts
Normal file
90
lib/manuals-tenant.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import {
|
||||
canonicalizeTenantDomain,
|
||||
manualVisibleForTenant,
|
||||
resolveManualsTenantDomain,
|
||||
tenantDomainVariants,
|
||||
} from "@/lib/manuals-tenant"
|
||||
|
||||
test("canonicalizeTenantDomain strips protocol, port, path, and casing", () => {
|
||||
assert.equal(
|
||||
canonicalizeTenantDomain("HTTPS://RMV.AbundancePartners.App:443/manuals"),
|
||||
"rmv.abundancepartners.app"
|
||||
)
|
||||
assert.equal(canonicalizeTenantDomain(""), "")
|
||||
assert.equal(canonicalizeTenantDomain(undefined), "")
|
||||
})
|
||||
|
||||
test("tenantDomainVariants includes root and www aliases", () => {
|
||||
assert.deepEqual(tenantDomainVariants("rmv.abundancepartners.app"), [
|
||||
"rmv.abundancepartners.app",
|
||||
"www.rmv.abundancepartners.app",
|
||||
])
|
||||
assert.deepEqual(tenantDomainVariants("www.rockymountainvending.com"), [
|
||||
"www.rockymountainvending.com",
|
||||
"rockymountainvending.com",
|
||||
])
|
||||
})
|
||||
|
||||
test("manualVisibleForTenant matches sourceDomain and siteVisibility aliases", () => {
|
||||
assert.equal(
|
||||
manualVisibleForTenant(
|
||||
{
|
||||
sourceDomain: "rmv.abundancepartners.app",
|
||||
},
|
||||
"https://rmv.abundancepartners.app/manuals"
|
||||
),
|
||||
true
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
manualVisibleForTenant(
|
||||
{
|
||||
siteVisibility: ["www.rockymountainvending.com"],
|
||||
},
|
||||
"rockymountainvending.com"
|
||||
),
|
||||
true
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
manualVisibleForTenant(
|
||||
{
|
||||
sourceDomain: "quickfreshvending.com",
|
||||
siteVisibility: ["quickfreshvending.com"],
|
||||
},
|
||||
"rmv.abundancepartners.app"
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test("resolveManualsTenantDomain prioritizes request host then env overrides", () => {
|
||||
assert.equal(
|
||||
resolveManualsTenantDomain({
|
||||
requestHost: "rmv.abundancepartners.app",
|
||||
envTenantDomain: "fallback.example",
|
||||
envSiteDomain: "another.example",
|
||||
}),
|
||||
"rmv.abundancepartners.app"
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
resolveManualsTenantDomain({
|
||||
requestHost: "",
|
||||
envTenantDomain: "tenant.example",
|
||||
envSiteDomain: "site.example",
|
||||
}),
|
||||
"tenant.example"
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
resolveManualsTenantDomain({
|
||||
requestHost: "",
|
||||
envTenantDomain: "",
|
||||
envSiteDomain: "site.example",
|
||||
}),
|
||||
"site.example"
|
||||
)
|
||||
})
|
||||
79
lib/manuals-tenant.ts
Normal file
79
lib/manuals-tenant.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
export type TenantScopedManual = {
|
||||
sourceDomain?: string
|
||||
siteVisibility?: string[]
|
||||
}
|
||||
|
||||
function stripProtocolAndPath(input: string) {
|
||||
const firstHost = input.split(",")[0] || ""
|
||||
|
||||
return firstHost
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/\.$/, "")
|
||||
}
|
||||
|
||||
export function canonicalizeTenantDomain(
|
||||
input: string | null | undefined
|
||||
): string {
|
||||
if (!input) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return stripProtocolAndPath(input)
|
||||
}
|
||||
|
||||
export function tenantDomainVariants(domain: string): string[] {
|
||||
const canonical = canonicalizeTenantDomain(domain)
|
||||
if (!canonical) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (canonical.startsWith("www.")) {
|
||||
return [canonical, canonical.replace(/^www\./, "")]
|
||||
}
|
||||
|
||||
return [canonical, `www.${canonical}`]
|
||||
}
|
||||
|
||||
export function manualVisibleForTenant(
|
||||
manual: TenantScopedManual,
|
||||
domain: string
|
||||
): boolean {
|
||||
const variants = new Set(tenantDomainVariants(domain))
|
||||
if (variants.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sourceDomain = canonicalizeTenantDomain(manual.sourceDomain)
|
||||
if (sourceDomain && variants.has(sourceDomain)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const visibility = Array.isArray(manual.siteVisibility)
|
||||
? manual.siteVisibility
|
||||
.map((entry) => canonicalizeTenantDomain(entry))
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
if (visibility.some((entry) => variants.has(entry))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function resolveManualsTenantDomain(params: {
|
||||
requestHost?: string | null
|
||||
envSiteDomain?: string | null
|
||||
envTenantDomain?: string | null
|
||||
}) {
|
||||
return (
|
||||
canonicalizeTenantDomain(params.requestHost) ||
|
||||
canonicalizeTenantDomain(params.envTenantDomain) ||
|
||||
canonicalizeTenantDomain(params.envSiteDomain)
|
||||
)
|
||||
}
|
||||
|
|
@ -8,14 +8,18 @@
|
|||
"build": "next build",
|
||||
"copy:check": "node scripts/check-public-copy.mjs",
|
||||
"diagnose:ebay": "node scripts/staging-smoke.mjs",
|
||||
"deploy:staging:convex-gate": "node scripts/check-convex-manuals-gate.mjs",
|
||||
"deploy:staging:env": "node scripts/deploy-readiness.mjs",
|
||||
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build",
|
||||
"deploy:staging:preflight": "node scripts/deploy-readiness.mjs --build && pnpm deploy:staging:convex-gate",
|
||||
"deploy:staging:smoke": "node scripts/staging-smoke.mjs",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
||||
"manuals:qdrant:build": "tsx scripts/build-manuals-qdrant-corpus.ts",
|
||||
"manuals:qdrant:eval": "tsx scripts/evaluate-manuals-qdrant-corpus.ts",
|
||||
"manuals:sync:convex": "tsx scripts/sync-manuals-to-convex.ts",
|
||||
"manuals:sync:convex:dry": "tsx scripts/sync-manuals-to-convex.ts --dry-run",
|
||||
"manuals:backfill:tenant": "tsx scripts/backfill-convex-manuals-tenant.ts",
|
||||
"ghl:export:call-transcripts": "tsx scripts/export-ghl-call-transcripts.ts",
|
||||
"ghl:export:outbound-call-transcripts": "tsx scripts/export-ghl-outbound-call-transcripts.ts",
|
||||
"convex:codegen": "node -e \"console.log('Convex generated stubs are committed. Run `pnpm exec convex dev` or `pnpm exec convex codegen` after configuring a deployment to replace them with typed output.')\"",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint .",
|
||||
|
|
|
|||
60
scripts/backfill-convex-manuals-tenant.ts
Normal file
60
scripts/backfill-convex-manuals-tenant.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { config as loadEnv } from "dotenv"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
import { resolveManualsTenantDomain } from "../lib/manuals-tenant"
|
||||
|
||||
loadEnv({ path: ".env.local" })
|
||||
loadEnv({ path: ".env.staging", override: false })
|
||||
|
||||
const BACKFILL_MUTATION = makeFunctionReference<"mutation">(
|
||||
"manuals:backfillTenantVisibility"
|
||||
)
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const domainFlagIndex = argv.indexOf("--domain")
|
||||
const domain =
|
||||
domainFlagIndex >= 0 ? (argv[domainFlagIndex + 1] || "").trim() : ""
|
||||
|
||||
return {
|
||||
domain,
|
||||
dryRun: argv.includes("--dry-run"),
|
||||
}
|
||||
}
|
||||
|
||||
function readConvexUrl() {
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_CONVEX_URL (or CONVEX_URL) is required for Convex backfill."
|
||||
)
|
||||
}
|
||||
|
||||
return convexUrl
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const domain = resolveManualsTenantDomain({
|
||||
envTenantDomain: args.domain || process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
||||
})
|
||||
|
||||
if (!domain) {
|
||||
throw new Error(
|
||||
"Could not resolve tenant domain. Pass --domain or set MANUALS_TENANT_DOMAIN / NEXT_PUBLIC_SITE_DOMAIN."
|
||||
)
|
||||
}
|
||||
|
||||
const convex = new ConvexHttpClient(readConvexUrl())
|
||||
const result = await convex.mutation(BACKFILL_MUTATION, {
|
||||
domain,
|
||||
dryRun: args.dryRun,
|
||||
})
|
||||
|
||||
console.log("[manuals-tenant-backfill] result", result)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[manuals-tenant-backfill] failed", error)
|
||||
process.exit(1)
|
||||
})
|
||||
82
scripts/check-convex-manuals-gate.mjs
Normal file
82
scripts/check-convex-manuals-gate.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import process from "node:process"
|
||||
import dotenv from "dotenv"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
|
||||
const LIST_MANUALS = makeFunctionReference("manuals:list")
|
||||
|
||||
function canonicalizeDomain(input) {
|
||||
return String(input || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/\.$/, "")
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const domainIndex = argv.indexOf("--domain")
|
||||
const domain = domainIndex >= 0 ? argv[domainIndex + 1] : ""
|
||||
const minCountIndex = argv.indexOf("--min-count")
|
||||
const minCount =
|
||||
minCountIndex >= 0
|
||||
? Number.parseInt(argv[minCountIndex + 1] || "", 10)
|
||||
: 1
|
||||
|
||||
return {
|
||||
domain,
|
||||
minCount: Number.isFinite(minCount) && minCount > 0 ? minCount : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function readConvexUrl() {
|
||||
const value =
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL || process.env.CONVEX_URL || ""
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
dotenv.config({ path: ".env.local", override: false })
|
||||
dotenv.config({ path: ".env.staging", override: false })
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const domain = canonicalizeDomain(
|
||||
args.domain ||
|
||||
process.env.MANUALS_TENANT_DOMAIN ||
|
||||
process.env.NEXT_PUBLIC_SITE_DOMAIN
|
||||
)
|
||||
const convexUrl = readConvexUrl()
|
||||
|
||||
if (!convexUrl) {
|
||||
throw new Error(
|
||||
"NEXT_PUBLIC_CONVEX_URL (or CONVEX_URL) is required for Convex manuals gate."
|
||||
)
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
throw new Error(
|
||||
"A tenant domain is required. Set NEXT_PUBLIC_SITE_DOMAIN / MANUALS_TENANT_DOMAIN or pass --domain."
|
||||
)
|
||||
}
|
||||
|
||||
const convex = new ConvexHttpClient(convexUrl)
|
||||
const manuals = await convex.query(LIST_MANUALS, { domain })
|
||||
const count = Array.isArray(manuals) ? manuals.length : 0
|
||||
|
||||
console.log(
|
||||
`[convex-manuals-gate] domain=${domain} count=${count} min=${args.minCount}`
|
||||
)
|
||||
|
||||
if (count < args.minCount) {
|
||||
throw new Error(
|
||||
`Convex manuals gate failed for ${domain}: expected at least ${args.minCount} manuals, got ${count}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[convex-manuals-gate] failed", error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -112,6 +112,35 @@ function readValue(name) {
|
|||
return String(process.env[name] ?? "").trim()
|
||||
}
|
||||
|
||||
function canonicalizeDomain(input) {
|
||||
return String(input || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/\.$/, "")
|
||||
}
|
||||
|
||||
function isValidHttpUrl(value) {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === "https:" || url.protocol === "http:"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isValidHostname(value) {
|
||||
const host = canonicalizeDomain(value)
|
||||
return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host)
|
||||
}
|
||||
|
||||
function hasVoiceRecordingConfig() {
|
||||
return [
|
||||
readValue("VOICE_RECORDING_ACCESS_KEY_ID") ||
|
||||
|
|
@ -234,6 +263,21 @@ function main() {
|
|||
console.log(`${group.label}: fallback in use`)
|
||||
}
|
||||
|
||||
const convexUrl = readValue("NEXT_PUBLIC_CONVEX_URL")
|
||||
if (!isValidHttpUrl(convexUrl)) {
|
||||
failures.push(
|
||||
"NEXT_PUBLIC_CONVEX_URL is malformed. It must be a full http(s) URL."
|
||||
)
|
||||
}
|
||||
|
||||
const siteDomain =
|
||||
readValue("MANUALS_TENANT_DOMAIN") || readValue("NEXT_PUBLIC_SITE_DOMAIN")
|
||||
if (!isValidHostname(siteDomain)) {
|
||||
failures.push(
|
||||
"NEXT_PUBLIC_SITE_DOMAIN (or MANUALS_TENANT_DOMAIN) is malformed. It must be a valid hostname."
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasManualStorageCredentials()) {
|
||||
failures.push(
|
||||
"Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release."
|
||||
|
|
|
|||
|
|
@ -78,6 +78,17 @@ function normalizeBaseUrl(value) {
|
|||
return value.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function canonicalizeDomain(input) {
|
||||
return String(input || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/^\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/\.$/, "")
|
||||
}
|
||||
|
||||
function envPresence(name) {
|
||||
return Boolean(String(process.env[name] ?? "").trim())
|
||||
}
|
||||
|
|
@ -198,6 +209,41 @@ async function checkPages(baseUrl, failures) {
|
|||
}
|
||||
}
|
||||
|
||||
async function checkManualsPayload(baseUrl, failures) {
|
||||
heading("Manuals Payload")
|
||||
const { response, text } = await requestJson(`${baseUrl}/manuals`)
|
||||
const ok = response.status === 200
|
||||
|
||||
report("GET /manuals payload", ok ? "ok" : "fail", `status=${response.status}`)
|
||||
if (!ok) {
|
||||
failures.push(`GET /manuals returned ${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (text.includes("Manuals Library Temporarily Unavailable")) {
|
||||
failures.push("Manuals page is in degraded mode.")
|
||||
}
|
||||
|
||||
if (text.includes('"initialManuals":[]')) {
|
||||
failures.push("Manuals page rendered with zero initial manuals.")
|
||||
}
|
||||
|
||||
const expectedHost = canonicalizeDomain(new URL(baseUrl).host)
|
||||
const domainMatch = text.match(/data-manuals-domain=\"([^\"]+)\"/)
|
||||
const runtimeDomain = canonicalizeDomain(domainMatch?.[1] || "")
|
||||
|
||||
console.log(` expectedDomain: ${expectedHost}`)
|
||||
console.log(` runtimeDomain: ${runtimeDomain || "missing"}`)
|
||||
|
||||
if (!runtimeDomain) {
|
||||
failures.push("Manuals page is missing runtime tenant domain marker.")
|
||||
} else if (runtimeDomain !== expectedHost) {
|
||||
failures.push(
|
||||
`Manuals runtime domain mismatch. Expected ${expectedHost}, got ${runtimeDomain}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkEbaySearch(baseUrl, failures) {
|
||||
heading("eBay Cache Search")
|
||||
const url = new URL(`${baseUrl}/api/ebay/search`)
|
||||
|
|
@ -499,6 +545,7 @@ async function main() {
|
|||
report("Base URL", baseUrl)
|
||||
|
||||
await checkPages(baseUrl, failures)
|
||||
await checkManualsPayload(baseUrl, failures)
|
||||
await checkEbaySearch(baseUrl, failures)
|
||||
await checkManualParts(
|
||||
baseUrl,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ import {
|
|||
import { businessConfig } from "../lib/seo-config"
|
||||
import { getSiteDomain } from "../lib/site-config"
|
||||
import { selectManualsForSite } from "../lib/manuals-site-selection"
|
||||
import {
|
||||
canonicalizeTenantDomain,
|
||||
resolveManualsTenantDomain,
|
||||
tenantDomainVariants,
|
||||
} from "../lib/manuals-tenant"
|
||||
import {
|
||||
getManualsFilesRoot,
|
||||
getManualsThumbnailsRoot,
|
||||
|
|
@ -73,8 +78,15 @@ function readConvexUrl() {
|
|||
}
|
||||
|
||||
function canonicalSiteVisibility(siteDomain: string) {
|
||||
const canonicalHost = new URL(businessConfig.website).hostname
|
||||
return Array.from(new Set([siteDomain, canonicalHost]))
|
||||
const canonicalHost = canonicalizeTenantDomain(
|
||||
new URL(businessConfig.website).hostname
|
||||
)
|
||||
return Array.from(
|
||||
new Set([
|
||||
...tenantDomainVariants(siteDomain),
|
||||
...tenantDomainVariants(canonicalHost),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
async function uploadSelectedAssets(
|
||||
|
|
@ -169,7 +181,17 @@ function normalizeManualForConvex(
|
|||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const siteDomain = getSiteDomain()
|
||||
const siteDomain = resolveManualsTenantDomain({
|
||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: getSiteDomain(),
|
||||
})
|
||||
|
||||
if (!siteDomain) {
|
||||
throw new Error(
|
||||
"Could not resolve manuals tenant domain. Set MANUALS_TENANT_DOMAIN or NEXT_PUBLIC_SITE_DOMAIN."
|
||||
)
|
||||
}
|
||||
|
||||
const allManuals = await scanManuals()
|
||||
const selection = selectManualsForSite(allManuals, siteDomain)
|
||||
const selectedManuals = args.limit
|
||||
|
|
|
|||
Loading…
Reference in a new issue