Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,3 +469,19 @@ No Stripe Connect code exists anywhere in the repo yet — `lib/stripe.js` only
- `lib/__tests__/rateLimit.test.js`: 12/12 unit tests passing (4 new assertions covering isolation, token independence, and Meta webhook ceilings).
- Full Vitest suite: 19/19 test files passed, 137/137 tests green (`yarn --ignore-engines test`).
- Production build: `yarn --ignore-engines build` exit code 0, 75 static/dynamic routes compiled cleanly in 78s.

---

## Session log (2026-10-17) — audit fixes: UK GDPR/PECR erasure, deletion callback, SMS opt-out (`fix/compliance`)

Audit against `e493421`. Tests in `lib/__tests__/`.

- ✅ **Public result PII** — `GET /api/result/:id` strips `ownerEmail`/`ownerUid` (`toPublicResult`). `/my/results` unchanged; `app/r/[id]` reads Firestore directly and never used those fields.
- ⚠️ **Meta data-deletion callback — NOT fixed, blocked.** Meta's `user_id` is the app-scoped Facebook *user* ID, but the Instagram/Messenger connect flows only take a pasted *Page* access token and validate it with `GET /{id}?fields=id,name` — no call returns a user ID, so `metaUserId` can't be stored and the callback still matches nothing (and still records `status: "completed"`).
- ✅ **Account erasure gaps** — `eraseUserData` also deletes `agencies/{uid}`, `inbound_tokens` (`uid`), `ghl_events` (`uid`), `invites` (`agencyId`), orphan `users` docs sharing the user's `stripeCustomerId`, and `arrayRemove`s the uid from another owner's agency `memberUids`.
- ✅ **Stripe cancel no longer swallowed** — `DELETE /api/account` aborts with a 500 *before* any erasure unless the cancel succeeds, the sub is `resource_missing`, or it's already `canceled` (`cancelSubscriptionBeforeErasure`).
- ✅ **SMS STOP bypass** — `/reminders/schedule` requires E.164 (spaces stripped, no country-code guessing); the cron fails any queued non-E.164 reminder (`invalid number`) and checks `smsSuppressed` against the normalized number. Same rule in `reminderService.js` (`normalizeE164`, shared by the live route).

### Still open
- [ ] **Meta deletion callback: capture the Meta user ID** — add an OAuth (Facebook Login) connect flow or call `GET /debug_token` (needs an app access token: `META_APP_ID|META_APP_SECRET`) at connect time to store `metaUserId` on the channel doc, then match `collectionGroup('channels').where('metaUserId','==',user_id)` in the callback. Until then, don't report deletions as `completed`. Channels connected before that change will have no `metaUserId`.
- [ ] **Pin canary/beta dependencies to stable releases** — React canary, Zod canary, Stripe beta, date-fns alpha.
61 changes: 44 additions & 17 deletions app/api/[[...path]]/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { logError } from "@/lib/logger";
import { AppError } from "@/lib/errors";
import { buildOpenApiSpec } from "@/lib/openapi";
import { createAgent, isAgentAccessDenied } from "@/lib/services/agentService";
import { eraseUserData } from "@/lib/services/accountService";
import {
eraseUserData,
cancelSubscriptionBeforeErasure,
SUBSCRIPTION_CANCEL_FAILED_MESSAGE,
} from "@/lib/services/accountService";
import {
createCheckoutSession,
createPortalSession,
Expand Down Expand Up @@ -54,14 +58,15 @@ import { testConnection as testEmailConnection, sendEmail } from "@/lib/email";
import { sendMail } from "@/lib/mail";

import { testTwilio, sendSMS } from "@/lib/sms";
import { normalizeE164, E164_ERROR } from "@/lib/services/reminderService";
import {
ghlValidate,
ghlGetContact,
ghlCreateContact,
ghlCreateAppointment,
} from "@/lib/ghl";
import { PROSPECT_CHANNELS } from "@/lib/prospects";
import { deriveResultState } from "@/lib/resultState";
import { deriveResultState, toPublicResult } from "@/lib/resultState";
import { findPlaceholders } from "@/lib/scriptText";
import { getBaseUrl } from "@/lib/baseUrl";

Expand Down Expand Up @@ -671,7 +676,7 @@ Rules:
request,
NextResponse.json({ error: "not found" }, { status: 404 }),
);
return handleCORS(request, NextResponse.json(ser(snap)));
return handleCORS(request, NextResponse.json(toPublicResult(ser(snap))));
}

// GET /api/competitors
Expand Down Expand Up @@ -1483,22 +1488,30 @@ Rules:
}
const uid = decoded.uid;

// 1. Cancel Stripe subscription if active
// 1. Cancel Stripe subscription if active. A failure aborts the whole
// request before any erasure, so the user can safely retry.
const userDoc = await db.collection("users").doc(uid).get();
const subId = userDoc.data()?.stripeSubscriptionId;
try {
const userDoc = await db.collection("users").doc(uid).get();
const subId = userDoc.data()?.stripeSubscriptionId;
if (subId) {
const stripe = getStripe();
await stripe.subscriptions.cancel(subId);
}
await cancelSubscriptionBeforeErasure({
stripe: subId ? getStripe() : null,
subscriptionId: subId,
});
} catch (err) {
logError("account deletion: stripe cancel error", err);
logError("account deletion: stripe cancel failed", err, { uid });
return handleCORS(
request,
NextResponse.json(
{ error: SUBSCRIPTION_CANCEL_FAILED_MESSAGE },
{ status: 500 },
),
);
}

