diff --git a/api/package.json b/api/package.json index de294d2d..add61ad4 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,7 @@ "db:cleanup": "node dist/cron/dbCleanup.js", "funnel-report": "node dist/scripts/funnelReport.js", "npm:audit": "npm audit --json > /tmp/audit.json && echo \"Audit complete\"", -"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", +"test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/outreach-active-devs.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/deleted-account-session.test.mjs && node tests/intent-funnel.test.mjs && node tests/credit-email-buylinks.test.mjs", "test:integration": "node tests/integration.test.js", "test:verify-activation": "node tests/verify-activation.test.mjs", "test:verify-resend": "node tests/verify-resend.test.mjs", diff --git a/api/src/index.ts b/api/src/index.ts index 9814d7be..8ab0e9d8 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -31,7 +31,7 @@ import workflowsRouter from "./routes/workflows.js"; import seoRouter from "./routes/seo.js"; import legalRouter from "./routes/legal.js"; import oauthRouter from "./routes/oauth.js"; -import authRouter, { verifySession } from "./routes/auth.js"; +import authRouter, { clearSessionCookie, verifySession } from "./routes/auth.js"; import chatRouter from "./routes/chat.js"; import directoryRouter from "./routes/directory.js"; import walletRouter from "./routes/wallet.js"; @@ -47,6 +47,8 @@ import trialRouter from "./routes/trial.js"; import affiliateRouter from "./routes/affiliate.js"; import mcpRouter from "./routes/mcp.js"; import unsubscribeRouter from "./routes/unsubscribe.js"; +import { prisma } from "./lib/prisma.js"; +import { isDeletedAgent } from "./lib/deletedAgent.js"; // x402 SDK (official Coinbase @x402/express integration) import { initX402Sdk, x402SdkMiddleware, getX402SdkStatus, warmX402Sdk } from "./middleware/x402-sdk.js"; @@ -395,9 +397,15 @@ app.get("/signup", (req: Request, res: Response) => { }); app.get("/register", (_req: Request, res: Response) => res.redirect(301, "/signup")); app.get("/login", (_req: Request, res: Response) => res.set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate").set("Pragma", "no-cache").set("Expires", "0").type("text/html").send(LOGIN_HTML)); -app.get("/dashboard", (req: Request, res: Response) => { +app.get("/dashboard", async (req: Request, res: Response) => { const token = req.cookies?.arch_session; - if (!token || !verifySession(token)) { + const payload = token ? verifySession(token) : null; + if (!payload) { + return res.redirect(302, "/login?next=/dashboard"); + } + const agent = await prisma.agent.findUnique({ where: { id: payload.sub } }).catch(() => null); + if (!agent || isDeletedAgent(agent)) { + clearSessionCookie(res); return res.redirect(302, "/login?next=/dashboard"); } return res.type("text/html").send(DASHBOARD_HTML); diff --git a/api/src/lib/deletedAgent.ts b/api/src/lib/deletedAgent.ts new file mode 100644 index 00000000..d523d976 --- /dev/null +++ b/api/src/lib/deletedAgent.ts @@ -0,0 +1,20 @@ +/** + * Deleted accounts are anonymized in-place so retained Purchase/X402 rows keep + * their foreign-key integrity. This predicate is the durable "disabled" marker + * for those anonymized Agent rows: deletion clears every credential and rewrites + * the email into the reserved deleted.invalid domain. + */ +export function isDeletedAgent(agent: { + email?: string | null; + apiKeyHash?: string | null; + apiKeyPrefix?: string | null; + passwordHash?: string | null; +} | null | undefined): boolean { + if (!agent) return false; + return Boolean( + agent.email?.endsWith("@deleted.invalid") && + !agent.apiKeyHash && + !agent.apiKeyPrefix && + !agent.passwordHash + ); +} diff --git a/api/src/middleware/auth.ts b/api/src/middleware/auth.ts index 87fe109b..aeb50dcb 100644 --- a/api/src/middleware/auth.ts +++ b/api/src/middleware/auth.ts @@ -3,6 +3,7 @@ import { prisma } from "../lib/prisma.js"; import { timingSafeEqual } from "crypto"; import bcrypt from "bcryptjs"; import { DISCOVERY_LINKS } from "../utils/discoveryLinks.js"; +import { isDeletedAgent } from "../lib/deletedAgent.js"; export interface AuthedRequest extends Request { agent?: { @@ -83,11 +84,11 @@ export async function requireAuth( } } - if (!agent) { + if (!agent || isDeletedAgent(agent)) { res.status(401).json({ ok: false, - error: "unauthorized", - message: "Invalid API key or OAuth token. Register at https://archtools.dev", + error: isDeletedAgent(agent) ? "account_deleted" : "unauthorized", + message: isDeletedAgent(agent) ? "This account has been deleted." : "Invalid API key or OAuth token. Register at https://archtools.dev", request_id: crypto.randomUUID(), }); return; diff --git a/api/src/routes/agent.ts b/api/src/routes/agent.ts index 3fafb1d1..858dd459 100644 --- a/api/src/routes/agent.ts +++ b/api/src/routes/agent.ts @@ -9,6 +9,7 @@ import { stripe } from "../lib/stripe.js"; import Stripe from "stripe"; import crypto from "crypto"; import bcrypt from "bcryptjs"; +import { clearSessionCookie } from "./auth.js"; import { SIGNUP_FREE_CREDITS, isDisposableEmail, issueEmailVerification, verifyEmailToken, peekEmailVerifyToken, enforceSignupLimits, recordSignupIp, normalizeEmailIdentity, allowVerificationResend, reissueEmailVerification } from "../lib/verification.js"; import { VERIFY_TOKEN_RE, renderVerifyConfirmPage, renderVerifyActivationPage, renderVerifyErrorPage, renderVerifyResendSentPage } from "../assets/verifyEmailHtml.js"; import { REFERRAL_REWARD } from "../lib/referralReward.js"; @@ -582,6 +583,7 @@ router.delete("/", requireAuth, requireApiKeyAuth, async (req: AuthedRequest, re // Structured log mirrors the DataDeletionAudit row (no PII — counts + audit id). logger.info(`[gdpr] account deleted agent=${agent.id} erased=${JSON.stringify(result.counts)} audit=${result.auditId} at=${new Date().toISOString()}`); + clearSessionCookie(res); res.json({ ok: true, diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index bf59d12f..ebf02f92 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -8,6 +8,7 @@ import { logAudit } from "../lib/audit.js"; import { logger } from "../lib/logger.js"; import { sendPasswordResetEmail } from "../services/email.js"; import { captureEvent, identifyUser } from "../lib/posthog.js"; +import { isDeletedAgent } from "../lib/deletedAgent.js"; const router = Router(); @@ -28,6 +29,11 @@ const COOKIE_OPTS = { path: "/", }; +export function clearSessionCookie(res: Response): void { + res.clearCookie(COOKIE_NAME, { path: "/", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" as const }); + res.cookie(COOKIE_NAME, "", { ...COOKIE_OPTS, maxAge: 0 }); +} + const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, @@ -173,8 +179,7 @@ router.post("/set-password", sensitiveLimiter, async (req: Request, res: Respons }); router.get("/logout", (_req: Request, res: Response): void => { - res.clearCookie(COOKIE_NAME, { path: "/", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" as const }); - res.cookie(COOKIE_NAME, "", { ...COOKIE_OPTS, maxAge: 0 }); + clearSessionCookie(res); res.redirect("/login"); }); @@ -191,9 +196,9 @@ router.get("/me", async (req: Request, res: Response): Promise => { return; } const agent = await prisma.agent.findUnique({ where: { id: payload.sub } }); - if (!agent) { - res.clearCookie(COOKIE_NAME, { path: "/" }); - res.status(401).json({ ok: false, error: "agent_not_found" }); + if (!agent || isDeletedAgent(agent)) { + clearSessionCookie(res); + res.status(401).json({ ok: false, error: isDeletedAgent(agent) ? "account_deleted" : "agent_not_found" }); return; } res.json({ @@ -212,7 +217,11 @@ router.get("/api-key", async (req: Request, res: Response): Promise => { const payload = verifySession(token); if (!payload) { res.status(401).json({ ok: false, error: "session_expired" }); return; } const agent = await prisma.agent.findUnique({ where: { id: payload.sub } }); - if (!agent) { res.status(401).json({ ok: false, error: "agent_not_found" }); return; } + if (!agent || isDeletedAgent(agent)) { + clearSessionCookie(res); + res.status(401).json({ ok: false, error: isDeletedAgent(agent) ? "account_deleted" : "agent_not_found" }); + return; + } // Plaintext keys are no longer stored — only the prefix can be shown. // Full keys are returned exactly once at registration/rotation. const masked = agent.apiKeyPrefix ? `${agent.apiKeyPrefix}…` : null; diff --git a/api/src/routes/billing.ts b/api/src/routes/billing.ts index 0d8fb48a..0c139308 100644 --- a/api/src/routes/billing.ts +++ b/api/src/routes/billing.ts @@ -2,13 +2,14 @@ import { Router, Request, Response, NextFunction } from "express"; import { prisma } from "../lib/prisma.js"; import { stripe } from "../lib/stripe.js"; import { requireAuth, AuthedRequest } from "../middleware/auth.js"; -import { verifySession } from "./auth.js"; +import { clearSessionCookie, verifySession } from "./auth.js"; import { reqId } from "../utils/credits.js"; import { sendPurchaseConfirmation, sendAdminAlert } from "../services/email.js"; import { fireWebhookEvent } from "../services/webhooks.js"; import { safeErr } from "../utils/credits.js"; import { tierFromSubscriptionPlanId } from "../lib/tiers.js"; import { clawbackDelta, proratedClawbackTarget } from "../lib/clawback.js"; +import { isDeletedAgent } from "../lib/deletedAgent.js"; const router = Router(); @@ -34,7 +35,8 @@ async function requireAuthOrSession(req: AuthedRequest, res: Response, next: Nex return; } const agent = await prisma.agent.findUnique({ where: { id: payload.sub } }).catch(() => null); - if (!agent) { + if (!agent || isDeletedAgent(agent)) { + if (isDeletedAgent(agent)) clearSessionCookie(res); res.status(401).json({ ok: false, error: "unauthorized", message: "Session invalid. Sign in again at /login", request_id: reqId() }); return; } diff --git a/api/tests/deleted-account-session.test.mjs b/api/tests/deleted-account-session.test.mjs new file mode 100644 index 00000000..1d5b1e8d --- /dev/null +++ b/api/tests/deleted-account-session.test.mjs @@ -0,0 +1,149 @@ +/** + * Deleted-account stale session regression. + * + * DELETE /v1/agent anonymizes the Agent row in-place for financial-record FK + * retention. A stateless arch_session JWT issued before deletion must not keep + * that anonymized row usable for browser auth or Stripe checkout. + * + * Run: cd api && npm run build && node tests/deleted-account-session.test.mjs + */ +import assert from "assert"; + +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret-do-not-use-in-prod"; +process.env.DATABASE_URL = process.env.DATABASE_URL || "postgresql://stub:stub@127.0.0.1:5432/stub"; +process.env.STRIPE_SECRET_KEY = ""; + +const express = (await import("express")).default; +const cookieParser = (await import("cookie-parser")).default; +const { prisma } = await import("../dist/lib/prisma.js"); +const { isDeletedAgent } = await import("../dist/lib/deletedAgent.js"); +const authModule = await import("../dist/routes/auth.js"); +const billingRouter = (await import("../dist/routes/billing.js")).default; + +const { default: authRouter, signSession } = authModule; + +let failures = 0; +async function test(name, fn) { + try { + await fn(); + console.log(` ok ${name}`); + } catch (e) { + failures++; + console.error(` FAIL ${name}: ${e.message}`); + } +} + +const deletedAgent = { + id: "agent_deleted_session", + email: "deleted-agent_deleted_session@deleted.invalid", + apiKeyPrefix: null, + apiKeyHash: null, + passwordHash: null, + credits: 0, + tier: "free", + totalCalls: 0, + createdAt: new Date("2026-08-06T00:00:00Z"), +}; + +const activeAgent = { + id: "agent_active_session", + email: "active@example.com", + apiKeyPrefix: "arch_abcdefg", + apiKeyHash: "$2b$10$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + passwordHash: null, + credits: 10, + tier: "free", + totalCalls: 0, + createdAt: new Date("2026-08-06T00:00:00Z"), +}; + +let currentAgent = deletedAgent; +prisma.agent.findUnique = async ({ where }) => { + return where?.id === currentAgent.id ? currentAgent : null; +}; + +const app = express(); +app.use(express.json()); +app.use(cookieParser()); +app.use("/auth", authRouter); +app.use("/v1/billing", billingRouter); + +const server = app.listen(0); +const BASE = `http://127.0.0.1:${server.address().port}`; + +function cookieFor(agentId) { + return `arch_session=${signSession(agentId)}`; +} + +async function postJson(path, body, cookie) { + const res = await fetch(`${BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json = {}; + try { json = JSON.parse(text); } catch {} + return { res, json }; +} + +async function getJson(path, cookie) { + const res = await fetch(`${BASE}${path}`, { headers: { Cookie: cookie } }); + const text = await res.text(); + let json = {}; + try { json = JSON.parse(text); } catch {} + return { res, json }; +} + +function assertClearsSession(res) { + const setCookie = res.headers.get("set-cookie") || ""; + assert.ok(setCookie.includes("arch_session="), "must emit Set-Cookie for arch_session"); + assert.ok(/Max-Age=0|Expires=Thu, 01 Jan 1970/i.test(setCookie), "must expire the stale session cookie"); +} + +console.log("deleted-account stale session:"); + +await test("helper recognizes the anonymized deleted-row marker only", () => { + assert.strictEqual(isDeletedAgent(deletedAgent), true); + assert.strictEqual(isDeletedAgent(activeAgent), false); + assert.strictEqual(isDeletedAgent({ ...deletedAgent, apiKeyHash: "still-has-a-key" }), false); +}); + +await test("/auth/me rejects and clears a stale deleted-account session", async () => { + currentAgent = deletedAgent; + const { res, json } = await getJson("/auth/me", cookieFor(deletedAgent.id)); + assert.strictEqual(res.status, 401); + assert.strictEqual(json.error, "account_deleted"); + assertClearsSession(res); +}); + +await test("/auth/api-key rejects and clears a stale deleted-account session", async () => { + currentAgent = deletedAgent; + const { res, json } = await getJson("/auth/api-key", cookieFor(deletedAgent.id)); + assert.strictEqual(res.status, 401); + assert.strictEqual(json.error, "account_deleted"); + assertClearsSession(res); +}); + +await test("/v1/billing/checkout rejects a deleted-account session before Stripe", async () => { + currentAgent = deletedAgent; + const { res, json } = await postJson("/v1/billing/checkout", { pack: "starter" }, cookieFor(deletedAgent.id)); + assert.strictEqual(res.status, 401); + assert.strictEqual(json.error, "unauthorized"); + assertClearsSession(res); +}); + +await test("active session still reaches the normal billing path", async () => { + currentAgent = activeAgent; + const { res, json } = await postJson("/v1/billing/checkout", { pack: "starter" }, cookieFor(activeAgent.id)); + assert.strictEqual(res.status, 503); + assert.strictEqual(json.error, "not_configured"); +}); + +server.close(); + +if (failures) { + console.error(`\n${failures} failure(s)`); + process.exit(1); +} +console.log("\nAll deleted-account stale-session tests passed."); diff --git a/api/tests/verify-activation.test.mjs b/api/tests/verify-activation.test.mjs index fcb94ef5..68aaff80 100644 --- a/api/tests/verify-activation.test.mjs +++ b/api/tests/verify-activation.test.mjs @@ -20,6 +20,7 @@ import assert from "assert"; process.env.DATABASE_URL ??= "postgresql://stub:stub@127.0.0.1:5432/stub"; +process.env.JWT_SECRET ??= "test-secret-do-not-use-in-prod"; const { prisma } = await import("../dist/lib/prisma.js"); const { peekEmailVerifyToken } = await import("../dist/lib/verification.js"); diff --git a/api/tests/verify-resend.test.mjs b/api/tests/verify-resend.test.mjs index 924c1a1d..03394550 100644 --- a/api/tests/verify-resend.test.mjs +++ b/api/tests/verify-resend.test.mjs @@ -23,6 +23,7 @@ import assert from "assert"; process.env.DATABASE_URL ??= "postgresql://stub:stub@127.0.0.1:5432/stub"; +process.env.JWT_SECRET ??= "test-secret-do-not-use-in-prod"; const { prisma } = await import("../dist/lib/prisma.js"); const { renderVerifyErrorPage, renderVerifyResendSentPage } = await import("../dist/assets/verifyEmailHtml.js");