chore: sync in-progress work across marketing, admin, API and tests

Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-09-05 16:27:16 -04:00
co-authored by Claude Opus 5
parent 8f90347659
commit 1d02598786
71 changed files with 3641 additions and 819 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
import type { AccountingProvider, OAuthTokens } from "./types"
import { redirectUri } from "./types"
// QuickBooks Online. Docs: https://developer.intuit.com/app/developer/qbo/docs/develop
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
import type { AccountingProvider, OAuthTokens } from "./types"
import { redirectUri } from "./types"
// Xero. Docs: https://developer.xero.com/documentation/guides/oauth2/
+5
View File
@@ -11,8 +11,13 @@ export type AdminAction =
| "stop_impersonate"
| "delete_user"
| "resend_verification"
| "mark_email_verified"
| "maintenance_mode"
| "ai_provider"
| "plan_change_stripe"
| "cancel_subscription"
| "resume_subscription"
| "refund"
/**
* Append one immutable row to admin_audit_log. Call this for EVERY mutating
+314
View File
@@ -0,0 +1,314 @@
import { eq } from "drizzle-orm"
import type Stripe from "stripe"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { stripe } from "@/lib/stripe/client"
import { resolvePriceId } from "@/lib/stripe/prices"
import type { Plan } from "@/types"
// ============================================================================
// Admin-side billing operations.
//
// Everything here TOUCHES REAL MONEY. Two rules the callers depend on:
//
// 1. Every function returns a discriminated result instead of throwing on an
// expected condition (no subscription, unsupported transition). The admin
// UI shows the message; only genuine Stripe/network faults throw.
// 2. When a subscription's price changes we ALSO rewrite its metadata.plan.
// The Stripe webhook (app/api/stripe/webhook/route.ts) derives the app plan
// from `subscription.metadata.plan` — leaving it stale would make the
// webhook immediately revert the change we just made.
// ============================================================================
export type BillingResult<T = undefined> =
| ({ ok: true } & (T extends undefined ? { detail?: string } : { data: T; detail?: string }))
| { ok: false; error: string }
const fail = (error: string): { ok: false; error: string } => ({ ok: false, error })
/** Plans that map to a recurring Stripe subscription. */
const RECURRING_PLANS: Plan[] = ["pro", "landlord"]
async function getProfile(userId: string) {
return db.query.profiles.findFirst({
where: eq(profiles.id, userId),
columns: {
id: true,
email: true,
plan: true,
stripe_customer_id: true,
stripe_subscription_id: true,
subscription_status: true,
},
})
}
/**
* Move a user to `plan` IN STRIPE, then mirror it locally.
*
* Supported transitions:
* paid → paid swap the subscription item's price (prorated)
* paid → starter cancel the subscription at period end
*
* Not supported (returns ok:false, never a silent no-op):
* → lifetime a one-time payment, not a subscription change
* no subscription nothing to modify — use a comp instead
*/
export async function changePlanInStripe(
userId: string,
plan: Plan,
interval: "month" | "year" = "month"
): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
if (plan === "lifetime") {
return fail(
"Lifetime is a one-time purchase, not a subscription. Grant it as a comp, " +
"or have the user complete lifetime checkout."
)
}
const subId = profile.stripe_subscription_id
if (!subId) {
return fail(
"This user has no active Stripe subscription to modify. Grant the plan as a comp instead."
)
}
let subscription: Stripe.Subscription
try {
subscription = await stripe.subscriptions.retrieve(subId)
} catch {
return fail("Could not load the subscription from Stripe — it may have been deleted.")
}
if (subscription.status === "canceled") {
return fail("That subscription is already canceled. Grant the plan as a comp instead.")
}
// ── downgrade to free: cancel at period end so they keep what they paid for ──
if (plan === "starter") {
await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
await db
.update(profiles)
.set({ subscription_status: "canceling" })
.where(eq(profiles.id, userId))
return {
ok: true,
detail:
"Subscription set to cancel at the end of the current period. The plan stays active until then.",
}
}
// ── paid → paid: swap the price on the existing item ────────────────────────
if (!RECURRING_PLANS.includes(plan)) return fail(`Unsupported plan: ${plan}`)
const priceId = await resolvePriceId(plan, interval)
if (!priceId) return fail("Could not resolve the Stripe price for that plan.")
const item = subscription.items.data[0]
if (!item) return fail("That subscription has no line items to modify.")
await stripe.subscriptions.update(subId, {
items: [{ id: item.id, price: priceId }],
// Bill the difference now rather than silently absorbing it.
proration_behavior: "create_prorations",
cancel_at_period_end: false,
// MUST stay in sync — the webhook reads plan from here.
metadata: { ...subscription.metadata, user_id: userId, plan },
})
await db
.update(profiles)
.set({ plan, subscription_status: "active" })
.where(eq(profiles.id, userId))
return { ok: true, detail: `Stripe subscription moved to ${plan} (${interval}ly), prorated.` }
}
/**
* Cancel a subscription. `immediate` ends access now and is the option that can
* surprise a paying customer, so the UI confirms it separately from the
* cancel-at-period-end default.
*/
export async function cancelSubscription(
userId: string,
immediate = false
): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
const subId = profile.stripe_subscription_id
if (!subId) return fail("This user has no active Stripe subscription.")
try {
if (immediate) {
await stripe.subscriptions.cancel(subId)
await db
.update(profiles)
.set({
plan: "starter",
subscription_status: "canceled",
stripe_subscription_id: null,
plan_expires_at: null,
})
.where(eq(profiles.id, userId))
return { ok: true, detail: "Subscription canceled immediately and plan reset to Starter." }
}
await stripe.subscriptions.update(subId, { cancel_at_period_end: true })
await db
.update(profiles)
.set({ subscription_status: "canceling" })
.where(eq(profiles.id, userId))
return { ok: true, detail: "Subscription will cancel at the end of the current period." }
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
/** Undo a pending cancel-at-period-end. */
export async function resumeSubscription(userId: string): Promise<BillingResult> {
const profile = await getProfile(userId)
if (!profile) return fail("User not found.")
const subId = profile.stripe_subscription_id
if (!subId) return fail("This user has no subscription to resume.")
try {
await stripe.subscriptions.update(subId, { cancel_at_period_end: false })
await db
.update(profiles)
.set({ subscription_status: "active" })
.where(eq(profiles.id, userId))
return { ok: true, detail: "Scheduled cancellation removed — the subscription will renew." }
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
export type ChargeRow = {
id: string
amount: number
amountRefunded: number
currency: string
created: number
status: string
refunded: boolean
description: string | null
receiptUrl: string | null
}
/** Recent charges for a user, newest first — the list the refund UI works from. */
export async function listUserCharges(userId: string, limit = 10): Promise<ChargeRow[]> {
const profile = await getProfile(userId)
if (!profile?.stripe_customer_id) return []
try {
const charges = await stripe.charges.list({
customer: profile.stripe_customer_id,
limit,
})
return charges.data.map((c) => ({
id: c.id,
amount: c.amount,
amountRefunded: c.amount_refunded,
currency: c.currency,
created: c.created,
status: c.status,
refunded: c.refunded,
description: c.description,
receiptUrl: c.receipt_url,
}))
} catch {
// Stripe unreachable or key unset — the page still renders without billing.
return []
}
}
/**
* Refund a charge, fully or partially. `amountCents` omitted = full refund of
* whatever remains unrefunded.
*
* The charge is re-read and verified to belong to THIS user's Stripe customer
* before refunding: the charge id arrives from the client, and without that
* check an admin action could be replayed with any charge id in the account.
*/
export async function refundCharge(
userId: string,
chargeId: string,
amountCents?: number,
reason?: "duplicate" | "fraudulent" | "requested_by_customer"
): Promise<BillingResult<{ refundId: string; amount: number }>> {
const profile = await getProfile(userId)
if (!profile?.stripe_customer_id) return fail("This user has no Stripe customer record.")
let charge: Stripe.Charge
try {
charge = await stripe.charges.retrieve(chargeId)
} catch {
return fail("Could not load that charge from Stripe.")
}
const chargeCustomer = typeof charge.customer === "string" ? charge.customer : charge.customer?.id
if (chargeCustomer !== profile.stripe_customer_id) {
return fail("That charge does not belong to this user.")
}
if (charge.refunded) return fail("That charge is already fully refunded.")
const remaining = charge.amount - charge.amount_refunded
if (remaining <= 0) return fail("Nothing left to refund on that charge.")
if (amountCents !== undefined) {
if (!Number.isInteger(amountCents) || amountCents <= 0) {
return fail("Refund amount must be a positive number of cents.")
}
if (amountCents > remaining) {
return fail(`Refund exceeds the ${(remaining / 100).toFixed(2)} still available on that charge.`)
}
}
try {
const refund = await stripe.refunds.create({
charge: chargeId,
...(amountCents !== undefined ? { amount: amountCents } : {}),
...(reason ? { reason } : {}),
})
return {
ok: true,
data: { refundId: refund.id, amount: refund.amount },
detail: `Refunded ${(refund.amount / 100).toFixed(2)} ${charge.currency.toUpperCase()}.`,
}
} catch (e) {
return fail((e as Error).message.slice(0, 300))
}
}
export type SubscriptionSummary = {
id: string
status: string
cancelAtPeriodEnd: boolean
currentPeriodEnd: number | null
priceNickname: string | null
amount: number | null
interval: string | null
} | null
/** Live subscription state straight from Stripe, for the admin user page. */
export async function getSubscriptionSummary(userId: string): Promise<SubscriptionSummary> {
const profile = await getProfile(userId)
if (!profile?.stripe_subscription_id) return null
try {
const s = await stripe.subscriptions.retrieve(profile.stripe_subscription_id)
const item = s.items.data[0]
return {
id: s.id,
status: s.status,
cancelAtPeriodEnd: s.cancel_at_period_end,
currentPeriodEnd: (item as unknown as { current_period_end?: number })?.current_period_end ?? null,
priceNickname: item?.price?.nickname ?? null,
amount: item?.price?.unit_amount ?? null,
interval: item?.price?.recurring?.interval ?? null,
}
} catch {
return null
}
}
+19 -1
View File
@@ -3,9 +3,27 @@ import crypto from "crypto"
// AES-256-GCM encryption for secrets at rest (OAuth tokens). The key is derived
// from ACCOUNTING_ENCRYPTION_KEY, falling back to BETTER_AUTH_SECRET, via SHA-256
// so no additional configuration is required.
let warnedAboutFallbackKey = false
function getKey(): Buffer {
const secret = process.env.ACCOUNTING_ENCRYPTION_KEY || process.env.BETTER_AUTH_SECRET
const dedicated = process.env.ACCOUNTING_ENCRYPTION_KEY
const secret = dedicated || process.env.BETTER_AUTH_SECRET
if (!secret) throw new Error("No encryption key configured (BETTER_AUTH_SECRET)")
// Riding on BETTER_AUTH_SECRET works, but couples two independent rotation
// schedules: rotating the auth secret would silently make every stored OAuth
// token undecryptable, with no migration path and no error until a user's
// next accounting sync fails. Warn once so this is caught before that
// happens rather than after.
if (!dedicated && !warnedAboutFallbackKey) {
warnedAboutFallbackKey = true
console.warn(
"[crypto] ACCOUNTING_ENCRYPTION_KEY is not set — deriving the at-rest key " +
"from BETTER_AUTH_SECRET. Rotating BETTER_AUTH_SECRET will make all " +
"stored OAuth tokens undecryptable. Set a dedicated key in production."
)
}
return crypto.createHash("sha256").update(secret).digest()
}
+101
View File
@@ -401,6 +401,107 @@ export async function getUserDetail(id: string) {
}
}
// ── per-user portfolio (admin support view) ───────────────────────────────────
// Read-only window into ONE user's actual records. Before this existed an admin
// could see only aggregate counts, so answering "what does this customer
// actually have?" meant impersonating them — which mutates their session and
// shows up in their own audit trail. This is deliberately read-only: it answers
// support questions without touching anything.
//
// Every query is scoped by user_id. Admin queries bypass the app's normal
// ownership scoping, so the caller MUST have passed requireAdmin()/getAdminSession().
export async function getUserPortfolio(userId: string) {
const [propertyRows, unitRows, tenantRows, leaseRows, paymentRows, maintenanceRows] =
await Promise.all([
db
.select({
id: properties.id,
name: properties.name,
address_line1: properties.address_line1,
city: properties.city,
state: properties.state,
total_units: properties.total_units,
created_at: properties.created_at,
})
.from(properties)
.where(eq(properties.user_id, userId))
.orderBy(desc(properties.created_at))
.limit(100),
db
.select({
id: units.id,
property_id: units.property_id,
unit_number: units.unit_number,
rent_amount: units.rent_amount,
status: units.status,
})
.from(units)
.where(eq(units.user_id, userId))
.orderBy(desc(units.created_at))
.limit(200),
db
.select({
id: tenants.id,
first_name: tenants.first_name,
last_name: tenants.last_name,
email: tenants.email,
phone: tenants.phone,
status: tenants.status,
move_in_date: tenants.move_in_date,
})
.from(tenants)
.where(eq(tenants.user_id, userId))
.orderBy(desc(tenants.created_at))
.limit(100),
db
.select({
id: leases.id,
tenant_id: leases.tenant_id,
lease_start: leases.lease_start,
lease_end: leases.lease_end,
rent_amount: leases.rent_amount,
status: leases.status,
})
.from(leases)
.where(eq(leases.user_id, userId))
.orderBy(desc(leases.created_at))
.limit(100),
db
.select({
id: rent_payments.id,
amount: rent_payments.amount,
due_date: rent_payments.due_date,
paid_date: rent_payments.paid_date,
status: rent_payments.status,
})
.from(rent_payments)
.where(eq(rent_payments.user_id, userId))
.orderBy(desc(rent_payments.due_date))
.limit(50),
db
.select({
id: maintenance_requests.id,
title: maintenance_requests.title,
priority: maintenance_requests.priority,
status: maintenance_requests.status,
created_at: maintenance_requests.created_at,
})
.from(maintenance_requests)
.where(eq(maintenance_requests.user_id, userId))
.orderBy(desc(maintenance_requests.created_at))
.limit(50),
])
return {
properties: propertyRows,
units: unitRows,
tenants: tenantRows,
leases: leaseRows,
payments: paymentRows,
maintenance: maintenanceRows,
}
}
// ── CSV helpers (shared by admin export routes) ─────────────────────────────────
export function toCsv(headers: string[], rows: (string | number | null | undefined)[][]) {
const esc = (v: string | number | null | undefined) => {
+18 -18
View File
@@ -12,43 +12,43 @@ interface UserState {
export function useUser(): UserState {
const { data: session, isPending } = useSession()
const [profile, setProfile] = useState<Profile | null>(null)
const [profileLoading, setProfileLoading] = useState(true)
// The fetched profile is stored together with the user id it belongs to, so
// "loaded" is DERIVED rather than tracked in a second state variable. That
// removes the synchronous setState in the effect body (which caused cascading
// renders) and, as a bonus, stops a previous user's profile from flashing
// while a new one loads.
const [fetched, setFetched] = useState<{ userId: string; profile: Profile | null } | null>(null)
const userId = session?.user?.id
useEffect(() => {
if (!userId) return
let active = true
if (!userId) {
setProfile(null)
setProfileLoading(false)
return
}
setProfileLoading(true)
fetch("/api/profile")
.then((r) => (r.ok ? r.json() : { profile: null }))
.then((data) => {
if (active) {
setProfile(data.profile ?? null)
setProfileLoading(false)
}
if (active) setFetched({ userId, profile: data.profile ?? null })
})
.catch(() => {
if (active) {
setProfile(null)
setProfileLoading(false)
}
if (active) setFetched({ userId, profile: null })
})
return () => {
active = false
}
}, [userId])
const isCurrent = !!userId && fetched?.userId === userId
return {
user: session?.user
? { id: session.user.id, email: session.user.email, name: session.user.name }
: null,
profile,
loading: isPending || profileLoading,
profile: isCurrent ? (fetched?.profile ?? null) : null,
// Signed out: nothing to load. Signed in: loading until this user's profile
// has actually come back.
loading: isPending || (!!userId && !isCurrent),
}
}
+40
View File
@@ -0,0 +1,40 @@
// Single source of truth for the marketing FAQ.
//
// This list is rendered as visible copy by components/marketing/faq.tsx AND
// serialised into the FAQPage JSON-LD in components/marketing/structured-data.tsx.
// Google requires FAQ structured data to match content that is visible on the
// same page, so both consumers MUST read from here — never from a second copy.
export const FAQS = [
{
q: "Is there really a free plan?",
a: "Yes — the Starter plan is free forever. You get 1 property, up to 3 tenants, rent tracking, maintenance requests, and lease management. No credit card needed to sign up.",
},
{
q: "What happens when my trial ends?",
a: "There's no trial — paid plans start immediately when you upgrade. If you're on the free Starter plan, it never expires. You upgrade when you outgrow it.",
},
{
q: "Can I cancel anytime?",
a: "Yes. Cancel any time from your billing portal — no questions, no fees. Your data stays accessible for 30 days after cancellation so you can export everything.",
},
{
q: "Do tenants need to create an account?",
a: "No. Each tenant gets a unique private link to their portal — no account creation, no password. They can view rent history and submit maintenance requests instantly.",
},
{
q: "Does Property Management Network handle actual rent collection?",
a: "Yes — the Pro and Landlord plans include Stripe payment link generation. You can create a payment link for each tenant and they pay directly via card or bank transfer.",
},
{
q: "Is my data secure?",
a: "Yes. Every request is authenticated and scoped to your account, so landlords only ever see their own data and tenants only see their own records. All data is encrypted at rest and in transit.",
},
{
q: "Can I manage multiple properties?",
a: "The Starter plan supports 1 property. Pro supports up to 10. Landlord and Lifetime plans support unlimited properties and units.",
},
{
q: "What's included in the Lifetime deal?",
a: "The Lifetime plan is a one-time payment of $199. You get everything in the Landlord plan — unlimited properties, tenants, 25GB storage, AI calls, team access — with no recurring fees, ever. All future updates included.",
},
] as const
+43 -3
View File
@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"
import { eq, sql } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { PLAN_LIMITS } from "@/lib/stripe/plans"
import { profiles, properties, tenants } from "@/lib/db/schema"
import { PLAN_LIMITS, checkLimit } from "@/lib/stripe/plans"
import { getUserStorageBytes } from "@/lib/storage"
import type { Plan } from "@/types"
@@ -39,3 +39,43 @@ export async function checkStorageLimit(
}
return null
}
// ── countable resource caps ──────────────────────────────────────────────────
// These live here, not inline in route handlers, because there is more than one
// way into the app: the session routes AND the public v1 API both create
// properties. When the check was written inline, v1 simply did not have it and a
// Starter user could mint an API key and create unlimited properties. Every
// creation path MUST call the helper for its resource — mirroring the same rule
// the upload allowlist follows in lib/storage.ts.
/**
* Returns a user-facing error message if the owner is at their plan's property
* cap, otherwise null. Call before every property insert, on every entry point.
*/
export async function checkPropertyLimit(ownerId: string): Promise<string | null> {
const plan = await getUserPlan(ownerId)
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(properties)
.where(eq(properties.user_id, ownerId))
if (!checkLimit(plan, "maxProperties", count)) {
return "Plan limit reached. Upgrade to add more properties."
}
return null
}
/**
* Returns a user-facing error message if the owner is at their plan's tenant
* cap, otherwise null. Call before every tenant insert, on every entry point.
*/
export async function checkTenantLimit(ownerId: string): Promise<string | null> {
const plan = await getUserPlan(ownerId)
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(tenants)
.where(eq(tenants.user_id, ownerId))
if (!checkLimit(plan, "maxTenants", count)) {
return "Plan limit reached. Upgrade to add more tenants."
}
return null
}
+107
View File
@@ -0,0 +1,107 @@
import { NextResponse } from "next/server"
// ============================================================================
// Fixed-window rate limiting for endpoints Better Auth does not cover.
//
// Better Auth throttles its own /api/auth/* endpoints (see lib/auth.ts). Nothing
// else was limited: the public v1 API, uploads, and the unauthenticated tenant
// portal maintenance submission were all unbounded. This closes that gap.
//
// SCOPE / LIMITATION: counters live in the process, so the limit is enforced
// PER INSTANCE. On a single container (the current Dokploy deployment) that is
// the real limit; if the app is ever scaled horizontally, an attacker's
// effective ceiling multiplies by the replica count. That is still a bounded,
// large improvement over "no limit at all", and the swap to a shared store
// (Postgres or Redis) only has to replace `hit()` below. Do NOT let the absence
// of a shared store be a reason to ship no limit.
// ============================================================================
type Counter = { count: number; resetAt: number }
const buckets = new Map<string, Counter>()
// Drop expired counters so a long-lived process doesn't accumulate a key per
// distinct IP forever. Runs opportunistically on write, not on a timer.
let lastSweep = 0
function sweep(now: number): void {
if (now - lastSweep < 60_000) return
lastSweep = now
for (const [key, c] of buckets) {
if (c.resetAt <= now) buckets.delete(key)
}
}
export type RateLimitResult = {
ok: boolean
/** Requests remaining in the current window. */
remaining: number
/** Seconds until the window resets — sent as Retry-After on a 429. */
retryAfter: number
limit: number
}
/**
* Record a hit against `key` and report whether it is within `limit` per
* `windowSeconds`. Callers pass a namespaced key (e.g. `v1:<userId>`) so
* different endpoints never share a bucket.
*/
export function hit(key: string, limit: number, windowSeconds: number): RateLimitResult {
const now = Date.now()
sweep(now)
const existing = buckets.get(key)
if (!existing || existing.resetAt <= now) {
const resetAt = now + windowSeconds * 1000
buckets.set(key, { count: 1, resetAt })
return { ok: true, remaining: limit - 1, retryAfter: windowSeconds, limit }
}
existing.count++
const retryAfter = Math.max(1, Math.ceil((existing.resetAt - now) / 1000))
return {
ok: existing.count <= limit,
remaining: Math.max(0, limit - existing.count),
retryAfter,
limit,
}
}
/**
* The caller's IP, taken from the proxy headers Traefik/Dokploy set. Falls back
* to a constant so a missing header degrades to one shared bucket (fail closed
* on volume) rather than to no limit at all.
*/
export function clientIp(request: Request): string {
const fwd = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
return fwd || request.headers.get("x-real-ip") || "unknown"
}
/** Standard 429 with rate-limit headers, or null when the request is allowed. */
export function rateLimitResponse(result: RateLimitResult): NextResponse | null {
if (result.ok) return null
return NextResponse.json(
{ error: "Too many requests. Please slow down and try again shortly." },
{
status: 429,
headers: {
"Retry-After": String(result.retryAfter),
"X-RateLimit-Limit": String(result.limit),
"X-RateLimit-Remaining": "0",
},
}
)
}
/**
* Convenience wrapper: hit the bucket and return a ready 429 if over the limit.
*
* const limited = enforceRateLimit(`upload:${ownerId}`, 30, 60)
* if (limited) return limited
*/
export function enforceRateLimit(
key: string,
limit: number,
windowSeconds: number
): NextResponse | null {
return rateLimitResponse(hit(key, limit, windowSeconds))
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next"
export const SITE_NAME = "Property Management Network"
// The generated Open Graph card (app/opengraph-image.tsx), served at this path.
// Declared explicitly because a page that sets its own `openGraph` object
// replaces the inherited one — including the image Next.js attaches from the
// file convention — which silently leaves that page with no share image.
const OG_IMAGE = "/opengraph-image"
type PageSeo = {
/**
* Page title. Rendered through the root layout's "%s | Property Management
* Network" template unless `absoluteTitle` is set.
*/
title: string
/** Render `title` verbatim, without the site-name suffix. */
absoluteTitle?: boolean
description: string
/** Canonical path, root-relative and without a trailing slash, e.g. "/terms". */
path: string
/**
* Headline used for the Open Graph / Twitter card when the share copy should
* differ from the page title. Defaults to the resolved page title.
*/
socialTitle?: string
/** Set false for pages that must stay out of the index. */
index?: boolean
}
/**
* Builds a complete, self-consistent metadata block for a public page.
*
* Every public page must go through this helper so that canonical, og:url,
* og:title, og:image and the Twitter card always agree with each other and with
* the page's real URL. Setting these ad hoc per page is what previously left the
* landing page with no share image and every legal page pointing og:url at "/".
*/
export function pageMetadata({
title,
absoluteTitle = false,
description,
path,
socialTitle,
index = true,
}: PageSeo): Metadata {
const fullTitle = absoluteTitle ? title : `${title} | ${SITE_NAME}`
const social = socialTitle ?? fullTitle
return {
title: absoluteTitle ? { absolute: title } : title,
description,
alternates: { canonical: path },
openGraph: {
title: social,
description,
url: path,
siteName: SITE_NAME,
locale: "en_US",
type: "website",
images: [OG_IMAGE],
},
twitter: {
card: "summary_large_image",
title: social,
description,
images: [OG_IMAGE],
},
...(index ? {} : { robots: { index: false, follow: true } }),
}
}
+13 -4
View File
@@ -35,12 +35,21 @@ const ADMIN_EMAILS = (process.env.ADMIN_EMAILS ?? "")
.filter(Boolean)
export function isAdminUser(
u: { id?: string; email?: string; role?: string | null } | null | undefined
u:
| { id?: string; email?: string; emailVerified?: boolean; role?: string | null }
| null
| undefined
): boolean {
if (!u) return false
if (u.role === "admin") return true
// ADMIN_USER_IDS is the unconditional bootstrap path: an id can only come from
// a row we created, so it is not attacker-selectable.
if (u.id && ADMIN_USER_IDS.includes(u.id)) return true
if (u.email && ADMIN_EMAILS.includes(u.email.toLowerCase())) return true
// ADMIN_EMAILS is matched on a user-supplied string, so it additionally
// requires a VERIFIED address. Otherwise, in any environment where
// REQUIRE_EMAIL_VERIFICATION is off, simply signing up with a listed address
// would grant admin without ever controlling the mailbox.
if (u.email && u.emailVerified && ADMIN_EMAILS.includes(u.email.toLowerCase())) return true
return false
}
@@ -52,7 +61,7 @@ export function isAdminUser(
export async function getAdminSession() {
const session = await auth.api.getSession({ headers: await headers() })
const user = session?.user ?? null
if (!isAdminUser(user as { role?: string | null })) return null
if (!isAdminUser(user)) return null
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user!.id) })
return { user: user!, profile: profile ?? null }
}
@@ -65,7 +74,7 @@ export async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() })
const user = session?.user ?? null
if (!user) redirect("/login")
if (!isAdminUser(user as { role?: string | null })) redirect("/dashboard")
if (!isAdminUser(user)) redirect("/dashboard")
const profile = await db.query.profiles.findFirst({ where: eq(profiles.id, user.id) })
return { user, profile: profile ?? null }
}
+14 -1
View File
@@ -13,7 +13,20 @@ export async function verifyTurnstile(
remoteIp?: string | null
): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY
if (!secret) return true // integration disabled — do not block auth
if (!secret) {
// Fail open ONLY outside production. In production a missing secret is a
// misconfiguration, not a deployment choice: silently dropping bot
// protection from login / signup / forgot-password is worse than a loud
// failure, so refuse the request and log once per occurrence.
if (process.env.NODE_ENV === "production") {
console.error(
"[turnstile] TURNSTILE_SECRET_KEY is not set in production — " +
"rejecting the request rather than silently disabling bot protection."
)
return false
}
return true // integration disabled in dev — do not block auth
}
if (!token) return false
try {
+65 -11
View File
@@ -39,18 +39,72 @@ function isPrivateIPv4(ip: string): boolean {
return false
}
/** True for IPv6 loopback, unspecified, ULA, link-local, multicast, or mapped-v4. */
/**
* Expand an IPv6 literal to its eight numeric hextets, or null if unparseable.
* Handles "::" compression and a trailing dotted-quad (::ffff:1.2.3.4).
*/
function ipv6Hextets(input: string): number[] | null {
let s = input.toLowerCase().split("%")[0].replace(/^\[|\]$/g, "")
// Fold a trailing dotted-quad into two hextets so one code path handles both
// spellings of an IPv4-mapped address.
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (dotted && dotted.index !== undefined) {
const parts = dotted[1].split(".").map((n) => parseInt(n, 10))
if (parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null
s =
s.slice(0, dotted.index) +
(((parts[0] << 8) | parts[1]) >>> 0).toString(16) +
":" +
(((parts[2] << 8) | parts[3]) >>> 0).toString(16)
}
const halves = s.split("::")
if (halves.length > 2) return null
const split = (chunk: string) => (chunk ? chunk.split(":").filter(Boolean) : [])
const head = split(halves[0])
const tail = halves.length === 2 ? split(halves[1]) : []
const groups =
halves.length === 2
? [...head, ...Array(Math.max(0, 8 - head.length - tail.length)).fill("0"), ...tail]
: head
if (groups.length !== 8) return null
const out: number[] = []
for (const g of groups) {
if (!/^[0-9a-f]{1,4}$/.test(g)) return null
out.push(parseInt(g, 16))
}
return out
}
/**
* True for IPv6 loopback, unspecified, ULA, link-local, multicast, or any
* address embedding a private IPv4 address.
*
* The embedded-IPv4 check works on the NUMERIC hextets, not on the text. Node's
* URL parser rewrites `::ffff:169.254.169.254` to `::ffff:a9fe:a9fe`, so a
* previous version that only matched the dotted-quad spelling let the cloud
* metadata endpoint — and every private range — straight through.
*/
function isPrivateIPv6(ip: string): boolean {
const addr = ip.toLowerCase().split("%")[0] // strip zone id
if (addr === "::1" || addr === "::") return true
// IPv4-mapped / -compatible (e.g. ::ffff:169.254.169.254) — check the v4 part.
const mapped = addr.match(/(?:^::ffff:|^::)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (mapped) return isPrivateIPv4(mapped[1])
const head = addr.replace(/^\[|\]$/g, "")
if (head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb"))
return true // fe80::/10 link-local
if (head.startsWith("fc") || head.startsWith("fd")) return true // fc00::/7 unique-local
if (head.startsWith("ff")) return true // ff00::/8 multicast
const h = ipv6Hextets(ip)
if (!h) return true // unparseable → treat as unsafe
// ::/96 (covers :: and ::1) and ::ffff:0:0/96 both carry an IPv4 address in
// the low 32 bits. Decode it and reuse the IPv4 rules.
const embedsIPv4 =
h[0] === 0 && h[1] === 0 && h[2] === 0 && h[3] === 0 && h[4] === 0 &&
(h[5] === 0 || h[5] === 0xffff)
if (embedsIPv4) {
const v4 = [h[6] >> 8, h[6] & 0xff, h[7] >> 8, h[7] & 0xff].join(".")
return isPrivateIPv4(v4)
}
const first = h[0]
if ((first & 0xffc0) === 0xfe80) return true // fe80::/10 link-local
if ((first & 0xfe00) === 0xfc00) return true // fc00::/7 unique-local
if ((first & 0xff00) === 0xff00) return true // ff00::/8 multicast
return false
}