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.
+ +Retrieving status record...
) : (- 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.'}