fix: address final review findings — lint, Node engine, remaining-work doc, create-rule validation

This commit is contained in:
belviskhoremk
2026-08-05 00:35:50 +00:00
parent 9ec17d36f9
commit 5821b630f6
5 changed files with 103 additions and 4 deletions

View File

@@ -0,0 +1,39 @@
# Phase 2A — Remaining work before this backend goes live
This backend (Firestore, Storage, Cloud Functions) was built entirely with placeholder
values because Task 1 of `docs/superpowers/plans/2026-08-04-phase2a-backend-foundation.md`
(manual Firebase/Resend account setup) hadn't been done yet. Before deploying for real:
## 1. Complete Task 1 of the plan
Create the Firebase project, upgrade to Blaze, enable Firestore/Auth/Storage,
create Mira's admin user, register a web app, create a Resend account + API key,
authenticate the Firebase CLI. Full instructions are in the plan's Task 1.
## 2. Replace these 4 placeholder values with real ones from Task 1
| Placeholder | Real value goes in | Also appears in |
|---|---|---|
| `demo-braids-by-mira` (fake project id) | `.firebaserc` | `package.json`'s `test:rules` script, `firebase-tests/firestore.rules.test.ts` |
| `dev-admin-placeholder-uid` (fake admin UID) | `firestore.rules`, `storage.rules` | `firebase-tests/firestore.rules.test.ts` |
| `bookings@braidsbymira.com` (placeholder from-address) | `functions/src/index.ts` (`FROM_ADDRESS`) | — must match a Resend-verified sending domain |
| `hello@braidsbymira.com` (placeholder Mira email) | `functions/src/index.ts` (`MIRA_EMAIL`) | — replace only if Mira's real notification inbox differs |
Also update `.env.local` (gitignored, not committed) with the real Firebase web app
config from Task 1, Step 5 — copy `.env.example` if you haven't already.
## 3. Run the one manual CLI step Task 6 skipped
```bash
npx firebase-tools functions:secrets:set RESEND_API_KEY
```
## 4. Run Task 8 of the plan
Deploy rules + functions, seed production data, and run the two live smoke tests
(create a real booking/inquiry document, confirm `bookedSlots` updates and email
arrives). Full steps are in the plan's Task 8.
## 5. Delete this file
Once all of the above is done and verified, delete this file — it's a temporary
tracking note, not permanent documentation.

View File

@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' import { describe, it, beforeAll, afterAll, beforeEach } from 'vitest'
import { import {
initializeTestEnvironment, initializeTestEnvironment,
assertSucceeds, assertSucceeds,
@@ -61,6 +61,32 @@ describe('Firestore security rules', () => {
) )
}) })
it('blocks creating a booking that is missing a required field', async () => {
const unauthed = testEnv.unauthenticatedContext()
await assertFails(
addDoc(collection(unauthed.firestore(), 'bookings'), {
serviceId: 'knotless',
date: '2026-09-01',
time: '10:00 AM',
status: 'confirmed',
// missing contact
}),
)
})
it('blocks creating a booking with a malformed date', async () => {
const unauthed = testEnv.unauthenticatedContext()
await assertFails(
addDoc(collection(unauthed.firestore(), 'bookings'), {
serviceId: 'knotless',
date: 'not-a-date',
time: '10:00 AM',
contact: { name: 'Jane', phone: '555-0100', email: 'jane@example.com', notes: '' },
status: 'confirmed',
}),
)
})
it('blocks a non-admin from reading bookings (PII protection)', async () => { it('blocks a non-admin from reading bookings (PII protection)', async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => { await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), 'bookings/b1'), { await setDoc(doc(ctx.firestore(), 'bookings/b1'), {
@@ -80,6 +106,28 @@ describe('Firestore security rules', () => {
await assertSucceeds(getDoc(doc(admin.firestore(), 'bookings/b1'))) await assertSucceeds(getDoc(doc(admin.firestore(), 'bookings/b1')))
}) })
it('allows anyone to create an inquiry with a valid shape', async () => {
const unauthed = testEnv.unauthenticatedContext()
await assertSucceeds(
addDoc(collection(unauthed.firestore(), 'inquiries'), {
productId: 'edge-control',
contact: { name: 'Jane', email: 'jane@example.com', notes: '' },
status: 'new',
}),
)
})
it('blocks creating an inquiry that is missing a required field', async () => {
const unauthed = testEnv.unauthenticatedContext()
await assertFails(
addDoc(collection(unauthed.firestore(), 'inquiries'), {
productId: 'edge-control',
status: 'new',
// missing contact
}),
)
})
it('blocks a non-admin from reading inquiries (PII protection)', async () => { it('blocks a non-admin from reading inquiries (PII protection)', async () => {
await testEnv.withSecurityRulesDisabled(async (ctx) => { await testEnv.withSecurityRulesDisabled(async (ctx) => {
await setDoc(doc(ctx.firestore(), 'inquiries/i1'), { await setDoc(doc(ctx.firestore(), 'inquiries/i1'), {

View File

@@ -31,12 +31,22 @@ service cloud.firestore {
} }
match /bookings/{bookingId} { match /bookings/{bookingId} {
allow create: if true; allow create: if request.resource.data.keys().hasAll(['serviceId', 'date', 'time', 'contact', 'status'])
&& request.resource.data.date is string
&& request.resource.data.date.matches('^[0-9]{4}-[0-9]{2}-[0-9]{2}$')
&& request.resource.data.time is string
&& request.resource.data.contact is map
&& request.resource.data.contact.keys().hasAll(['name', 'phone', 'email', 'notes'])
&& request.resource.data.status is string;
allow read, update, delete: if isAdmin(); allow read, update, delete: if isAdmin();
} }
match /inquiries/{inquiryId} { match /inquiries/{inquiryId} {
allow create: if true; allow create: if request.resource.data.keys().hasAll(['productId', 'contact', 'status'])
&& request.resource.data.productId is string
&& request.resource.data.contact is map
&& request.resource.data.contact.keys().hasAll(['name', 'email', 'notes'])
&& request.resource.data.status is string;
allow read, update, delete: if isAdmin(); allow read, update, delete: if isAdmin();
} }
} }

View File

@@ -2,7 +2,7 @@
"name": "functions", "name": "functions",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "node": "20" }, "engines": { "node": "22" },
"main": "lib/index.js", "main": "lib/index.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",

View File

@@ -16,6 +16,8 @@ export const onBookingCreated = onDocumentCreated(
async (event) => { async (event) => {
const booking = event.data?.data() const booking = event.data?.data()
if (!booking) return if (!booking) return
if (typeof booking.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(booking.date)) return
if (typeof booking.time !== 'string' || booking.time.length === 0) return
const db = getFirestore() const db = getFirestore()
await db await db