3. Cloud Functions as the Canonical Write Path; Firestore Rules Deny-by-Default
Status
Accepted — 2026-04-08 (hardened 2026-04)
Context
The blog accepts contact submissions, stores timeclock entries, schedules LinkedIn posts, and aggregates post ratings. Every one of these flows needs validation, rate limiting, audit trails, and in some cases third-party side effects (Resend, LinkedIn). Writing that logic into Firestore security rules would put business rules in a language not designed for them and leave no trail of what happened or why. Writing it on the client would trust code the server cannot verify.
Decision
Cloud Functions are the canonical write path. Firestore rules deny direct client writes by default.
- Sensitive collections (
inbox_messages,timeclock_entries,linkedin_posts,rate_limits,*_config) declareallow create, update, delete: if false. All writes come from callable or trigger functions using the Admin SDK, which bypasses rules. - Read access on those collections is gated by the admin custom claim (
isOwner()). - Audit subcollections (
{collection}/{id}/audit/{auditId}) are written exclusively byonDocumentUpdatedtriggers. Clients cannot read or write audits; admins read only. - The one exception is
ratings/{postSlug}/votes/{voterId}: direct client writes are allowed because the write is high-frequency, low-value, and its validity is fully expressible in rules (field allowlist, type checks, timestamp equality, voter UID match). The aggregate document above it is still function-written.
Consequences
- All mutation logic lives in TypeScript with tests, not in the rules DSL. Validation, rate limiting, and audit trails are colocated with the write.
- The surface area of the rules file stays small and auditable. It reads as a permissions manifest, not a business-logic document.
- Every mutation path costs a function invocation. For the traffic profile of a blog, this is negligible; for a high-write system it would be meaningful.
- New collections must decide up front whether they follow the default (deny + function write) or the ratings-style exception. Absent an explicit justification, the default applies.
- Changing this posture later — moving validation into rules to save function cost — would require rebuilding the audit trail, rate limiting, and third-party integration logic elsewhere.