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
+17 -4
View File
@@ -35,6 +35,19 @@ export async function GET() {
return NextResponse.json(data)
}
// Shape of one item in the model's JSON response. Every field is optional
// because the model is not a trusted schema — the insert below supplies a
// fallback for each, so a missing key degrades instead of throwing.
type AiPrediction = {
type?: string
title?: string
prediction?: string
confidence?: string
timeframe?: string
risk_level?: string
data?: Record<string, unknown> | null
}
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -184,7 +197,7 @@ Only return valid JSON, no other text.`
json: true,
})
let predictions: any[] = []
let predictions: AiPrediction[] = []
try {
const parsed = JSON.parse(content || "{}")
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
@@ -195,11 +208,11 @@ Only return valid JSON, no other text.`
// Replace old predictions
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, ownerId))
const toInsert = predictions.map((p: any) => ({
const toInsert = predictions.map((p: AiPrediction) => ({
user_id: ownerId,
type: p.type ?? "growth_opportunity",
title: p.title,
prediction: p.prediction,
title: p.title ?? "Untitled prediction",
prediction: p.prediction ?? "",
confidence: p.confidence ?? "medium",
timeframe: p.timeframe ?? "Next 30 days",
risk_level: p.risk_level ?? "low",
+20 -7
View File
@@ -34,6 +34,18 @@ export async function GET() {
return NextResponse.json(data)
}
// Shape of one item in the model's JSON response. Optional for the same reason
// as AiPrediction: the model output is untrusted input, not a schema.
type AiRecommendation = {
type?: string
title?: string
description?: string
impact?: string
priority?: string
action_label?: string
action_data?: Record<string, unknown> | null
}
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -169,7 +181,7 @@ Return a JSON object with key "recommendations" containing an array. Each recomm
Only return valid JSON, no other text.`
let recommendations: any[] = []
let recommendations: AiRecommendation[] = []
try {
const content = await aiComplete({
messages: [{ role: "user", content: prompt }],
@@ -178,8 +190,9 @@ Only return valid JSON, no other text.`
})
const parsed = JSON.parse(content || "{}")
recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
} catch (err: any) {
return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 })
} catch (err) {
const message = err instanceof Error ? err.message : "AI generation failed"
return NextResponse.json({ error: message }, { status: 500 })
}
// Delete old pending recommendations and insert new ones
@@ -187,12 +200,12 @@ Only return valid JSON, no other text.`
.delete(ai_recommendations)
.where(and(eq(ai_recommendations.user_id, ownerId), eq(ai_recommendations.status, "pending")))
const toInsert = recommendations.map((r: any) => ({
const toInsert = recommendations.map((r: AiRecommendation) => ({
user_id: ownerId,
type: r.type ?? "opportunity",
title: r.title,
description: r.description,
impact: r.impact,
title: r.title ?? "Untitled recommendation",
description: r.description ?? "",
impact: r.impact ?? "",
priority: r.priority ?? "medium",
status: "pending",
action_label: r.action_label ?? "Apply",
+3 -1
View File
@@ -38,7 +38,9 @@ export async function POST(request: Request) {
}
// Pass only the whitelisted, validated fields to the model.
const { payment_id, ...receiptFields } = parsed.data
// payment_id identifies the row but must not reach the model — destructured
// out deliberately, hence the leading underscore.
const { payment_id: _payment_id, ...receiptFields } = parsed.data
const text = await aiComplete({
messages: [
+6 -1
View File
@@ -115,7 +115,12 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="property-management-network.ics"',
"Cache-Control": "public, max-age=3600",
// PRIVATE, never shared-cacheable: the only credential is the token in the
// URL, and the body carries tenant names, rent amounts, property addresses
// and lease dates. A `public` cache directive would let any intermediary
// or CDN retain that PII.
"Cache-Control": "private, max-age=3600",
"X-Robots-Tag": "noindex, nofollow",
},
})
}
+12 -1
View File
@@ -8,6 +8,7 @@ import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { enforceRateLimit, clientIp } from "@/lib/rate-limit"
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
const VALID_PRIORITIES = ["low", "medium", "high", "emergency"]
@@ -62,12 +63,22 @@ export async function POST(request: Request) {
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
userId = ctx.ownerId
} else {
// Tenant portal submission — verify portal_token
// Tenant portal submission — verify portal_token.
//
// This is the one unauthenticated write path in the app, so it carries its
// own limits: a per-IP budget that also caps portal-token guessing, and a
// tighter per-token budget so a leaked token cannot flood a landlord's queue.
const ipLimited = enforceRateLimit(`portal-maintenance-ip:${clientIp(request)}`, 20, 3600)
if (ipLimited) return ipLimited
const portalToken = body.portal_token as string | undefined
if (!portalToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const tokenLimited = enforceRateLimit(`portal-maintenance:${portalToken}`, 10, 3600)
if (tokenLimited) return tokenLimited
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.portal_token, portalToken),
columns: { id: true, user_id: true, property_id: true, unit_id: true },
+4 -13
View File
@@ -1,11 +1,10 @@
import { NextResponse } from "next/server"
import { desc, eq, sql } from "drizzle-orm"
import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { propertySchema } from "@/lib/validations"
import { getUserPlan } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { checkPropertyLimit } from "@/lib/plan-limits"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding"
@@ -37,16 +36,8 @@ export async function POST(request: Request) {
const parsed = propertySchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Check plan limit
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(properties)
.where(eq(properties.user_id, ownerId))
const plan = await getUserPlan(ownerId)
if (!checkLimit(plan, "maxProperties", count)) {
return NextResponse.json({ error: "Plan limit reached. Upgrade to add more properties." }, { status: 403 })
}
const limitError = await checkPropertyLimit(ownerId)
if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
// Best-effort geocode so the property shows up on the map (never blocks save).
const coords = await geocodeAddress(parsed.data)
+12 -2
View File
@@ -5,6 +5,16 @@ import { db } from "@/lib/db"
import { profiles, rent_payments } from "@/lib/db/schema"
import type Stripe from "stripe"
/**
* `current_period_end` is present on the webhook payload but absent from the
* Subscription type in this pinned API version, so it is read through a narrow
* accessor rather than casting the whole object to `any`.
*/
function subscriptionPeriodEnd(sub: Stripe.Subscription): number | null {
const v = (sub as unknown as { current_period_end?: unknown }).current_period_end
return typeof v === "number" ? v : null
}
export async function POST(request: Request) {
const body = await request.text()
const sig = request.headers.get("stripe-signature")!
@@ -54,8 +64,8 @@ export async function POST(request: Request) {
plan: (plan ?? "starter") as typeof profiles.$inferSelect.plan,
stripe_subscription_id: subscription.id,
subscription_status: subscription.status,
plan_expires_at: (subscription as any).current_period_end
? new Date((subscription as any).current_period_end * 1000).toISOString()
plan_expires_at: subscriptionPeriodEnd(subscription)
? new Date(subscriptionPeriodEnd(subscription)! * 1000).toISOString()
: null,
})
.where(eq(profiles.id, userId))
+3 -14
View File
@@ -5,8 +5,7 @@ import { tenants, units } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { tenantSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getUserPlan } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { checkTenantLimit } from "@/lib/plan-limits"
import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
@@ -64,18 +63,8 @@ export async function POST(request: Request) {
const parsed = tenantSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Enforce per-plan tenant limit (Starter = 3).
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 NextResponse.json(
{ error: "Plan limit reached. Upgrade to add more tenants." },
{ status: 403 }
)
}
const limitError = await checkTenantLimit(ownerId)
if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
if (
!(await ownsProperty(ownerId, parsed.data.property_id)) ||
+6
View File
@@ -9,6 +9,7 @@ import {
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits"
import { enforceRateLimit } from "@/lib/rate-limit"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"]
@@ -24,6 +25,11 @@ export async function POST(request: Request) {
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId
// Storage quota caps total bytes, but not the rate of writes — throttle so a
// single account cannot hammer Spaces (or fill a plan's quota) in one burst.
const limited = enforceRateLimit(`upload:${ownerId}`, 60, 60)
if (limited) return limited
const fd = await request.formData()
const file = fd.get("file") as File | null
const scopeRaw = (fd.get("scope") as string) || "misc"
+5
View File
@@ -6,6 +6,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — update a single maintenance request. Bearer API-key
@@ -26,6 +27,10 @@ const maintenancePatchSchema = maintenanceSchema.partial().extend({
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
+9
View File
@@ -5,6 +5,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — maintenance requests. Bearer API-key auth.
@@ -22,6 +23,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const priority = searchParams.get("priority")
@@ -55,6 +60,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
+9
View File
@@ -5,6 +5,7 @@ import { rent_payments } from "@/lib/db/schema"
import { rentPaymentSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — rent payments. Bearer API-key auth. Maps to the
@@ -21,6 +22,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url)
const status = searchParams.get("status")
const tenantId = searchParams.get("tenant_id")
@@ -57,6 +62,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
+18
View File
@@ -4,6 +4,8 @@ import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema"
import { propertySchema } from "@/lib/validations"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { checkPropertyLimit } from "@/lib/plan-limits"
import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding"
@@ -19,6 +21,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const data = await db.query.properties.findMany({
where: eq(properties.user_id, ctx.ownerId),
with: { units: { columns: { id: true, status: true } } },
@@ -31,6 +37,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)
@@ -42,6 +52,14 @@ export async function POST(request: Request) {
)
}
// Same plan cap the session route enforces — the public API is a creation
// entry point too, and skipping this here let a Starter key create unlimited
// properties.
const limitError = await checkPropertyLimit(ctx.ownerId)
if (limitError) {
return NextResponse.json({ error: { code: 403, message: limitError } }, { status: 403 })
}
const coords = await geocodeAddress(parsed.data)
const [data] = await db
+5
View File
@@ -3,6 +3,7 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
// Public REST API (v1) — Bearer API-key auth. Scoped by the resolved owner id.
@@ -13,6 +14,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id")
const status = searchParams.get("status")
+13
View File
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -32,6 +33,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { id } = await params
const data = await db.query.webhook_endpoints.findFirst({
where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)),
@@ -44,6 +49,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
@@ -93,6 +102,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const { id } = await params
+9
View File
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -37,6 +38,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const data = await db
.select(PUBLIC_COLUMNS)
.from(webhook_endpoints)
@@ -49,6 +54,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null)