diff --git a/.claude/launch.json b/.claude/launch.json index fd84377..064f570 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,6 +5,7 @@ "name": "dev", "runtimeExecutable": "yarn", "runtimeArgs": [ + "--ignore-engines", "run", "dev" ], diff --git a/.claude/skills/auditing-dmforge-frontend/references/known-gaps.md b/.claude/skills/auditing-dmforge-frontend/references/known-gaps.md index f4ad3a9..2ecb307 100644 --- a/.claude/skills/auditing-dmforge-frontend/references/known-gaps.md +++ b/.claude/skills/auditing-dmforge-frontend/references/known-gaps.md @@ -116,6 +116,7 @@ machine (corepack shim points at a missing don't flag missing `next/image` usage as high-priority; it buys less here than usual. **Confirmed resolved from earlier sessions (don't re-flag):** + - `.env.local` is ASCII, not UTF-16LE — the local-build-breaking encoding issue from the 2026-07-12 session log is gone. diff --git a/.claude/skills/prod-verify/SKILL.md b/.claude/skills/prod-verify/SKILL.md index ad7205c..20bc9be 100644 --- a/.claude/skills/prod-verify/SKILL.md +++ b/.claude/skills/prod-verify/SKILL.md @@ -44,7 +44,7 @@ that is the only thing that reflects what users experience. Unit tests, a green args, echo them into logs, or commit them. Pipe secret values via stdin. - **Escalate what you genuinely can't reach.** Console-only actions (provider env vars, secret rotation, DNS, repo settings) may be outside your tools. When so, produce a precise, - self-contained handoff rather than pretending or stalling — but do everything you *can* reach first. + self-contained handoff rather than pretending or stalling — but do everything you _can_ reach first. ## Inputs to establish first @@ -76,7 +76,7 @@ route; 500 = server fault to diagnose in phase 6. This is the part that finds real bugs. Register a genuine account via the platform's auth REST API, obtain a token, then exercise: user provisioning, an authed data read, the core feature -(the thing the product is *for*), and billing entry points. Label the test account clearly so it's +(the thing the product is _for_), and billing entry points. Label the test account clearly so it's easy to delete, and report it for cleanup. See `references/recipes.md` for the exact calls. ### 4. Real-browser pass (when click-driven flows matter) diff --git a/.claude/skills/shipping-dmforge/references/reconciled-facts.md b/.claude/skills/shipping-dmforge/references/reconciled-facts.md index a029a84..5adab76 100644 --- a/.claude/skills/shipping-dmforge/references/reconciled-facts.md +++ b/.claude/skills/shipping-dmforge/references/reconciled-facts.md @@ -114,6 +114,7 @@ directly on their own machine. Tell the user this is a compile check, not a full build, and that they should run `yarn build` on their real machine before actually shipping. + - **This VM's local git config has no `user.name`/`user.email`** (the global `.gitconfig` lives on the Windows side and isn't visible here) — first commit attempt fails with "Author identity unknown." Fix with a **local** diff --git a/app/api/[[...path]]/route.js b/app/api/[[...path]]/route.js index f0952db..96aad7b 100644 --- a/app/api/[[...path]]/route.js +++ b/app/api/[[...path]]/route.js @@ -657,23 +657,6 @@ Rules: ); } - // GET /api/result/:id - if (route.startsWith("/result/") && method === "GET") { - const id = route.split("/")[2]; - if (!id) - return handleCORS( - request, - NextResponse.json({ error: "result id required" }, { status: 400 }), - ); - const snap = await db.collection("results").doc(id).get(); - if (!snap.exists) - return handleCORS( - request, - NextResponse.json({ error: "not found" }, { status: 404 }), - ); - return handleCORS(request, NextResponse.json(ser(snap))); - } - // GET /api/competitors if (route === "/competitors" && method === "GET") { return handleCORS(request, NextResponse.json({ competitors })); @@ -806,6 +789,19 @@ Rules: method === "GET" ) { const id = path[1]; + const agentSnap = await db.collection("agents").doc(id).get(); + if (!agentSnap.exists) + return handleCORS( + request, + NextResponse.json({ error: "agent not found" }, { status: 404 }), + ); + const agent = agentSnap.data(); + if (agent.ownerUid && (!decoded || decoded.uid !== agent.ownerUid)) { + return handleCORS( + request, + NextResponse.json({ error: "forbidden" }, { status: 403 }), + ); + } const qs = await db .collection("agents") .doc(id) @@ -1394,26 +1390,30 @@ Rules: const metaUserId = parsedPayload?.user_id || "unknown"; const confirmationCode = `DMFORGE-DEL-${crypto.randomBytes(4).toString("hex").toUpperCase()}`; - // Clean up any meta_channel_index doc mapping to this user - if (metaUserId && metaUserId !== "unknown") { - await db - .collection("meta_channel_index") - .doc(String(metaUserId)) - .delete() - .catch(() => {}); - } - - // Record deletion status + // Meta sends an app-scoped user id. Channels connect with a pasted page + // token, so nothing stored is keyed by that id and it can't be erased + // automatically. Record it honestly as pending and route it to a human + // instead of claiming a deletion that never happened. await db .collection("meta_data_deletions") .doc(confirmationCode) .set({ confirmationCode, metaUserId: String(metaUserId), - status: "completed", + status: "pending", requestedAt: FieldValue.serverTimestamp(), - completedAt: FieldValue.serverTimestamp(), }); + after(() => + sendMail({ + to: "support@dmforge.org", + subject: `Meta data deletion request ${confirmationCode}`, + text: `Meta app-scoped user id: ${metaUserId}\nConfirmation code: ${confirmationCode}\n\nFind and erase this user's data, then set meta_data_deletions/${confirmationCode} status to "completed".`, + }).catch((err) => + logError("Meta data deletion support email failed", err, { + confirmationCode, + }), + ), + ); await recordAuditEvent({ eventType: "meta.data_deletion", @@ -1464,7 +1464,7 @@ Rules: request, NextResponse.json({ confirmation_code: code, - status: data.status || "completed", + status: data.status || "pending", requestedAt: ser(data)?.requestedAt || null, }), ); @@ -1483,16 +1483,32 @@ Rules: } const uid = decoded.uid; - // 1. Cancel Stripe subscription if active + // 1. Cancel Stripe subscription if active. On failure, stop before erasing: + // a deleted user can't log in to cancel, so billing would run forever. 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); + const sub = await stripe.subscriptions.retrieve(subId); + if (!["canceled", "incomplete_expired"].includes(sub.status)) + await stripe.subscriptions.cancel(subId); } } catch (err) { - logError("account deletion: stripe cancel error", err); + // A subscription Stripe no longer has can't bill anyone. + if (err?.code !== "resource_missing") { + logError("account deletion: stripe cancel error", err); + return handleCORS( + request, + NextResponse.json( + { + error: + "Could not cancel your subscription. Please retry or contact support.", + }, + { status: 502 }, + ), + ); + } } // 2. Erase all Firestore data, subcollections included. On failure, stop @@ -1606,7 +1622,8 @@ Rules: status: "pending", createdAt: FieldValue.serverTimestamp(), }); - const acceptUrl = `${process.env.NEXT_PUBLIC_BASE_URL || ""}/api/agency/accept?token=${token}`; + // The /invite page signs the invitee in, then calls /api/agency/accept. + const acceptUrl = `${getBaseUrl()}/invite?token=${token}`; // fire-and-forget — invite link still returned so owner can share manually if email fails sendMail({ to: inviteEmail, @@ -1709,10 +1726,15 @@ Rules: request, NextResponse.json({ error: "no agency found" }, { status: 404 }), ); + const memberUids = agencySnap.data().memberUids || []; + // Without this check an owner could reset any user's role and agencyId. + if (!memberUids.includes(memberUid)) + return handleCORS( + request, + NextResponse.json({ error: "not a member" }, { status: 404 }), + ); await agencyRef.update({ - memberUids: (agencySnap.data().memberUids || []).filter( - (u) => u !== memberUid, - ), + memberUids: memberUids.filter((u) => u !== memberUid), }); await db .collection("users") diff --git a/app/invite/page.js b/app/invite/page.js new file mode 100644 index 0000000..330a61e --- /dev/null +++ b/app/invite/page.js @@ -0,0 +1,52 @@ +'use client' +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useAuth, authFetch } from '@/lib/auth-context' +import { AuthModal } from '@/components/auth-modal' +import { Button } from '@/components/ui/button' + +// Agency invite emails link here. It sits outside the /settings auth redirect +// (proxy.js) so a signed-out invitee keeps the token, signs in, then accepts: +// the accept API needs a bearer token a plain link click can't carry. +export default function InvitePage() { + const { user, loading, getToken } = useAuth() + const router = useRouter() + const [token, setToken] = useState(null) + const [error, setError] = useState(null) + const [authOpen, setAuthOpen] = useState(false) + + useEffect(() => { + setToken(new URLSearchParams(window.location.search).get('token')) + }, []) + + useEffect(() => { + if (!user || !token) return + authFetch(`/api/agency/accept?token=${encodeURIComponent(token)}`, { method: 'GET' }, getToken) + .then(async (res) => { + if (res.ok) return router.replace('/settings/team') + const d = await res.json().catch(() => ({})) + setError(d.error || 'Could not accept the invite') + }) + .catch(() => setError('Could not accept the invite')) + }, [user, token]) + + let body + if (loading || (user && token && !error)) body =

