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,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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user