From a8d736a6e32efa9efcfdc503d99ae37e21e7bb44 Mon Sep 17 00:00:00 2001 From: Mcgyver-ai Date: Sat, 17 Oct 2026 13:35:57 +0100 Subject: [PATCH 01/11] fix(account): close GDPR erasure gaps and stop swallowing Stripe cancel failures eraseUserData also deletes agencies/{uid}, inbound_tokens, ghl_events, invites, orphan users docs sharing the stripeCustomerId, and removes the uid from another owner's agency. cancelSubscriptionBeforeErasure aborts deletion unless the subscription is cancelled or already gone. --- lib/__tests__/accountService.test.js | 147 +++++++++++++++++++++++---- lib/services/accountService.js | 60 ++++++++++- 2 files changed, 184 insertions(+), 23 deletions(-) diff --git a/lib/__tests__/accountService.test.js b/lib/__tests__/accountService.test.js index f3e678e..60ddeac 100644 --- a/lib/__tests__/accountService.test.js +++ b/lib/__tests__/accountService.test.js @@ -1,19 +1,38 @@ import { describe, it, expect, vi } from 'vitest' -import { eraseUserData } from '../services/accountService' +import { + eraseUserData, + cancelSubscriptionBeforeErasure, + SUBSCRIPTION_CANCEL_FAILED_MESSAGE, +} from '../services/accountService' import { ValidationError } from '../errors' -// Fake Firestore: doc refs are path strings; `owned` maps "collection.field" to matching doc paths. -function fakeDb({ owned = {}, failPaths = [] } = {}) { +const FieldValue = { arrayRemove: (...items) => ({ arrayRemove: items }) } + +// Fake Firestore: doc refs carry their path; `owned` maps "collection.field=value" to +// matching doc paths, `docs` holds the data a doc read returns. +function fakeDb({ owned = {}, docs = {}, failPaths = [], updateError } = {}) { const recursiveDelete = vi.fn(async (ref) => { - if (failPaths.includes(ref)) throw new Error(`boom ${ref}`) + if (failPaths.includes(ref.path)) throw new Error(`boom ${ref.path}`) + }) + const update = vi.fn(async () => { + if (updateError) throw updateError + }) + const docRef = (path) => ({ + path, + get: async () => (docs[path] ? { exists: true, data: () => docs[path] } : { exists: false }), + update: (data) => update(path, data), }) const db = { recursiveDelete, + update, collection: (name) => ({ - doc: (id) => `${name}/${id}`, + doc: (id) => docRef(`${name}/${id}`), where: (field, _op, value) => ({ get: async () => ({ - docs: (owned[`${name}.${field}`] || []).map((path) => ({ ref: path, value })), + docs: (owned[`${name}.${field}=${value}`] || []).map((path) => ({ + id: path.split('/')[1], + ref: docRef(path), + })), }), }), }), @@ -21,35 +40,127 @@ function fakeDb({ owned = {}, failPaths = [] } = {}) { return db } +const deletedPaths = (db) => db.recursiveDelete.mock.calls.map(([ref]) => ref.path) + describe('eraseUserData (lib/services/accountService.js)', () => { it('rejects a missing uid', async () => { - await expect(eraseUserData({ db: fakeDb(), uid: '' })).rejects.toThrow(ValidationError) + await expect(eraseUserData({ db: fakeDb(), FieldValue, uid: '' })).rejects.toThrow(ValidationError) }) it('recursively deletes the uid-keyed trees and every owned top-level doc', async () => { const db = fakeDb({ owned: { - 'agents.ownerUid': ['agents/a1'], - 'conversations.ownerUid': ['conversations/c1'], - 'results.ownerUid': ['results/r1', 'results/r2'], - 'meta_channel_index.uid': ['meta_channel_index/page1'], + 'agents.ownerUid=u1': ['agents/a1'], + 'conversations.ownerUid=u1': ['conversations/c1'], + 'results.ownerUid=u1': ['results/r1', 'results/r2'], + 'meta_channel_index.uid=u1': ['meta_channel_index/page1'], + 'inbound_tokens.uid=u1': ['inbound_tokens/tok1'], + 'ghl_events.uid=u1': ['ghl_events/e1'], + 'invites.agencyId=u1': ['invites/inv1'], }, }) - const outcome = await eraseUserData({ db, uid: 'u1' }) + const outcome = await eraseUserData({ db, FieldValue, uid: 'u1' }) - const deleted = db.recursiveDelete.mock.calls.map(([ref]) => ref) - expect(deleted).toEqual([ - 'users/u1', 'leads/u1', 'reminders/u1', + expect(deletedPaths(db)).toEqual([ + 'users/u1', 'leads/u1', 'reminders/u1', 'agencies/u1', 'agents/a1', 'conversations/c1', 'results/r1', 'results/r2', 'meta_channel_index/page1', + 'inbound_tokens/tok1', 'ghl_events/e1', 'invites/inv1', ]) - expect(outcome).toEqual({ deletedTrees: 8 }) + expect(outcome).toEqual({ deletedTrees: 12 }) + expect(db.update).not.toHaveBeenCalled() + }) + + it('also deletes orphan users docs sharing the stripeCustomerId, but not the user twice', async () => { + const db = fakeDb({ + docs: { 'users/u1': { stripeCustomerId: 'cus_1' } }, + owned: { 'users.stripeCustomerId=cus_1': ['users/u1', 'users/cus_1'] }, + }) + + await eraseUserData({ db, FieldValue, uid: 'u1' }) + + const deleted = deletedPaths(db) + expect(deleted).toContain('users/cus_1') + expect(deleted.filter((p) => p === 'users/u1')).toHaveLength(1) + }) + + it("removes the uid from another owner's agency memberUids", async () => { + const db = fakeDb({ docs: { 'users/u1': { agencyId: 'owner9' } } }) + + await eraseUserData({ db, FieldValue, uid: 'u1' }) + + expect(db.update).toHaveBeenCalledWith('agencies/owner9', { memberUids: { arrayRemove: ['u1'] } }) + }) + + it('does not touch memberUids when the user owns the agency', async () => { + const db = fakeDb({ docs: { 'users/u1': { agencyId: 'u1' } } }) + + await eraseUserData({ db, FieldValue, uid: 'u1' }) + + expect(db.update).not.toHaveBeenCalled() + }) + + it('tolerates an already-deleted agency but fails on any other update error', async () => { + const gone = fakeDb({ docs: { 'users/u1': { agencyId: 'owner9' } }, updateError: { code: 5 } }) + await expect(eraseUserData({ db: gone, FieldValue, uid: 'u1' })).resolves.toEqual({ deletedTrees: 4 }) + + const broken = fakeDb({ docs: { 'users/u1': { agencyId: 'owner9' } }, updateError: new Error('unavailable') }) + await expect(eraseUserData({ db: broken, FieldValue, uid: 'u1' })).rejects.toThrow('erasure incomplete: 1 of 5') }) it('throws after attempting every delete when any of them fails', async () => { const db = fakeDb({ failPaths: ['leads/u1'] }) - await expect(eraseUserData({ db, uid: 'u1' })).rejects.toThrow('erasure incomplete: 1 of 3') - expect(db.recursiveDelete).toHaveBeenCalledTimes(3) + await expect(eraseUserData({ db, FieldValue, uid: 'u1' })).rejects.toThrow('erasure incomplete: 1 of 4') + expect(db.recursiveDelete).toHaveBeenCalledTimes(4) + }) +}) + +describe('cancelSubscriptionBeforeErasure (lib/services/accountService.js)', () => { + const stripeWith = ({ cancel, retrieve }) => ({ + subscriptions: { cancel: vi.fn(cancel), retrieve: vi.fn(retrieve) }, + }) + + it('is a no-op without a subscription', async () => { + await expect(cancelSubscriptionBeforeErasure({ stripe: null, subscriptionId: undefined })).resolves.toBeUndefined() + }) + + it('cancels an active subscription', async () => { + const stripe = stripeWith({ cancel: async () => ({ status: 'canceled' }) }) + await cancelSubscriptionBeforeErasure({ stripe, subscriptionId: 'sub_1' }) + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith('sub_1') + }) + + it('continues when the subscription no longer exists', async () => { + const stripe = stripeWith({ cancel: async () => { throw Object.assign(new Error('No such subscription'), { code: 'resource_missing' }) } }) + await expect(cancelSubscriptionBeforeErasure({ stripe, subscriptionId: 'sub_1' })).resolves.toBeUndefined() + expect(stripe.subscriptions.retrieve).not.toHaveBeenCalled() + }) + + it('continues when the cancel errors but the subscription is already canceled', async () => { + const stripe = stripeWith({ + cancel: async () => { throw new Error('already canceled') }, + retrieve: async () => ({ status: 'canceled' }), + }) + await expect(cancelSubscriptionBeforeErasure({ stripe, subscriptionId: 'sub_1' })).resolves.toBeUndefined() + }) + + it('aborts with the user-facing 500 for any other failure', async () => { + const stripe = stripeWith({ + cancel: async () => { throw Object.assign(new Error('api down'), { code: 'api_error' }) }, + retrieve: async () => ({ status: 'active' }), + }) + await expect(cancelSubscriptionBeforeErasure({ stripe, subscriptionId: 'sub_1' })).rejects.toMatchObject({ + message: SUBSCRIPTION_CANCEL_FAILED_MESSAGE, + statusCode: 500, + }) + }) + + it('aborts when the status check itself fails', async () => { + const stripe = stripeWith({ + cancel: async () => { throw new Error('timeout') }, + retrieve: async () => { throw new Error('timeout') }, + }) + await expect(cancelSubscriptionBeforeErasure({ stripe, subscriptionId: 'sub_1' })).rejects.toThrow(SUBSCRIPTION_CANCEL_FAILED_MESSAGE) }) }) diff --git a/lib/services/accountService.js b/lib/services/accountService.js index 335aea7..80e3494 100644 --- a/lib/services/accountService.js +++ b/lib/services/accountService.js @@ -1,7 +1,10 @@ -import { ValidationError } from '@/lib/errors' +import { AppError, ValidationError } from '@/lib/errors' + +export const SUBSCRIPTION_CANCEL_FAILED_MESSAGE = + "Couldn't cancel your subscription. Nothing was deleted. Please retry or contact support." // Docs keyed by the user's uid; each roots subcollections (channels, webhooks, prospects, pending reminders…). -const UID_KEYED_COLLECTIONS = ['users', 'leads', 'reminders'] +const UID_KEYED_COLLECTIONS = ['users', 'leads', 'reminders', 'agencies'] // Top-level docs that point back at their owner through a field. const OWNER_FIELD_BY_COLLECTION = [ @@ -9,8 +12,14 @@ const OWNER_FIELD_BY_COLLECTION = [ ['conversations', 'ownerUid'], ['results', 'ownerUid'], ['meta_channel_index', 'uid'], + ['inbound_tokens', 'uid'], + ['ghl_events', 'uid'], + ['invites', 'agencyId'], ] +// Firestore NOT_FOUND: the agency was already deleted, so there's nothing to leave. +const GRPC_NOT_FOUND = 5 + /** * Erases every Firestore document holding a user's personal data (GDPR Art. 17). * Deleting a parent doc leaves its subcollections behind, so each owned tree goes @@ -19,25 +28,66 @@ const OWNER_FIELD_BY_COLLECTION = [ * * @param {object} params * @param {any} params.db Firestore Admin instance + * @param {any} params.FieldValue Firestore Admin FieldValue * @param {string} params.uid * @returns {Promise<{ deletedTrees: number }>} */ -export async function eraseUserData({ db, uid }) { +export async function eraseUserData({ db, FieldValue, uid }) { if (!uid) throw new ValidationError('uid required') + // Read before anything is deleted: these fields lead to data held elsewhere. + const userSnap = await db.collection('users').doc(uid).get() + const { stripeCustomerId, agencyId } = userSnap.exists ? userSnap.data() : {} + const ownedRefs = UID_KEYED_COLLECTIONS.map((name) => db.collection(name).doc(uid)) for (const [name, field] of OWNER_FIELD_BY_COLLECTION) { const snap = await db.collection(name).where(field, '==', uid).get() ownedRefs.push(...snap.docs.map((doc) => doc.ref)) } + // The Stripe webhook creates users docs keyed by customer id when it can't + // match a uid; they hold the same email and billing state. + if (stripeCustomerId) { + const snap = await db.collection('users').where('stripeCustomerId', '==', stripeCustomerId).get() + ownedRefs.push(...snap.docs.filter((doc) => doc.id !== uid).map((doc) => doc.ref)) + } + + const operations = ownedRefs.map((ref) => db.recursiveDelete(ref)) + if (agencyId && agencyId !== uid) { + operations.push( + db.collection('agencies').doc(agencyId) + .update({ memberUids: FieldValue.arrayRemove(uid) }) + .catch((err) => { if (err?.code !== GRPC_NOT_FOUND) throw err }), + ) + } - const outcomes = await Promise.allSettled(ownedRefs.map((ref) => db.recursiveDelete(ref))) + const outcomes = await Promise.allSettled(operations) const failures = outcomes.filter((outcome) => outcome.status === 'rejected') if (failures.length > 0) { throw new AggregateError( failures.map((failure) => failure.reason), - `erasure incomplete: ${failures.length} of ${ownedRefs.length} deletes failed`, + `erasure incomplete: ${failures.length} of ${operations.length} deletes failed`, ) } return { deletedTrees: ownedRefs.length } } + +/** + * Cancels the user's subscription ahead of erasure. Erasing a still-billing + * customer would keep charging someone we can no longer identify, so any + * failure other than "already gone" aborts before anything is deleted. + * + * @param {object} params + * @param {any} params.stripe Stripe client + * @param {string} [params.subscriptionId] + */ +export async function cancelSubscriptionBeforeErasure({ stripe, subscriptionId }) { + if (!subscriptionId) return + try { + await stripe.subscriptions.cancel(subscriptionId) + } catch (err) { + if (err?.code === 'resource_missing') return + const sub = await stripe.subscriptions.retrieve(subscriptionId).catch(() => null) + if (sub?.status === 'canceled') return + throw new AppError(SUBSCRIPTION_CANCEL_FAILED_MESSAGE, 500, 'SUBSCRIPTION_CANCEL_FAILED', { cause: err }) + } +} From 352c8d3cf0dbe14f598b7edd42e0a2021118b7ec Mon Sep 17 00:00:00 2001 From: Mcgyver-ai Date: Sat, 17 Oct 2026 13:35:58 +0100 Subject: [PATCH 02/11] fix(sms): require E.164 reminder numbers so STOP opt-outs always match smsSuppressed is keyed by Twilio's E.164 From number. Reminders are now validated as E.164 at schedule time, and the cron fails any queued non-E.164 reminder instead of sending past an opt-out. --- lib/__tests__/reminderService.test.js | 94 +++++++++++++++++++++++++++ lib/services/reminderService.js | 31 +++++++-- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/reminderService.test.js b/lib/__tests__/reminderService.test.js index 5eb48d0..9645853 100644 --- a/lib/__tests__/reminderService.test.js +++ b/lib/__tests__/reminderService.test.js @@ -4,6 +4,7 @@ import { disconnectSMSChannel, scheduleReminders, sendDueReminders, + normalizeE164, } from '../services/reminderService' import { ValidationError, @@ -125,3 +126,96 @@ describe('Reminder Service (lib/services/reminderService.js)', () => { }) }) +describe('SMS opt-out safety (lib/services/reminderService.js)', () => { + const fakeFieldValue = { serverTimestamp: () => 'MOCK_TIMESTAMP' } + + it('normalizeE164 strips spaces and refuses anything not provably E.164', () => { + expect(normalizeE164('+44 7700 900123')).toBe('+447700900123') + expect(normalizeE164('07700 900123')).toBeNull() + expect(normalizeE164('447700900123')).toBeNull() + expect(normalizeE164('+0123456789')).toBeNull() + expect(normalizeE164('+1234567')).toBeNull() + expect(normalizeE164(undefined)).toBeNull() + }) + + it('rejects a non-E.164 number at schedule time without writing', async () => { + const setDoc = vi.fn() + const db = { collection: () => ({ doc: () => ({ collection: () => ({ doc: () => ({ set: setDoc }) }) }) }) } + await expect( + scheduleReminders({ + db, + FieldValue: fakeFieldValue, + user: { uid: 'u1' }, + body: { to: '07700 900123', scheduledAt: new Date(Date.now() + 48 * 3600_000).toISOString() }, + }) + ).rejects.toThrow('to must be E.164, e.g. +447700900123') + expect(setDoc).not.toHaveBeenCalled() + }) + + function cronDb({ reminders, suppressed = [] }) { + const docs = reminders.map((r) => ({ data: () => r, ref: { update: vi.fn(async () => {}) } })) + const smsSuppressedLookups = [] + const db = { + collectionGroup: () => { + const q = { where: () => q, limit: () => q, get: async () => ({ size: docs.length, docs }) } + return q + }, + runTransaction: async (fn) => fn({ get: async () => ({ exists: true, data: () => ({ status: 'pending' }) }), update: () => {} }), + collection: () => ({ + doc: () => ({ + collection: (sub) => ({ + doc: (id) => ({ + get: async () => { + if (sub === 'smsSuppressed') smsSuppressedLookups.push(id) + if (sub === 'smsSuppressed') return { exists: suppressed.includes(id) } + return { exists: true, data: () => ({ connected: true, encryptedCreds: 'enc' }) } + }, + }), + }), + }), + }), + } + return { db, docs, smsSuppressedLookups } + } + + it('skips a suppressed E.164 recipient in the cron', async () => { + const sendSMSFn = vi.fn() + const { db, docs } = cronDb({ + reminders: [{ uid: 'u1', to: '+447700900123', body: 'hi' }], + suppressed: ['+447700900123'], + }) + const res = await sendDueReminders({ db, FieldValue: fakeFieldValue, cronSecret: 's', authHeader: 'Bearer s', decryptFn: () => '{}', sendSMSFn }) + expect(sendSMSFn).not.toHaveBeenCalled() + expect(docs[0].ref.update).toHaveBeenCalledWith({ status: 'skipped', error: 'recipient opted out' }) + expect(res.sent).toBe(0) + }) + + it('matches suppression for a legacy spaced number via its E.164 form', async () => { + const sendSMSFn = vi.fn() + const { db, smsSuppressedLookups } = cronDb({ + reminders: [{ uid: 'u1', to: '+44 7700 900123', body: 'hi' }], + suppressed: ['+447700900123'], + }) + await sendDueReminders({ db, FieldValue: fakeFieldValue, cronSecret: 's', authHeader: 'Bearer s', decryptFn: () => '{}', sendSMSFn }) + expect(smsSuppressedLookups).toEqual(['+447700900123']) + expect(sendSMSFn).not.toHaveBeenCalled() + }) + + it('fails an already-queued non-E.164 reminder without sending', async () => { + const sendSMSFn = vi.fn() + const { db, docs, smsSuppressedLookups } = cronDb({ reminders: [{ uid: 'u1', to: '07700 900123', body: 'hi' }] }) + const res = await sendDueReminders({ db, FieldValue: fakeFieldValue, cronSecret: 's', authHeader: 'Bearer s', decryptFn: () => '{}', sendSMSFn }) + expect(docs[0].ref.update).toHaveBeenCalledWith({ status: 'failed', error: 'invalid number' }) + expect(smsSuppressedLookups).toEqual([]) + expect(sendSMSFn).not.toHaveBeenCalled() + expect(res.failed).toBe(1) + }) + + it('sends to an unsuppressed E.164 recipient', async () => { + const sendSMSFn = vi.fn(async () => {}) + const { db } = cronDb({ reminders: [{ uid: 'u1', to: '+447700900123', body: 'hi' }] }) + const res = await sendDueReminders({ db, FieldValue: fakeFieldValue, cronSecret: 's', authHeader: 'Bearer s', decryptFn: () => '{}', sendSMSFn }) + expect(sendSMSFn).toHaveBeenCalledWith({}, '+447700900123', 'hi') + expect(res.sent).toBe(1) + }) +}) diff --git a/lib/services/reminderService.js b/lib/services/reminderService.js index e8f12e8..df78afc 100644 --- a/lib/services/reminderService.js +++ b/lib/services/reminderService.js @@ -15,6 +15,19 @@ import { validate, } from '@/lib/schemas' +export const E164_ERROR = 'to must be E.164, e.g. +447700900123' + +/** + * Returns the number in E.164 form (spaces stripped) or null. Twilio reports + * STOP senders in E.164 and smsSuppressed is keyed by that, so a reminder + * stored in any other format would never match an opt-out. No country-code + * guessing: a number we can't prove is E.164 is rejected. + */ +export function normalizeE164(raw) { + const compact = String(raw ?? '').replace(/\s+/g, '') + return /^\+[1-9]\d{7,14}$/.test(compact) ? compact : null +} + function truncate(str, max) { if (typeof str !== 'string') return str return str.slice(0, max) @@ -76,7 +89,9 @@ export async function disconnectSMSChannel({ db, user }) { */ export async function scheduleReminders({ db, FieldValue, user, body }) { if (!user?.uid) throw new UnauthorizedError('unauthorized') - const { to, scheduledAt, leadName } = validate(reminderScheduleSchema, body) + const { to: rawTo, scheduledAt, leadName } = validate(reminderScheduleSchema, body) + const to = normalizeE164(rawTo) + if (!to) throw new ValidationError(E164_ERROR) const when = Date.parse(scheduledAt) const pendingRef = db @@ -99,7 +114,7 @@ export async function scheduleReminders({ db, FieldValue, user, body }) { pendingRef.doc(id).set({ id, uid: user.uid, - to: truncate(String(to), 40), + to, body: `Hi ${name}, reminder: your call is in ${o.label}.`, sendAt: new Date(sendAt), status: 'pending', @@ -162,11 +177,19 @@ export async function sendDueReminders({ 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({ @@ -185,7 +208,7 @@ export async function sendDueReminders({ throw new Error('sms channel not connected') } const creds = JSON.parse(decryptFn(chSnap.data().encryptedCreds)) - await sendSMSFn(creds, r.to, r.body) + await sendSMSFn(creds, to, r.body) sent++ } catch (e) { failed++ From cc58b3c64499e1fcbd01b8f5469ac3cc65a4c7cd Mon Sep 17 00:00:00 2001 From: Mcgyver-ai Date: Sat, 17 Oct 2026 13:35:59 +0100 Subject: [PATCH 03/11] fix(api): strip owner PII from public results and wire erasure and SMS fixes into routes --- app/api/[[...path]]/route.js | 61 ++++++++++++++++++++++--------- lib/__tests__/resultState.test.js | 20 ++++++++++ lib/resultState.js | 7 ++++ 3 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 lib/__tests__/resultState.test.js diff --git a/app/api/[[...path]]/route.js b/app/api/[[...path]]/route.js index f0952db..3efc5da 100644 --- a/app/api/[[...path]]/route.js +++ b/app/api/[[...path]]/route.js @@ -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, @@ -54,6 +58,7 @@ 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, @@ -61,7 +66,7 @@ import { 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"; @@ -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 @@ -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( @@ -1922,9 +1935,9 @@ 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( @@ -1932,6 +1945,12 @@ Rules: { status: 400 }, ), ); + const to = normalizeE164(body.to); + if (!to) + return handleCORS( + request, + NextResponse.json({ error: E164_ERROR }, { status: 400 }), + ); const pendingRef = db .collection("reminders") @@ -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", @@ -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({ @@ -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++; diff --git a/lib/__tests__/resultState.test.js b/lib/__tests__/resultState.test.js new file mode 100644 index 0000000..b9942cf --- /dev/null +++ b/lib/__tests__/resultState.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' +import { toPublicResult } from '../resultState' + +describe('toPublicResult (lib/resultState.js)', () => { + it('drops the owner identity from a public result', () => { + const out = toPublicResult({ + id: 'r1', + transcript: [{ role: 'assistant', content: 'hey' }], + ownerEmail: 'coach@example.com', + ownerUid: 'uid-1', + }) + expect(out).not.toHaveProperty('ownerEmail') + expect(out).not.toHaveProperty('ownerUid') + expect(out).toEqual({ id: 'r1', transcript: [{ role: 'assistant', content: 'hey' }] }) + }) + + it('tolerates a null result', () => { + expect(toPublicResult(null)).toEqual({}) + }) +}) diff --git a/lib/resultState.js b/lib/resultState.js index 6b92b87..f90aefa 100644 --- a/lib/resultState.js +++ b/lib/resultState.js @@ -31,3 +31,10 @@ export function deriveResultState(transcript, agent = {}) { return { qualified, booked, bookedSlot } } + +// GET /api/result/:id is public (share links), so it must not carry the +// owner's identity. /my/results is the authenticated view that keeps them. +export function toPublicResult(result) { + const { ownerEmail, ownerUid, ...publicFields } = result || {} + return publicFields +} From 53a7e77087a66782268355f67e54f5d70da907ea Mon Sep 17 00:00:00 2001 From: Mcgyver-ai Date: Sat, 17 Oct 2026 13:36:00 +0100 Subject: [PATCH 04/11] docs(tasks): record compliance audit fixes and the blocked Meta deletion callback --- TASKS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/TASKS.md b/TASKS.md index 88c6fa1..34ccf54 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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. From 6b12678d3b53316ae7fe6140bb166b296c818c71 Mon Sep 17 00:00:00 2001 From: Mcgyver-ai Date: Sat, 17 Oct 2026 13:36:01 +0100 Subject: [PATCH 05/11] chore(graphify): refresh knowledge graph --- graphify-out/.graphify_labels.json | 22 +- graphify-out/GRAPH_REPORT.md | 191 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 5121 ++++++++++++++++++---------- graphify-out/manifest.json | 926 ++--- 5 files changed, 3969 insertions(+), 2299 deletions(-) diff --git a/graphify-out/.graphify_labels.json b/graphify-out/.graphify_labels.json index 508bc74..0f68b68 100644 --- a/graphify-out/.graphify_labels.json +++ b/graphify-out/.graphify_labels.json @@ -11,14 +11,14 @@ "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", @@ -26,7 +26,7 @@ "24": "carousel.jsx", "25": "input-otp.jsx", "26": "navigation-menu.jsx", - "27": "prospects.js", + "27": "logError", "28": "drawer.jsx", "29": "@sentry/nextjs", "30": "paths", @@ -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", @@ -55,13 +55,13 @@ "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", @@ -69,9 +69,11 @@ "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", diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index e9219c3..a6ac971 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,17 +1,17 @@ -# Graph Report - friendly-easley-93ee36 (2026-10-16) +# Graph Report - compliance (2026-10-17) ## Corpus Check -- 201 files · ~96,028 words +- 209 files · ~101,436 words - Verdict: corpus is large enough that graph structure adds value. - Unclassified: 21 file(s) not represented in the graph (top: (none) 15, .example 1, .code-workspace 1) ## Summary -- 1236 nodes · 2735 edges · 81 communities (51 shown, 30 thin omitted) -- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 53 edges (avg confidence: 0.93) +- 1276 nodes · 2834 edges · 83 communities (51 shown, 32 thin omitted) +- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 61 edges (avg confidence: 0.93) - Token cost: 0 input · 0 output ## Graph Freshness -- Built from commit: `26c6f70f` +- Built from commit: `e493421c` - Run `git rev-parse HEAD` and compare to check if the graph is stale. - Run `graphify update .` after code changes (no API cost). @@ -28,14 +28,14 @@ - package.json - @playwright/test - utils.js -- auth-context.js +- dashboard/page.js - alert-dialog.jsx -- menubar.jsx +- agentService.js - command.jsx - components.json - biome.json - form.jsx -- health/route.js +- rateLimit.js - prospectService.js - UnauthorizedError - toggle-group.jsx @@ -43,7 +43,7 @@ - carousel.jsx - input-otp.jsx - navigation-menu.jsx -- prospects.js +- logError - drawer.jsx - @sentry/nextjs - paths @@ -57,8 +57,8 @@ - ref_node_url - scripts - app/layout.js -- encryption.js -- Use cases by area +- context-menu.jsx +- handleRoute - webhookAuth.js - contact/page.js - privacy/page.js @@ -72,20 +72,21 @@ - DMForge - @radix-ui/react-aspect-ratio - @radix-ui/react-collapsible -- graphify in DMForge +- auth-context.js - next.config.js - vercel.json -- email.js +- health/route.js - DMForge Deployment Runbook -- [id]/page.js +- Test Run 1 - Firebase Auth + Firestore E2E Testing - Bug Report Template - Feature Request Template - Pull Request Template - Legal & Regulatory Compliance Notes - useAuth +- accordion.jsx - about/page.js +- complaints/page.js - DMForge E2E — real-browser click tests (Playwright) -- Security Policy - vitest.config.mjs - CLEAN_CODE_REPORT.md - engines @@ -97,26 +98,26 @@ ## God Nodes (most connected - your core abstractions) 1. `cn()` - 224 edges 2. `authFetch()` - 69 edges -3. `react` - 64 edges -4. `handleRoute()` - 56 edges +3. `handleRoute()` - 66 edges +4. `react` - 65 edges 5. `UnauthorizedError` - 54 edges -6. `ValidationError` - 42 edges +6. `ValidationError` - 46 edges 7. `lucide-react` - 34 edges 8. `logError()` - 30 edges 9. `useAuth()` - 29 edges -10. `next` - 28 edges +10. `next` - 29 edges ## Surprising Connections (you probably didn't know these) - `Recommendations` --references--> `authFetch()` [INFERRED] test_result.md → lib/auth-context.js - `Test Coverage` --references--> `authFetch()` [INFERRED] test_result.md → lib/auth-context.js -- `Health check (2026-10-16)` --references--> `UnauthorizedError` [INFERRED] - docs/GRAPHIFY.md → lib/errors.js -- `Health check (2026-10-16)` --references--> `ValidationError` [INFERRED] - docs/GRAPHIFY.md → lib/errors.js -- `Use cases by area` --references--> `checkLlmRateLimit()` [INFERRED] - docs/GRAPHIFY.md → lib/rateLimit.js +- `Specification & Background` --references--> `handleRoute()` [INFERRED] + project-tasks/inbound-rate-limit-tasklist.md → app/api/[[...path]]/route.js +- `[x] Task 3: Wire `checkInboundRateLimit` into `app/api/[[...path]]/route.js`` --references--> `handleRoute()` [INFERRED] + project-tasks/inbound-rate-limit-tasklist.md → app/api/[[...path]]/route.js +- `Use cases by area` --references--> `useAuth()` [INFERRED] + docs/GRAPHIFY.md → lib/auth-context.js ## Import Cycles - None detected. @@ -124,11 +125,11 @@ ## Hyperedges (group relationships) - **Design Governance Stack** — impeccable_design_context, figma_design_system, brand_tokens [EXTRACTED 1.00] -## Communities (81 total, 30 thin omitted) +## Communities (83 total, 32 thin omitted) ### Community 0 - "react" -Cohesion: 0.22 -Nodes (15): ProspectCard(), relTime(), STATUS_STYLE, STATUSES, ThreadDrawer(), GREETING, Badge(), badgeVariants (+7 more) +Cohesion: 0.20 +Nodes (16): ProspectCard(), relTime(), STATUS_STYLE, STATUSES, ThreadDrawer(), GREETING, Badge(), badgeVariants (+8 more) ### Community 1 - "dependencies" Cohesion: 0.03 @@ -151,12 +152,12 @@ Cohesion: 0.06 Nodes (41): Separator, components_ui_sheet_sheet, SheetContent, SheetDescription, SheetFooter(), SheetHeader(), SheetOverlay, SheetTitle (+33 more) ### Community 6 - "cn" -Cohesion: 0.06 -Nodes (50): Avatar, AvatarFallback, AvatarImage, CardContent, CardDescription, CardFooter, CardHeader, CardTitle (+42 more) +Cohesion: 0.05 +Nodes (52): Avatar, AvatarFallback, AvatarImage, CardContent, CardDescription, CardFooter, CardHeader, CardTitle (+44 more) ### Community 7 - "[[...path]]/route.js" Cohesion: 0.10 -Nodes (28): agentDenied(), ALLOWED_ORIGINS, CHANNEL_LABELS, CHAT_TURN_SCHEMA, DELETE, EMPTY_CHAT_STATE, formatPrice(), GET (+20 more) +Nodes (26): ALLOWED_ORIGINS, CHANNEL_LABELS, CHAT_TURN_SCHEMA, DELETE, EMPTY_CHAT_STATE, formatPrice(), GET, handleCORS() (+18 more) ### Community 8 - "functions/index.js" Cohesion: 0.08 @@ -164,27 +165,27 @@ Nodes (27): crypto, decrypt(), { defineSecret }, deriveKey(), ENCRYPTION_KEY, EN ### Community 9 - "package.json" Cohesion: 0.06 -Nodes (31): homepage, firebase-admin, license, name, packageManager, private, version, autoprefixer (+23 more) +Nodes (33): homepage, firebase-admin, license, name, packageManager, private, version, autoprefixer (+25 more) ### Community 10 - "@playwright/test" -Cohesion: 0.07 -Nodes (18): findPlaceholders(), leadFacingFields(), scriptPlaceholders(), { defineConfig, devices }, IMPORTANT: Defaults to localhost (safe for local/CI testing)., @playwright/test, { test, expect }, { test, expect } (+10 more) +Cohesion: 0.06 +Nodes (19): BOOKED_CONFIRMATION, deriveResultState(), toPublicResult(), { defineConfig, devices }, IMPORTANT: Defaults to localhost (safe for local/CI testing)., @playwright/test, { test, expect }, { test, expect } (+11 more) ### Community 11 - "utils.js" -Cohesion: 0.05 -Nodes (27): AccordionContent, AccordionItem, AccordionTrigger, Checkbox, HoverCardContent, PopoverContent, Progress, RadioGroup (+19 more) +Cohesion: 0.06 +Nodes (21): Checkbox, HoverCardContent, PopoverContent, Progress, RadioGroup, RadioGroupItem, ResizableHandle(), ResizablePanelGroup() (+13 more) -### Community 12 - "auth-context.js" -Cohesion: 0.16 -Nodes (7): Logo(), AuthCtx, auth, db, firebaseConfig, googleProvider, firebase +### Community 12 - "dashboard/page.js" +Cohesion: 0.17 +Nodes (5): generateMetadata(), getResult(), ResultPage(), Logo(), @testing-library/react ### Community 13 - "alert-dialog.jsx" Cohesion: 0.11 Nodes (20): AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter(), AlertDialogHeader(), AlertDialogOverlay, AlertDialogTitle (+12 more) -### Community 14 - "menubar.jsx" -Cohesion: 0.11 -Nodes (12): Menubar, MenubarCheckboxItem, MenubarContent, MenubarItem, MenubarLabel, MenubarRadioItem, MenubarSeparator, MenubarShortcut() (+4 more) +### Community 14 - "agentService.js" +Cohesion: 0.21 +Nodes (14): agentDenied(), AppError, chat(), chatJSON(), GEMINI_URL(), repairLLMJson(), toGemini(), findPlaceholders() (+6 more) ### Community 15 - "command.jsx" Cohesion: 0.11 @@ -202,21 +203,21 @@ Nodes (41): source, assist, actions, noUnusedImports, noUnusedVariables, files, Cohesion: 0.17 Nodes (13): FormControl, FormDescription, FormFieldContext, FormItem, FormItemContext, FormLabel, FormMessage, useFormField() (+5 more) -### Community 19 - "health/route.js" -Cohesion: 0.13 -Nodes (23): checkFirestore(), checkGemini(), checkStripe(), dynamic, GET(), chat(), chatJSON(), GEMINI_BASE() (+15 more) +### Community 19 - "rateLimit.js" +Cohesion: 0.15 +Nodes (18): buckets, checkInboundRateLimit(), checkLlmRateLimit(), checkRateLimit(), clientIp(), getLimiter(), hit(), hitMemory() (+10 more) ### Community 20 - "prospectService.js" Cohesion: 0.35 Nodes (14): NotFoundError, normalizeChannel(), normalizeStatus(), addProspectMessage(), appendProspectMessage(), createProspect(), deleteProspect(), getOrCreateInboundToken() (+6 more) ### Community 21 - "UnauthorizedError" -Cohesion: 0.07 -Nodes (73): AppError, BadGatewayError, ForbiddenError, UnauthorizedError, ValidationError, log(), logError(), sendMetaMessage() (+65 more) +Cohesion: 0.05 +Nodes (82): cleanSecret(), decrypt(), deriveKey(), encrypt(), getCurrentKey(), getDecryptKeys(), packAndEncrypt(), unpackAndDecrypt() (+74 more) ### Community 22 - "toggle-group.jsx" -Cohesion: 0.31 -Nodes (7): ToggleGroup, ToggleGroupContext, ToggleGroupItem, Toggle, toggleVariants, @radix-ui/react-toggle, @radix-ui/react-toggle-group +Cohesion: 0.17 +Nodes (12): Alert, AlertDescription, AlertTitle, alertVariants, ToggleGroup, ToggleGroupContext, ToggleGroupItem, Toggle (+4 more) ### Community 23 - "chart.jsx" Cohesion: 0.29 @@ -231,19 +232,19 @@ Cohesion: 0.33 Nodes (5): InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, input-otp ### Community 26 - "navigation-menu.jsx" -Cohesion: 0.15 -Nodes (13): Alert, AlertDescription, AlertTitle, alertVariants, NavigationMenu, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuList (+5 more) +Cohesion: 0.25 +Nodes (8): NavigationMenu, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle, NavigationMenuViewport, @radix-ui/react-navigation-menu -### Community 27 - "prospects.js" -Cohesion: 0.32 -Nodes (10): ghlCreateAppointment(), ghlCreateContact(), ghlGetContact(), ghlValidate(), headers(), onProspectBooked(), PROSPECT_CHANNELS, PROSPECT_STATUSES (+2 more) +### Community 27 - "logError" +Cohesion: 0.24 +Nodes (13): resolveSeats(), ghlCreateAppointment(), ghlCreateContact(), ghlGetContact(), ghlValidate(), headers(), logError(), onProspectBooked() (+5 more) ### Community 28 - "drawer.jsx" Cohesion: 0.22 Nodes (7): DrawerContent, DrawerDescription, DrawerFooter(), DrawerHeader(), DrawerOverlay, DrawerTitle, vaul ### Community 29 - "@sentry/nextjs" -Cohesion: 0.33 +Cohesion: 0.29 Nodes (3): onRequestError(), register(), @sentry/nextjs ### Community 30 - "paths" @@ -263,8 +264,8 @@ Cohesion: 0.08 Nodes (24): compilerOptions, alwaysStrict, esModuleInterop, exactOptionalPropertyTypes, forceConsistentCasingInFileNames, isolatedModules, lib, module (+16 more) ### Community 37 - "Project: DMForge — AI DM setter builder + Stripe-enabled SaaS" -Cohesion: 0.10 -Nodes (19): API endpoints (all working), Backend sub-agent log, Critical Issues Requiring Fix, Detailed Findings, Frontend sub-agent log, Key Findings, Notes, Pages (+11 more) +Cohesion: 0.14 +Nodes (13): API endpoints (all working), Backend sub-agent log, Frontend sub-agent log, Key Findings, Notes, Pages, Phase 2 NOT yet built, Project: DMForge — AI DM setter builder + Stripe-enabled SaaS (+5 more) ### Community 39 - "scripts" Cohesion: 0.14 @@ -274,17 +275,17 @@ Nodes (14): scripts, audit, build, dev, dev:emulated, emulators, start, test (+6 Cohesion: 0.07 Nodes (28): fetchSession(), metadata, Success(), app_globals, baseUrl, body, display, metadata (+20 more) -### Community 41 - "encryption.js" -Cohesion: 0.36 -Nodes (8): cleanSecret(), decrypt(), deriveKey(), encrypt(), getCurrentKey(), getDecryptKeys(), packAndEncrypt(), unpackAndDecrypt() +### Community 41 - "context-menu.jsx" +Cohesion: 0.18 +Nodes (10): ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut(), ContextMenuSubContent (+2 more) -### Community 42 - "Use cases by area" -Cohesion: 0.22 -Nodes (21): dynamic, POST(), syncSubscription(), Command → use-case map, Health check (2026-10-16), Use cases by area, ensureInit(), getAdminAuth() (+13 more) +### Community 42 - "handleRoute" +Cohesion: 0.19 +Nodes (26): handleRoute(), dynamic, POST(), syncSubscription(), Blind spots (static analysis limits), Command → use-case map, Health check (2026-10-16), Use cases by area (+18 more) ### Community 43 - "webhookAuth.js" -Cohesion: 0.36 -Nodes (7): BLOCKED_SUFFIXES, isPrivateIPv4(), isPrivateIPv6(), isPublicHttpsUrl(), safeEqual(), verifyMetaSignature(), ref_net +Cohesion: 0.18 +Nodes (12): BLOCKED_SUFFIXES, isPrivateIPv4(), isPrivateIPv6(), isPublicHttpsUrl(), parseMetaSignedRequest(), safeEqual(), verifyMetaSignature(), ref_net (+4 more) ### Community 47 - "breadcrumb.jsx" Cohesion: 0.25 @@ -299,56 +300,56 @@ Cohesion: 0.67 Nodes (3): repository, type, url ### Community 53 - "DMForge" -Cohesion: 0.07 -Nodes (28): DMForge Project Rules, DMForge Brand Tokens, Commands, DMForge — Project Rules, graphify, Layout, Rules, Stack (+20 more) +Cohesion: 0.06 +Nodes (35): DMForge Project Rules, DMForge Brand Tokens, Commands, DMForge — Project Rules, graphify, Layout, Rules, Stack (+27 more) -### Community 56 - "graphify in DMForge" -Cohesion: 0.29 -Nodes (6): graphify in DMForge, How it stays fresh, Integrations available (not yet used here), Recommendations, What is excluded (`.graphifyignore`), What it is +### Community 56 - "auth-context.js" +Cohesion: 0.39 +Nodes (6): AuthCtx, auth, db, firebaseConfig, googleProvider, firebase -### Community 60 - "email.js" -Cohesion: 0.48 -Nodes (6): buildTransport(), resolveCreds(), sendEmail(), sendSystemEmail(), testConnection(), nodemailer +### Community 60 - "health/route.js" +Cohesion: 0.39 +Nodes (7): checkFirestore(), checkGemini(), checkStripe(), dynamic, GET(), GEMINI_BASE(), MODEL -### Community 62 - "[id]/page.js" -Cohesion: 0.83 -Nodes (3): generateMetadata(), getResult(), ResultPage() +### Community 62 - "Test Run 1 - Firebase Auth + Firestore E2E Testing" +Cohesion: 0.33 +Nodes (6): Critical Issues Requiring Fix, Detailed Findings, Recommendations, Test Artifacts, Test Coverage, Test Run 1 - Firebase Auth + Firestore E2E Testing ### Community 68 - "useAuth" -Cohesion: 0.13 -Nodes (20): App(), Nav(), Pricing(), checkout(), portal(), AuthModal(), google(), submit() (+12 more) +Cohesion: 0.15 +Nodes (18): App(), Nav(), Pricing(), checkout(), portal(), AuthModal(), google(), submit() (+10 more) + +### Community 70 - "accordion.jsx" +Cohesion: 0.40 +Nodes (4): AccordionContent, AccordionItem, AccordionTrigger, @radix-ui/react-accordion ### Community 83 - "DMForge E2E — real-browser click tests (Playwright)" Cohesion: 0.29 Nodes (6): DMForge E2E — real-browser click tests (Playwright), Install (one time, in the DMForge repo root), Note: keep test files out of the deployed bundle, Reuse for future projects, Run, What it covers (tests/e2e/smoke.spec.js) -### Community 89 - "Security Policy" -Cohesion: 0.50 -Nodes (3): Reporting a Vulnerability, Scope, Security Policy - ### Community 134 - "audit-code.js" Cohesion: 0.21 Nodes (10): ref_fs, ref_path, audit(), EXTENSIONS, fs, IGNORED_DIRS, main(), path (+2 more) ## Knowledge Gaps -- **349 isolated node(s):** `Tech Stack`, `Prerequisites`, `Setup`, `Local dev against the Firebase emulators (safe for webhook tests)`, `Running Tests` (+344 more) - These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 455 node(s) total have ≤1 connection when file, concept and rationale nodes are included.) -- **30 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. +- **355 isolated node(s):** `cloudflare-api`, `cloudflare-ai-gateway`, `metadata`, `ALLOWED_ORIGINS`, `EMPTY_CHAT_STATE` (+350 more) + These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 472 node(s) total have ≤1 connection when file, concept and rationale nodes are included.) +- **32 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **Why does `cn()` connect `cn` to `react`, `useAuth`, `sidebar.jsx`, `Use cases by area`, `utils.js`, `alert-dialog.jsx`, `menubar.jsx`, `breadcrumb.jsx`, `command.jsx`, `form.jsx`, `toggle-group.jsx`, `chart.jsx`, `carousel.jsx`, `input-otp.jsx`, `navigation-menu.jsx`, `drawer.jsx`?** - _High betweenness centrality (0.177) - this node is a cross-community bridge._ -- **Why does `dependencies` connect `dependencies` to `package.json`?** - _High betweenness centrality (0.136) - this node is a cross-community bridge._ -- **Why does `next` connect `react` to `best/[slug]/page.js`, `useAuth`, `[[...path]]/route.js`, `about/page.js`, `app/layout.js`, `Use cases by area`, `package.json`, `auth-context.js`, `health/route.js`, `UnauthorizedError`, `proxy.js`, `[id]/page.js`, `settings/layout.js`?** - _High betweenness centrality (0.114) - this node is a cross-community bridge._ +- **Why does `cn()` connect `cn` to `react`, `sidebar.jsx`, `accordion.jsx`, `context-menu.jsx`, `handleRoute`, `utils.js`, `alert-dialog.jsx`, `breadcrumb.jsx`, `command.jsx`, `form.jsx`, `toggle-group.jsx`, `chart.jsx`, `carousel.jsx`, `input-otp.jsx`, `navigation-menu.jsx`, `drawer.jsx`?** + _High betweenness centrality (0.243) - this node is a cross-community bridge._ +- **Why does `authFetch()` connect `authFetch` to `react`, `useAuth`, `handleRoute`, `dashboard/page.js`, `auth-context.js`, `Test Run 1 - Firebase Auth + Firestore E2E Testing`?** + _High betweenness centrality (0.106) - this node is a cross-community bridge._ +- **Why does `next` connect `react` to `best/[slug]/page.js`, `useAuth`, `[[...path]]/route.js`, `about/page.js`, `app/layout.js`, `handleRoute`, `data-deletion/page.js`, `dashboard/page.js`, `package.json`, `proxy.js`, `logError`, `health/route.js`, `settings/layout.js`?** + _High betweenness centrality (0.095) - this node is a cross-community bridge._ - **Are the 3 inferred relationships involving `cn()` (e.g. with `Command → use-case map` and `Health check (2026-10-16)`) actually correct?** _`cn()` has 3 INFERRED edges - model-reasoned connections that need verification._ - **Are the 5 inferred relationships involving `authFetch()` (e.g. with `Command → use-case map` and `Health check (2026-10-16)`) actually correct?** _`authFetch()` has 5 INFERRED edges - model-reasoned connections that need verification._ -- **Are the 4 inferred relationships involving `handleRoute()` (e.g. with `Blind spots (static analysis limits)` and `Command → use-case map`) actually correct?** - _`handleRoute()` has 4 INFERRED edges - model-reasoned connections that need verification._ -- **What connects `Tech Stack`, `Prerequisites`, `Setup` to the rest of the system?** - _349 weakly-connected nodes found - possible documentation gaps or missing edges._ \ No newline at end of file +- **Are the 6 inferred relationships involving `handleRoute()` (e.g. with `Blind spots (static analysis limits)` and `Command → use-case map`) actually correct?** + _`handleRoute()` has 6 INFERRED edges - model-reasoned connections that need verification._ +- **What connects `cloudflare-api`, `cloudflare-ai-gateway`, `metadata` to the rest of the system?** + _355 weakly-connected nodes found - possible documentation gaps or missing edges._ \ No newline at end of file diff --git a/graphify-out/graph.html b/graphify-out/graph.html index b6319fb..cdb54be 100644 --- a/graphify-out/graph.html +++ b/graphify-out/graph.html @@ -63,12 +63,12 @@

Communities

-
1236 nodes · 2735 edges · 81 communities
+
1276 nodes · 2834 edges · 83 communities