Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes

Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-03 04:45:24 -04:00
co-authored by Claude Opus 4.8
parent 917a06ee85
commit 5495b94924
86 changed files with 7647 additions and 1182 deletions
+81 -1
View File
@@ -143,6 +143,53 @@ function sanitizeSegment(s: string): string {
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
}
/**
* True iff a storage key lives in the given owner's namespace (`<ownerId>/…`).
* Keys are generated server-side as `<sanitized ownerId>/<scope>/<file>`, so any
* client-supplied key/path whose first segment differs belongs to another tenant
* (or is malformed) and must be rejected.
*/
export function keyBelongsToOwner(key: string | null | undefined, ownerId: string): boolean {
if (!key || !ownerId) return false
const first = key.replace(/^\/+/, "").split(/[\\/]+/)[0]
return first === sanitizeSegment(ownerId)
}
/**
* Lightweight magic-byte check: reject a file whose real content doesn't match
* its claimed extension (e.g. an HTML/script payload renamed to `.pdf`). Types
* without a reliable file signature (csv/txt) are allowed through. `head` should
* be the first ~16 bytes of the file.
*/
export function contentMatchesExtension(head: Buffer, ext: string): boolean {
const at = (offset: number, sig: number[]) =>
head.length >= offset + sig.length && sig.every((b, i) => head[offset + i] === b)
switch (ext) {
case "pdf":
return at(0, [0x25, 0x50, 0x44, 0x46]) // %PDF
case "png":
return at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
case "jpg":
case "jpeg":
return at(0, [0xff, 0xd8, 0xff])
case "gif":
return at(0, [0x47, 0x49, 0x46, 0x38]) // GIF8
case "webp":
return at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]) // RIFF…WEBP
case "docx":
case "xlsx":
return at(0, [0x50, 0x4b, 0x03, 0x04]) || at(0, [0x50, 0x4b, 0x05, 0x06]) // zip (PK)
case "doc":
case "xls":
return at(0, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || at(0, [0x50, 0x4b]) // OLE or zip
case "csv":
case "txt":
return true // no reliable signature
default:
return true
}
}
async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
if (!body) return Buffer.alloc(0)
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body.
@@ -194,6 +241,36 @@ export async function saveFile(
return { key, size: file.size, type }
}
/**
* Persist raw bytes under `${userId}/${scope}/<random>.<ext>` (server-generated,
* so the key is always in the owner's namespace) and return the storage key.
* Used for server-side artifacts like signed e-sign PDFs.
*/
export async function saveBuffer(
buffer: Buffer,
opts: { userId: string; scope: string; ext: string }
): Promise<{ key: string }> {
const ext = opts.ext.replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin"
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${Date.now()}-${randomBytes(6).toString("hex")}.${ext}`
if (usingSpaces()) {
await s3().send(
new PutObjectCommand({
Bucket: SPACES_BUCKET,
Key: key,
Body: buffer,
ContentType: contentTypeForKey(key),
ACL: "private",
})
)
} else {
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
const abs = resolveKey(key)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, buffer)
}
return { key }
}
export async function readFile(key: string): Promise<Buffer> {
if (usingSpaces()) {
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
@@ -227,7 +304,10 @@ export async function presignGetUrl(
return toCdnUrl(signed)
}
export async function deleteFile(key: string): Promise<void> {
export async function deleteFile(key: string, ownerId: string): Promise<void> {
// Defense in depth: never delete an object outside the caller's own namespace,
// even if a stored storage_path was tampered with to point at another tenant.
if (!keyBelongsToOwner(key, ownerId)) return
try {
if (usingSpaces()) {
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))