// 2. Erase all Firestore data, subcollections included. On failure, stop
// before deleting the Auth user so the request can be retried.
try {
await eraseUserData({ db, uid });
await eraseUserData({ db, FieldValue, uid });
} catch (err) {
logError("account deletion: firestore erasure failed", err);
return handleCORS(
Expand Down Expand Up @@ -1922,16 +1935,22 @@ Rules:
request,
NextResponse.json({ error: "invalid JSON body" }, { status: 400 }),
);
const { to, scheduledAt, leadName } = body;
const { scheduledAt, leadName } = body;
const when = Date.parse(scheduledAt);
if (!to || !Number.isFinite(when))
if (!body.to || !Number.isFinite(when))
return handleCORS(
request,
NextResponse.json(
{ error: "to and a valid scheduledAt are required" },
{ status: 400 },
),
);
const to = normalizeE164(body.to);
if (!to)
return handleCORS(
request,
NextResponse.json({ error: E164_ERROR }, { status: 400 }),
);

const pendingRef = db
.collection("reminders")
Expand All @@ -1952,7 +1971,7 @@ Rules:
pendingRef.doc(id).set({
id,
uid: decoded.uid,
to: truncate(String(to), 40),
to,
body: `Hi ${name}, reminder: your call is in ${o.label}.`,
sendAt: new Date(sendAt),
status: "pending",
Expand Down Expand Up @@ -2013,11 +2032,19 @@ Rules:
});
if (!claimed) continue;
try {
// Reminders queued before E.164 validation may hold numbers no
// opt-out can match; never send to those.
const to = normalizeE164(r.to);
if (!to) {
failed++;
await doc.ref.update({ status: "failed", error: "invalid number" });
continue;
}
const suppressed = await db
.collection("users")
.doc(r.uid)
.collection("smsSuppressed")
.doc(r.to)
.doc(to)
.get();
if (suppressed.exists) {
await doc.ref.update({
Expand All @@ -2035,7 +2062,7 @@ Rules:
if (!chSnap.exists || !chSnap.data().connected)
throw new Error("sms channel not connected");
const creds = JSON.parse(decrypt(chSnap.data().encryptedCreds));
await sendSMS(creds, r.to, r.body);
await sendSMS(creds, to, r.body);
sent++;
} catch (e) {
failed++;
Expand Down
22 changes: 12 additions & 10 deletions graphify-out/.graphify_labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@
"9": "package.json",
"10": "@playwright/test",
"11": "utils.js",
"12": "auth-context.js",
"12": "dashboard/page.js",
"13": "alert-dialog.jsx",
"14": "menubar.jsx",
"14": "agentService.js",
"15": "command.jsx",
"16": "components.json",
"17": "biome.json",
"18": "form.jsx",
"19": "health/route.js",
"19": "rateLimit.js",
"20": "prospectService.js",
"21": "UnauthorizedError",
"22": "toggle-group.jsx",
"23": "chart.jsx",
"24": "carousel.jsx",
"25": "input-otp.jsx",
"26": "navigation-menu.jsx",
"27": "prospects.js",
"27": "logError",
"28": "drawer.jsx",
"29": "@sentry/nextjs",
"30": "paths",
Expand All @@ -40,8 +40,8 @@
"38": "ref_node_url",
"39": "scripts",
"40": "app/layout.js",
"41": "encryption.js",
"42": "Use cases by area",
"41": "context-menu.jsx",
"42": "handleRoute",
"43": "webhookAuth.js",
"44": "contact/page.js",
"45": "privacy/page.js",
Expand All @@ -55,23 +55,25 @@
"53": "DMForge",
"54": "@radix-ui/react-aspect-ratio",
"55": "@radix-ui/react-collapsible",
"56": "graphify in DMForge",
"56": "auth-context.js",
"57": "next.config.js",
"58": "tailwind.config.js",
"59": "vercel.json",
"60": "email.js",
"60": "health/route.js",
"61": "DMForge Deployment Runbook",
"62": "[id]/page.js",
"62": "Test Run 1 - Firebase Auth + Firestore E2E Testing",
"63": "Bug Report Template",
"64": "Feature Request Template",
"65": "Pull Request Template",
"66": "Legal & Regulatory Compliance Notes",
"67": "postcss.config.js",
"68": "useAuth",
"69": "__init__.py",
"70": "accordion.jsx",
"71": "about/page.js",
"72": "complaints/page.js",
"73": "data-deletion/page.js",
"83": "DMForge E2E — real-browser click tests (Playwright)",
"89": "Security Policy",
"93": "vitest.config.mjs",
"94": "CLEAN_CODE_REPORT.md",
"95": "engines",
Expand Down
Loading
Loading