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,53 @@
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
|
||||
import { registerAuthRoutes } from '../modules/auth/routes/auth-routes.js';
|
||||
import { registerAdminInventoryRoutes } from '../modules/inventory/routes/admin-inventory-routes.js';
|
||||
import { registerAdminImageRoutes } from '../modules/inventory/routes/admin-image-routes.js';
|
||||
import { registerAdminListingRoutes } from '../modules/listings/routes/admin-listing-routes.js';
|
||||
import { registerAdminProductRoutes } from '../modules/products/routes/admin-product-routes.js';
|
||||
|
||||
export function buildApp(dependencies) {
|
||||
const app = Fastify();
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
const message = statusCode >= 500 ? 'Internal server error.' : error.message;
|
||||
|
||||
if (statusCode >= 500) {
|
||||
request.log.error(error);
|
||||
}
|
||||
|
||||
return reply.code(statusCode).send({
|
||||
error: {
|
||||
code: error.code ?? 'INTERNAL_SERVER_ERROR',
|
||||
message,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ data: { status: 'ok' } }));
|
||||
app.register(cookie);
|
||||
app.register(registerAuthRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/auth',
|
||||
});
|
||||
app.register(registerAdminProductRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
app.register(registerAdminInventoryRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
app.register(registerAdminImageRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
app.register(registerAdminListingRoutes, {
|
||||
...dependencies,
|
||||
prefix: '/api/v1/admin',
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import { buildApp } from './build-app.js';
|
||||
import { createDatabase } from '../infrastructure/database/client.js';
|
||||
import { createPasswordResetNotifier } from '../infrastructure/email/password-reset-notifier.js';
|
||||
import { createRedisConnection } from '../infrastructure/redis/client.js';
|
||||
import { createAuthenticationService } from '../modules/auth/services/authentication-service.js';
|
||||
import { createMfaService } from '../modules/auth/services/mfa-service.js';
|
||||
import { createSecretProtector } from '../modules/auth/domain/secret-protection.js';
|
||||
import { createMfaMethodRepository } from '../modules/auth/repositories/mfa-method-repository.js';
|
||||
import { createSecurityAuditRepository } from '../modules/auth/repositories/security-audit-repository.js';
|
||||
import { createSessionRepository } from '../modules/auth/repositories/session-repository.js';
|
||||
import { createUserRepository } from '../modules/auth/repositories/user-repository.js';
|
||||
import { createPasswordResetRepository } from '../modules/auth/repositories/password-reset-repository.js';
|
||||
import { createRedisLoginRateLimiter } from '../modules/auth/services/login-rate-limiter.js';
|
||||
import { createPasswordRecoveryService } from '../modules/auth/services/password-recovery-service.js';
|
||||
import { createInventoryItemRepository } from '../modules/inventory/repositories/inventory-item-repository.js';
|
||||
import { createInventoryItemService } from '../modules/inventory/services/inventory-item-service.js';
|
||||
import { createInventoryImageRepository } from '../modules/inventory/repositories/inventory-image-repository.js';
|
||||
import { createInventoryImageService } from '../modules/inventory/services/inventory-image-service.js';
|
||||
import { createConditionRepository } from '../modules/inventory/repositories/condition-repository.js';
|
||||
import { createListingRepository } from '../modules/listings/repositories/listing-repository.js';
|
||||
import { createListingService } from '../modules/listings/services/listing-service.js';
|
||||
import { createProductRepository } from '../modules/products/repositories/product-repository.js';
|
||||
import { createProductService } from '../modules/products/services/product-service.js';
|
||||
|
||||
function parsePort(value) {
|
||||
const port = Number.parseInt(value ?? '3000', 10);
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('API_PORT must be an integer between 1 and 65535.');
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const database = createDatabase();
|
||||
const redis = await createRedisConnection();
|
||||
const productRepository = createProductRepository(database.db);
|
||||
const inventoryItemRepository = createInventoryItemRepository(database.db);
|
||||
const inventoryImageRepository = createInventoryImageRepository(database.db);
|
||||
const listingRepository = createListingRepository(database.db);
|
||||
const conditionRepository = createConditionRepository(database.db);
|
||||
const userRepository = createUserRepository(database.db);
|
||||
const sessionRepository = createSessionRepository(database.db);
|
||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||
const mfaMethodRepository = createMfaMethodRepository(database.db);
|
||||
const securityAuditRepository = createSecurityAuditRepository(database.db);
|
||||
const passwordResetNotifier = createPasswordResetNotifier();
|
||||
const authenticationService = createAuthenticationService({
|
||||
mfaService,
|
||||
sessionRepository,
|
||||
securityAuditRepository,
|
||||
userRepository,
|
||||
});
|
||||
const app = buildApp({
|
||||
authenticationService,
|
||||
authenticateAdmin: (request) =>
|
||||
authenticationService.authenticate(request.cookies.pokemon_inventory_session),
|
||||
authorizeUnsafeRequest: (sessionToken, csrfToken) =>
|
||||
authenticationService.authorizeUnsafeRequest(sessionToken, csrfToken),
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
loginRateLimiter: createRedisLoginRateLimiter({ redisClient: redis.client }),
|
||||
mfaService,
|
||||
passwordRecoveryService: createPasswordRecoveryService({
|
||||
passwordResetNotifier,
|
||||
passwordResetRepository,
|
||||
securityAuditRepository,
|
||||
userRepository,
|
||||
}),
|
||||
passwordResetRateLimiter: createRedisLoginRateLimiter({
|
||||
keyPrefix: 'pokemon-inventory:auth:password-reset:',
|
||||
redisClient: redis.client,
|
||||
}),
|
||||
inventoryItemService: createInventoryItemService({
|
||||
conditionRepository,
|
||||
inventoryItemRepository,
|
||||
productRepository,
|
||||
}),
|
||||
inventoryImageService: createInventoryImageService({ inventoryImageRepository }),
|
||||
listingService: createListingService({ listingRepository, inventoryImageRepository }),
|
||||
productService: createProductService({ productRepository }),
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await database.close();
|
||||
await redis.close();
|
||||
});
|
||||
|
||||
await app.listen({ host: '0.0.0.0', port: parsePort(process.env.API_PORT) });
|
||||
}
|
||||
|
||||
startServer().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
function requiredConnectionString(connectionString) {
|
||||
if (typeof connectionString !== 'string' || connectionString.length === 0) {
|
||||
throw new Error('DATABASE_URL is required.');
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
export function createDatabase(connectionString = process.env.DATABASE_URL) {
|
||||
const pool = new Pool({ connectionString: requiredConnectionString(connectionString) });
|
||||
|
||||
return {
|
||||
db: drizzle({ client: pool }),
|
||||
close: () => pool.end(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const userRole = pgEnum('user_role', ['ADMIN']);
|
||||
export const inventoryStatus = pgEnum('inventory_status', [
|
||||
'ACTIVE',
|
||||
'SOLD',
|
||||
'ARCHIVED',
|
||||
]);
|
||||
export const listingStatus = pgEnum('listing_status', [
|
||||
'DRAFT',
|
||||
'AVAILABLE',
|
||||
'PENDING',
|
||||
'CLOSED',
|
||||
'ARCHIVED',
|
||||
]);
|
||||
|
||||
const timestamps = {
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
};
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: text('email').notNull(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
role: userRole('role').default('ADMIN').notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex('users_email_unique').on(table.email)],
|
||||
);
|
||||
|
||||
export const sellers = pgTable(
|
||||
'sellers',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
displayName: text('display_name').notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex('sellers_user_id_unique').on(table.userId)],
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
sessionTokenHash: text('session_token_hash').notNull(),
|
||||
csrfTokenHash: text('csrf_token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
lastActivityAt: timestamp('last_activity_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('sessions_token_hash_unique').on(table.sessionTokenHash),
|
||||
index('sessions_user_id_index').on(table.userId),
|
||||
index('sessions_expires_at_index').on(table.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const securityAuditEvents = pgTable(
|
||||
'security_audit_events',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id),
|
||||
event: text('event').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
metadata: jsonb('metadata').default({}).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('security_audit_events_user_id_index').on(table.userId),
|
||||
index('security_audit_events_event_created_at_index').on(
|
||||
table.event,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const passwordResetTokens = pgTable(
|
||||
'password_reset_tokens',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('password_reset_tokens_token_hash_unique').on(table.tokenHash),
|
||||
index('password_reset_tokens_user_id_index').on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const mfaMethods = pgTable(
|
||||
'mfa_methods',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: text('type').notNull(),
|
||||
secretCiphertext: text('secret_ciphertext').notNull(),
|
||||
name: text('name').notNull(),
|
||||
isEnabled: boolean('is_enabled').default(false).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => [index('mfa_methods_user_id_index').on(table.userId)],
|
||||
);
|
||||
|
||||
export const categories = pgTable(
|
||||
'categories',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('categories_name_unique').on(table.name),
|
||||
uniqueIndex('categories_slug_unique').on(table.slug),
|
||||
],
|
||||
);
|
||||
|
||||
export const conditionProfiles = pgTable('condition_profiles', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
categoryId: uuid('category_id')
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
name: text('name').notNull(),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const conditions = pgTable(
|
||||
'conditions',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
conditionProfileId: uuid('condition_profile_id')
|
||||
.notNull()
|
||||
.references(() => conditionProfiles.id),
|
||||
name: text('name').notNull(),
|
||||
code: text('code').notNull(),
|
||||
qualityRank: integer('quality_rank').notNull(),
|
||||
description: text('description'),
|
||||
active: boolean('active').default(true).notNull(),
|
||||
sort: integer('sort').default(0).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('conditions_profile_code_unique').on(
|
||||
table.conditionProfileId,
|
||||
table.code,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const products = pgTable(
|
||||
'products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
sellerId: uuid('seller_id')
|
||||
.notNull()
|
||||
.references(() => sellers.id),
|
||||
categoryId: uuid('category_id')
|
||||
.notNull()
|
||||
.references(() => categories.id),
|
||||
name: text('name').notNull(),
|
||||
slug: text('slug').notNull(),
|
||||
description: text('description'),
|
||||
brand: text('brand'),
|
||||
setName: text('set_name'),
|
||||
cardNumber: text('card_number'),
|
||||
eventName: text('event_name'),
|
||||
releaseYear: integer('release_year'),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('products_seller_slug_unique').on(table.sellerId, table.slug),
|
||||
check(
|
||||
'products_release_year_valid',
|
||||
sql`${table.releaseYear} IS NULL OR ${table.releaseYear} BETWEEN 1996 AND 9999`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const inventoryItems = pgTable('inventory_items', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id),
|
||||
conditionId: uuid('condition_id')
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
status: inventoryStatus('status').default('ACTIVE').notNull(),
|
||||
notes: text('notes'),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const inventoryImages = pgTable(
|
||||
'inventory_images',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
storageKey: text('storage_key').notNull(),
|
||||
originalFilename: text('original_filename').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
fileSize: integer('file_size').notNull(),
|
||||
width: integer('width').notNull(),
|
||||
height: integer('height').notNull(),
|
||||
altText: text('alt_text'),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
isPrimary: boolean('is_primary').default(false).notNull(),
|
||||
processingStatus: text('processing_status').default('PENDING').notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [index('inventory_images_inventory_item_id_index').on(table.inventoryItemId)],
|
||||
);
|
||||
|
||||
export const listings = pgTable('listings', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
inventoryItemId: uuid('inventory_item_id')
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id),
|
||||
productName: text('product_name').notNull(),
|
||||
categoryId: uuid('category_id').notNull(),
|
||||
conditionId: uuid('condition_id')
|
||||
.notNull()
|
||||
.references(() => conditions.id),
|
||||
askingPriceMinor: integer('asking_price_minor').notNull(),
|
||||
imageCount: integer('image_count').notNull(),
|
||||
status: listingStatus('status').default('DRAFT').notNull(),
|
||||
notes: text('notes'),
|
||||
...timestamps,
|
||||
}, (table) => [
|
||||
index('listings_inventory_item_id_index').on(table.inventoryItemId),
|
||||
index('listings_status_index').on(table.status),
|
||||
]);
|
||||
@@ -0,0 +1,33 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
function requiredValue(value, name) {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createPasswordResetNotifier({
|
||||
from = process.env.SMTP_FROM,
|
||||
resetUrl = process.env.PASSWORD_RESET_URL,
|
||||
smtpUrl = process.env.SMTP_URL,
|
||||
transport = nodemailer.createTransport({ url: requiredValue(smtpUrl, 'SMTP_URL') }),
|
||||
} = {}) {
|
||||
const sender = requiredValue(from, 'SMTP_FROM');
|
||||
const resetPasswordUrl = requiredValue(resetUrl, 'PASSWORD_RESET_URL');
|
||||
|
||||
return {
|
||||
async send({ email, token }) {
|
||||
const url = new URL(resetPasswordUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
await transport.sendMail({
|
||||
from: sender,
|
||||
to: email,
|
||||
subject: 'Reset your Pokemon Inventory password',
|
||||
text: `Use this link to reset your password: ${url.toString()}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function createImageStorageContract(implementation) {
|
||||
for (const method of ['put', 'get', 'delete']) {
|
||||
if (typeof implementation?.[method] !== 'function') {
|
||||
throw new TypeError(`ImageStorage must implement ${method}().`);
|
||||
}
|
||||
}
|
||||
|
||||
return implementation;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createImageStorageContract } from './image-storage.js';
|
||||
|
||||
function safePath(rootDirectory, key) {
|
||||
if (typeof key !== 'string' || key.length === 0 || path.isAbsolute(key)) {
|
||||
throw new Error('Image storage key must be a relative non-empty path.');
|
||||
}
|
||||
|
||||
const root = path.resolve(rootDirectory);
|
||||
const target = path.resolve(root, key);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error('Image storage key escapes the storage root.');
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
export function createLocalImageStorage(rootDirectory) {
|
||||
if (typeof rootDirectory !== 'string' || rootDirectory.length === 0) {
|
||||
throw new TypeError('Image storage root directory is required.');
|
||||
}
|
||||
|
||||
return createImageStorageContract({
|
||||
async put({ key, data }) {
|
||||
if (!Buffer.isBuffer(data)) {
|
||||
throw new TypeError('Image storage data must be a Buffer.');
|
||||
}
|
||||
|
||||
const target = safePath(rootDirectory, key);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, data, { flag: 'wx' });
|
||||
return { key };
|
||||
},
|
||||
|
||||
async get(key) {
|
||||
return readFile(safePath(rootDirectory, key));
|
||||
},
|
||||
|
||||
async delete(key) {
|
||||
await rm(safePath(rootDirectory, key), { force: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient } from 'redis';
|
||||
|
||||
function requiredRedisUrl(redisUrl) {
|
||||
if (typeof redisUrl !== 'string' || redisUrl.length === 0) {
|
||||
throw new Error('REDIS_URL is required.');
|
||||
}
|
||||
|
||||
return redisUrl;
|
||||
}
|
||||
|
||||
export async function createRedisConnection(redisUrl = process.env.REDIS_URL) {
|
||||
const client = createClient({ url: requiredRedisUrl(redisUrl) });
|
||||
client.on('error', (error) => console.error('Redis client error.', error));
|
||||
await client.connect();
|
||||
|
||||
return {
|
||||
client,
|
||||
close: () => client.close(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function createApplicationError(code, message, statusCode = 400) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
Reference in New Issue
Block a user