Joining the agency…

+ else if (error) body =

{error}

+ else if (!token) body =

This invite link is missing its token.

+ else + body = ( + <> +

You've been invited to join a DMForge agency. Sign in with the invited email to accept.

+ + setAuthOpen(false)} defaultMode="login" /> + + ) + + return ( +
+

Agency invite

+ {body} +
+ ) +} diff --git a/app/legal/data-deletion/page.js b/app/legal/data-deletion/page.js index 0850afd..612dfa8 100644 --- a/app/legal/data-deletion/page.js +++ b/app/legal/data-deletion/page.js @@ -47,9 +47,11 @@ function DeletionStatusContent() {

Retrieving status record...

) : (
- Status: {statusData?.status ? statusData.status.toUpperCase() : 'COMPLETED'} + Status: {(statusData?.status || 'pending').toUpperCase()}

- Your linked Meta ID and associated message transcripts have been deleted from our systems. + {statusData?.status === 'completed' + ? 'Your linked Meta ID and associated message transcripts have been deleted from our systems.' + : 'We have received your request and will delete your data within 30 days. Check back with this code for updates.'}

)} diff --git a/functions/index.js b/functions/index.js index 8075b22..2b01abb 100644 --- a/functions/index.js +++ b/functions/index.js @@ -125,6 +125,15 @@ exports.sendReminders = onSchedule({ if (!claimed) continue try { + // A lead who texted STOP is recorded by the Twilio webhook (route.js /webhooks/twilio). + const suppressed = await db + .collection('users').doc(r.uid) + .collection('smsSuppressed').doc(r.to) + .get() + if (suppressed.exists) { + await doc.ref.update({ status: 'skipped', error: 'recipient opted out' }) + continue + } const chSnap = await db .collection('users').doc(r.uid) .collection('channels').doc('sms') diff --git a/lib/__tests__/agencyService.test.js b/lib/__tests__/agencyService.test.js index 2c9f19d..d6d7d8a 100644 --- a/lib/__tests__/agencyService.test.js +++ b/lib/__tests__/agencyService.test.js @@ -228,6 +228,32 @@ describe('Agency Service (lib/services/agencyService.js)', () => { expect(updateAgency).toHaveBeenCalledWith({ memberUids: ['mem2'] }) expect(setUser).toHaveBeenCalled() }) + + it('refuses a uid that is not a member and writes nothing', async () => { + const updateAgency = vi.fn() + const setUser = vi.fn() + const mockDb = { + collection: (col) => + col === 'agencies' + ? { + doc: () => ({ + get: async () => ({ exists: true, data: () => ({ memberUids: ['mem1'] }) }), + update: updateAgency, + }), + } + : { doc: () => ({ set: setUser }) }, + } + await expect( + removeAgencyMember({ + db: mockDb, + FieldValue: fakeFieldValue, + user: { uid: 'owner1' }, + body: { memberUid: 'other-agency-owner' }, + }) + ).rejects.toThrow(NotFoundError) + expect(updateAgency).not.toHaveBeenCalled() + expect(setUser).not.toHaveBeenCalled() + }) }) describe('updateWhiteLabel', () => { diff --git a/lib/openapi.js b/lib/openapi.js index c730849..47068d1 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -33,7 +33,6 @@ const ROUTES = [ ['post', '/agent/chat', 'optional', 'Send a message to an agent (LLM)', agentChatSchema], ['post', '/support/chat', 'none', 'Site support bot (LLM)'], ['post', '/result/save', 'optional', 'Save a live-test transcript with an LLM summary'], - ['get', '/result/{id}', 'none', 'Get a saved result'], ['get', '/competitors', 'none', 'Competitor comparison data'], ['get', '/plans', 'none', 'Billing plans'], ['get', '/me', 'optional', 'Current user and plan ({user:null} when signed out)'], diff --git a/lib/services/agencyService.js b/lib/services/agencyService.js index 63065de..6b47f79 100644 --- a/lib/services/agencyService.js +++ b/lib/services/agencyService.js @@ -2,6 +2,7 @@ import crypto from 'crypto' import { getStripe } from '@/lib/stripe' import { logError } from '@/lib/logger' import { sendMail } from '@/lib/mail' +import { getBaseUrl } from '@/lib/baseUrl' import { ValidationError, UnauthorizedError, @@ -81,7 +82,8 @@ export async function inviteAgencyMember({ db, FieldValue, sendMail: mailer = se createdAt: FieldValue.serverTimestamp(), }) - const acceptUrl = `${baseUrl || process.env.NEXT_PUBLIC_BASE_URL || ''}/api/agency/accept?token=${token}` + // The /invite page signs the invitee in, then calls /api/agency/accept. + const acceptUrl = `${baseUrl || getBaseUrl()}/invite?token=${token}` if (typeof mailer === 'function') { mailer({ to: email, @@ -165,8 +167,11 @@ export async function removeAgencyMember({ db, FieldValue, user, body }) { if (!agencySnap.exists) { throw new NotFoundError('no agency found') } + const memberUids = agencySnap.data().memberUids || [] + // Without this check an owner could reset any user's role and agencyId. + if (!memberUids.includes(memberUid)) throw new NotFoundError('not a member') await agencyRef.update({ - memberUids: (agencySnap.data().memberUids || []).filter((u) => u !== memberUid), + memberUids: memberUids.filter((u) => u !== memberUid), }) await db.collection('users').doc(memberUid).set( { role: 'member', agencyId: FieldValue.delete() }, diff --git a/tests/e2e/followup-sequence.spec.js b/tests/e2e/followup-sequence.spec.js index 863156e..d5f036a 100644 --- a/tests/e2e/followup-sequence.spec.js +++ b/tests/e2e/followup-sequence.spec.js @@ -6,8 +6,7 @@ test('sequence generate/get require the agent to exist', async ({ request }) => const gen = await request.post('/api/agents/does-not-exist/sequences/generate') expect(gen.status()).toBe(404) + // The GET is owner-checked like the PUT, so it needs the agent doc too. const list = await request.get('/api/agents/does-not-exist/sequences') - const data = await list.json() - expect(list.status()).toBe(200) - expect(data.sequence).toEqual([]) + expect(list.status()).toBe(404) })