Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.
Open
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions src/accounts/deletion/barrier.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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<void> => {
const identityHash = hashDeletedIdentity(args.type, args.externalKey);
await tx.deletedIdentity.upsert({
where: { identityHash },
update: {},
create: { identityHash },
});
};
34 changes: 34 additions & 0 deletions src/accounts/deletion/identity-hash.ts
Original file line number Diff line number Diff line change
@@ -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()}`);
48 changes: 48 additions & 0 deletions src/accounts/repository.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
// $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;
Expand All @@ -10,8 +45,21 @@ export async function upsertAuthMethodAndAccount(args: {
accountId: string,
) => Promise<unknown>;
}): 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 },
Expand Down
42 changes: 42 additions & 0 deletions src/accounts/require-live-account.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
const rows = await tx.$queryRaw<Array<{ ok: number }>>`
SELECT 1 AS ok FROM "Account" WHERE id = ${accountId}::uuid FOR KEY SHARE
`;
if (rows.length === 0) {
throw new AccountNotLiveError(accountId);
}
};
7 changes: 6 additions & 1 deletion src/api/v2/agents/assets/agent-assets.router.ts
Original file line number Diff line number Diff line change
@@ -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,
);
46 changes: 42 additions & 4 deletions src/api/v2/agents/assets/handlers/get-presigned-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand All @@ -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",
Expand Down
Loading
Loading