fix: address final review findings — lint, Node engine, remaining-work doc, create-rule validation
This commit is contained in:
39
docs/superpowers/PHASE2A_REMAINING.md
Normal file
39
docs/superpowers/PHASE2A_REMAINING.md
Normal 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.
|
||||||
@@ -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'), {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user