diff --git a/docs/superpowers/PHASE2A_REMAINING.md b/docs/superpowers/PHASE2A_REMAINING.md new file mode 100644 index 0000000..f25f4f6 --- /dev/null +++ b/docs/superpowers/PHASE2A_REMAINING.md @@ -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. diff --git a/firebase-tests/firestore.rules.test.ts b/firebase-tests/firestore.rules.test.ts index adad84a..8a473e2 100644 --- a/firebase-tests/firestore.rules.test.ts +++ b/firebase-tests/firestore.rules.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs' -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { describe, it, beforeAll, afterAll, beforeEach } from 'vitest' import { initializeTestEnvironment, 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 () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'bookings/b1'), { @@ -80,6 +106,28 @@ describe('Firestore security rules', () => { 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 () => { await testEnv.withSecurityRulesDisabled(async (ctx) => { await setDoc(doc(ctx.firestore(), 'inquiries/i1'), { diff --git a/firestore.rules b/firestore.rules index 622f80f..b034858 100644 --- a/firestore.rules +++ b/firestore.rules @@ -31,12 +31,22 @@ service cloud.firestore { } 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(); } 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(); } } diff --git a/functions/package.json b/functions/package.json index 2c33c2c..5fd4ab4 100644 --- a/functions/package.json +++ b/functions/package.json @@ -2,7 +2,7 @@ "name": "functions", "private": true, "type": "module", - "engines": { "node": "20" }, + "engines": { "node": "22" }, "main": "lib/index.js", "scripts": { "build": "tsc", diff --git a/functions/src/index.ts b/functions/src/index.ts index 60356e7..f7837d4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,6 +16,8 @@ export const onBookingCreated = onDocumentCreated( async (event) => { const booking = event.data?.data() 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() await db