fix: enforce tenant-scoped manuals and deployment gates

This commit is contained in:
DMleadgen 2026-04-16 15:09:26 -06:00
parent c5e40c5caf
commit f077966bb2
Signed by: matt
GPG key ID: C2720CF8CD701894
12 changed files with 636 additions and 18 deletions

View file

@ -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">
<ManualsPageExperience initialManuals={manuals} />
<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>
</>
)

View file

@ -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,
}
},
})

View 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

View file

@ -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)
}

View 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
View 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)
)
}

View file

@ -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 .",

View 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)
})

View 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)
})

View file

@ -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."

View file

@ -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,

View file

@ -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