diff --git a/.env.example b/.env.example index 26adf28a..f6524ebe 100644 --- a/.env.example +++ b/.env.example @@ -167,6 +167,12 @@ SIWE_ALLOWED_CHAIN_IDS=1 # Generate with: openssl rand -hex 32 # Treat as a secret; rotate via deploy if compromised (invalidates in-flight nonces, 5-min TTL absorbs). NONCE_HMAC_SECRET= +# REQUIRED — HMAC secret keying account-deletion barrier hashes and pseudonymous +# deletion-record refs. Must be >= 64 hex chars (32 bytes). +# Generate with: openssl rand -hex 32 +# PERMANENT: never rotate — rotation orphans every DeletedIdentity barrier row +# (silently lifting the deletion bar) and breaks deletion-record lookups. +DELETION_HASH_SECRET= # --- Payments / Credits --- # REQUIRED — All five PAYMENTS_* knobs below are hard-required. Backend diff --git a/src/accounts/deletion/barrier.ts b/src/accounts/deletion/barrier.ts new file mode 100644 index 00000000..6a7e85be --- /dev/null +++ b/src/accounts/deletion/barrier.ts @@ -0,0 +1,38 @@ +import type { Prisma } from "@prisma/client"; +import { hashDeletedIdentity } from "@/accounts/deletion/identity-hash"; +import { prisma } from "@/utils/prisma"; + +/** + * The deletion barrier. One DeletedIdentity row per deleted auth identity, + * keyed by hashDeletedIdentity. Consulted at token mint after successful SIWE + * verification and before the auto-provisioning upsert: a barred identity + * gets the terminal 410 identity_deleted response and never re-creates an + * account (or re-earns the signup bonus). The bar is permanent. + */ + +export const isIdentityBarred = async ( + type: string, + externalKey: string, +): Promise => { + const row = await prisma.deletedIdentity.findUnique({ + where: { identityHash: hashDeletedIdentity(type, externalKey) }, + select: { identityHash: true }, + }); + return row !== null; +}; + +/** + * Write the barrier row inside the deletion transaction. Idempotent: a + * deletion retry that re-runs the teardown converges on the same row. + */ +export const barIdentityWithTx = async ( + tx: Prisma.TransactionClient, + args: { type: string; externalKey: string }, +): Promise => { + const identityHash = hashDeletedIdentity(args.type, args.externalKey); + await tx.deletedIdentity.upsert({ + where: { identityHash }, + update: {}, + create: { identityHash }, + }); +}; diff --git a/src/accounts/deletion/identity-hash.ts b/src/accounts/deletion/identity-hash.ts new file mode 100644 index 00000000..e459b119 --- /dev/null +++ b/src/accounts/deletion/identity-hash.ts @@ -0,0 +1,34 @@ +import { createHmac } from "node:crypto"; +import { DELETION_HASH_SECRET } from "@/config"; + +/** + * Keyed pseudonymization for retained deletion data. Raw identifiers (SIWE + * address, account id) never survive a deletion; these HMAC-SHA256 digests do. + * The two helpers use distinct domain-separation prefixes so an identity hash + * can never collide with an account ref even if the inputs ever overlapped. + * + * Stability contract: DELETION_HASH_SECRET must never rotate — a rotation + * would orphan every DeletedIdentity barrier row (silently lifting the bar) + * and break deletion-record lookups. See src/config.ts. + */ + +const hmacHex = (input: string): string => + createHmac("sha256", DELETION_HASH_SECRET).update(input).digest("hex"); + +/** + * Barrier hash for a deleted auth identity. Keyed by the AuthMethod natural + * key (type + externalKey); the external key is lowercased so the hash is + * insensitive to address casing (SIWE addresses are stored lowercased today, + * but EIP-55 checksummed input must map to the same barrier row). + */ +export const hashDeletedIdentity = ( + type: string, + externalKey: string, +): string => hmacHex(`identity:${type}:${externalKey.toLowerCase()}`); + +/** + * Pseudonymous reference to a deleted account, used on DeletionRecord, + * SubscriptionTombstone, and AdminAudit deletion entries. + */ +export const hashAccountRef = (accountId: string): string => + hmacHex(`account:${accountId.toLowerCase()}`); diff --git a/src/accounts/repository.ts b/src/accounts/repository.ts index 47a1b2a1..40586f8e 100644 --- a/src/accounts/repository.ts +++ b/src/accounts/repository.ts @@ -1,7 +1,42 @@ import { Prisma } from "@prisma/client"; import type { AuthMethodType } from "@/accounts/auth-method-type"; +import { hashDeletedIdentity } from "@/accounts/deletion/identity-hash"; import { prisma } from "@/utils/prisma"; +/** + * Thrown when the auto-provisioning upsert finds the identity behind the + * permanent deletion barrier. The mint handler maps this to the terminal + * 410 identity_deleted response. + */ +export class IdentityBarredError extends Error { + constructor( + public readonly type: AuthMethodType, + public readonly externalKey: string, + ) { + super("Identity has been deleted"); + this.name = "IdentityBarredError"; + Object.setPrototypeOf(this, IdentityBarredError.prototype); + } +} + +/** + * Transaction-scoped advisory lock on one auth identity — the common + * serialization primitive between token mint and the deletion teardown. + * Both sides take it before touching the barrier or the AuthMethod rows, so + * a mint racing a deletion either completes first (and is then torn down) or + * observes the committed barrier inside its own transaction. Without it, a + * mint that passed the handler's unlocked barrier pre-check could recreate + * a freshly deleted account behind its permanent barrier. + */ +export const lockIdentityForMintOrDeletion = async ( + tx: Prisma.TransactionClient, + identityHash: string, +): Promise => { + // $executeRaw: pg_advisory_xact_lock returns void, which $queryRaw cannot + // deserialize. + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${identityHash}, 0))`; +}; + export async function upsertAuthMethodAndAccount(args: { type: AuthMethodType; externalKey: string; @@ -10,8 +45,21 @@ export async function upsertAuthMethodAndAccount(args: { accountId: string, ) => Promise; }): Promise<{ accountId: string; created: boolean }> { + const identityHash = hashDeletedIdentity(args.type, args.externalKey); const findOrInsert = () => prisma.$transaction(async (tx) => { + // Serialize with the deletion teardown, then re-check the barrier + // inside this transaction: the handler's earlier check ran unlocked, + // and a deletion may have committed in between. + await lockIdentityForMintOrDeletion(tx, identityHash); + const barred = await tx.deletedIdentity.findUnique({ + where: { identityHash }, + select: { identityHash: true }, + }); + if (barred) { + throw new IdentityBarredError(args.type, args.externalKey); + } + const existing = await tx.authMethod.findUnique({ where: { type_externalKey: { type: args.type, externalKey: args.externalKey }, diff --git a/src/accounts/require-live-account.ts b/src/accounts/require-live-account.ts new file mode 100644 index 00000000..645f0032 --- /dev/null +++ b/src/accounts/require-live-account.ts @@ -0,0 +1,42 @@ +import type { Prisma } from "@prisma/client"; + +/** + * Thrown by requireLiveAccount when the account row is gone (deleted, or never + * existed). Callers map it to their route's auth-failure response. + */ +export class AccountNotLiveError extends Error { + constructor(public readonly accountId: string) { + super("Account is not live"); + this.name = "AccountNotLiveError"; + Object.setPrototypeOf(this, AccountNotLiveError.prototype); + } +} + +/** + * Existence check + serialization point for writers that attach + * account-linked state, fencing them against a concurrent account deletion. + * + * `SELECT ... FOR KEY SHARE` conflicts with the deletion transaction's + * `FOR UPDATE` on the same Account row but not with other FOR KEY SHARE + * holders, so writers serialize against deletion only, never against each + * other. Under READ COMMITTED, a writer that blocks on the deletion's lock + * re-reads once the deletion commits, finds no row, and aborts here; a writer + * that acquired its lock first commits ahead of the deletion, whose sweep + * statements then see and remove its rows. + * + * Mandatory at FK-less writer sites (ClientIdentifier upsert, AdminAudit + * insert); FK-backed writers get the same lock implicitly from their + * referential-integrity check. Must run inside the same transaction as the + * write it fences. + */ +export const requireLiveAccount = async ( + tx: Prisma.TransactionClient, + accountId: string, +): Promise => { + const rows = await tx.$queryRaw>` + SELECT 1 AS ok FROM "Account" WHERE id = ${accountId}::uuid FOR KEY SHARE + `; + if (rows.length === 0) { + throw new AccountNotLiveError(accountId); + } +}; diff --git a/src/api/v2/agents/assets/agent-assets.router.ts b/src/api/v2/agents/assets/agent-assets.router.ts index df58f7a2..b88ff3ed 100644 --- a/src/api/v2/agents/assets/agent-assets.router.ts +++ b/src/api/v2/agents/assets/agent-assets.router.ts @@ -1,6 +1,11 @@ import { Router } from "express"; +import { requireAccount } from "@/middleware/auth"; import { getAgentPresignedUrlHandler } from "./handlers/get-presigned-url"; export const agentAssetsRouter = Router(); -agentAssetsRouter.get("/presigned", getAgentPresignedUrlHandler); +agentAssetsRouter.get( + "/presigned", + requireAccount, + getAgentPresignedUrlHandler, +); diff --git a/src/api/v2/agents/assets/handlers/get-presigned-url.ts b/src/api/v2/agents/assets/handlers/get-presigned-url.ts index 3ee09250..befb4f0f 100644 --- a/src/api/v2/agents/assets/handlers/get-presigned-url.ts +++ b/src/api/v2/agents/assets/handlers/get-presigned-url.ts @@ -3,7 +3,9 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import type { Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; +import { accountIdSchema } from "@/utils/account-id"; import { AppError } from "@/utils/errors"; +import { prisma } from "@/utils/prisma"; const envSchema = z.object({ PUBLIC_ASSETS_BUCKET: z.string().min(1).optional(), @@ -19,12 +21,19 @@ const env = envSchema.parse({ const s3Client = env.PUBLIC_ASSETS_BUCKET ? new S3Client({}) : null; -const getAgentPresignedURL = async () => { +const querySchema = z.object({ + // The trusted agent-key caller may attribute the upload to the same owner + // it asserts when creating a template. JWT callers always use their own + // authenticated account and cannot override it. + ownerAccountId: accountIdSchema.optional(), +}); + +const getAgentPresignedURL = async (accountId: string) => { if (!env.PUBLIC_ASSETS_BUCKET || !s3Client) { throw new AppError(503, "File uploads not available - S3 not configured"); } - const objectKey = `a/${uuidv4()}`; + const objectKey = `a/${accountId}/${uuidv4()}`; const command = new PutObjectCommand({ Bucket: env.PUBLIC_ASSETS_BUCKET, @@ -42,9 +51,38 @@ const getAgentPresignedURL = async () => { export async function getAgentPresignedUrlHandler(req: Request, res: Response) { try { - req.log.info("v2 agent assets presigned URL request"); + const query = querySchema.safeParse(req.query); + if (!query.success) { + res.status(400).json({ error: "Invalid ownerAccountId" }); + return; + } + + let accountId = res.locals.accountId; + if ( + res.locals.isApiKeyListener === true && + query.data.ownerAccountId !== undefined + ) { + const assertedOwner = await prisma.account.findUnique({ + where: { id: query.data.ownerAccountId }, + select: { id: true }, + }); + if (!assertedOwner) { + res + .status(400) + .json({ error: "Asserted ownerAccountId does not exist" }); + return; + } + accountId = assertedOwner.id; + } + if (!accountId) { + res.status(403).json({ error: "Account required" }); + return; + } + + req.log.info({ accountId }, "v2 agent assets presigned URL request"); - const { objectKey, uploadUrl, assetUrl } = await getAgentPresignedURL(); + const { objectKey, uploadUrl, assetUrl } = + await getAgentPresignedURL(accountId); res.set({ "Cache-Control": "no-store", diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index 82254df5..f7fdd935 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -1,6 +1,11 @@ import type { Request, Response } from "express"; import { z } from "zod"; -import { upsertAuthMethodAndAccount } from "@/accounts/repository"; +import { isIdentityBarred } from "@/accounts/deletion/barrier"; +import { + IdentityBarredError, + upsertAuthMethodAndAccount, +} from "@/accounts/repository"; +import { requireLiveAccount } from "@/accounts/require-live-account"; import { consumeNonce } from "@/api/v2/auth/auth-nonce.repository"; import { InvalidSiweError, verifySiwe } from "@/api/v2/auth/handlers/siwe"; import { @@ -101,10 +106,38 @@ export async function generateToken( throw err; } - // 3d. Upsert Account + AuthMethod. On first creation, grant the signup + // 3d. Deletion barrier. Checked only after full SIWE validation succeeded + // (never for bad nonce/signature — no unauthenticated deletion oracle). + // A barred identity gets the terminal identity-deleted response, the one + // signal clients may treat as deletion confirmation, and never reaches + // the auto-provisioning upsert below (so no account or signup bonus can + // ever be silently recreated). + try { + if (await isIdentityBarred("SIWE", address)) { + req.log.info( + { deviceId: body.deviceId }, + "auth.token.identity_deleted", + ); + res.status(410).json({ + error: "This identity has been deleted", + code: "identity_deleted", + }); + return; + } + } catch (err) { + req.log.error({ err }, "auth.token.barrier_check_failed"); + res.status(500).json({ error: "Failed to generate token" }); + return; + } + + // 3e. Upsert Account + AuthMethod. On first creation, grant the signup // bonus inside the same transaction (atomic) so a new account can never // exist without its bonus. A failure rolls the account back and surfaces // as a retryable 500 rather than silently dropping the bonus. + // The upsert re-checks the deletion barrier inside its own transaction + // under the per-identity advisory lock (shared with the teardown), so a + // deletion committing after the pre-check above can never be followed by + // a silent account re-creation — it surfaces here as IdentityBarredError. let upserted: { accountId: string; created: boolean }; try { upserted = await upsertAuthMethodAndAccount({ @@ -121,6 +154,17 @@ export async function generateToken( : undefined, }); } catch (err) { + if (err instanceof IdentityBarredError) { + req.log.info( + { deviceId: body.deviceId }, + "auth.token.identity_deleted", + ); + res.status(410).json({ + error: "This identity has been deleted", + code: "identity_deleted", + }); + return; + } req.log.error({ err }, "auth.account.create_failed"); res.status(500).json({ error: "Failed to create account" }); return; @@ -145,37 +189,48 @@ export async function generateToken( // wallet-switch case. Truly simultaneous arrivals resolve to lock // acquisition order (non-deterministic, but final state is still a // valid one of the two — no torn writes). - try { - const count = await prisma.$transaction(async (tx) => { - // Acquire row-level lock; no-op if device row doesn't exist - // (returns 0 rows, no lock taken, subsequent updateMany also 0). - await tx.$queryRaw` - SELECT 1 FROM "DeviceRegistration" - WHERE "deviceId" = ${body.deviceId} - FOR UPDATE - `; - const result = await tx.deviceRegistration.updateMany({ - where: { deviceId: body.deviceId }, - data: { accountId }, + if (device?.accountId === accountId) { + req.log.info( + { deviceId: body.deviceId, accountId }, + "auth.device.account_backfill_noop", + ); + } else { + try { + const count = await prisma.$transaction(async (tx) => { + // Account lock first (lock-order law: Account before the device + // row) — fences the backfill against a concurrent deletion of this + // account. AccountNotLiveError lands in the fail-soft catch below. + await requireLiveAccount(tx, upserted.accountId); + // Acquire row-level lock; no-op if device row doesn't exist + // (returns 0 rows, no lock taken, subsequent updateMany also 0). + await tx.$queryRaw` + SELECT 1 FROM "DeviceRegistration" + WHERE "deviceId" = ${body.deviceId} + FOR UPDATE + `; + const result = await tx.deviceRegistration.updateMany({ + where: { deviceId: body.deviceId }, + data: { accountId }, + }); + return result.count; }); - return result.count; - }); - if (count > 0) { - req.log.info( - { deviceId: body.deviceId, accountId }, - "auth.device.account_backfill", - ); - } else { - req.log.info( - { deviceId: body.deviceId, accountId }, - "auth.device.account_backfill_noop", + if (count > 0) { + req.log.info( + { deviceId: body.deviceId, accountId }, + "auth.device.account_backfill", + ); + } else { + req.log.info( + { deviceId: body.deviceId, accountId }, + "auth.device.account_backfill_noop", + ); + } + } catch (err) { + req.log.warn( + { err, deviceId: body.deviceId, accountId }, + "auth.device.account_backfill_failed", ); } - } catch (err) { - req.log.warn( - { err, deviceId: body.deviceId, accountId }, - "auth.device.account_backfill_failed", - ); } } diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 22ff6030..b18cbbc7 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -135,7 +135,7 @@ v2Router.use("/assets", authMiddleware, assetsRouter); v2Router.use( "/agents/assets", agentAssetPreAuthLimiter, - agentApiKeyAuth, + authOrAgentApiKeyAuth, agentAssetLimiter, agentAssetsRouter, ); diff --git a/src/config.ts b/src/config.ts index 4d55c3bc..57b43662 100644 --- a/src/config.ts +++ b/src/config.ts @@ -131,6 +131,22 @@ export const SIWE_URI = process.env.SIWE_URI; export const SIWE_ALLOWED_CHAIN_IDS: readonly number[] = parsedChainIds; export const NONCE_HMAC_SECRET = process.env.NONCE_HMAC_SECRET; +// Account-deletion hashing secret (required). Keys the HMAC that produces the +// deletion-barrier identity hashes and the pseudonymous account refs on +// retained deletion records. Deliberately distinct from NONCE_HMAC_SECRET: +// nonce secrets must stay freely rotatable (nonces live minutes), while +// rotating this secret would orphan every DeletedIdentity barrier row and +// silently lift the bar. Treat as permanent once set. +if ( + !process.env.DELETION_HASH_SECRET || + process.env.DELETION_HASH_SECRET.length < 64 +) { + throw new Error( + "DELETION_HASH_SECRET is not configured or too short (need >= 64 chars / 32 bytes hex)", + ); +} +export const DELETION_HASH_SECRET = process.env.DELETION_HASH_SECRET; + // Builder / template-gen + moderation (optional — services fail open / no-op // when these are unset; cached at module-load to avoid call-time process.env // reads on every generation). diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index d0cbe46b..f7aee2dc 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -5,11 +5,65 @@ import { AppError } from "@/utils/errors"; import { verifyAppCheckToken } from "@/utils/firebase"; import { isNotificationExtensionOnlyToken, verifyJwtToken } from "@/utils/jwt"; import logger from "@/utils/logger"; +import { prisma } from "@/utils/prisma"; import { getRuntimeConfig } from "@/utils/runtimeConfig"; export const AUTH_HEADER = "X-Convos-AuthToken"; export const APPCHECK_HEADER = "X-Firebase-AppCheck"; +/** + * The single deleted-account carve-out: DELETE /v2/accounts/me accepts a + * validly-signed, unexpired token whose account is already gone, so an + * idempotent deletion retry can re-read its stored record. Every other + * accountId-bearing request is fenced below. + */ +const isDeleteReplayCarveOut = (req: Request): boolean => { + if (req.method !== "DELETE") return false; + const fullPath = `${req.baseUrl}${req.path}`.replace(/\/+$/, ""); + return fullPath === "/api/v2/accounts/me"; +}; + +/** + * Deletion fence, applied inside JWT authentication itself so no route + * registration can forget it: a JWT carrying an accountId claim is only + * accepted while the Account row still exists. A deleted account's + * unexpired token gets a generic 401 on every route (never a + * deletion-specific signal — the mint-path 410 is the only confirmation + * channel). No positive caching: fail-closed means every check hits the + * database. Returns false after writing the response when the request must + * not proceed. + */ +type VerifiedJwtPayload = Awaited>; + +const enforceLiveAccountClaim = async ( + req: Request, + res: Response, + payload: VerifiedJwtPayload, +): Promise => { + if (!payload.accountId || isDeleteReplayCarveOut(req)) return true; + if (!accountIdSchema.safeParse(payload.accountId).success) { + res.status(401).json({ error: "Unauthorized" }); + return false; + } + let account: { id: string } | null; + try { + account = await prisma.account.findUnique({ + where: { id: payload.accountId }, + select: { id: true }, + }); + } catch (error) { + req.log.error({ error }, "auth.fence.account_lookup_failed"); + res.status(500).json({ error: "Internal server error" }); + return false; + } + if (!account) { + req.log.warn({ deviceId: payload.deviceId }, "auth.fence.account_not_live"); + res.status(401).json({ error: "Unauthorized" }); + return false; + } + return true; +}; + export const appCheckOnlyMiddleware = async ( req: Request, res: Response, @@ -95,6 +149,11 @@ export const authMiddleware = async ( return; } + // Deletion fence: an accountId claim is only honored while the account + // row exists (fail-closed on every route, delete-replay carve-out + // excepted). + if (!(await enforceLiveAccountClaim(req, res, payload))) return; + req.log.info({ deviceId: payload.deviceId }, "JWT verification successful"); next(); } catch (error) { @@ -159,6 +218,11 @@ export const authMiddlewareAllowNSE = async ( } } + // Deletion fence: same fail-closed rule as authMiddleware — a deleted + // account's unexpired token must not pass even the diagnostic + // auth-check. + if (!(await enforceLiveAccountClaim(req, res, payload))) return; + req.log.info( { deviceId: payload.deviceId, @@ -178,7 +242,7 @@ export const authMiddlewareAllowNSE = async ( } }; -export const requireAccount = ( +export const requireAccount = async ( req: Request, res: Response, next: NextFunction, @@ -191,6 +255,31 @@ export const requireAccount = ( res.status(403).json({ error: "Account required" }); return; } + // Fail closed: the JWT claim alone is not enough — the account row must + // still exist. A deleted account holding an unexpired token gets a generic + // 401 (never a deletion-specific signal: the mint-path 410 is the only + // confirmation channel). Single indexed PK lookup per request. + try { + const account = await prisma.account.findUnique({ + where: { id: res.locals.accountId as string }, + select: { id: true }, + }); + if (!account) { + ((req as { log?: Request["log"] }).log ?? logger).warn( + { deviceId: res.locals.deviceId }, + "auth.require_account.missing_account", + ); + res.status(401).json({ error: "Unauthorized" }); + return; + } + } catch (error) { + ((req as { log?: Request["log"] }).log ?? logger).error( + { error }, + "auth.require_account.lookup_failed", + ); + res.status(500).json({ error: "Internal server error" }); + return; + } next(); }; diff --git a/tests/account-auth-check.test.ts b/tests/account-auth-check.test.ts index 344e0814..6a475039 100644 --- a/tests/account-auth-check.test.ts +++ b/tests/account-auth-check.test.ts @@ -4,6 +4,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest"; import { authMiddleware, requireAccount } from "@/middleware/auth"; import { pinoMiddleware } from "@/middleware/pino"; import { createJwtToken, validateJWTKeys } from "@/utils/jwt"; +import { prisma } from "@/utils/prisma"; vi.mock("firebase-admin/app"); vi.mock("firebase-admin/app-check"); @@ -29,13 +30,54 @@ beforeAll(async () => { describe("/account-auth-check", () => { test("SIWE-upgraded JWT (with accountId) → 200", async () => { - const accountId = "33333333-3333-3333-3333-333333333333"; - const token = await createJwtToken({ deviceId: "dev-siwe", accountId }); + // requireAccount is fail-closed: the account row must exist. + const account = await prisma.account.create({ data: {} }); + try { + const token = await createJwtToken({ + deviceId: "dev-siwe", + accountId: account.id, + }); + const res = await request(makeApp()) + .get("/account-auth-check") + .set("X-Convos-AuthToken", token); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + } finally { + await prisma.account.delete({ where: { id: account.id } }); + } + }); + + test("SIWE-upgraded JWT for a deleted account → generic 401", async () => { + const account = await prisma.account.create({ data: {} }); + const token = await createJwtToken({ + deviceId: "dev-deleted", + accountId: account.id, + }); + await prisma.account.delete({ where: { id: account.id } }); const res = await request(makeApp()) .get("/account-auth-check") .set("X-Convos-AuthToken", token); - expect(res.status).toBe(200); - expect(res.body).toEqual({ success: true }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); + }); + + test("account fence lookup failure → 500, never 401", async () => { + const token = await createJwtToken({ + deviceId: "dev-db-error", + accountId: "11111111-1111-4111-8111-111111111111", + }); + const lookup = vi + .spyOn(prisma.account, "findUnique") + .mockRejectedValueOnce(new Error("database unavailable")); + try { + const res = await request(makeApp()) + .get("/account-auth-check") + .set("X-Convos-AuthToken", token); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: "Internal server error" }); + } finally { + lookup.mockRestore(); + } }); test("legacy device-only JWT (no accountId) → 403 Account required", async () => { diff --git a/tests/agent-assets-presigned.test.ts b/tests/agent-assets-presigned.test.ts new file mode 100644 index 00000000..e82858f4 --- /dev/null +++ b/tests/agent-assets-presigned.test.ts @@ -0,0 +1,73 @@ +import type { Request, Response } from "express"; +import { afterEach, expect, test, vi } from "vitest"; +import { getAgentPresignedUrlHandler } from "@/api/v2/agents/assets/handlers/get-presigned-url"; + +const ACCOUNT_ID = "11111111-1111-4111-8111-111111111111"; +const { getSignedUrl } = vi.hoisted(() => ({ + getSignedUrl: vi.fn((_client: unknown, _command: unknown) => + Promise.resolve("https://signed.example/put"), + ), +})); + +vi.mock("@aws-sdk/client-s3", () => ({ + S3Client: class { + config = {}; + }, + PutObjectCommand: class { + input: unknown; + constructor(input: unknown) { + this.input = input; + } + }, +})); + +vi.mock("@aws-sdk/s3-request-presigner", () => ({ getSignedUrl })); + +type MockResponse = Pick & { + body?: unknown; + locals: Response["locals"]; + statusCode: number; +}; + +const response = (): MockResponse => { + const res = { + locals: { accountId: ACCOUNT_ID }, + statusCode: 200, + } as MockResponse; + res.status = (statusCode) => { + res.statusCode = statusCode; + return res as Response; + }; + res.json = (body) => { + res.body = body; + return res as Response; + }; + res.set = () => res as Response; + return res; +}; + +afterEach(() => { + getSignedUrl.mockClear(); +}); + +test("mints an avatar key inside the authenticated account namespace", async () => { + const req = { + query: {}, + log: { error: vi.fn(), info: vi.fn() }, + } as unknown as Request; + const res = response(); + + await getAgentPresignedUrlHandler(req, res as Response); + + expect(res.statusCode).toBe(200); + const body = res.body as { assetUrl: string; objectKey: string }; + expect(body.objectKey).toMatch( + new RegExp( + `^a/${ACCOUNT_ID}/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, + ), + ); + const command = getSignedUrl.mock.calls[0]?.[1] as + | { input?: { Key?: string } } + | undefined; + expect(command?.input?.Key).toBe(body.objectKey); +}); diff --git a/tests/agent-prompt-hints.admin.test.ts b/tests/agent-prompt-hints.admin.test.ts index c76f698f..08318d83 100644 --- a/tests/agent-prompt-hints.admin.test.ts +++ b/tests/agent-prompt-hints.admin.test.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import type { Server } from "node:http"; import express from "express"; import { @@ -303,7 +302,10 @@ describe("Agent prompt hints admin endpoints", () => { }); test("non-admin authenticated account is rejected (403), and no row is created", async () => { - const headers = await jwtHeaders(randomUUID()); + // requireAccount is fail-closed, so the non-admin account must exist for + // the request to reach the admin gate at all. + const nonAdmin = await prisma.account.create({ data: {} }); + const headers = await jwtHeaders(nonAdmin.id); // Write route (POST /) is admin-gated. const text = `${TEST_PREFIX}non-admin`; @@ -316,6 +318,8 @@ describe("Agent prompt hints admin endpoints", () => { // Admin read route (GET /admin) is admin-gated too. const adminList = await listAdmin(headers); expect(adminList.response.status).toBe(403); + + await prisma.account.delete({ where: { id: nonAdmin.id } }); }); test("admin account's own JWT passes the admin gate (201)", async () => { diff --git a/tests/agent-templates.conventions.test.ts b/tests/agent-templates.conventions.test.ts index b8d61888..ca94128c 100644 --- a/tests/agent-templates.conventions.test.ts +++ b/tests/agent-templates.conventions.test.ts @@ -87,12 +87,21 @@ const createTemplate = async ( // templates visible). const READER_ACCOUNT_ID = "00000000-0000-4000-8000-cccccccc0002"; -const readerAuthHeaders = async (): Promise> => ({ - "X-Convos-AuthToken": await createJwtToken({ - deviceId: "test-device-agent-templates-conventions", - accountId: READER_ACCOUNT_ID, - }), -}); +const readerAuthHeaders = async (): Promise> => { + // Fail-closed auth: a JWT accountId claim must reference a live Account + // row, so the synthetic reader account has to exist. + await prisma.account.upsert({ + where: { id: READER_ACCOUNT_ID }, + update: {}, + create: { id: READER_ACCOUNT_ID }, + }); + return { + "X-Convos-AuthToken": await createJwtToken({ + deviceId: "test-device-agent-templates-conventions", + accountId: READER_ACCOUNT_ID, + }), + }; +}; const readJson = async (args: { path: string }) => { const response = await fetch(`${baseURL}${args.path}`, { diff --git a/tests/agent-templates.cross.helpers.ts b/tests/agent-templates.cross.helpers.ts index 9106edaa..f9dc45bd 100644 --- a/tests/agent-templates.cross.helpers.ts +++ b/tests/agent-templates.cross.helpers.ts @@ -7,6 +7,7 @@ import { noRouteMiddleware } from "@/middleware/noRoute"; import { pinoMiddleware } from "@/middleware/pino"; import { ADMIN_ACCOUNT_ID } from "@/utils/constants"; import { createJwtToken } from "@/utils/jwt"; +import { prisma } from "@/utils/prisma"; import { buildUrlSlug } from "@/utils/url-slug"; /** @@ -92,13 +93,22 @@ export const jwtHeaders = async () => ({ // archived templates remain invisible in the listing. const READER_ACCOUNT_ID = "00000000-0000-4000-8000-cccccccc0001"; -export const readerHeaders = async (): Promise> => ({ - "Content-Type": "application/json", - "X-Convos-AuthToken": await createJwtToken({ - deviceId: "test-device-agent-templates-cross-reader", - accountId: READER_ACCOUNT_ID, - }), -}); +export const readerHeaders = async (): Promise> => { + // Fail-closed auth: a JWT accountId claim must reference a live Account + // row, so the synthetic reader account has to exist. + await prisma.account.upsert({ + where: { id: READER_ACCOUNT_ID }, + update: {}, + create: { id: READER_ACCOUNT_ID }, + }); + return { + "Content-Type": "application/json", + "X-Convos-AuthToken": await createJwtToken({ + deviceId: "test-device-agent-templates-cross-reader", + accountId: READER_ACCOUNT_ID, + }), + }; +}; export const agentKeyHeaders = () => ({ "Content-Type": "application/json", diff --git a/tests/agent-templates.detail.test.ts b/tests/agent-templates.detail.test.ts index 652b9a05..a8d68a84 100644 --- a/tests/agent-templates.detail.test.ts +++ b/tests/agent-templates.detail.test.ts @@ -64,12 +64,21 @@ const createTemplate = async ( // published/unlisted/archived from other owners. const READER_ACCOUNT_ID = "00000000-0000-4000-8000-000000000002"; -const readerAuthHeaders = async (): Promise> => ({ - "X-Convos-AuthToken": await createJwtToken({ - deviceId: "test-device-agent-templates-detail", - accountId: READER_ACCOUNT_ID, - }), -}); +const readerAuthHeaders = async (): Promise> => { + // Fail-closed auth: a JWT accountId claim must reference a live Account + // row, so the synthetic reader account has to exist. + await prisma.account.upsert({ + where: { id: READER_ACCOUNT_ID }, + update: {}, + create: { id: READER_ACCOUNT_ID }, + }); + return { + "X-Convos-AuthToken": await createJwtToken({ + deviceId: "test-device-agent-templates-detail", + accountId: READER_ACCOUNT_ID, + }), + }; +}; const readDetail = async (args: { path: string }) => { const response = await fetch(`${baseURL}${args.path}`, { diff --git a/tests/agent-templates.list.test.ts b/tests/agent-templates.list.test.ts index 307cd48f..8853ee51 100644 --- a/tests/agent-templates.list.test.ts +++ b/tests/agent-templates.list.test.ts @@ -44,12 +44,21 @@ const encodeCursor = (cursor: { // see only published templates owned by ADMIN. const READER_ACCOUNT_ID = "00000000-0000-4000-8000-000000000001"; -const readerAuthHeaders = async (): Promise> => ({ - "X-Convos-AuthToken": await createJwtToken({ - deviceId: "test-device-agent-templates-list", - accountId: READER_ACCOUNT_ID, - }), -}); +const readerAuthHeaders = async (): Promise> => { + // Fail-closed auth: a JWT accountId claim must reference a live Account + // row, so the synthetic reader account has to exist. + await prisma.account.upsert({ + where: { id: READER_ACCOUNT_ID }, + update: {}, + create: { id: READER_ACCOUNT_ID }, + }); + return { + "X-Convos-AuthToken": await createJwtToken({ + deviceId: "test-device-agent-templates-list", + accountId: READER_ACCOUNT_ID, + }), + }; +}; const readList = async (path = "/api/v2/agent-templates") => { const response = await fetch(`${baseURL}${path}`, { diff --git a/tests/auth-require-account.test.ts b/tests/auth-require-account.test.ts index 24a2e611..ace2bc64 100644 --- a/tests/auth-require-account.test.ts +++ b/tests/auth-require-account.test.ts @@ -2,6 +2,7 @@ import express from "express"; import request from "supertest"; import { describe, expect, test, vi } from "vitest"; import { requireAccount } from "@/middleware/auth"; +import { prisma } from "@/utils/prisma"; vi.mock("firebase-admin/app"); vi.mock("firebase-admin/app-check"); @@ -38,12 +39,26 @@ describe("requireAccount middleware", () => { expect(res.body).toEqual({ error: "Account required" }); }); - test("200 when accountId is a uuid", async () => { + test("200 when accountId is a uuid and the account row exists", async () => { + const account = await prisma.account.create({ data: {} }); + try { + const res = await request(makeApp(account.id)).get("/gated"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + } finally { + await prisma.account.delete({ where: { id: account.id } }); + } + }); + + test("fail-closed: 401 generic when the account row does not exist", async () => { + // A well-formed claim for a deleted (or never-created) account must get a + // generic 401 — never a deletion-specific signal; the mint-path 410 is + // the only confirmation channel. const res = await request( makeApp("11111111-1111-4111-8111-111111111111"), ).get("/gated"); - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); }); test("warn log carries presence flag only, never the value", async () => { diff --git a/tests/connections.test.ts b/tests/connections.test.ts index 609427a4..3603e959 100644 --- a/tests/connections.test.ts +++ b/tests/connections.test.ts @@ -25,6 +25,7 @@ import { authMiddleware, requireAccount } from "@/middleware/auth"; import { jsonMiddleware } from "@/middleware/json"; import { pinoMiddleware } from "@/middleware/pino"; import { createJwtToken } from "@/utils/jwt"; +import { prisma } from "@/utils/prisma"; vi.mock("firebase-admin/app"); vi.mock("firebase-admin/app-check"); @@ -217,6 +218,12 @@ function installStub(stub: ComposioStub) { describe("Connections API", () => { beforeAll(async () => { + // requireAccount is fail-closed: the JWT's account row must exist. + await prisma.account.upsert({ + where: { id: ACCOUNT_ID }, + update: {}, + create: { id: ACCOUNT_ID }, + }); await new Promise((resolve) => { server = app.listen(4012, () => { resolve(); @@ -231,6 +238,7 @@ describe("Connections API", () => { }); }); __resetComposioServiceForTests(null); + await prisma.account.deleteMany({ where: { id: ACCOUNT_ID } }); }); beforeEach(() => { diff --git a/tests/deletion/barrier-mint.test.ts b/tests/deletion/barrier-mint.test.ts new file mode 100644 index 00000000..98e0e86e --- /dev/null +++ b/tests/deletion/barrier-mint.test.ts @@ -0,0 +1,147 @@ +import cookieParser from "cookie-parser"; +import express from "express"; +import request from "supertest"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { + barIdentityWithTx, + isIdentityBarred, +} from "@/accounts/deletion/barrier"; +import { hashDeletedIdentity } from "@/accounts/deletion/identity-hash"; +import { + AccountNotLiveError, + requireLiveAccount, +} from "@/accounts/require-live-account"; +import { issueNonce } from "@/api/v2/auth/auth-nonce.repository"; +import { authRouter } from "@/api/v2/auth/auth.router"; +import { NONCE_COOKIE_NAME, signNonce } from "@/api/v2/auth/nonce-cookie"; +import { pinoMiddleware } from "@/middleware/pino"; +import { ADMIN_ACCOUNT_ID } from "@/utils/constants"; +import { prisma } from "@/utils/prisma"; +import { buildSiweMessage } from "../helpers/siwe"; + +vi.mock("firebase-admin/app"); +vi.mock("firebase-admin/app-check"); +vi.mock("firebase-admin/messaging"); + +function makeApp() { + const app = express(); + app.use(pinoMiddleware); + app.use(express.json()); + app.use(cookieParser()); + app.use("/auth", authRouter); + return app; +} + +const APPCHECK = ["X-Firebase-AppCheck", "valid-app-check-token"] as const; + +async function mintWithSiwe(deviceId: string, signerKey?: string) { + const nonce = await issueNonce(); + const { messageStr, signature, address } = await buildSiweMessage({ + deviceId, + nonce, + signerKey, + }); + const res = await request(makeApp()) + .post("/auth/token") + .set(...APPCHECK) + .set("Cookie", `${NONCE_COOKIE_NAME}=${signNonce(nonce)}`) + .send({ deviceId, siwe: { message: messageStr, signature } }); + return { res, address }; +} + +async function reset() { + await prisma.deviceRegistration.deleteMany(); + await prisma.authMethod.deleteMany(); + await prisma.creditLedger.deleteMany(); + await prisma.userCredits.deleteMany(); + await prisma.account.deleteMany({ where: { id: { not: ADMIN_ACCOUNT_ID } } }); + await prisma.authNonce.deleteMany(); + await prisma.deletedIdentity.deleteMany(); +} + +describe("deletion barrier at token mint", () => { + beforeAll(reset); + afterEach(reset); + + test("barred identity: 410 identity_deleted, no account, no signup bonus", async () => { + // Bar the identity before it ever mints (the address the default test + // signer produces), then attempt a fully-valid SIWE mint. + const probe = await buildSiweMessage({ + deviceId: "dev-barred", + nonce: "0".repeat(64), + }); + await prisma.$transaction((tx) => + barIdentityWithTx(tx, { type: "SIWE", externalKey: probe.address }), + ); + + const { res, address } = await mintWithSiwe("dev-barred"); + + expect(res.status).toBe(410); + expect(res.body).toEqual({ + error: "This identity has been deleted", + code: "identity_deleted", + }); + // No account/auth-method auto-provisioned, no signup bonus granted. + expect( + await prisma.authMethod.count({ where: { externalKey: address } }), + ).toBe(0); + expect( + await prisma.account.count({ where: { id: { not: ADMIN_ACCOUNT_ID } } }), + ).toBe(0); + expect(await prisma.creditLedger.count()).toBe(0); + }); + + test("barrier hash is case-insensitive on the external key", async () => { + const lower = "0x" + "ab".repeat(20); + const upper = "0x" + "AB".repeat(20); + expect(hashDeletedIdentity("SIWE", lower)).toBe( + hashDeletedIdentity("SIWE", upper), + ); + await prisma.$transaction((tx) => + barIdentityWithTx(tx, { type: "SIWE", externalKey: upper }), + ); + expect(await isIdentityBarred("SIWE", lower)).toBe(true); + }); + + test("unbarred mint succeeds", async () => { + const { res, address } = await mintWithSiwe("dev-live"); + expect(res.status).toBe(200); + + const method = await prisma.authMethod.findFirst({ + where: { externalKey: address }, + }); + expect(method).not.toBeNull(); + }); + + test("barIdentityWithTx is idempotent", async () => { + const externalKey = "0x" + "cd".repeat(20); + await prisma.$transaction((tx) => + barIdentityWithTx(tx, { type: "SIWE", externalKey }), + ); + await expect( + prisma.$transaction((tx) => + barIdentityWithTx(tx, { type: "SIWE", externalKey }), + ), + ).resolves.not.toThrow(); + expect(await prisma.deletedIdentity.count()).toBe(1); + }); +}); + +describe("requireLiveAccount", () => { + afterEach(reset); + + test("passes for a live account", async () => { + const account = await prisma.account.create({ data: {} }); + await expect( + prisma.$transaction((tx) => requireLiveAccount(tx, account.id)), + ).resolves.toBeUndefined(); + }); + + test("throws AccountNotLiveError when the account row is gone", async () => { + const account = await prisma.account.create({ data: {} }); + await prisma.account.delete({ where: { id: account.id } }); + await expect( + prisma.$transaction((tx) => requireLiveAccount(tx, account.id)), + ).rejects.toBeInstanceOf(AccountNotLiveError); + }); +}); diff --git a/tests/deletion/router-fencing.test.ts b/tests/deletion/router-fencing.test.ts new file mode 100644 index 00000000..85fd9faf --- /dev/null +++ b/tests/deletion/router-fencing.test.ts @@ -0,0 +1,142 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import cookieParser from "cookie-parser"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import v2Router from "@/api/v2"; +import { globalJsonMiddleware } from "@/middleware/json"; +import { pinoMiddleware } from "@/middleware/pino"; +import { createJwtToken, validateJWTKeys } from "@/utils/jwt"; +import { prisma } from "@/utils/prisma"; + +vi.mock("firebase-admin/app"); +vi.mock("firebase-admin/app-check"); +vi.mock("firebase-admin/messaging"); + +/** + * Deletion-fence audit over the REAL /v2 router tree. + * + * The fence lives inside JWT authentication itself (enforceLiveAccountClaim + * in src/middleware/auth.ts), so the structural guarantee is: every code + * path that accepts a JWT runs the fence. Two layers of assertion: + * + * 1. Source audit — verifyJwtToken may only be called from the fenced + * middlewares' module. A new middleware that verifies JWTs anywhere else + * would bypass the fence and fails this test until it is either routed + * through the fenced middlewares or explicitly allowlisted with a fence + * of its own. + * 2. Behavioral audit — the real production v2 router (not a synthetic + * mount) rejects a deleted account's unexpired JWT with the generic 401 + * on every JWT surface and honors the single DELETE /v2/accounts/me + * carve-out. + */ + +const makeRealApp = () => { + const app = express(); + app.use(globalJsonMiddleware); + app.use(cookieParser()); + app.use(pinoMiddleware); + app.use("/api/v2", v2Router); + return app; +}; + +/** Files allowed to call verifyJwtToken. */ +const JWT_VERIFICATION_ALLOWLIST = new Set([ + "src/middleware/auth.ts", // fenced: enforceLiveAccountClaim + "src/utils/jwt.ts", // the definition itself +]); + +describe("deletion fence: source audit", () => { + test("verifyJwtToken is only called from the fenced auth middlewares", () => { + const repoRoot = path.resolve(__dirname, "../.."); + const stdout = execFileSync( + "grep", + ["-rln", "verifyJwtToken", "src", "--include=*.ts"], + { cwd: repoRoot, encoding: "utf-8" }, + ); + const callers = stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const unfenced = callers.filter( + (file) => !JWT_VERIFICATION_ALLOWLIST.has(file), + ); + expect( + unfenced, + "These files verify JWTs outside the fenced middlewares — a deleted " + + "account's token would not be fenced there. Route them through " + + "authMiddleware/authMiddlewareAllowNSE or add an equivalent fence: " + + unfenced.join(", "), + ).toEqual([]); + }); +}); + +describe("deletion fence: real router behavior", () => { + let deletedAccountToken: string; + + beforeAll(async () => { + await validateJWTKeys(); + const account = await prisma.account.create({ data: {} }); + deletedAccountToken = await createJwtToken({ + deviceId: "dev-fence-audit", + accountId: account.id, + }); + await prisma.account.delete({ where: { id: account.id } }); + }); + + afterAll(async () => { + await prisma.deletionRecord.deleteMany(); + }); + + // Cover every JWT-authenticated surface previously found unfenced, plus one + // representative per mounted subtree that carries authMiddleware. All must + // return the generic 401. + const jwtSurfaces: Array<{ method: "get" | "post" | "delete"; url: string }> = + [ + { method: "get", url: "/api/v2/auth-check" }, + { method: "get", url: "/api/v2/account-auth-check" }, + { method: "post", url: "/api/v2/invite-codes/redeem" }, + { method: "get", url: "/api/v2/invite-codes/somecode/status" }, + { method: "get", url: "/api/v2/attachments/presigned" }, + { method: "get", url: "/api/v2/accounts/me/credits" }, + { method: "get", url: "/api/v2/accounts/me/subscription" }, + { method: "post", url: "/api/v2/accounts/me/subscription/verify" }, + // slice 5 restores the subscription/claim surface here + { method: "post", url: "/api/v2/agents/join" }, + { method: "get", url: "/api/v2/agents/join/some-instance" }, + { method: "post", url: "/api/v2/assets/renew-batch" }, + { method: "get", url: "/api/v2/connections" }, + { method: "post", url: "/api/v2/notifications/subscribe" }, + ]; + + for (const surface of jwtSurfaces) { + test(`${surface.method.toUpperCase()} ${surface.url} rejects a deleted account's unexpired JWT with a generic 401`, async () => { + const app = makeRealApp(); + const res = await request(app) + [surface.method](surface.url) + .set("X-Convos-AuthToken", deletedAccountToken) + .send({}); + expect( + res.status, + `expected 401, got ${res.status}: ${JSON.stringify(res.body)}`, + ).toBe(401); + expect(res.body).toEqual({ error: "Unauthorized" }); + }); + } + + // slice 3 restores the DELETE /v2/accounts/me carve-out test + + test("a live account's JWT still passes the fence (no false 401)", async () => { + const account = await prisma.account.create({ data: {} }); + const token = await createJwtToken({ + deviceId: "dev-fence-live", + accountId: account.id, + }); + const res = await request(makeRealApp()) + .get("/api/v2/auth-check") + .set("X-Convos-AuthToken", token); + expect(res.status).toBe(200); + await prisma.account.delete({ where: { id: account.id } }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 6f90d071..c242ba96 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -50,6 +50,10 @@ process.env.SIWE_ALLOWED_CHAIN_IDS = process.env.SIWE_ALLOWED_CHAIN_IDS || "1"; process.env.NONCE_HMAC_SECRET = process.env.NONCE_HMAC_SECRET || "0000000000000000000000000000000000000000000000000000000000000000"; +// 64-char hex = 32 bytes. Test secret only. +process.env.DELETION_HASH_SECRET = + process.env.DELETION_HASH_SECRET || + "1111111111111111111111111111111111111111111111111111111111111111"; // v2 JWT test keys (ECDSA P-256) - must be set before config.ts loads process.env.JWT_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----