feat: establish auth listing and image vertical slices
Includes MFA-capable authentication, session and CSRF security, password recovery, listing lifecycle and ownership rules, inventory image metadata, local ImageStorage, migrations, tests, and implementation status documentation. Verification: npm test (62 passing) and integration suite (14 passing).
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
const keyLength = 64;
|
||||
const scryptParameters = Object.freeze({ N: 16384, r: 8, p: 1 });
|
||||
|
||||
function requiredPassword(password) {
|
||||
if (typeof password !== 'string' || password.length === 0) {
|
||||
throw new Error('Password is required.');
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
export async function hashPassword(password) {
|
||||
const salt = randomBytes(16);
|
||||
const derivedKey = await scryptAsync(
|
||||
requiredPassword(password),
|
||||
salt,
|
||||
keyLength,
|
||||
scryptParameters,
|
||||
);
|
||||
|
||||
return [
|
||||
'scrypt',
|
||||
scryptParameters.N,
|
||||
scryptParameters.r,
|
||||
scryptParameters.p,
|
||||
salt.toString('base64url'),
|
||||
Buffer.from(derivedKey).toString('base64url'),
|
||||
].join('$');
|
||||
}
|
||||
|
||||
export async function verifyPassword(password, passwordHash) {
|
||||
if (typeof password !== 'string' || typeof passwordHash !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [algorithm, cost, blockSize, parallelization, salt, expectedKey] =
|
||||
passwordHash.split('$');
|
||||
|
||||
if (
|
||||
algorithm !== 'scrypt' ||
|
||||
cost !== String(scryptParameters.N) ||
|
||||
blockSize !== String(scryptParameters.r) ||
|
||||
parallelization !== String(scryptParameters.p) ||
|
||||
!salt ||
|
||||
!expectedKey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const derivedKey = Buffer.from(
|
||||
await scryptAsync(password, Buffer.from(salt, 'base64url'), keyLength, scryptParameters),
|
||||
);
|
||||
const expectedBuffer = Buffer.from(expectedKey, 'base64url');
|
||||
|
||||
return (
|
||||
derivedKey.length === expectedBuffer.length &&
|
||||
timingSafeEqual(derivedKey, expectedBuffer)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
function encryptionKey(value) {
|
||||
const key = Buffer.from(value ?? '', 'base64');
|
||||
|
||||
if (key.length !== 32) {
|
||||
throw new Error('MFA_ENCRYPTION_KEY must be a base64-encoded 32-byte key.');
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function createSecretProtector(keyValue = process.env.MFA_ENCRYPTION_KEY) {
|
||||
const key = encryptionKey(keyValue);
|
||||
|
||||
return {
|
||||
encrypt(secret) {
|
||||
const initializationVector = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, initializationVector);
|
||||
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
|
||||
|
||||
return [
|
||||
initializationVector.toString('base64url'),
|
||||
cipher.getAuthTag().toString('base64url'),
|
||||
ciphertext.toString('base64url'),
|
||||
].join('.');
|
||||
},
|
||||
|
||||
decrypt(encryptedSecret) {
|
||||
const [initializationVector, authTag, ciphertext] = encryptedSecret.split('.');
|
||||
|
||||
if (!initializationVector || !authTag || !ciphertext) {
|
||||
throw new Error('Encrypted MFA secret is invalid.');
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
key,
|
||||
Buffer.from(initializationVector, 'base64url'),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(authTag, 'base64url'));
|
||||
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertext, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
export function createSessionToken() {
|
||||
return createToken();
|
||||
}
|
||||
|
||||
export function createCsrfToken() {
|
||||
return createToken();
|
||||
}
|
||||
|
||||
export function createPasswordResetToken() {
|
||||
return createToken();
|
||||
}
|
||||
|
||||
function createToken() {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
|
||||
return { token, tokenHash: hashSessionToken(token) };
|
||||
}
|
||||
|
||||
export function hashSessionToken(token) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Secret, TOTP } from 'otpauth';
|
||||
|
||||
const issuer = 'Pokemon Inventory';
|
||||
|
||||
function createTotp(secret, label) {
|
||||
return new TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
issuer,
|
||||
label,
|
||||
period: 30,
|
||||
secret: Secret.fromBase32(secret),
|
||||
});
|
||||
}
|
||||
|
||||
export function createTotpEnrollment(label) {
|
||||
const secret = new Secret({ size: 20 }).base32;
|
||||
const totp = createTotp(secret, label);
|
||||
|
||||
return { secret, provisioningUri: totp.toString() };
|
||||
}
|
||||
|
||||
export function verifyTotp({ code, secret, timestamp }) {
|
||||
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return createTotp(secret, 'Pokemon Inventory').validate({
|
||||
token: code,
|
||||
window: 1,
|
||||
timestamp,
|
||||
}) !== null;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
import { mfaMethods } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createMfaMethodRepository(db) {
|
||||
return {
|
||||
async create(method) {
|
||||
const [createdMethod] = await db.insert(mfaMethods).values(method).returning();
|
||||
return createdMethod;
|
||||
},
|
||||
|
||||
async findActiveByIdAndUserId(id, userId) {
|
||||
const [method] = await db
|
||||
.select()
|
||||
.from(mfaMethods)
|
||||
.where(
|
||||
and(
|
||||
eq(mfaMethods.id, id),
|
||||
eq(mfaMethods.userId, userId),
|
||||
isNull(mfaMethods.disabledAt),
|
||||
),
|
||||
);
|
||||
|
||||
return method ?? null;
|
||||
},
|
||||
|
||||
async listEnabledByUserId(userId) {
|
||||
return db
|
||||
.select()
|
||||
.from(mfaMethods)
|
||||
.where(
|
||||
and(
|
||||
eq(mfaMethods.userId, userId),
|
||||
eq(mfaMethods.isEnabled, true),
|
||||
isNull(mfaMethods.disabledAt),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async markUsed(id, lastUsedAt) {
|
||||
await db
|
||||
.update(mfaMethods)
|
||||
.set({ lastUsedAt })
|
||||
.where(and(eq(mfaMethods.id, id), eq(mfaMethods.isEnabled, true)));
|
||||
},
|
||||
|
||||
async enable(id, lastUsedAt) {
|
||||
const [method] = await db
|
||||
.update(mfaMethods)
|
||||
.set({ isEnabled: true, lastUsedAt })
|
||||
.where(and(eq(mfaMethods.id, id), eq(mfaMethods.isEnabled, false)))
|
||||
.returning();
|
||||
|
||||
return method ?? null;
|
||||
},
|
||||
|
||||
async disable(id, disabledAt) {
|
||||
const [method] = await db
|
||||
.update(mfaMethods)
|
||||
.set({ isEnabled: false, disabledAt })
|
||||
.where(and(eq(mfaMethods.id, id), isNull(mfaMethods.disabledAt)))
|
||||
.returning();
|
||||
|
||||
return method ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
passwordResetTokens,
|
||||
sessions,
|
||||
users,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createPasswordResetRepository(db) {
|
||||
return {
|
||||
async issue({ userId, tokenHash, expiresAt }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
await transaction
|
||||
.update(passwordResetTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(passwordResetTokens.userId, userId),
|
||||
isNull(passwordResetTokens.usedAt),
|
||||
),
|
||||
);
|
||||
const [resetToken] = await transaction
|
||||
.insert(passwordResetTokens)
|
||||
.values({ userId, tokenHash, expiresAt })
|
||||
.returning();
|
||||
|
||||
return resetToken;
|
||||
});
|
||||
},
|
||||
|
||||
async consume({ tokenHash, passwordHash, now }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
const [resetToken] = await transaction
|
||||
.update(passwordResetTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(passwordResetTokens.tokenHash, tokenHash),
|
||||
isNull(passwordResetTokens.usedAt),
|
||||
gt(passwordResetTokens.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.returning({ userId: passwordResetTokens.userId });
|
||||
|
||||
if (!resetToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await transaction
|
||||
.update(users)
|
||||
.set({ passwordHash, updatedAt: now })
|
||||
.where(eq(users.id, resetToken.userId));
|
||||
await transaction
|
||||
.update(sessions)
|
||||
.set({ revokedAt: now })
|
||||
.where(and(eq(sessions.userId, resetToken.userId), isNull(sessions.revokedAt)));
|
||||
|
||||
return resetToken.userId;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { securityAuditEvents } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createSecurityAuditRepository(db) {
|
||||
return {
|
||||
async record(event) {
|
||||
const [createdEvent] = await db
|
||||
.insert(securityAuditEvents)
|
||||
.values(event)
|
||||
.returning();
|
||||
|
||||
return createdEvent;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { and, desc, eq, gt, isNull, ne } from 'drizzle-orm';
|
||||
|
||||
import { sellers, sessions, users } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createSessionRepository(db) {
|
||||
return {
|
||||
async create(session) {
|
||||
const [createdSession] = await db.insert(sessions).values(session).returning();
|
||||
return createdSession;
|
||||
},
|
||||
|
||||
async findActiveAuthenticationSession(tokenHash, now) {
|
||||
const [session] = await db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
userId: users.id,
|
||||
role: users.role,
|
||||
sellerId: sellers.id,
|
||||
csrfTokenHash: sessions.csrfTokenHash,
|
||||
lastActivityAt: sessions.lastActivityAt,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.leftJoin(sellers, eq(sellers.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.sessionTokenHash, tokenHash),
|
||||
isNull(sessions.revokedAt),
|
||||
gt(sessions.expiresAt, now),
|
||||
),
|
||||
);
|
||||
|
||||
return session ?? null;
|
||||
},
|
||||
|
||||
async listActiveByUserId(userId, now) {
|
||||
return db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
createdAt: sessions.createdAt,
|
||||
expiresAt: sessions.expiresAt,
|
||||
lastActivityAt: sessions.lastActivityAt,
|
||||
ipAddress: sessions.ipAddress,
|
||||
userAgent: sessions.userAgent,
|
||||
})
|
||||
.from(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, userId),
|
||||
isNull(sessions.revokedAt),
|
||||
gt(sessions.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(sessions.lastActivityAt));
|
||||
},
|
||||
|
||||
async revokeByTokenHash(tokenHash, revokedAt) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt })
|
||||
.where(and(eq(sessions.sessionTokenHash, tokenHash), isNull(sessions.revokedAt)));
|
||||
},
|
||||
|
||||
async touchById(id, lastActivityAt) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ lastActivityAt })
|
||||
.where(and(eq(sessions.id, id), isNull(sessions.revokedAt)));
|
||||
},
|
||||
|
||||
async revokeByIdAndUserId(id, userId, revokedAt) {
|
||||
const revokedSessions = await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.id, id),
|
||||
eq(sessions.userId, userId),
|
||||
isNull(sessions.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
return revokedSessions.length === 1;
|
||||
},
|
||||
|
||||
async revokeOtherByUserId(userId, currentSessionId, revokedAt) {
|
||||
await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, userId),
|
||||
ne(sessions.id, currentSessionId),
|
||||
isNull(sessions.revokedAt),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
import { sellers, users } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createUserRepository(db) {
|
||||
return {
|
||||
async createInitialAdmin({ email, passwordHash, sellerName }) {
|
||||
return db.transaction(async (transaction) => {
|
||||
await transaction.execute(sql`SELECT pg_advisory_xact_lock(45873129)`);
|
||||
const [{ userCount }] = await transaction
|
||||
.select({ userCount: sql`count(*)` })
|
||||
.from(users);
|
||||
|
||||
if (Number(userCount) > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [user] = await transaction
|
||||
.insert(users)
|
||||
.values({ email, passwordHash, role: 'ADMIN' })
|
||||
.returning();
|
||||
const [seller] = await transaction
|
||||
.insert(sellers)
|
||||
.values({ userId: user.id, displayName: sellerName })
|
||||
.returning();
|
||||
|
||||
return { user, seller };
|
||||
});
|
||||
},
|
||||
|
||||
async findAuthenticationUserByEmail(email) {
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
passwordHash: users.passwordHash,
|
||||
role: users.role,
|
||||
sellerId: sellers.id,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(sellers, eq(sellers.userId, users.id))
|
||||
.where(sql`lower(${users.email}) = lower(${email})`);
|
||||
|
||||
return user ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
enrollMfaSchema,
|
||||
loginSchema,
|
||||
requestPasswordResetSchema,
|
||||
resetPasswordSchema,
|
||||
sessionIdSchema,
|
||||
verifyMfaSchema,
|
||||
} from '@pokemon-inventory/validation/auth';
|
||||
import { toMfaMethodDto, toSessionDto } from '@pokemon-inventory/contracts/auth';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sessionCookieOptions(expiresAt, isProduction) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
function csrfCookieOptions(expiresAt, isProduction) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app, options) {
|
||||
const {
|
||||
authenticationService,
|
||||
isProduction = false,
|
||||
loginRateLimiter,
|
||||
mfaService,
|
||||
passwordRecoveryService,
|
||||
passwordResetRateLimiter = loginRateLimiter,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
} = options;
|
||||
|
||||
app.post('/login', async (request, reply) => {
|
||||
const rateLimit = await loginRateLimiter.consume(request.ip);
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
reply.header('retry-after', String(rateLimit.retryAfterSeconds));
|
||||
return reply.code(429).send({
|
||||
error: {
|
||||
code: 'AUTH_RATE_LIMITED',
|
||||
message: 'Too many login attempts. Try again later.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = loginSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const session = await authenticationService.login({
|
||||
...result.data,
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
});
|
||||
|
||||
reply.setCookie(
|
||||
sessionCookieName,
|
||||
session.sessionToken,
|
||||
sessionCookieOptions(session.expiresAt, isProduction),
|
||||
);
|
||||
reply.setCookie(
|
||||
csrfCookieName,
|
||||
session.csrfToken,
|
||||
csrfCookieOptions(session.expiresAt, isProduction),
|
||||
);
|
||||
|
||||
return reply.code(200).send({ data: { expiresAt: session.expiresAt } });
|
||||
});
|
||||
|
||||
app.post('/forgot-password', async (request, reply) => {
|
||||
const rateLimit = await passwordResetRateLimiter.consume(request.ip);
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
reply.header('retry-after', String(rateLimit.retryAfterSeconds));
|
||||
return reply.code(429).send({
|
||||
error: {
|
||||
code: 'AUTH_RATE_LIMITED',
|
||||
message: 'Too many password reset requests. Try again later.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = requestPasswordResetSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
await passwordRecoveryService.requestReset({
|
||||
...result.data,
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
});
|
||||
|
||||
return reply.code(202).send({ data: null });
|
||||
});
|
||||
|
||||
app.post('/reset-password', async (request, reply) => {
|
||||
const result = resetPasswordSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
await passwordRecoveryService.resetPassword({
|
||||
...result.data,
|
||||
ipAddress: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
|
||||
app.post('/mfa/enroll', async (request, reply) => {
|
||||
const result = enrollMfaSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const principal = await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const enrollment = await mfaService.enroll({ ...result.data, userId: principal.userId });
|
||||
|
||||
return reply.code(201).send({ data: enrollment });
|
||||
});
|
||||
|
||||
app.post('/mfa/verify', async (request, reply) => {
|
||||
const result = verifyMfaSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const principal = await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const method = await mfaService.verifyEnrollment({
|
||||
...result.data,
|
||||
userId: principal.userId,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ data: toMfaMethodDto(method) });
|
||||
});
|
||||
|
||||
app.post('/mfa/disable', async (request, reply) => {
|
||||
const result = verifyMfaSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const principal = await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
await mfaService.disable({ ...result.data, userId: principal.userId });
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
|
||||
app.post('/logout', async (request, reply) => {
|
||||
const principal = await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
await authenticationService.logout(
|
||||
request.cookies[sessionCookieName],
|
||||
principal.userId,
|
||||
);
|
||||
reply.clearCookie(sessionCookieName, { path: '/' });
|
||||
reply.clearCookie(csrfCookieName, { path: '/' });
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
|
||||
app.get('/me', async (request) => ({
|
||||
data: await authenticationService.authenticate(request.cookies[sessionCookieName]),
|
||||
}));
|
||||
|
||||
app.get('/sessions', async (request) => {
|
||||
const activeSessions = await authenticationService.listActiveSessions(
|
||||
request.cookies[sessionCookieName],
|
||||
);
|
||||
|
||||
return {
|
||||
data: activeSessions.sessions.map((session) =>
|
||||
toSessionDto(session, activeSessions.currentSessionId),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/sessions/:id', async (request, reply) => {
|
||||
const result = sessionIdSchema.safeParse(request.params.id);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const revokedSession = await authenticationService.revokeSession(
|
||||
request.cookies[sessionCookieName],
|
||||
result.data,
|
||||
);
|
||||
|
||||
if (revokedSession.currentSessionRevoked) {
|
||||
reply.clearCookie(sessionCookieName, { path: '/' });
|
||||
reply.clearCookie(csrfCookieName, { path: '/' });
|
||||
}
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
|
||||
app.delete('/sessions', async (request, reply) => {
|
||||
await authenticationService.authorizeUnsafeRequest(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
await authenticationService.revokeOtherSessions(
|
||||
request.cookies[sessionCookieName],
|
||||
);
|
||||
|
||||
return reply.code(200).send({ data: null });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { createDatabase } from '../../../infrastructure/database/client.js';
|
||||
import { createUserRepository } from '../repositories/user-repository.js';
|
||||
import { createInitialAdminService } from '../services/initial-admin-service.js';
|
||||
|
||||
function requiredEnvironmentVariable(name) {
|
||||
const value = process.env[name];
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required to provision the initial administrator.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async function provisionInitialAdmin() {
|
||||
const database = createDatabase();
|
||||
|
||||
try {
|
||||
const initialAdminService = createInitialAdminService({
|
||||
userRepository: createUserRepository(database.db),
|
||||
});
|
||||
const administrator = await initialAdminService.provision({
|
||||
email: requiredEnvironmentVariable('INITIAL_ADMIN_EMAIL'),
|
||||
password: requiredEnvironmentVariable('INITIAL_ADMIN_PASSWORD'),
|
||||
sellerName: requiredEnvironmentVariable('INITIAL_SELLER_NAME'),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Initial administrator created for ${administrator.email} (user ${administrator.userId}, seller ${administrator.sellerId}).`,
|
||||
);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
provisionInitialAdmin().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { verifyPassword } from '../domain/password.js';
|
||||
import {
|
||||
createCsrfToken,
|
||||
createSessionToken,
|
||||
hashSessionToken,
|
||||
} from '../domain/session.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
const ADMIN_ROLE = 'ADMIN';
|
||||
const noOpSecurityAuditRepository = Object.freeze({ async record() {} });
|
||||
|
||||
function authenticationError() {
|
||||
return createApplicationError('UNAUTHORIZED', 'Invalid email or password.', 401);
|
||||
}
|
||||
|
||||
function sessionNotFoundError() {
|
||||
return createApplicationError('SESSION_NOT_FOUND', 'Session was not found.', 404);
|
||||
}
|
||||
|
||||
export function createAuthenticationService({
|
||||
now = () => new Date(),
|
||||
passwordVerifier = verifyPassword,
|
||||
rememberMeSessionDurationHours = 24 * 30,
|
||||
sessionActivityRenewalMinutes = 5,
|
||||
sessionDurationHours = 8,
|
||||
sessionRepository,
|
||||
securityAuditRepository = noOpSecurityAuditRepository,
|
||||
mfaService = { async verifyLogin() {} },
|
||||
userRepository,
|
||||
}) {
|
||||
return {
|
||||
async login({
|
||||
email,
|
||||
password,
|
||||
mfaCode,
|
||||
rememberMe = false,
|
||||
ipAddress = null,
|
||||
userAgent = null,
|
||||
}) {
|
||||
const user = await userRepository.findAuthenticationUserByEmail(
|
||||
typeof email === 'string' ? email.trim() : '',
|
||||
);
|
||||
|
||||
if (!user || !(await passwordVerifier(password, user.passwordHash))) {
|
||||
await securityAuditRepository.record({
|
||||
event: 'LOGIN_FAILURE',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
throw authenticationError();
|
||||
}
|
||||
|
||||
if (user.role !== ADMIN_ROLE || !user.sellerId) {
|
||||
await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'LOGIN_FAILURE',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
throw authenticationError();
|
||||
}
|
||||
|
||||
await mfaService.verifyLogin({ userId: user.id, code: mfaCode });
|
||||
|
||||
const { token: sessionToken, tokenHash: sessionTokenHash } = createSessionToken();
|
||||
const { token: csrfToken, tokenHash: csrfTokenHash } = createCsrfToken();
|
||||
const issuedAt = now();
|
||||
const sessionHours = rememberMe
|
||||
? rememberMeSessionDurationHours
|
||||
: sessionDurationHours;
|
||||
const expiresAt = new Date(
|
||||
issuedAt.getTime() + sessionHours * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash,
|
||||
csrfTokenHash,
|
||||
expiresAt,
|
||||
lastActivityAt: issuedAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'LOGIN_SUCCESS',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'SESSION_CREATED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionToken,
|
||||
csrfToken,
|
||||
expiresAt,
|
||||
principal: { userId: user.id, sellerId: user.sellerId, role: user.role },
|
||||
};
|
||||
},
|
||||
|
||||
async authenticate(sessionToken) {
|
||||
const session = await activeAdminSession(sessionToken, sessionRepository, now);
|
||||
await renewSessionActivity(
|
||||
session,
|
||||
sessionRepository,
|
||||
now,
|
||||
sessionActivityRenewalMinutes,
|
||||
);
|
||||
|
||||
return { userId: session.userId, sellerId: session.sellerId, role: session.role };
|
||||
},
|
||||
|
||||
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
||||
if (typeof sessionToken !== 'string' || typeof csrfToken !== 'string') {
|
||||
throw createApplicationError('FORBIDDEN', 'CSRF validation failed.', 403);
|
||||
}
|
||||
|
||||
const session = await sessionRepository.findActiveAuthenticationSession(
|
||||
hashSessionToken(sessionToken),
|
||||
now(),
|
||||
);
|
||||
|
||||
if (!session || session.csrfTokenHash !== hashSessionToken(csrfToken)) {
|
||||
throw createApplicationError('FORBIDDEN', 'CSRF validation failed.', 403);
|
||||
}
|
||||
|
||||
await renewSessionActivity(
|
||||
session,
|
||||
sessionRepository,
|
||||
now,
|
||||
sessionActivityRenewalMinutes,
|
||||
);
|
||||
|
||||
return { userId: session.userId, sellerId: session.sellerId, sessionId: session.id };
|
||||
},
|
||||
|
||||
async logout(sessionToken, userId = null) {
|
||||
if (typeof sessionToken !== 'string' || sessionToken.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionRepository.revokeByTokenHash(hashSessionToken(sessionToken), now());
|
||||
await securityAuditRepository.record({ userId, event: 'SESSION_REVOKED' });
|
||||
},
|
||||
|
||||
async listActiveSessions(sessionToken) {
|
||||
const session = await activeAdminSession(sessionToken, sessionRepository, now);
|
||||
const activeSessions = await sessionRepository.listActiveByUserId(
|
||||
session.userId,
|
||||
now(),
|
||||
);
|
||||
|
||||
return { currentSessionId: session.id, sessions: activeSessions };
|
||||
},
|
||||
|
||||
async revokeSession(sessionToken, sessionId) {
|
||||
const session = await activeAdminSession(sessionToken, sessionRepository, now);
|
||||
const revoked = await sessionRepository.revokeByIdAndUserId(
|
||||
sessionId,
|
||||
session.userId,
|
||||
now(),
|
||||
);
|
||||
|
||||
if (!revoked) {
|
||||
throw sessionNotFoundError();
|
||||
}
|
||||
|
||||
await securityAuditRepository.record({
|
||||
userId: session.userId,
|
||||
event: 'SESSION_REVOKED',
|
||||
metadata: { targetSessionId: sessionId },
|
||||
});
|
||||
|
||||
return { currentSessionRevoked: session.id === sessionId };
|
||||
},
|
||||
|
||||
async revokeOtherSessions(sessionToken) {
|
||||
const session = await activeAdminSession(sessionToken, sessionRepository, now);
|
||||
|
||||
await sessionRepository.revokeOtherByUserId(
|
||||
session.userId,
|
||||
session.id,
|
||||
now(),
|
||||
);
|
||||
await securityAuditRepository.record({
|
||||
userId: session.userId,
|
||||
event: 'SESSION_REVOKED',
|
||||
metadata: { scope: 'OTHER_SESSIONS' },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function activeAdminSession(sessionToken, sessionRepository, now) {
|
||||
if (typeof sessionToken !== 'string' || sessionToken.length === 0) {
|
||||
throw authenticationError();
|
||||
}
|
||||
|
||||
const session = await sessionRepository.findActiveAuthenticationSession(
|
||||
hashSessionToken(sessionToken),
|
||||
now(),
|
||||
);
|
||||
|
||||
if (!session || session.role !== ADMIN_ROLE || !session.sellerId) {
|
||||
throw authenticationError();
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function renewSessionActivity(
|
||||
session,
|
||||
sessionRepository,
|
||||
now,
|
||||
sessionActivityRenewalMinutes,
|
||||
) {
|
||||
if (!(session.lastActivityAt instanceof Date)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = now();
|
||||
const renewalMilliseconds = sessionActivityRenewalMinutes * 60 * 1000;
|
||||
|
||||
if (currentTime.getTime() - session.lastActivityAt.getTime() < renewalMilliseconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionRepository.touchById?.(session.id, currentTime);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { hashPassword } from '../domain/password.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizedEmail(email) {
|
||||
const value = requiredText(email, 'Administrator email').toLowerCase();
|
||||
|
||||
if (!value.includes('@')) {
|
||||
throw new Error('Administrator email must be valid.');
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createInitialAdminService({
|
||||
passwordHasher = hashPassword,
|
||||
userRepository,
|
||||
}) {
|
||||
return {
|
||||
async provision({ email, password, sellerName }) {
|
||||
const createdAdmin = await userRepository.createInitialAdmin({
|
||||
email: normalizedEmail(email),
|
||||
passwordHash: await passwordHasher(requiredText(password, 'Administrator password')),
|
||||
sellerName: requiredText(sellerName, 'Seller name'),
|
||||
});
|
||||
|
||||
if (!createdAdmin) {
|
||||
throw createApplicationError(
|
||||
'INITIAL_ADMIN_ALREADY_EXISTS',
|
||||
'An administrator already exists.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
userId: createdAdmin.user.id,
|
||||
sellerId: createdAdmin.seller.id,
|
||||
email: createdAdmin.user.email,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const defaultWindowMilliseconds = 15 * 60 * 1000;
|
||||
const consumeScript = `
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return { count, redis.call('PTTL', KEYS[1]) }
|
||||
`;
|
||||
|
||||
export function createRedisLoginRateLimiter({
|
||||
keyPrefix = 'pokemon-inventory:auth:login:',
|
||||
maxAttempts = 5,
|
||||
redisClient,
|
||||
windowMilliseconds = defaultWindowMilliseconds,
|
||||
} = {}) {
|
||||
if (!redisClient) {
|
||||
throw new Error('redisClient is required.');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
||||
throw new Error('maxAttempts must be a positive integer.');
|
||||
}
|
||||
|
||||
if (!Number.isInteger(windowMilliseconds) || windowMilliseconds < 1) {
|
||||
throw new Error('windowMilliseconds must be a positive integer.');
|
||||
}
|
||||
|
||||
return {
|
||||
async consume(clientId) {
|
||||
const [count, remainingMilliseconds] = await redisClient.eval(consumeScript, {
|
||||
keys: [`${keyPrefix}${clientId}`],
|
||||
arguments: [String(windowMilliseconds)],
|
||||
});
|
||||
|
||||
if (Number(count) > maxAttempts) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: Math.max(
|
||||
1,
|
||||
Math.ceil(Number(remainingMilliseconds) / 1000),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createTotpEnrollment, verifyTotp } from '../domain/totp.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
const noOpSecurityAuditRepository = Object.freeze({ async record() {} });
|
||||
|
||||
function mfaMethodNotFoundError() {
|
||||
return createApplicationError('MFA_METHOD_NOT_FOUND', 'MFA method was not found.', 404);
|
||||
}
|
||||
|
||||
function invalidMfaCodeError() {
|
||||
return createApplicationError('MFA_CODE_INVALID', 'MFA code is invalid.', 400);
|
||||
}
|
||||
|
||||
export function createMfaService({
|
||||
mfaMethodRepository,
|
||||
now = () => new Date(),
|
||||
secretProtector,
|
||||
securityAuditRepository = noOpSecurityAuditRepository,
|
||||
totpEnrollment = createTotpEnrollment,
|
||||
totpVerifier = verifyTotp,
|
||||
}) {
|
||||
return {
|
||||
async verifyLogin({ userId, code }) {
|
||||
const methods = await mfaMethodRepository.listEnabledByUserId(userId);
|
||||
|
||||
if (methods.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usedMethod = methods.find((method) =>
|
||||
totpVerifier({
|
||||
code,
|
||||
secret: secretProtector.decrypt(method.secretCiphertext),
|
||||
timestamp: now(),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!usedMethod) {
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_FAILURE' });
|
||||
throw invalidMfaCodeError();
|
||||
}
|
||||
|
||||
await mfaMethodRepository.markUsed(usedMethod.id, now());
|
||||
},
|
||||
|
||||
async enroll({ userId, name }) {
|
||||
const enrollment = totpEnrollment(name);
|
||||
const method = await mfaMethodRepository.create({
|
||||
userId,
|
||||
type: 'TOTP',
|
||||
name,
|
||||
secretCiphertext: secretProtector.encrypt(enrollment.secret),
|
||||
});
|
||||
|
||||
return { id: method.id, provisioningUri: enrollment.provisioningUri };
|
||||
},
|
||||
|
||||
async verifyEnrollment({ userId, methodId, code }) {
|
||||
const method = await mfaMethodRepository.findActiveByIdAndUserId(methodId, userId);
|
||||
|
||||
if (!method) {
|
||||
throw mfaMethodNotFoundError();
|
||||
}
|
||||
|
||||
if (!totpVerifier({ code, secret: secretProtector.decrypt(method.secretCiphertext), timestamp: now() })) {
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_FAILURE' });
|
||||
throw invalidMfaCodeError();
|
||||
}
|
||||
|
||||
const enabledMethod = await mfaMethodRepository.enable(method.id, now());
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_ENROLLED' });
|
||||
|
||||
return { id: enabledMethod.id, name: enabledMethod.name, type: enabledMethod.type };
|
||||
},
|
||||
|
||||
async disable({ userId, methodId, code }) {
|
||||
const method = await mfaMethodRepository.findActiveByIdAndUserId(methodId, userId);
|
||||
|
||||
if (!method) {
|
||||
throw mfaMethodNotFoundError();
|
||||
}
|
||||
|
||||
if (!method.isEnabled || !totpVerifier({ code, secret: secretProtector.decrypt(method.secretCiphertext), timestamp: now() })) {
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_FAILURE' });
|
||||
throw invalidMfaCodeError();
|
||||
}
|
||||
|
||||
const disabledMethod = await mfaMethodRepository.disable(method.id, now());
|
||||
await securityAuditRepository.record({ userId, event: 'MFA_DISABLED' });
|
||||
|
||||
return { id: disabledMethod.id };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { hashPassword } from '../domain/password.js';
|
||||
import { createPasswordResetToken, hashSessionToken } from '../domain/session.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
const noOpNotifier = Object.freeze({ async send() {} });
|
||||
const noOpSecurityAuditRepository = Object.freeze({ async record() {} });
|
||||
const resetDurationMilliseconds = 60 * 60 * 1000;
|
||||
|
||||
export function createPasswordRecoveryService({
|
||||
now = () => new Date(),
|
||||
passwordHasher = hashPassword,
|
||||
passwordResetNotifier = noOpNotifier,
|
||||
passwordResetRepository,
|
||||
securityAuditRepository = noOpSecurityAuditRepository,
|
||||
userRepository,
|
||||
}) {
|
||||
return {
|
||||
async requestReset({ email, ipAddress = null, userAgent = null }) {
|
||||
const normalizedEmail = typeof email === 'string' ? email.trim().toLowerCase() : '';
|
||||
const user = await userRepository.findAuthenticationUserByEmail(normalizedEmail);
|
||||
|
||||
if (!user) {
|
||||
await securityAuditRepository.record({
|
||||
event: 'PASSWORD_RESET_REQUESTED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { token, tokenHash } = createPasswordResetToken();
|
||||
const issuedAt = now();
|
||||
const expiresAt = new Date(issuedAt.getTime() + resetDurationMilliseconds);
|
||||
|
||||
await passwordResetRepository.issue({
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt,
|
||||
});
|
||||
await passwordResetNotifier.send({ email: user.email, token, expiresAt });
|
||||
await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'PASSWORD_RESET_REQUESTED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
},
|
||||
|
||||
async resetPassword({ token, password, ipAddress = null, userAgent = null }) {
|
||||
const resetAt = now();
|
||||
const userId = await passwordResetRepository.consume({
|
||||
tokenHash: hashSessionToken(token),
|
||||
passwordHash: await passwordHasher(password),
|
||||
now: resetAt,
|
||||
});
|
||||
|
||||
if (!userId) {
|
||||
throw createApplicationError('PASSWORD_RESET_TOKEN_INVALID', 'Reset token is invalid or expired.', 400);
|
||||
}
|
||||
|
||||
await securityAuditRepository.record({
|
||||
userId,
|
||||
event: 'PASSWORD_RESET',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export const INVENTORY_STATUS = Object.freeze({
|
||||
ACTIVE: 'ACTIVE',
|
||||
SOLD: 'SOLD',
|
||||
ARCHIVED: 'ARCHIVED',
|
||||
});
|
||||
|
||||
const permittedTransitions = Object.freeze({
|
||||
[INVENTORY_STATUS.ACTIVE]: new Set([
|
||||
INVENTORY_STATUS.SOLD,
|
||||
INVENTORY_STATUS.ARCHIVED,
|
||||
]),
|
||||
[INVENTORY_STATUS.SOLD]: new Set(),
|
||||
[INVENTORY_STATUS.ARCHIVED]: new Set(),
|
||||
});
|
||||
|
||||
export function transitionInventoryStatus(currentStatus, nextStatus) {
|
||||
if (!Object.values(INVENTORY_STATUS).includes(currentStatus)) {
|
||||
throw new Error('Unknown current inventory status.');
|
||||
}
|
||||
|
||||
if (!Object.values(INVENTORY_STATUS).includes(nextStatus)) {
|
||||
throw new Error('Unknown next inventory status.');
|
||||
}
|
||||
|
||||
if (!permittedTransitions[currentStatus].has(nextStatus)) {
|
||||
throw new Error(
|
||||
`Inventory status cannot transition from ${currentStatus} to ${nextStatus}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
export function assertInventoryItemEditable(status) {
|
||||
if (status === INVENTORY_STATUS.SOLD || status === INVENTORY_STATUS.ARCHIVED) {
|
||||
throw new Error('Inventory item is locked against ordinary edits.');
|
||||
}
|
||||
}
|
||||
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function createInventoryItem({ productId, conditionId, notes }) {
|
||||
return {
|
||||
productId: requiredText(productId, 'Product ID'),
|
||||
conditionId: requiredText(conditionId, 'Condition ID'),
|
||||
...(typeof notes === 'string' && notes.trim().length > 0
|
||||
? { notes: notes.trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
conditionProfiles,
|
||||
conditions,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createConditionRepository(db) {
|
||||
return {
|
||||
async findById(id) {
|
||||
const [condition] = await db
|
||||
.select({
|
||||
id: conditions.id,
|
||||
categoryId: conditionProfiles.categoryId,
|
||||
})
|
||||
.from(conditions)
|
||||
.innerJoin(
|
||||
conditionProfiles,
|
||||
eq(conditions.conditionProfileId, conditionProfiles.id),
|
||||
)
|
||||
.where(eq(conditions.id, id));
|
||||
|
||||
return condition ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
inventoryImages,
|
||||
inventoryItems,
|
||||
products,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createInventoryImageRepository(db) {
|
||||
return {
|
||||
async createForSeller(image, sellerId) {
|
||||
const [ownedItem] = await db
|
||||
.select({ id: inventoryItems.id })
|
||||
.from(inventoryItems)
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(inventoryItems.id, image.inventoryItemId), eq(products.sellerId, sellerId)));
|
||||
|
||||
if (!ownedItem) return null;
|
||||
|
||||
const [createdImage] = await db.insert(inventoryImages).values(image).returning();
|
||||
return createdImage;
|
||||
},
|
||||
|
||||
async countByInventoryItemId(inventoryItemId) {
|
||||
const rows = await db
|
||||
.select({ id: inventoryImages.id })
|
||||
.from(inventoryImages)
|
||||
.where(eq(inventoryImages.inventoryItemId, inventoryItemId));
|
||||
|
||||
return rows.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
|
||||
import { inventoryItems } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createInventoryItemRepository(db) {
|
||||
return {
|
||||
async create(inventoryItem) {
|
||||
const [createdInventoryItem] = await db
|
||||
.insert(inventoryItems)
|
||||
.values(inventoryItem)
|
||||
.returning();
|
||||
|
||||
return createdInventoryItem;
|
||||
},
|
||||
|
||||
async listByProductId(productId) {
|
||||
return db
|
||||
.select()
|
||||
.from(inventoryItems)
|
||||
.where(eq(inventoryItems.productId, productId))
|
||||
.orderBy(asc(inventoryItems.createdAt));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { toAdminInventoryImageDto } from '@pokemon-inventory/contracts/image';
|
||||
import { createInventoryImageSchema } from '@pokemon-inventory/validation/image';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerAdminImageRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
inventoryImageService,
|
||||
} = options;
|
||||
|
||||
app.post('/inventory/:id/images', async (request, reply) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
|
||||
const result = createInventoryImageSchema.safeParse(request.body);
|
||||
if (!result.success) return validationError(reply, result);
|
||||
|
||||
const image = await inventoryImageService.create({
|
||||
...result.data,
|
||||
inventoryItemId: request.params.id,
|
||||
sellerId: admin.sellerId,
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: toAdminInventoryImageDto(image) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { toAdminInventoryItemDto } from '@pokemon-inventory/contracts/inventory';
|
||||
import { createInventoryItemSchema } from '@pokemon-inventory/validation/inventory';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultAuthenticateAdmin() {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
export async function registerAdminInventoryRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = defaultAuthenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
inventoryItemService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.post('/inventory', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const result = createInventoryItemSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const inventoryItem = await inventoryItemService.create({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: toAdminInventoryItemDto(inventoryItem) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export function createInventoryImageService({ inventoryImageRepository }) {
|
||||
return {
|
||||
async create(input) {
|
||||
const image = await inventoryImageRepository.createForSeller(
|
||||
{ ...input, processingStatus: 'PENDING' },
|
||||
input.sellerId,
|
||||
);
|
||||
|
||||
if (!image) {
|
||||
throw createApplicationError('INVENTORY_ITEM_NOT_FOUND', 'Inventory item was not found.', 404);
|
||||
}
|
||||
|
||||
return image;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createInventoryItem } from '../domain/inventory-item.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export function createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
}) {
|
||||
return {
|
||||
async create(input) {
|
||||
const inventoryItem = createInventoryItem(input);
|
||||
const [product, condition] = await Promise.all([
|
||||
productRepository.findById(inventoryItem.productId),
|
||||
conditionRepository.findById(inventoryItem.conditionId),
|
||||
]);
|
||||
|
||||
if (!product || product.sellerId !== input.sellerId) {
|
||||
throw createApplicationError('PRODUCT_NOT_FOUND', 'Product was not found.');
|
||||
}
|
||||
|
||||
if (!condition) {
|
||||
throw createApplicationError('CONDITION_NOT_FOUND', 'Condition was not found.');
|
||||
}
|
||||
|
||||
if (product.categoryId !== condition.categoryId) {
|
||||
throw createApplicationError(
|
||||
'CONDITION_CATEGORY_MISMATCH',
|
||||
'Condition does not belong to the product category.',
|
||||
);
|
||||
}
|
||||
|
||||
return inventoryItemRepository.create(inventoryItem);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export const LISTING_STATUS = Object.freeze({
|
||||
DRAFT: 'DRAFT',
|
||||
AVAILABLE: 'AVAILABLE',
|
||||
PENDING: 'PENDING',
|
||||
CLOSED: 'CLOSED',
|
||||
ARCHIVED: 'ARCHIVED',
|
||||
});
|
||||
|
||||
const permittedTransitions = Object.freeze({
|
||||
[LISTING_STATUS.DRAFT]: new Set([
|
||||
LISTING_STATUS.AVAILABLE,
|
||||
LISTING_STATUS.PENDING,
|
||||
]),
|
||||
[LISTING_STATUS.AVAILABLE]: new Set([
|
||||
LISTING_STATUS.PENDING,
|
||||
LISTING_STATUS.CLOSED,
|
||||
LISTING_STATUS.ARCHIVED,
|
||||
]),
|
||||
[LISTING_STATUS.PENDING]: new Set([
|
||||
LISTING_STATUS.AVAILABLE,
|
||||
LISTING_STATUS.CLOSED,
|
||||
LISTING_STATUS.ARCHIVED,
|
||||
]),
|
||||
[LISTING_STATUS.CLOSED]: new Set([LISTING_STATUS.ARCHIVED]),
|
||||
[LISTING_STATUS.ARCHIVED]: new Set(),
|
||||
});
|
||||
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function createListing({
|
||||
inventoryItemId,
|
||||
productName,
|
||||
categoryId,
|
||||
conditionId,
|
||||
askingPriceMinor,
|
||||
imageCount,
|
||||
notes,
|
||||
}) {
|
||||
return {
|
||||
inventoryItemId: requiredText(inventoryItemId, 'Inventory item ID'),
|
||||
productName: requiredText(productName, 'Product name'),
|
||||
categoryId: requiredText(categoryId, 'Category ID'),
|
||||
conditionId: requiredText(conditionId, 'Condition ID'),
|
||||
askingPriceMinor: requirePositiveMinorUnit(askingPriceMinor, 'Asking price'),
|
||||
imageCount: requirePositiveInteger(imageCount, 'Image count'),
|
||||
status: LISTING_STATUS.DRAFT,
|
||||
...(typeof notes === 'string' && notes.trim().length > 0
|
||||
? { notes: notes.trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function canPublishListing({
|
||||
productName,
|
||||
categoryId,
|
||||
conditionId,
|
||||
inventoryItemId,
|
||||
askingPriceMinor,
|
||||
imageCount,
|
||||
inventoryStatus,
|
||||
}) {
|
||||
const hasRequiredIdentity =
|
||||
typeof productName === 'string' && productName.trim().length > 0 &&
|
||||
typeof categoryId === 'string' && categoryId.trim().length > 0 &&
|
||||
typeof conditionId === 'string' && conditionId.trim().length > 0 &&
|
||||
typeof inventoryItemId === 'string' && inventoryItemId.trim().length > 0;
|
||||
|
||||
const hasPrice = Number.isInteger(askingPriceMinor) && askingPriceMinor > 0;
|
||||
const hasImages = Number.isInteger(imageCount) && imageCount > 0;
|
||||
const hasPublishableInventory = !['SOLD', 'ARCHIVED'].includes(inventoryStatus);
|
||||
|
||||
if (!hasRequiredIdentity || !hasPrice || !hasImages || !hasPublishableInventory) {
|
||||
if (!hasPublishableInventory) {
|
||||
throw createApplicationError(
|
||||
'LISTING_INVENTORY_UNAVAILABLE',
|
||||
'The physical inventory item cannot be published.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
throw createApplicationError(
|
||||
'LISTING_REQUIREMENTS_NOT_MET',
|
||||
'Listing requirements are not met.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function transitionListingStatus(currentStatus, nextStatus) {
|
||||
if (!Object.values(LISTING_STATUS).includes(currentStatus)) {
|
||||
throw new Error('Unknown current listing status.');
|
||||
}
|
||||
|
||||
if (!Object.values(LISTING_STATUS).includes(nextStatus)) {
|
||||
throw new Error('Unknown next listing status.');
|
||||
}
|
||||
|
||||
if (!permittedTransitions[currentStatus].has(nextStatus)) {
|
||||
throw new Error(
|
||||
`Listing status cannot transition from ${currentStatus} to ${nextStatus}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
function requirePositiveMinorUnit(value, fieldName) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${fieldName} must be a positive integer minor unit.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value, fieldName) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${fieldName} must be a positive integer.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { and, asc, desc, eq } from 'drizzle-orm';
|
||||
|
||||
import {
|
||||
inventoryItems,
|
||||
listings,
|
||||
products,
|
||||
} from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createListingRepository(db) {
|
||||
return {
|
||||
async create(listing) {
|
||||
const [createdListing] = await db.insert(listings).values(listing).returning();
|
||||
return createdListing;
|
||||
},
|
||||
|
||||
async listByInventoryItemId(inventoryItemId) {
|
||||
return db
|
||||
.select()
|
||||
.from(listings)
|
||||
.where(eq(listings.inventoryItemId, inventoryItemId))
|
||||
.orderBy(asc(listings.createdAt));
|
||||
},
|
||||
|
||||
async findByIdForSeller(listingId, sellerId) {
|
||||
const [result] = await db
|
||||
.select({ listing: listings, inventoryStatus: inventoryItems.status })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(and(eq(listings.id, listingId), eq(products.sellerId, sellerId)));
|
||||
|
||||
return result ? { ...result.listing, inventoryStatus: result.inventoryStatus } : null;
|
||||
},
|
||||
|
||||
async listBySellerId(sellerId) {
|
||||
const results = await db
|
||||
.select({ listing: listings })
|
||||
.from(listings)
|
||||
.innerJoin(inventoryItems, eq(inventoryItems.id, listings.inventoryItemId))
|
||||
.innerJoin(products, eq(products.id, inventoryItems.productId))
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(desc(listings.createdAt));
|
||||
|
||||
return results.map(({ listing }) => listing);
|
||||
},
|
||||
|
||||
async updateStatus(listingId, status) {
|
||||
const [updatedListing] = await db
|
||||
.update(listings)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(eq(listings.id, listingId))
|
||||
.returning();
|
||||
|
||||
return updatedListing ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { toAdminListingDto } from '@pokemon-inventory/contracts/listing';
|
||||
import { createListingSchema } from '@pokemon-inventory/validation/listing';
|
||||
import { LISTING_STATUS } from '../domain/listing.js';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultAuthenticateAdmin() {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
export async function registerAdminListingRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = defaultAuthenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
listingService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.get('/listings', async (request) => {
|
||||
const listings = await listingService.list({ sellerId: request.admin.sellerId });
|
||||
return { data: listings.map(toAdminListingDto) };
|
||||
});
|
||||
|
||||
app.get('/listings/:id', async (request) => {
|
||||
const listing = await listingService.get({
|
||||
listingId: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
return { data: toAdminListingDto(listing) };
|
||||
});
|
||||
|
||||
app.post('/listings', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
|
||||
const result = createListingSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const listing = await listingService.create({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: toAdminListingDto(listing) });
|
||||
});
|
||||
|
||||
async function transition(request, reply, nextStatus) {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
|
||||
const listing = await listingService.transition({
|
||||
listingId: request.params.id,
|
||||
sellerId: request.admin.sellerId,
|
||||
nextStatus,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ data: toAdminListingDto(listing) });
|
||||
}
|
||||
|
||||
app.post('/listings/:id/publish', async (request, reply) =>
|
||||
transition(request, reply, LISTING_STATUS.AVAILABLE));
|
||||
app.post('/listings/:id/unpublish', async (request, reply) =>
|
||||
transition(request, reply, LISTING_STATUS.CLOSED));
|
||||
app.post('/listings/:id/pending', async (request, reply) =>
|
||||
transition(request, reply, LISTING_STATUS.PENDING));
|
||||
app.post('/listings/:id/archive', async (request, reply) =>
|
||||
transition(request, reply, LISTING_STATUS.ARCHIVED));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
canPublishListing,
|
||||
createListing,
|
||||
transitionListingStatus,
|
||||
} from '../domain/listing.js';
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
export function createListingService({ listingRepository, inventoryImageRepository }) {
|
||||
return {
|
||||
async create(input) {
|
||||
canPublishListing(input);
|
||||
const listing = createListing(input);
|
||||
return listingRepository.create(listing);
|
||||
},
|
||||
|
||||
async transition({ listingId, sellerId, nextStatus }) {
|
||||
const listing = await listingRepository.findByIdForSeller(listingId, sellerId);
|
||||
|
||||
if (!listing) {
|
||||
throw createApplicationError('LISTING_NOT_FOUND', 'Listing was not found.', 404);
|
||||
}
|
||||
|
||||
const status = transitionListingStatus(listing.status, nextStatus);
|
||||
|
||||
if (status === 'AVAILABLE') {
|
||||
const imageCount = inventoryImageRepository
|
||||
? await inventoryImageRepository.countByInventoryItemId(listing.inventoryItemId)
|
||||
: listing.imageCount;
|
||||
canPublishListing({ ...listing, imageCount });
|
||||
}
|
||||
|
||||
return listingRepository.updateStatus(listing.id, status);
|
||||
},
|
||||
|
||||
async get({ listingId, sellerId }) {
|
||||
const listing = await listingRepository.findByIdForSeller(listingId, sellerId);
|
||||
|
||||
if (!listing) {
|
||||
throw createApplicationError('LISTING_NOT_FOUND', 'Listing was not found.', 404);
|
||||
}
|
||||
|
||||
return listing;
|
||||
},
|
||||
|
||||
async list({ sellerId }) {
|
||||
return listingRepository.listBySellerId(sellerId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
function requiredText(value, fieldName) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} is required.`);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function createProduct({ name, categoryId, sellerId }) {
|
||||
return {
|
||||
name: requiredText(name, 'Product name'),
|
||||
categoryId: requiredText(categoryId, 'Category ID'),
|
||||
sellerId: requiredText(sellerId, 'Seller ID'),
|
||||
};
|
||||
}
|
||||
|
||||
export function createProductSlug(name) {
|
||||
const normalizedName = requiredText(name, 'Product name')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (normalizedName.length === 0) {
|
||||
throw new Error('Product name must include letters or numbers.');
|
||||
}
|
||||
|
||||
return normalizedName;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
|
||||
import { products } from '../../../infrastructure/database/schema.js';
|
||||
|
||||
export function createProductRepository(db) {
|
||||
return {
|
||||
async create(product) {
|
||||
const [createdProduct] = await db.insert(products).values(product).returning();
|
||||
return createdProduct;
|
||||
},
|
||||
|
||||
async findById(id) {
|
||||
const [product] = await db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(eq(products.id, id));
|
||||
|
||||
return product ?? null;
|
||||
},
|
||||
|
||||
async findBySellerIdAndSlug(sellerId, slug) {
|
||||
const [product] = await db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(and(eq(products.sellerId, sellerId), eq(products.slug, slug)));
|
||||
|
||||
return product ?? null;
|
||||
},
|
||||
|
||||
async listBySellerId(sellerId) {
|
||||
return db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(eq(products.sellerId, sellerId))
|
||||
.orderBy(asc(products.name));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { toAdminProductDto } from '@pokemon-inventory/contracts/product';
|
||||
import { createProductSchema } from '@pokemon-inventory/validation/product';
|
||||
|
||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||
|
||||
function validationError(reply, result) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed.',
|
||||
details: result.error.issues.map(({ message, path }) => ({ message, path })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultAuthenticateAdmin() {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
export async function registerAdminProductRoutes(app, options) {
|
||||
const {
|
||||
authenticateAdmin = defaultAuthenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
csrfCookieName = 'pokemon_inventory_csrf',
|
||||
sessionCookieName = 'pokemon_inventory_session',
|
||||
productService,
|
||||
} = options;
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
const admin = await authenticateAdmin(request);
|
||||
|
||||
if (!admin?.sellerId) {
|
||||
throw createApplicationError('UNAUTHORIZED', 'Authentication is required.', 401);
|
||||
}
|
||||
|
||||
request.admin = admin;
|
||||
});
|
||||
|
||||
app.get('/products', async (request) => ({
|
||||
data: (await productService.listBySellerId(request.admin.sellerId)).map(
|
||||
toAdminProductDto,
|
||||
),
|
||||
}));
|
||||
|
||||
app.post('/products', async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
request.headers['x-csrf-token'],
|
||||
);
|
||||
const result = createProductSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return validationError(reply, result);
|
||||
}
|
||||
|
||||
const product = await productService.create({
|
||||
...result.data,
|
||||
sellerId: request.admin.sellerId,
|
||||
});
|
||||
|
||||
return reply.code(201).send({ data: toAdminProductDto(product) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createProduct, createProductSlug } from '../domain/product.js';
|
||||
|
||||
export function createProductService({ productRepository }) {
|
||||
return {
|
||||
async create(input) {
|
||||
const product = createProduct(input);
|
||||
const baseSlug = createProductSlug(product.name);
|
||||
const slug = await createAvailableSlug(
|
||||
productRepository,
|
||||
product.sellerId,
|
||||
baseSlug,
|
||||
);
|
||||
|
||||
return productRepository.create({ ...product, slug });
|
||||
},
|
||||
|
||||
async listBySellerId(sellerId) {
|
||||
return productRepository.listBySellerId(sellerId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createAvailableSlug(productRepository, sellerId, baseSlug) {
|
||||
let suffix = 1;
|
||||
let slug = baseSlug;
|
||||
|
||||
while (await productRepository.findBySellerIdAndSlug(sellerId, slug)) {
|
||||
suffix += 1;
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
Reference in New Issue
Block a user