Skip to content
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- #18: Add soft-delete support to username_registry
-- Adds a nullable deleted_at timestamp column. A NULL value means the record
-- is active; a non-null value means the account was unregistered and should
-- be excluded from all normal federation/lookup queries.

ALTER TABLE "username_registry" ADD COLUMN "deleted_at" TIMESTAMP(3);

-- Index to make soft-delete filters efficient (most rows will be NULL, so a
-- partial index on non-null values keeps the footprint small on Postgres).
CREATE INDEX "username_registry_deleted_at_idx" ON "username_registry"("deleted_at");
5 changes: 5 additions & 0 deletions stellar-payment-platform/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ model User {
memo String?
createdAt DateTime @default(now()) @map("created_at")
flaggedAt DateTime? @map("flagged_at")
// #18 — Soft-delete timestamp. NULL means the record is active; a non-null
// value means the account was unregistered and should be excluded from all
// normal lookups.
deletedAt DateTime? @map("deleted_at")

@@index([username])
@@index([deletedAt])
@@map("username_registry")
}
59 changes: 50 additions & 9 deletions stellar-payment-platform/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,9 @@ app.get('/federation', etagCache, async (req, res, next) => {
try {
if (type === 'id') {
const row = await prisma.user.findFirst({
where: { address: { equals: queryValue, mode: 'insensitive' } },
select: { username: true, address: true, memoType: true, memo: true, flaggedAt: true },
where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null },
select: { username: true, address: true, memoType: true, memo: true, flaggedAt: true },

});

if (!row) {
Expand Down Expand Up @@ -324,8 +325,8 @@ app.get('/federation', etagCache, async (req, res, next) => {

let row = null;
try {
row = await prisma.user.findUnique({
where: { username: queryName },
row = await prisma.user.findFirst({
where: { username: queryName, deletedAt: null },
select: { address: true, memoType: true, memo: true, flaggedAt: true },
});

Expand Down Expand Up @@ -533,8 +534,8 @@ app.post('/register', idempotencyMiddleware(redisClient), async (req, res, next)
try {
let existing = null;
try {
existing = await prisma.user.findUnique({
where: { address },
existing = await prisma.user.findFirst({
where: { address, deletedAt: null },
});
} catch (error) {
if (!shouldFallbackToLocalRegistry(error)) {
Expand Down Expand Up @@ -660,6 +661,43 @@ app.post('/register', idempotencyMiddleware(redisClient), async (req, res, next)

app.all('/register', (req, res) => res.status(405).json({ error: "Method Not Allowed" }));

// #18 — Soft-delete endpoint. Sets deleted_at to now() instead of running a
// hard DELETE so the row is preserved for historical auditing.
app.delete('/register/:username', async (req, res, next) => {
const username = normalizeNameTag(
typeof req.params.username === 'string' ? req.params.username.trim() : '',
).toLowerCase();

if (!username) {
const error = new Error('Missing username parameter');
error.statusCode = 400;
return next(error);
}

try {
const existing = await prisma.user.findFirst({
where: { username, deletedAt: null },
});

if (!existing) {
const notFoundError = new Error('Username not found or already deleted');
notFoundError.statusCode = 404;
return next(notFoundError);
}

await prisma.user.update({
where: { username },
data: { deletedAt: new Date() },
});

return res.status(200).json({ ok: true, username, deleted: true });
} catch {
const dbError = new Error('Failed to unregister account');
dbError.statusCode = 500;
return next(dbError);
}
});

app.get('/lookup', async (req, res, next) => {
const address = typeof req.query.address === 'string' ? req.query.address.trim() : '';
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
Expand All @@ -674,8 +712,8 @@ app.get('/lookup', async (req, res, next) => {
try {
let row = null;
try {
row = await prisma.user.findUnique({
where: { address },
row = await prisma.user.findFirst({
where: { address, deletedAt: null },
select: { username: true },
});
} catch (error) {
Expand Down Expand Up @@ -708,6 +746,7 @@ app.get('/lookup', async (req, res, next) => {
const skip = (page - 1) * limit;

const where = {
deletedAt: null,
OR: [
{ username: { contains: search, mode: 'insensitive' } },
{ address: { contains: search, mode: 'insensitive' } },
Expand Down Expand Up @@ -761,12 +800,13 @@ app.get('/users', async (req, res, next) => {

const where = search
? {
deletedAt: null,
OR: [
{ username: { contains: search, mode: 'insensitive' } },
{ address: { contains: search, mode: 'insensitive' } },
],
}
: {};
: { deletedAt: null };

try {
const [totalCount, rows] = await prisma.$transaction([
Expand Down Expand Up @@ -822,6 +862,7 @@ app.get('/api/v1/time', (_req, res) => {

app.get('/health', async (req, res) => {
try {
// Lightweight sanity check — confirms the DB connection is alive
await prisma.$queryRaw`SELECT 1`;
res.json({ status: 'ok', database: 'connected' });
} catch (err) {
Expand Down
7 changes: 5 additions & 2 deletions stellar-payment-platform/src/cleanup-cron.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,15 @@ async function runCleanup(prisma) {

const activeAddresses = [...ACTIVE_NETWORK_ADDRESSES];

// 1. Delete stale rows that are NOT active network addresses.
const pruneResult = await prisma.user.deleteMany({
// 1. Soft-delete stale rows that are NOT active network addresses.
// #18 — Use soft deletes so historical records are preserved for auditing.
const pruneResult = await prisma.user.updateMany({
where: {
createdAt: { lt: cutoff },
address: { notIn: activeAddresses },
deletedAt: null,
},
data: { deletedAt: new Date() },
});

// 2. Flag stale rows that ARE active network addresses.
Expand Down
6 changes: 3 additions & 3 deletions stellar-payment-platform/src/routes/v1/federationRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ router.get('/federation', etagCache, async (req, res, next) => {
try {
if (type === 'id') {
const row = await prisma.user.findFirst({
where: { address: { equals: queryValue, mode: 'insensitive' } },
where: { address: { equals: queryValue, mode: 'insensitive' }, deletedAt: null },
select: { username: true, address: true, memoType: true, memo: true },
});

Expand All @@ -40,8 +40,8 @@ router.get('/federation', etagCache, async (req, res, next) => {
const nameTag = normalizeNameTag(queryValue);
const queryName = nameTag.toLowerCase();

const row = await prisma.user.findUnique({
where: { username: queryName },
const row = await prisma.user.findFirst({
where: { username: queryName, deletedAt: null },
select: { address: true, memoType: true, memo: true },
});

Expand Down
49 changes: 44 additions & 5 deletions stellar-payment-platform/src/routes/v1/userRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ router.post('/register', async (req, res, next) => {
}

try {
const existing = await prisma.user.findUnique({
where: { address }
const existing = await prisma.user.findFirst({
where: { address, deletedAt: null },
});

if (existing) {
Expand Down Expand Up @@ -155,6 +155,43 @@ router.post('/register', async (req, res, next) => {

router.all('/register', (req, res) => res.status(405).json({ error: "Method Not Allowed" }));

// #18 — Soft-delete endpoint. Sets deleted_at to now() instead of running a
// hard DELETE so the row is preserved for historical auditing.
router.delete('/register/:username', async (req, res, next) => {
const username = normalizeNameTag(
typeof req.params.username === 'string' ? req.params.username.trim() : '',
).toLowerCase();

if (!username) {
const error = new Error('Missing username parameter');
error.statusCode = 400;
return next(error);
}

try {
const existing = await prisma.user.findFirst({
where: { username, deletedAt: null },
});

if (!existing) {
const notFoundError = new Error('Username not found or already deleted');
notFoundError.statusCode = 404;
return next(notFoundError);
}

await prisma.user.update({
where: { username },
data: { deletedAt: new Date() },
});

return res.status(200).json({ ok: true, username, deleted: true });
} catch {
const dbError = new Error('Failed to unregister account');
dbError.statusCode = 500;
return next(dbError);
}
});

router.get('/lookup', async (req, res, next) => {
const address = typeof req.query.address === 'string' ? req.query.address.trim() : '';
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
Expand All @@ -167,8 +204,8 @@ router.get('/lookup', async (req, res, next) => {

if (address) {
try {
const row = await prisma.user.findUnique({
where: { address },
const row = await prisma.user.findFirst({
where: { address, deletedAt: null },
select: { username: true },
});

Expand All @@ -191,6 +228,7 @@ router.get('/lookup', async (req, res, next) => {
const skip = (page - 1) * limit;

const where = {
deletedAt: null,
OR: [
{ username: { contains: search, mode: 'insensitive' } },
{ address: { contains: search, mode: 'insensitive' } },
Expand Down Expand Up @@ -231,12 +269,13 @@ router.get('/users', async (req, res, next) => {

const where = search
? {
deletedAt: null,
OR: [
{ username: { contains: search, mode: 'insensitive' } },
{ address: { contains: search, mode: 'insensitive' } },
],
}
: {};
: { deletedAt: null };

try {
const [totalCount, rows] = await prisma.$transaction([
Expand Down
Loading