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
+84
View File
@@ -0,0 +1,84 @@
import { test, expect } from "@playwright/test"
import { hit, clientIp } from "@/lib/rate-limit"
// The limiter is module-level state shared across the process, so every test
// uses a unique key prefix to stay independent of the others.
let n = 0
const key = (label: string) => `test:${label}:${++n}:${Math.random()}`
test.describe("hit()", () => {
test("allows requests up to the limit and rejects the one after", () => {
const k = key("basic")
for (let i = 0; i < 5; i++) {
expect(hit(k, 5, 60).ok, `request ${i + 1} of 5 should be allowed`).toBe(true)
}
expect(hit(k, 5, 60).ok, "the 6th request should be rejected").toBe(false)
})
test("reports remaining budget accurately", () => {
const k = key("remaining")
expect(hit(k, 3, 60).remaining).toBe(2)
expect(hit(k, 3, 60).remaining).toBe(1)
expect(hit(k, 3, 60).remaining).toBe(0)
// Over the limit, remaining stays clamped at zero rather than going negative.
expect(hit(k, 3, 60).remaining).toBe(0)
})
test("keeps separate keys independent", () => {
const a = key("iso-a")
const b = key("iso-b")
for (let i = 0; i < 3; i++) hit(a, 3, 60)
expect(hit(a, 3, 60).ok, "key A is exhausted").toBe(false)
expect(hit(b, 3, 60).ok, "key B is untouched").toBe(true)
})
test("resets after the window elapses", async () => {
const k = key("window")
// A 1-second window so the test can actually wait it out.
expect(hit(k, 1, 1).ok).toBe(true)
expect(hit(k, 1, 1).ok).toBe(false)
await new Promise((r) => setTimeout(r, 1100))
expect(hit(k, 1, 1).ok, "a fresh window should allow requests again").toBe(true)
})
test("returns a retryAfter of at least one second while limited", () => {
const k = key("retry")
hit(k, 1, 60)
const limited = hit(k, 1, 60)
expect(limited.ok).toBe(false)
expect(limited.retryAfter).toBeGreaterThan(0)
expect(limited.retryAfter).toBeLessThanOrEqual(60)
})
test("reports the configured limit back to the caller", () => {
expect(hit(key("limit"), 42, 60).limit).toBe(42)
})
})
test.describe("clientIp()", () => {
const req = (headers: Record<string, string>) => new Request("https://x.test", { headers })
test("takes the first entry of x-forwarded-for", () => {
expect(clientIp(req({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 9.9.9.9" }))).toBe("1.2.3.4")
})
test("trims whitespace around the client address", () => {
expect(clientIp(req({ "x-forwarded-for": " 1.2.3.4 , 5.6.7.8" }))).toBe("1.2.3.4")
})
test("falls back to x-real-ip when x-forwarded-for is absent", () => {
expect(clientIp(req({ "x-real-ip": "8.8.8.8" }))).toBe("8.8.8.8")
})
test("degrades to a single shared bucket rather than no limit at all", () => {
// The important property: a request with no proxy headers must still land in
// SOME bucket. Returning a unique value per request would silently disable
// the limit for anyone who can strip headers.
expect(clientIp(req({}))).toBe("unknown")
expect(clientIp(req({}))).toBe("unknown")
})
test("ignores an empty x-forwarded-for and falls through", () => {
expect(clientIp(req({ "x-forwarded-for": "", "x-real-ip": "8.8.4.4" }))).toBe("8.8.4.4")
})
})
+136
View File
@@ -0,0 +1,136 @@
import { test, expect } from "@playwright/test"
import { generateApiKey, hashApiKey } from "@/lib/api-auth"
import { encrypt, decrypt } from "@/lib/crypto"
import { checkLimit, getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans"
import type { Plan } from "@/types"
// A dedicated key so the crypto tests never depend on BETTER_AUTH_SECRET being
// set. lib/crypto reads the env lazily inside getKey() rather than at module
// load, so this assignment lands before the first encrypt() call even though
// the import above is hoisted.
process.env.ACCOUNTING_ENCRYPTION_KEY = "test-encryption-key-do-not-use-in-production"
test.describe("API keys", () => {
test("issues a prefixed key with 48 hex characters of entropy", () => {
const { plaintext } = generateApiKey()
expect(plaintext).toMatch(/^pmn_live_[0-9a-f]{48}$/)
})
test("never repeats a key", () => {
const keys = new Set(Array.from({ length: 200 }, () => generateApiKey().plaintext))
expect(keys.size).toBe(200)
})
test("stores only a hash — the plaintext must not be recoverable from it", () => {
const { plaintext, hash } = generateApiKey()
expect(hash).toMatch(/^[0-9a-f]{64}$/) // sha256 hex
expect(hash).not.toContain(plaintext)
expect(plaintext).not.toContain(hash)
})
test("hashing is deterministic, so lookup by hash works", () => {
const { plaintext, hash } = generateApiKey()
expect(hashApiKey(plaintext)).toBe(hash)
expect(hashApiKey(plaintext)).toBe(hashApiKey(plaintext))
})
test("different keys hash differently", () => {
expect(hashApiKey("pmn_live_aaa")).not.toBe(hashApiKey("pmn_live_aab"))
})
test("the display prefix reveals only a short, non-secret fragment", () => {
const { plaintext, prefix } = generateApiKey()
expect(prefix.startsWith("pmn_live_")).toBe(true)
// 8 hex chars shown out of 48 — the rest must not leak.
const shown = prefix.replace("pmn_live_", "").replace("…", "")
expect(shown).toHaveLength(8)
expect(plaintext).toContain(shown)
expect(prefix.length).toBeLessThan(plaintext.length)
})
})
test.describe("secrets at rest (AES-256-GCM)", () => {
test("round-trips a value", async () => {
const secret = "oauth-refresh-token-abc123"
expect(decrypt(encrypt(secret))).toBe(secret)
})
test("round-trips unicode and empty strings", async () => {
for (const v of ["", "ünïcödé ✓ 日本語", "a".repeat(5000)]) {
expect(decrypt(encrypt(v))).toBe(v)
}
})
test("produces a different ciphertext each time (random IV)", async () => {
const a = encrypt("same-value")
const b = encrypt("same-value")
expect(a).not.toBe(b)
})
test("emits the documented iv:tag:ciphertext shape", async () => {
const parts = encrypt("x").split(":")
expect(parts).toHaveLength(3)
for (const p of parts) expect(p.length).toBeGreaterThan(0)
})
test("rejects a tampered ciphertext instead of returning garbage", async () => {
const [iv, tag, data] = encrypt("sensitive").split(":")
// Flip a byte in the payload — GCM's auth tag must catch it.
const buf = Buffer.from(data, "base64")
buf[0] ^= 0xff
expect(() => decrypt([iv, tag, buf.toString("base64")].join(":"))).toThrow()
// A forged auth tag must also fail.
const tagBuf = Buffer.from(tag, "base64")
tagBuf[0] ^= 0xff
expect(() => decrypt([iv, tagBuf.toString("base64"), data].join(":"))).toThrow()
})
})
test.describe("plan limits", () => {
test("Starter is capped at 1 property and 3 tenants", () => {
expect(checkLimit("starter", "maxProperties", 0)).toBe(true)
expect(checkLimit("starter", "maxProperties", 1)).toBe(false)
expect(checkLimit("starter", "maxTenants", 2)).toBe(true)
expect(checkLimit("starter", "maxTenants", 3)).toBe(false)
})
test("Pro allows 10 properties", () => {
expect(checkLimit("pro", "maxProperties", 9)).toBe(true)
expect(checkLimit("pro", "maxProperties", 10)).toBe(false)
})
test("unlimited plans never cap", () => {
for (const plan of ["landlord", "lifetime"] as Plan[]) {
expect(checkLimit(plan, "maxProperties", 10_000)).toBe(true)
expect(checkLimit(plan, "maxTenants", 10_000)).toBe(true)
}
})
test("boolean entitlements are returned directly", () => {
expect(checkLimit("starter", "hasTeamAccess", 0)).toBe(false)
expect(checkLimit("landlord", "hasTeamAccess", 0)).toBe(true)
expect(checkLimit("starter", "hasWhiteLabel", 0)).toBe(false)
expect(checkLimit("lifetime", "hasWhiteLabel", 0)).toBe(true)
})
test("AI is gated off on Starter", () => {
expect(PLAN_LIMITS.starter.maxAiCalls).toBe(0)
expect(PLAN_LIMITS.pro.maxAiCalls).toBeGreaterThan(0)
})
test("every plan has a display label", () => {
for (const plan of ["starter", "pro", "landlord", "lifetime"] as Plan[]) {
expect(getPlanLabel(plan)).toBeTruthy()
}
})
test("limits are monotonic as plans get more expensive", () => {
// A cheaper plan must never allow more than a pricier one.
expect(PLAN_LIMITS.pro.maxProperties).toBeGreaterThan(PLAN_LIMITS.starter.maxProperties)
expect(PLAN_LIMITS.landlord.maxProperties).toBeGreaterThan(PLAN_LIMITS.pro.maxProperties)
expect(PLAN_LIMITS.pro.maxStorageMB).toBeGreaterThan(PLAN_LIMITS.starter.maxStorageMB)
expect(PLAN_LIMITS.landlord.maxStorageMB).toBeGreaterThan(PLAN_LIMITS.pro.maxStorageMB)
})
})
+135
View File
@@ -0,0 +1,135 @@
import { test, expect } from "@playwright/test"
import { assertSafeWebhookUrl, isSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
/**
* Webhook URLs are attacker-supplied and dialled by our server on a schedule,
* which makes this guard the difference between "outbound webhook" and "open
* proxy into our private network". Every case below uses an IP LITERAL or a
* scheme/shape violation so the guard short-circuits before DNS — the suite
* stays hermetic and never resolves a hostname.
*/
async function rejects(url: string) {
await expect(assertSafeWebhookUrl(url), `${url} must be rejected`).rejects.toThrow(
WebhookUrlError
)
}
async function allows(url: string) {
await expect(assertSafeWebhookUrl(url), `${url} must be allowed`).resolves.toBeUndefined()
}
test.describe("scheme and shape", () => {
test("rejects a non-URL", async () => {
await rejects("not a url")
})
test("rejects non-http schemes", async () => {
await rejects("file:///etc/passwd")
await rejects("gopher://8.8.8.8/")
await rejects("ftp://8.8.8.8/")
})
test("rejects embedded credentials", async () => {
// Credentials in the URL are a classic way to smuggle a different
// authority past naive parsing.
await rejects("https://user:pass@8.8.8.8/hook")
})
test("allows plain https to a public address", async () => {
await allows("https://8.8.8.8/hook")
})
})
test.describe("IPv4 private and reserved ranges", () => {
const blocked = [
["0.0.0.0", "this-network"],
["10.0.0.1", "private class A"],
["127.0.0.1", "loopback"],
["100.64.0.1", "CGNAT"],
["169.254.169.254", "cloud metadata"],
["172.16.0.1", "private class B (low)"],
["172.31.255.254", "private class B (high)"],
["192.0.0.1", "IETF protocol assignments"],
["192.168.1.1", "private class C"],
["198.18.0.1", "benchmarking"],
["224.0.0.1", "multicast"],
["255.255.255.255", "broadcast"],
] as const
for (const [ip, label] of blocked) {
test(`rejects ${ip} (${label})`, async () => {
await rejects(`https://${ip}/hook`)
})
}
const allowed = ["8.8.8.8", "1.1.1.1", "172.15.0.1", "172.32.0.1", "192.167.0.1"]
for (const ip of allowed) {
test(`allows public ${ip}`, async () => {
await allows(`https://${ip}/hook`)
})
}
})
test.describe("IPv6", () => {
const blocked = [
["[::1]", "loopback"],
["[::]", "unspecified"],
["[fe80::1]", "link-local"],
["[fd00::1]", "unique-local"],
["[fc00::1]", "unique-local"],
["[ff02::1]", "multicast"],
["[::ffff:169.254.169.254]", "IPv4-mapped metadata"],
["[::ffff:127.0.0.1]", "IPv4-mapped loopback"],
] as const
for (const [host, label] of blocked) {
test(`rejects ${host} (${label})`, async () => {
await rejects(`https://${host}/hook`)
})
}
})
test.describe("IPv6 regression: hex-normalised IPv4-mapped addresses", () => {
// Node's URL parser rewrites ::ffff:169.254.169.254 to ::ffff:a9fe:a9fe. A
// guard that only recognises the dotted-quad spelling therefore treats the
// cloud metadata endpoint as a public address and dials it. These assert the
// NORMALISED forms directly so the bypass cannot silently return.
const mapped = [
["[::ffff:a9fe:a9fe]", "169.254.169.254 — cloud metadata"],
["[::ffff:7f00:1]", "127.0.0.1 — loopback"],
["[::ffff:a00:1]", "10.0.0.1 — private"],
["[::ffff:c0a8:1]", "192.168.0.1 — private"],
["[::ffff:ac10:1]", "172.16.0.1 — private"],
] as const
for (const [host, label] of mapped) {
test(`rejects ${host} (${label})`, async () => {
await rejects(`https://${host}/hook`)
})
}
test("still allows a mapped PUBLIC address", async () => {
// ::ffff:8.8.8.8 — mapped, but the embedded address is public.
await allows("https://[::ffff:808:808]/hook")
})
test("allows a genuinely public IPv6 address", async () => {
await allows("https://[2001:4860:4860::8888]/hook")
})
})
test.describe("localhost by name", () => {
test("rejects localhost and its subdomains", async () => {
await rejects("https://localhost/hook")
await rejects("https://api.localhost/hook")
})
})
test.describe("isSafeWebhookUrl", () => {
test("mirrors assertSafeWebhookUrl without throwing", async () => {
expect(await isSafeWebhookUrl("https://8.8.8.8/hook")).toBe(true)
expect(await isSafeWebhookUrl("https://169.254.169.254/latest/meta-data/")).toBe(false)
expect(await isSafeWebhookUrl("nonsense")).toBe(false)
})
})
+161
View File
@@ -0,0 +1,161 @@
import { test, expect } from "@playwright/test"
import {
ALLOWED_UPLOAD_EXTENSIONS,
isAllowedUploadExt,
extOf,
contentTypeForKey,
contentMatchesExtension,
keyBelongsToOwner,
} from "@/lib/storage"
/**
* Upload validation is the boundary between "a landlord attached a lease PDF"
* and "a tenant stored an HTML file that executes on our origin". Two
* independent gates matter: the extension allowlist and the magic-byte check.
* Neither is sufficient alone.
*/
const sig = (...bytes: number[]) => Buffer.from(bytes)
const PDF = sig(0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37)
const PNG = sig(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
const JPG = sig(0xff, 0xd8, 0xff, 0xe0)
const GIF = sig(0x47, 0x49, 0x46, 0x38, 0x39, 0x61)
const ZIP = sig(0x50, 0x4b, 0x03, 0x04)
const OLE = sig(0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1)
const WEBP = Buffer.concat([sig(0x52, 0x49, 0x46, 0x46), sig(0, 0, 0, 0), sig(0x57, 0x45, 0x42, 0x50)])
const HTML = Buffer.from("<html><script>alert(1)</script>", "utf8")
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg">', "utf8")
test.describe("extension allowlist", () => {
test("accepts every documented extension", () => {
for (const ext of ALLOWED_UPLOAD_EXTENSIONS) {
expect(isAllowedUploadExt(`file.${ext}`), `${ext} should be allowed`).toBe(true)
}
})
test("rejects executable and markup types", () => {
// svg and html can execute JavaScript when served inline from our origin.
for (const name of [
"x.svg",
"x.html",
"x.htm",
"x.js",
"x.mjs",
"x.exe",
"x.sh",
"x.php",
"x.xml",
"x.json",
]) {
expect(isAllowedUploadExt(name), `${name} should be rejected`).toBe(false)
}
})
test("is case-insensitive", () => {
expect(isAllowedUploadExt("SCAN.PDF")).toBe(true)
expect(isAllowedUploadExt("Photo.JPeG")).toBe(true)
// …and stays case-insensitive for the denied set.
expect(isAllowedUploadExt("payload.SVG")).toBe(false)
})
test("uses the LAST extension in a multi-dot name", () => {
// "invoice.pdf.html" is html, not pdf — the classic double-extension trick.
expect(isAllowedUploadExt("invoice.pdf.html")).toBe(false)
expect(isAllowedUploadExt("archive.tar.pdf")).toBe(true)
expect(extOf("invoice.pdf.html")).toBe("html")
})
test("rejects a name with no extension", () => {
expect(isAllowedUploadExt("noextension")).toBe(false)
})
})
test.describe("magic-byte verification", () => {
test("accepts content matching its claimed extension", () => {
expect(contentMatchesExtension(PDF, "pdf")).toBe(true)
expect(contentMatchesExtension(PNG, "png")).toBe(true)
expect(contentMatchesExtension(JPG, "jpg")).toBe(true)
expect(contentMatchesExtension(JPG, "jpeg")).toBe(true)
expect(contentMatchesExtension(GIF, "gif")).toBe(true)
expect(contentMatchesExtension(WEBP, "webp")).toBe(true)
expect(contentMatchesExtension(ZIP, "docx")).toBe(true)
expect(contentMatchesExtension(ZIP, "xlsx")).toBe(true)
expect(contentMatchesExtension(OLE, "doc")).toBe(true)
expect(contentMatchesExtension(OLE, "xls")).toBe(true)
})
test("rejects HTML disguised with an allowed extension", () => {
// The whole point: an allowlisted extension over script content.
expect(contentMatchesExtension(HTML, "pdf")).toBe(false)
expect(contentMatchesExtension(HTML, "png")).toBe(false)
expect(contentMatchesExtension(HTML, "jpg")).toBe(false)
expect(contentMatchesExtension(HTML, "docx")).toBe(false)
expect(contentMatchesExtension(SVG, "png")).toBe(false)
})
test("rejects one image type renamed as another", () => {
expect(contentMatchesExtension(PNG, "pdf")).toBe(false)
expect(contentMatchesExtension(PDF, "png")).toBe(false)
expect(contentMatchesExtension(GIF, "webp")).toBe(false)
})
test("rejects a truncated header that cannot be verified", () => {
expect(contentMatchesExtension(sig(0x25, 0x50), "pdf")).toBe(false)
expect(contentMatchesExtension(Buffer.alloc(0), "png")).toBe(false)
})
test("allows csv and txt, which have no reliable signature", () => {
// Documented behaviour — these are served as attachments, not inline.
expect(contentMatchesExtension(HTML, "csv")).toBe(true)
expect(contentMatchesExtension(HTML, "txt")).toBe(true)
})
})
test.describe("contentTypeForKey", () => {
test("maps known extensions and defaults to octet-stream", () => {
expect(contentTypeForKey("a/b/c.pdf")).toBe("application/pdf")
expect(contentTypeForKey("a/b/c.PNG")).toBe("image/png")
expect(contentTypeForKey("a/b/c.jpeg")).toBe("image/jpeg")
expect(contentTypeForKey("a/b/c.unknown")).toBe("application/octet-stream")
expect(contentTypeForKey("noext")).toBe("application/octet-stream")
})
test("never returns an inline-executable content type", () => {
for (const name of ["x.svg", "x.html", "x.js"]) {
expect(contentTypeForKey(name)).toBe("application/octet-stream")
}
})
})
test.describe("keyBelongsToOwner — cross-tenant isolation", () => {
test("accepts a key in the owner's own namespace", () => {
expect(keyBelongsToOwner("user123/documents/a.pdf", "user123")).toBe(true)
expect(keyBelongsToOwner("/user123/documents/a.pdf", "user123")).toBe(true)
})
test("rejects another tenant's namespace", () => {
expect(keyBelongsToOwner("user999/documents/a.pdf", "user123")).toBe(false)
})
test("rejects a prefix that merely starts with the owner id", () => {
// "user1234" must not satisfy owner "user123".
expect(keyBelongsToOwner("user1234/documents/a.pdf", "user123")).toBe(false)
})
test("rejects traversal attempts", () => {
expect(keyBelongsToOwner("../user999/a.pdf", "user123")).toBe(false)
expect(keyBelongsToOwner("..\\\\user999\\\\a.pdf", "user123")).toBe(false)
})
test("rejects empty inputs rather than defaulting open", () => {
expect(keyBelongsToOwner("", "user123")).toBe(false)
expect(keyBelongsToOwner(null, "user123")).toBe(false)
expect(keyBelongsToOwner(undefined, "user123")).toBe(false)
expect(keyBelongsToOwner("user123/a.pdf", "")).toBe(false)
})
test("handles backslash separators the same as forward slashes", () => {
expect(keyBelongsToOwner("user123\\\\documents\\\\a.pdf", "user123")).toBe(true)
expect(keyBelongsToOwner("user999\\\\documents\\\\a.pdf", "user123")).toBe(false)
})
})