Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions api/src/lib/deletedAgent.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
7 changes: 4 additions & 3 deletions api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions api/src/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 15 additions & 6 deletions api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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,
Expand Down Expand Up @@ -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");
});

Expand All @@ -191,9 +196,9 @@ router.get("/me", async (req: Request, res: Response): Promise<void> => {
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({
Expand All @@ -212,7 +217,11 @@ router.get("/api-key", async (req: Request, res: Response): Promise<void> => {
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;
Expand Down
6 changes: 4 additions & 2 deletions api/src/routes/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;
}
Expand Down
149 changes: 149 additions & 0 deletions api/tests/deleted-account-session.test.mjs
Original file line number Diff line number Diff line change
@@ -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.");
1 change: 1 addition & 0 deletions api/tests/verify-activation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions api/tests/verify-resend.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading