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,17 @@
|
||||
API_PORT=3000
|
||||
DATABASE_URL=postgresql://pokemon_inventory:change-me@localhost:5433/pokemon_inventory
|
||||
INITIAL_ADMIN_EMAIL=
|
||||
INITIAL_ADMIN_PASSWORD=
|
||||
INITIAL_SELLER_NAME=
|
||||
MFA_ENCRYPTION_KEY=
|
||||
PASSWORD_RESET_URL=http://localhost:5173/reset-password
|
||||
SMTP_FROM=
|
||||
SMTP_URL=
|
||||
POSTGRES_DB=pokemon_inventory
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_PORT=5433
|
||||
POSTGRES_USER=pokemon_inventory
|
||||
REDIS_PORT=6380
|
||||
REDIS_URL=redis://localhost:6380
|
||||
TEST_REDIS_URL=redis://localhost:6380
|
||||
TEST_DATABASE_URL=postgresql://pokemon_inventory:change-me@localhost:5433/pokemon_inventory_test
|
||||
@@ -0,0 +1,85 @@
|
||||
# GitHub Copilot Coding Instructions
|
||||
|
||||
## Language and framework
|
||||
|
||||
- Use Vue 3, Vite, modern JavaScript, and the latest stable ECMAScript supported by the project.
|
||||
- Prefer Composition API and reusable atomic components.
|
||||
- Do not introduce TypeScript unless a concrete technical justification is documented.
|
||||
- Use Vue Router for routing.
|
||||
- Use Pinia only for genuinely shared client state; do not create stores for every component.
|
||||
- Use Node.js and Fastify for the API.
|
||||
- Use Drizzle ORM for PostgreSQL access.
|
||||
|
||||
## Frontend layering
|
||||
|
||||
`Component → Composable → API Service → REST API`
|
||||
|
||||
Components should focus on presentation and user interaction. Composables hold reusable UI behavior. API services encapsulate HTTP access.
|
||||
|
||||
## Backend layering
|
||||
|
||||
`Route → Validation → Application Service → Domain Logic → Repository → PostgreSQL`
|
||||
|
||||
- Routes translate HTTP requests/responses.
|
||||
- Schemas validate requests and responses.
|
||||
- Services coordinate use cases.
|
||||
- Domain logic owns business rules.
|
||||
- Repositories own persistence.
|
||||
- Provider adapters own external APIs.
|
||||
- Workers own asynchronous jobs.
|
||||
|
||||
## Data and financial rules
|
||||
|
||||
- Use integer minor units for money.
|
||||
- Never use floating-point arithmetic for financial values.
|
||||
- Store/calculation currency is USD unless a documented exception exists.
|
||||
- Use purpose-built public and admin DTOs.
|
||||
- Never serialize database models directly as public responses.
|
||||
|
||||
## External integrations
|
||||
|
||||
- Pricing providers implement a common provider interface.
|
||||
- Currency conversion is behind a service/provider abstraction.
|
||||
- Image storage is behind a storage interface.
|
||||
- Provider-specific behavior must stay inside provider adapters.
|
||||
|
||||
## Search
|
||||
|
||||
Use the shared `SearchService`. Do not implement separate search algorithms for public and admin unless their data visibility requirements require separate query construction.
|
||||
|
||||
## Accessibility
|
||||
|
||||
Target WCAG 2.2 AA:
|
||||
- semantic HTML
|
||||
- keyboard operation
|
||||
- visible focus
|
||||
- accessible forms/errors
|
||||
- meaningful alt text
|
||||
- no color-only meaning
|
||||
- accessible dynamic content
|
||||
- proper focus management for dialogs/galleries
|
||||
|
||||
## Testing
|
||||
|
||||
Every meaningful feature should include appropriate unit, integration, component, E2E, and/or accessibility tests.
|
||||
|
||||
Do not disable tests merely to make CI pass.
|
||||
|
||||
## Code quality
|
||||
|
||||
Prefer:
|
||||
- clear names
|
||||
- small functions
|
||||
- explicit dependencies
|
||||
- reusable components
|
||||
- predictable error handling
|
||||
- minimal coupling
|
||||
- comments only where they explain non-obvious decisions
|
||||
|
||||
Avoid:
|
||||
- duplicated business rules
|
||||
- giant components
|
||||
- giant service classes
|
||||
- magic financial calculations
|
||||
- direct database access from routes
|
||||
- hidden global state
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
coverage/
|
||||
dist/
|
||||
*.log
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": 1,
|
||||
"projectName": "pokemon-inventory",
|
||||
"projectPath": "/Users/fragginwagon/Developer/Source Code/pokemon/pokemon-inventory",
|
||||
"updatedAt": "2026-09-07T15:05:39.264Z",
|
||||
"lastSeenAt": "2026-09-07T15:05:39.264Z",
|
||||
"ports": [
|
||||
{
|
||||
"service": "api",
|
||||
"protocol": "tcp",
|
||||
"port": 3000,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"service": "postgres",
|
||||
"protocol": "tcp",
|
||||
"port": 5433,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"service": "redis",
|
||||
"protocol": "tcp",
|
||||
"port": 6380,
|
||||
"notes": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Mission
|
||||
|
||||
Build and maintain the Pokémon Inventory & Sales Reference Catalog according to the documentation in `docs/`.
|
||||
|
||||
The documentation is the source of truth. Do not invent requirements when an authoritative project rule exists.
|
||||
|
||||
## Before changing code
|
||||
|
||||
1. Read the relevant documentation.
|
||||
2. Identify affected domain modules and tests.
|
||||
3. Preserve the Product → Inventory Item → Listing → Sale separation.
|
||||
4. Check public/private data boundaries.
|
||||
5. Check accessibility requirements.
|
||||
6. Add or update automated tests with the change.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- Use the modular-monolith architecture.
|
||||
- Keep HTTP handling in routes.
|
||||
- Keep application behavior in services.
|
||||
- Keep business invariants in domain logic.
|
||||
- Keep persistence in repositories.
|
||||
- Keep external integrations behind provider/storage interfaces.
|
||||
- Keep asynchronous work in the worker.
|
||||
- Do not introduce microservices without an explicit architecture decision.
|
||||
- Do not bypass the API from the browser.
|
||||
- Do not put server-only business logic in shared browser packages.
|
||||
|
||||
## Security
|
||||
|
||||
- The frontend is never the security boundary.
|
||||
- Enforce authorization server-side.
|
||||
- Use secure HttpOnly SameSite sessions.
|
||||
- Never store authentication tokens in localStorage.
|
||||
- Never commit credentials or secrets.
|
||||
- Treat uploads, user input, and provider data as untrusted.
|
||||
|
||||
## Domain rules
|
||||
|
||||
- Physical inventory copies are separate Inventory Item records.
|
||||
- Provider mappings belong to Products, not physical copies.
|
||||
- Sales are manual records; there is no checkout.
|
||||
- Sold history is retained.
|
||||
- Financial values use integer minor units.
|
||||
- USD is the canonical stored/calculated currency.
|
||||
- Public DTOs must never expose private financial, buyer, provider-debugging, or audit data.
|
||||
|
||||
## Change discipline
|
||||
|
||||
Prefer small, focused changes. Avoid unrelated refactoring.
|
||||
|
||||
When a decision changes the architecture or requirements, update the relevant documentation before or with the implementation.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Pokémon Inventory & Sales Reference Catalog
|
||||
|
||||
A personal Pokémon inventory and sales reference catalog for documenting owned Pokémon products, comparing market pricing from supported providers, and presenting public asking prices.
|
||||
|
||||
## Scope
|
||||
|
||||
This is a catalog and sales-reference application, not an e-commerce platform.
|
||||
|
||||
The application supports:
|
||||
- Pokémon trading cards
|
||||
- Pokémon merchandise
|
||||
- Sealed products
|
||||
- Accessories
|
||||
- Pokémon Worlds / Pokémon Center merchandise
|
||||
- Other Pokémon-related products
|
||||
|
||||
The public site communicates what is available, what the market suggests it is worth, and what the seller is asking. Transactions occur outside the application.
|
||||
|
||||
## Core domain model
|
||||
|
||||
`Product → Inventory Item → Listing → Sale`
|
||||
|
||||
- **Product:** what an item is.
|
||||
- **Inventory Item:** an individual physical copy owned by the seller.
|
||||
- **Listing:** the current public offer for an inventory item.
|
||||
- **Sale:** a manual record of an external transaction.
|
||||
|
||||
Products remain in the database after inventory is sold.
|
||||
|
||||
## Technology
|
||||
|
||||
- Vue 3 + Vite
|
||||
- Modern JavaScript / latest ECMAScript
|
||||
- Vue Router
|
||||
- Pinia where justified
|
||||
- Node.js + Fastify
|
||||
- PostgreSQL + Drizzle ORM
|
||||
- Redis + BullMQ
|
||||
- REST API under `/api/v1`
|
||||
- Docker + Docker Compose
|
||||
- GitHub Actions
|
||||
- PostgreSQL-backed search behind a `SearchService`
|
||||
- Pluggable image storage and pricing-provider adapters
|
||||
- Automated unit, integration, component, E2E, and accessibility tests
|
||||
- WCAG 2.2 AA target
|
||||
|
||||
TypeScript is not used unless a documented technical justification establishes a concrete benefit that cannot reasonably be achieved with modern JavaScript.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `apps/web` — Vue frontend
|
||||
- `apps/api` — Fastify API
|
||||
- `apps/worker` — asynchronous jobs
|
||||
- `packages/*` — genuinely shared contracts, validation, and safe utilities
|
||||
- `database` — migrations and seeds
|
||||
- `docs` — source-of-truth project documentation
|
||||
- `.github` — Copilot instructions and CI
|
||||
|
||||
Read `AGENTS.md` and `.github/copilot-instructions.md` before implementing features.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Start future work from [docs/IMPLEMENTATION-STATUS.md](docs/IMPLEMENTATION-STATUS.md). It records the completed foundation, the next bounded milestone, the overall roadmap, and verification commands.
|
||||
|
||||
## Database Development
|
||||
|
||||
1. Copy `.env.example` to `.env` and replace `change-me` with a local-only password in both connection URLs.
|
||||
2. Run `npm install`, `npm run db:up`, and `npm run db:migrate` to prepare the development database and Redis.
|
||||
3. Create and migrate the isolated `pokemon_inventory_test` database before running integration tests.
|
||||
4. Run `TEST_DATABASE_URL='postgresql://...' TEST_REDIS_URL='redis://...' npm run test:integration`.
|
||||
|
||||
Integration tests reject database names not ending in `_test` and truncate their tables before each test. The Redis limiter test uses a unique temporary key prefix and does not modify application session data.
|
||||
|
||||
## Initial Administrator
|
||||
|
||||
After the development database is migrated, set `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD`, and `INITIAL_SELLER_NAME` in your local `.env`, then run `npm run provision:admin` once. The command creates the only initial administrator and linked Seller; it refuses to run after any User exists.
|
||||
|
||||
## Password Recovery
|
||||
|
||||
Set `SMTP_URL`, `SMTP_FROM`, and `PASSWORD_RESET_URL` in the local `.env` before starting the API. Password reset tokens are delivered only through SMTP, expire after one hour, are stored as hashes, and are single use.
|
||||
|
||||
## MFA Secret Protection
|
||||
|
||||
Set `MFA_ENCRYPTION_KEY` to a base64-encoded 32-byte key before starting the API. Generate one with `openssl rand -base64 32`; keep it in secret management and retain it for as long as enrolled MFA methods must remain readable.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@pokemon-inventory/api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"provision:admin": "node src/modules/auth/scripts/provision-initial-admin.js",
|
||||
"start": "node src/app/server.js",
|
||||
"test:application": "node --test tests/application/*.test.js",
|
||||
"test:domain": "node --test tests/domain/*.test.js",
|
||||
"test:http": "node --test tests/http/*.test.js",
|
||||
"test:integration": "node --test --test-concurrency=1 tests/integration/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@pokemon-inventory/contracts": "*",
|
||||
"@pokemon-inventory/validation": "*",
|
||||
"dotenv": "^16.6.1",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"fastify": "^5.6.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"otpauth": "^9.4.1",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^5.8.2"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { hashPassword, verifyPassword } from '../../src/modules/auth/domain/password.js';
|
||||
import { createAuthenticationService } from '../../src/modules/auth/services/authentication-service.js';
|
||||
|
||||
test('verifies a password hash without accepting a different password', async () => {
|
||||
const passwordHash = await hashPassword('correct-password');
|
||||
|
||||
assert.equal(await verifyPassword('correct-password', passwordHash), true);
|
||||
assert.equal(await verifyPassword('incorrect-password', passwordHash), false);
|
||||
});
|
||||
|
||||
test('creates only a hashed session token after valid credentials', async () => {
|
||||
let createdSession;
|
||||
const auditEvents = [];
|
||||
const service = createAuthenticationService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
passwordVerifier: async (password) => password === 'correct-password',
|
||||
sessionRepository: {
|
||||
async create(session) {
|
||||
createdSession = session;
|
||||
},
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
userRepository: {
|
||||
async findAuthenticationUserByEmail() {
|
||||
return {
|
||||
id: 'user-id',
|
||||
sellerId: 'seller-id',
|
||||
role: 'ADMIN',
|
||||
passwordHash: 'stored-hash',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.login({
|
||||
email: 'admin@example.test',
|
||||
password: 'correct-password',
|
||||
});
|
||||
|
||||
assert.notEqual(createdSession.sessionTokenHash, result.sessionToken);
|
||||
assert.notEqual(createdSession.csrfTokenHash, result.csrfToken);
|
||||
assert.equal(createdSession.userId, 'user-id');
|
||||
assert.equal(result.principal.sellerId, 'seller-id');
|
||||
assert.deepEqual(auditEvents, [
|
||||
{ userId: 'user-id', event: 'LOGIN_SUCCESS', ipAddress: null, userAgent: null },
|
||||
{ userId: 'user-id', event: 'SESSION_CREATED', ipAddress: null, userAgent: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not create a session for invalid credentials', async () => {
|
||||
let sessionCreated = false;
|
||||
const auditEvents = [];
|
||||
const service = createAuthenticationService({
|
||||
passwordVerifier: async () => false,
|
||||
sessionRepository: {
|
||||
async create() {
|
||||
sessionCreated = true;
|
||||
},
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
userRepository: {
|
||||
async findAuthenticationUserByEmail() {
|
||||
return { id: 'user-id', passwordHash: 'stored-hash' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.login({ email: 'admin@example.test', password: 'incorrect-password' }),
|
||||
(error) => error.code === 'UNAUTHORIZED',
|
||||
);
|
||||
assert.equal(sessionCreated, false);
|
||||
assert.deepEqual(auditEvents, [{ event: 'LOGIN_FAILURE', ipAddress: null, userAgent: null }]);
|
||||
});
|
||||
|
||||
test('does not create a session until enabled MFA verifies the login code', async () => {
|
||||
let sessionCreated = false;
|
||||
let receivedMfaInput;
|
||||
const service = createAuthenticationService({
|
||||
mfaService: {
|
||||
async verifyLogin(input) {
|
||||
receivedMfaInput = input;
|
||||
const error = new Error('MFA code is invalid.');
|
||||
error.code = 'MFA_CODE_INVALID';
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
passwordVerifier: async () => true,
|
||||
sessionRepository: { async create() { sessionCreated = true; } },
|
||||
userRepository: {
|
||||
async findAuthenticationUserByEmail() {
|
||||
return {
|
||||
id: 'user-id',
|
||||
sellerId: 'seller-id',
|
||||
role: 'ADMIN',
|
||||
passwordHash: 'stored-hash',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.login({ email: 'admin@example.test', password: 'correct-password' }),
|
||||
(error) => error.code === 'MFA_CODE_INVALID',
|
||||
);
|
||||
|
||||
assert.deepEqual(receivedMfaInput, { userId: 'user-id', code: undefined });
|
||||
assert.equal(sessionCreated, false);
|
||||
});
|
||||
|
||||
test('rejects an unsafe request without the session-bound CSRF token', async () => {
|
||||
const service = createAuthenticationService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
sessionRepository: {
|
||||
async findActiveAuthenticationSession() {
|
||||
return { csrfTokenHash: 'different-token-hash' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.authorizeUnsafeRequest('session-token', 'csrf-token'),
|
||||
(error) => error.code === 'FORBIDDEN',
|
||||
);
|
||||
});
|
||||
|
||||
test('lists and revokes only sessions owned by the authenticated user', async () => {
|
||||
let revokedSession;
|
||||
let revokedOthers;
|
||||
const auditEvents = [];
|
||||
const service = createAuthenticationService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
sessionRepository: {
|
||||
async findActiveAuthenticationSession() {
|
||||
return { id: 'current-session', userId: 'user-id', sellerId: 'seller-id', role: 'ADMIN' };
|
||||
},
|
||||
async listActiveByUserId(userId) {
|
||||
assert.equal(userId, 'user-id');
|
||||
return [{ id: 'current-session' }, { id: 'other-session' }];
|
||||
},
|
||||
async revokeByIdAndUserId(id, userId) {
|
||||
revokedSession = { id, userId };
|
||||
return true;
|
||||
},
|
||||
async revokeOtherByUserId(userId, currentSessionId) {
|
||||
revokedOthers = { userId, currentSessionId };
|
||||
},
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
});
|
||||
|
||||
const sessions = await service.listActiveSessions('session-token');
|
||||
const revoked = await service.revokeSession('session-token', 'other-session');
|
||||
await service.revokeOtherSessions('session-token');
|
||||
|
||||
assert.equal(sessions.currentSessionId, 'current-session');
|
||||
assert.deepEqual(revokedSession, { id: 'other-session', userId: 'user-id' });
|
||||
assert.equal(revoked.currentSessionRevoked, false);
|
||||
assert.deepEqual(revokedOthers, { userId: 'user-id', currentSessionId: 'current-session' });
|
||||
assert.deepEqual(auditEvents, [
|
||||
{
|
||||
userId: 'user-id',
|
||||
event: 'SESSION_REVOKED',
|
||||
metadata: { targetSessionId: 'other-session' },
|
||||
},
|
||||
{
|
||||
userId: 'user-id',
|
||||
event: 'SESSION_REVOKED',
|
||||
metadata: { scope: 'OTHER_SESSIONS' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('renews stale active session activity without updating recent activity', async () => {
|
||||
let touchedAt;
|
||||
const currentTime = new Date('2026-09-07T01:00:00.000Z');
|
||||
const service = createAuthenticationService({
|
||||
now: () => currentTime,
|
||||
sessionRepository: {
|
||||
async findActiveAuthenticationSession() {
|
||||
return {
|
||||
id: 'current-session',
|
||||
userId: 'user-id',
|
||||
sellerId: 'seller-id',
|
||||
role: 'ADMIN',
|
||||
lastActivityAt: new Date('2026-09-07T00:50:00.000Z'),
|
||||
};
|
||||
},
|
||||
async touchById(id, activityTime) {
|
||||
touchedAt = { id, activityTime };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.authenticate('session-token');
|
||||
|
||||
assert.deepEqual(touchedAt, { id: 'current-session', activityTime: currentTime });
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createImageStorageContract } from '../../src/infrastructure/images/image-storage.js';
|
||||
import { createLocalImageStorage } from '../../src/infrastructure/images/local-image-storage.js';
|
||||
|
||||
test('local image storage round-trips and deletes binary data', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'pokemon-images-'));
|
||||
const storage = createLocalImageStorage(root);
|
||||
|
||||
try {
|
||||
await storage.put({ key: 'inventory/item-1/front.jpg', data: Buffer.from('image-bytes') });
|
||||
assert.deepEqual(await storage.get('inventory/item-1/front.jpg'), Buffer.from('image-bytes'));
|
||||
await storage.delete('inventory/item-1/front.jpg');
|
||||
await assert.rejects(() => storage.get('inventory/item-1/front.jpg'), { code: 'ENOENT' });
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('local image storage rejects traversal and duplicate writes', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'pokemon-images-'));
|
||||
const storage = createLocalImageStorage(root);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => storage.put({ key: '../outside.jpg', data: Buffer.from('image-bytes') }),
|
||||
/escapes the storage root/,
|
||||
);
|
||||
await storage.put({ key: 'same.jpg', data: Buffer.from('first') });
|
||||
await assert.rejects(
|
||||
() => storage.put({ key: 'same.jpg', data: Buffer.from('second') }),
|
||||
{ code: 'EEXIST' },
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('image storage contracts reject incomplete adapters', () => {
|
||||
assert.throws(
|
||||
() => createImageStorageContract({ put() {} }),
|
||||
/must implement get\(\)/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createInitialAdminService } from '../../src/modules/auth/services/initial-admin-service.js';
|
||||
|
||||
test('provisions a normalized administrator with a linked seller', async () => {
|
||||
let provisionedInput;
|
||||
const service = createInitialAdminService({
|
||||
passwordHasher: async (password) => `hashed:${password}`,
|
||||
userRepository: {
|
||||
async createInitialAdmin(input) {
|
||||
provisionedInput = input;
|
||||
return {
|
||||
user: { id: 'user-id', email: input.email },
|
||||
seller: { id: 'seller-id' },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const administrator = await service.provision({
|
||||
email: ' ADMIN@EXAMPLE.TEST ',
|
||||
password: 'local-password',
|
||||
sellerName: ' Catalog Owner ',
|
||||
});
|
||||
|
||||
assert.deepEqual(administrator, {
|
||||
userId: 'user-id',
|
||||
sellerId: 'seller-id',
|
||||
email: 'admin@example.test',
|
||||
});
|
||||
assert.deepEqual(provisionedInput, {
|
||||
email: 'admin@example.test',
|
||||
passwordHash: 'hashed:local-password',
|
||||
sellerName: 'Catalog Owner',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects provisioning after an administrator already exists', async () => {
|
||||
const service = createInitialAdminService({
|
||||
passwordHasher: async () => 'password-hash',
|
||||
userRepository: { async createInitialAdmin() { return null; } },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.provision({
|
||||
email: 'admin@example.test',
|
||||
password: 'local-password',
|
||||
sellerName: 'Catalog Owner',
|
||||
}),
|
||||
(error) => error.code === 'INITIAL_ADMIN_ALREADY_EXISTS',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js';
|
||||
|
||||
function createService({ product, condition }) {
|
||||
return createInventoryItemService({
|
||||
productRepository: { async findById() { return product; } },
|
||||
conditionRepository: { async findById() { return condition; } },
|
||||
inventoryItemRepository: { async create(inventoryItem) { return inventoryItem; } },
|
||||
});
|
||||
}
|
||||
|
||||
test('creates an inventory item when its condition belongs to the product category', async () => {
|
||||
const service = createService({
|
||||
product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' },
|
||||
condition: { id: 'condition-id', categoryId: 'cards' },
|
||||
});
|
||||
|
||||
const inventoryItem = await service.create({
|
||||
productId: 'product-id',
|
||||
conditionId: 'condition-id',
|
||||
sellerId: 'seller-id',
|
||||
notes: ' Near mint ',
|
||||
});
|
||||
|
||||
assert.deepEqual(inventoryItem, {
|
||||
productId: 'product-id',
|
||||
conditionId: 'condition-id',
|
||||
notes: 'Near mint',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects conditions from another category', async () => {
|
||||
const service = createService({
|
||||
product: { id: 'product-id', sellerId: 'seller-id', categoryId: 'cards' },
|
||||
condition: { id: 'condition-id', categoryId: 'merchandise' },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.create({
|
||||
productId: 'product-id',
|
||||
conditionId: 'condition-id',
|
||||
sellerId: 'seller-id',
|
||||
}),
|
||||
(error) => error.code === 'CONDITION_CATEGORY_MISMATCH',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createListingService } from '../../src/modules/listings/services/listing-service.js';
|
||||
|
||||
test('creates a draft listing only after the listing publish requirements are met', async () => {
|
||||
const created = [];
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async create(listing) {
|
||||
const record = { id: 'listing-1', ...listing };
|
||||
created.push(record);
|
||||
return record;
|
||||
},
|
||||
},
|
||||
inventoryImageRepository: {
|
||||
async countByInventoryItemId() {
|
||||
return 2;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listing = await service.create({
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: ' Charizard ex ',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
notes: ' Near mint ',
|
||||
});
|
||||
|
||||
assert.deepEqual(listing, {
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
notes: 'Near mint',
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.create({
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 0,
|
||||
imageCount: 2,
|
||||
}),
|
||||
(error) => error.code === 'LISTING_REQUIREMENTS_NOT_MET',
|
||||
);
|
||||
|
||||
assert.equal(created.length, 1);
|
||||
});
|
||||
|
||||
test('publishes an owned draft only when its persisted requirements are met', async () => {
|
||||
const updated = [];
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async findByIdForSeller() {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
};
|
||||
},
|
||||
async updateStatus(id, status) {
|
||||
updated.push({ id, status });
|
||||
return { id, status };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
await service.transition({
|
||||
listingId: 'listing-1',
|
||||
sellerId: 'seller-1',
|
||||
nextStatus: 'AVAILABLE',
|
||||
}),
|
||||
{ id: 'listing-1', status: 'AVAILABLE' },
|
||||
);
|
||||
assert.deepEqual(updated, [{ id: 'listing-1', status: 'AVAILABLE' }]);
|
||||
});
|
||||
|
||||
test('does not transition a listing owned by another seller', async () => {
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async findByIdForSeller() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.transition({ listingId: 'listing-1', sellerId: 'seller-2', nextStatus: 'AVAILABLE' }),
|
||||
(error) => error.code === 'LISTING_NOT_FOUND' && error.statusCode === 404,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not publish a listing for sold or archived inventory', async () => {
|
||||
for (const inventoryStatus of ['SOLD', 'ARCHIVED']) {
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async findByIdForSeller() {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
inventoryStatus,
|
||||
status: 'DRAFT',
|
||||
};
|
||||
},
|
||||
async updateStatus() {
|
||||
throw new Error('A blocked listing must not be updated.');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.transition({ listingId: 'listing-1', sellerId: 'seller-1', nextStatus: 'AVAILABLE' }),
|
||||
(error) => error.code === 'LISTING_INVENTORY_UNAVAILABLE' && error.statusCode === 409,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('lists and retrieves only seller-owned listings', async () => {
|
||||
const ownedListing = { id: 'listing-1', status: 'DRAFT' };
|
||||
const service = createListingService({
|
||||
listingRepository: {
|
||||
async listBySellerId(sellerId) {
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return [ownedListing];
|
||||
},
|
||||
async findByIdForSeller(listingId, sellerId) {
|
||||
assert.equal(listingId, 'listing-1');
|
||||
assert.equal(sellerId, 'seller-1');
|
||||
return ownedListing;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await service.list({ sellerId: 'seller-1' }), [ownedListing]);
|
||||
assert.deepEqual(await service.get({ listingId: 'listing-1', sellerId: 'seller-1' }), ownedListing);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createSecretProtector } from '../../src/modules/auth/domain/secret-protection.js';
|
||||
import { createMfaService } from '../../src/modules/auth/services/mfa-service.js';
|
||||
|
||||
function createService({ method, isCodeValid = true } = {}) {
|
||||
const createdMethods = [];
|
||||
const auditEvents = [];
|
||||
const repository = {
|
||||
async create(input) {
|
||||
const createdMethod = { id: 'method-id', isEnabled: false, ...input };
|
||||
createdMethods.push(createdMethod);
|
||||
return createdMethod;
|
||||
},
|
||||
async findActiveByIdAndUserId() {
|
||||
return method ?? createdMethods[0] ?? null;
|
||||
},
|
||||
async enable(id) {
|
||||
return { id, name: 'Authenticator', type: 'TOTP', isEnabled: true };
|
||||
},
|
||||
async disable(id) {
|
||||
return { id, isEnabled: false };
|
||||
},
|
||||
};
|
||||
const service = createMfaService({
|
||||
mfaMethodRepository: repository,
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
secretProtector: {
|
||||
encrypt(secret) { return `encrypted:${secret}`; },
|
||||
decrypt(ciphertext) { return ciphertext.replace('encrypted:', ''); },
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
totpEnrollment: () => ({ secret: 'TOTPSECRET', provisioningUri: 'otpauth://test' }),
|
||||
totpVerifier: () => isCodeValid,
|
||||
});
|
||||
|
||||
return { auditEvents, createdMethods, service };
|
||||
}
|
||||
|
||||
test('encrypts the TOTP secret and returns the provisioning URI only during enrollment', async () => {
|
||||
const { createdMethods, service } = createService();
|
||||
|
||||
const enrollment = await service.enroll({ userId: 'user-id', name: 'Authenticator' });
|
||||
|
||||
assert.deepEqual(enrollment, { id: 'method-id', provisioningUri: 'otpauth://test' });
|
||||
assert.equal(createdMethods[0].secretCiphertext, 'encrypted:TOTPSECRET');
|
||||
assert.equal(createdMethods[0].isEnabled, false);
|
||||
assert.equal('secret' in enrollment, false);
|
||||
});
|
||||
|
||||
test('encrypts MFA secrets with authenticated encryption', () => {
|
||||
const protector = createSecretProtector(Buffer.alloc(32, 7).toString('base64'));
|
||||
const ciphertext = protector.encrypt('TOTPSECRET');
|
||||
|
||||
assert.notEqual(ciphertext, 'TOTPSECRET');
|
||||
assert.equal(protector.decrypt(ciphertext), 'TOTPSECRET');
|
||||
});
|
||||
|
||||
test('enables an owned MFA method only after a valid code and records enrollment', async () => {
|
||||
const { auditEvents, service } = createService({
|
||||
method: {
|
||||
id: 'method-id',
|
||||
userId: 'user-id',
|
||||
type: 'TOTP',
|
||||
name: 'Authenticator',
|
||||
secretCiphertext: 'encrypted:TOTPSECRET',
|
||||
isEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const method = await service.verifyEnrollment({
|
||||
userId: 'user-id',
|
||||
methodId: 'method-id',
|
||||
code: '123456',
|
||||
});
|
||||
|
||||
assert.deepEqual(method, { id: 'method-id', name: 'Authenticator', type: 'TOTP' });
|
||||
assert.deepEqual(auditEvents, [{ userId: 'user-id', event: 'MFA_ENROLLED' }]);
|
||||
});
|
||||
|
||||
test('rejects invalid MFA codes without enabling or disabling a method', async () => {
|
||||
const { auditEvents, service } = createService({
|
||||
isCodeValid: false,
|
||||
method: {
|
||||
id: 'method-id',
|
||||
userId: 'user-id',
|
||||
type: 'TOTP',
|
||||
name: 'Authenticator',
|
||||
secretCiphertext: 'encrypted:TOTPSECRET',
|
||||
isEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.disable({ userId: 'user-id', methodId: 'method-id', code: '000000' }),
|
||||
(error) => error.code === 'MFA_CODE_INVALID',
|
||||
);
|
||||
assert.deepEqual(auditEvents, [{ userId: 'user-id', event: 'MFA_FAILURE' }]);
|
||||
});
|
||||
|
||||
test('requires a valid TOTP code when an enabled MFA method exists at login', async () => {
|
||||
let markedMethod;
|
||||
const repository = {
|
||||
async listEnabledByUserId(userId) {
|
||||
assert.equal(userId, 'user-id');
|
||||
return [{ id: 'method-id', secretCiphertext: 'encrypted:TOTPSECRET' }];
|
||||
},
|
||||
async markUsed(id) {
|
||||
markedMethod = id;
|
||||
},
|
||||
};
|
||||
const service = createMfaService({
|
||||
mfaMethodRepository: repository,
|
||||
secretProtector: { decrypt() { return 'TOTPSECRET'; } },
|
||||
totpVerifier: ({ code }) => code === '123456',
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.verifyLogin({ userId: 'user-id', code: undefined }),
|
||||
(error) => error.code === 'MFA_CODE_INVALID',
|
||||
);
|
||||
await service.verifyLogin({ userId: 'user-id', code: '123456' });
|
||||
|
||||
assert.equal(markedMethod, 'method-id');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createPasswordRecoveryService } from '../../src/modules/auth/services/password-recovery-service.js';
|
||||
|
||||
test('does not issue a token for an unknown email while recording a generic request', async () => {
|
||||
let resetIssued = false;
|
||||
const auditEvents = [];
|
||||
const service = createPasswordRecoveryService({
|
||||
passwordResetRepository: { async issue() { resetIssued = true; } },
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
userRepository: { async findAuthenticationUserByEmail() { return null; } },
|
||||
});
|
||||
|
||||
await service.requestReset({ email: 'unknown@example.test' });
|
||||
|
||||
assert.equal(resetIssued, false);
|
||||
assert.deepEqual(auditEvents, [
|
||||
{ event: 'PASSWORD_RESET_REQUESTED', ipAddress: null, userAgent: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('delivers a reset token without persisting the raw token', async () => {
|
||||
let issuedReset;
|
||||
let deliveredReset;
|
||||
const service = createPasswordRecoveryService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
passwordResetNotifier: { async send(reset) { deliveredReset = reset; } },
|
||||
passwordResetRepository: { async issue(reset) { issuedReset = reset; } },
|
||||
userRepository: {
|
||||
async findAuthenticationUserByEmail() {
|
||||
return { id: 'user-id', email: 'admin@example.test' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.requestReset({ email: 'admin@example.test' });
|
||||
|
||||
assert.equal(issuedReset.userId, 'user-id');
|
||||
assert.notEqual(issuedReset.tokenHash, deliveredReset.token);
|
||||
assert.equal(deliveredReset.email, 'admin@example.test');
|
||||
assert.equal(deliveredReset.expiresAt.toISOString(), '2026-09-07T01:00:00.000Z');
|
||||
});
|
||||
|
||||
test('consumes a reset token once and audits a successful password reset', async () => {
|
||||
let consumedReset;
|
||||
const auditEvents = [];
|
||||
const service = createPasswordRecoveryService({
|
||||
now: () => new Date('2026-09-07T00:00:00.000Z'),
|
||||
passwordHasher: async (password) => `hashed:${password}`,
|
||||
passwordResetRepository: {
|
||||
async consume(reset) {
|
||||
consumedReset = reset;
|
||||
return 'user-id';
|
||||
},
|
||||
},
|
||||
securityAuditRepository: { async record(event) { auditEvents.push(event); } },
|
||||
});
|
||||
|
||||
await service.resetPassword({ token: 'a-valid-reset-token', password: 'new-password-value' });
|
||||
|
||||
assert.equal(consumedReset.passwordHash, 'hashed:new-password-value');
|
||||
assert.notEqual(consumedReset.tokenHash, 'a-valid-reset-token');
|
||||
assert.deepEqual(auditEvents, [
|
||||
{ userId: 'user-id', event: 'PASSWORD_RESET', ipAddress: null, userAgent: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects expired or previously used reset tokens', async () => {
|
||||
const service = createPasswordRecoveryService({
|
||||
passwordHasher: async () => 'new-password-hash',
|
||||
passwordResetRepository: { async consume() { return null; } },
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.resetPassword({ token: 'invalid-reset-token', password: 'new-password-value' }),
|
||||
(error) => error.code === 'PASSWORD_RESET_TOKEN_INVALID',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProductService } from '../../src/modules/products/services/product-service.js';
|
||||
|
||||
test('creates seller-scoped slugs and increments duplicate names', async () => {
|
||||
const createdProducts = [];
|
||||
const productService = createProductService({
|
||||
productRepository: {
|
||||
async create(product) {
|
||||
createdProducts.push(product);
|
||||
return product;
|
||||
},
|
||||
async findBySellerIdAndSlug(sellerId, slug) {
|
||||
return (
|
||||
createdProducts.find(
|
||||
(product) => product.sellerId === sellerId && product.slug === slug,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'category-id',
|
||||
sellerId: 'seller-id',
|
||||
};
|
||||
|
||||
await productService.create(input);
|
||||
const secondProduct = await productService.create(input);
|
||||
|
||||
assert.equal(secondProduct.slug, 'pikachu-plush-2');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assertInventoryItemEditable,
|
||||
INVENTORY_STATUS,
|
||||
transitionInventoryStatus,
|
||||
} from '../../src/modules/inventory/domain/inventory-item.js';
|
||||
|
||||
test('allows an active inventory item to be sold', () => {
|
||||
assert.equal(
|
||||
transitionInventoryStatus(INVENTORY_STATUS.ACTIVE, INVENTORY_STATUS.SOLD),
|
||||
INVENTORY_STATUS.SOLD,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects reopening sold inventory', () => {
|
||||
assert.throws(
|
||||
() => transitionInventoryStatus(INVENTORY_STATUS.SOLD, INVENTORY_STATUS.ACTIVE),
|
||||
/cannot transition/,
|
||||
);
|
||||
});
|
||||
|
||||
test('locks sold and archived items against ordinary edits', () => {
|
||||
assert.throws(
|
||||
() => assertInventoryItemEditable(INVENTORY_STATUS.SOLD),
|
||||
/locked against ordinary edits/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertInventoryItemEditable(INVENTORY_STATUS.ARCHIVED),
|
||||
/locked against ordinary edits/,
|
||||
);
|
||||
assert.doesNotThrow(() => assertInventoryItemEditable(INVENTORY_STATUS.ACTIVE));
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
LISTING_STATUS,
|
||||
canPublishListing,
|
||||
createListing,
|
||||
transitionListingStatus,
|
||||
} from '../../src/modules/listings/domain/listing.js';
|
||||
|
||||
test('creates a draft listing with the required public offer fields', () => {
|
||||
const listing = createListing({
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: ' Charizard ex ',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
notes: ' Near mint ',
|
||||
});
|
||||
|
||||
assert.deepEqual(listing, {
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: LISTING_STATUS.DRAFT,
|
||||
notes: 'Near mint',
|
||||
});
|
||||
});
|
||||
|
||||
test('requires the publication prerequisites before a listing can be made available', () => {
|
||||
const valid = {
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
conditionId: 'condition-id',
|
||||
inventoryItemId: 'inventory-item-id',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => canPublishListing(valid));
|
||||
|
||||
assert.throws(
|
||||
() => canPublishListing({ ...valid, askingPriceMinor: 0 }),
|
||||
(error) => error.code === 'LISTING_REQUIREMENTS_NOT_MET',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => canPublishListing({ ...valid, imageCount: 0 }),
|
||||
(error) => error.code === 'LISTING_REQUIREMENTS_NOT_MET',
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => canPublishListing({ ...valid, inventoryStatus: 'SOLD' }),
|
||||
(error) => error.code === 'LISTING_INVENTORY_UNAVAILABLE' && error.statusCode === 409,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => canPublishListing({ ...valid, inventoryStatus: 'ARCHIVED' }),
|
||||
(error) => error.code === 'LISTING_INVENTORY_UNAVAILABLE',
|
||||
);
|
||||
});
|
||||
|
||||
test('allows only status transitions permitted by the listing lifecycle', () => {
|
||||
assert.equal(transitionListingStatus(LISTING_STATUS.DRAFT, LISTING_STATUS.AVAILABLE), LISTING_STATUS.AVAILABLE);
|
||||
assert.throws(
|
||||
() => transitionListingStatus(LISTING_STATUS.DRAFT, LISTING_STATUS.ARCHIVED),
|
||||
/Listing status cannot transition from DRAFT to ARCHIVED/,
|
||||
);
|
||||
assert.equal(transitionListingStatus(LISTING_STATUS.CLOSED, LISTING_STATUS.ARCHIVED), LISTING_STATUS.ARCHIVED);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createProduct } from '../../src/modules/products/domain/product.js';
|
||||
|
||||
test('creates a product with required product identity fields', () => {
|
||||
const product = createProduct({
|
||||
name: ' Charizard ex ',
|
||||
categoryId: 'category-id',
|
||||
sellerId: 'seller-id',
|
||||
});
|
||||
|
||||
assert.deepEqual(product, {
|
||||
name: 'Charizard ex',
|
||||
categoryId: 'category-id',
|
||||
sellerId: 'seller-id',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects a product without a name or category', () => {
|
||||
assert.throws(
|
||||
() => createProduct({ name: ' ', categoryId: 'category-id', sellerId: 'seller-id' }),
|
||||
/Product name is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createProduct({ name: 'Pikachu', categoryId: '', sellerId: 'seller-id' }),
|
||||
/Category ID is required/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { createInventoryItemService } from '../../src/modules/inventory/services/inventory-item-service.js';
|
||||
import { createProductService } from '../../src/modules/products/services/product-service.js';
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest } = {}) {
|
||||
const products = [];
|
||||
const productRepository = {
|
||||
async create(product) {
|
||||
const createdProduct = {
|
||||
id: '8a0a836a-a597-454d-8c5e-e146d27dbbf1',
|
||||
...product,
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
};
|
||||
products.push(createdProduct);
|
||||
return createdProduct;
|
||||
},
|
||||
async findBySellerIdAndSlug(sellerId, slug) {
|
||||
return products.find(
|
||||
(product) => product.sellerId === sellerId && product.slug === slug,
|
||||
);
|
||||
},
|
||||
async listBySellerId(sellerId) {
|
||||
return products.filter((product) => product.sellerId === sellerId);
|
||||
},
|
||||
};
|
||||
|
||||
return buildApp({
|
||||
authenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
inventoryItemService: createInventoryItemService({
|
||||
productRepository: {
|
||||
async findById(id) {
|
||||
return products.find((product) => product.id === id) ?? null;
|
||||
},
|
||||
},
|
||||
conditionRepository: {
|
||||
async findById(id) {
|
||||
return id === '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e'
|
||||
? {
|
||||
id,
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
}
|
||||
: null;
|
||||
},
|
||||
},
|
||||
inventoryItemRepository: {
|
||||
async create(inventoryItem) {
|
||||
return {
|
||||
id: 'inventory-1',
|
||||
...inventoryItem,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
productService: createProductService({ productRepository }),
|
||||
});
|
||||
}
|
||||
|
||||
test('creates and lists products for the authenticated seller', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(createResponse.statusCode, 201);
|
||||
assert.equal(createResponse.json().data.slug, 'pikachu-plush');
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/products',
|
||||
});
|
||||
|
||||
assert.equal(listResponse.statusCode, 200);
|
||||
assert.equal(listResponse.json().data.length, 1);
|
||||
});
|
||||
|
||||
test('rejects invalid product requests', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: { name: '' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error.code, 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
test('rejects unauthenticated admin requests by default', async (testContext) => {
|
||||
const app = createTestApp();
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/products',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 401);
|
||||
assert.equal(response.json().error.code, 'UNAUTHORIZED');
|
||||
});
|
||||
|
||||
test('creates inventory only for the authenticated sellers product', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
const productResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory',
|
||||
payload: {
|
||||
productId: productResponse.json().data.id,
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.status, 'ACTIVE');
|
||||
});
|
||||
|
||||
test('rejects an authenticated product write without a CSRF token', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'b078adf4-6a91-4b3d-a263-740246c11e3f' }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/products',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
payload: {
|
||||
name: 'Pikachu Plush',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { createRedisLoginRateLimiter } from '../../src/modules/auth/services/login-rate-limiter.js';
|
||||
|
||||
function createTestApp(authenticationService, dependencies = {}) {
|
||||
return buildApp({
|
||||
authenticationService,
|
||||
loginRateLimiter: { async consume() { return { allowed: true }; } },
|
||||
...dependencies,
|
||||
});
|
||||
}
|
||||
|
||||
test('places a successful login session only in a strict HttpOnly cookie', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async login() {
|
||||
return {
|
||||
sessionToken: 'raw-session-token',
|
||||
csrfToken: 'raw-csrf-token',
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
};
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@example.test', password: 'correct-password' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.sessionToken, undefined);
|
||||
const cookies = response.headers['set-cookie'].join('\n');
|
||||
assert.match(cookies, /HttpOnly/);
|
||||
assert.match(cookies, /SameSite=Strict/);
|
||||
assert.match(cookies, /pokemon_inventory_session=raw-session-token/);
|
||||
assert.match(cookies, /pokemon_inventory_csrf=raw-csrf-token/);
|
||||
});
|
||||
|
||||
test('rejects malformed login bodies before authentication', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async login() {
|
||||
throw new Error('Authentication should not run.');
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'not-an-email', password: '' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(response.json().error.code, 'VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
test('reads the authenticated principal from the session cookie', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async authenticate(sessionToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
return { userId: 'user-id', sellerId: 'seller-id', role: 'ADMIN' };
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.sellerId, 'seller-id');
|
||||
});
|
||||
|
||||
test('revokes the session and clears its cookie on logout', async (testContext) => {
|
||||
let revokedToken;
|
||||
const app = createTestApp({
|
||||
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
assert.equal(csrfToken, 'raw-csrf-token');
|
||||
return { userId: 'user-id' };
|
||||
},
|
||||
async logout(sessionToken) {
|
||||
revokedToken = sessionToken;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: {
|
||||
cookie: 'pokemon_inventory_session=raw-session-token',
|
||||
'x-csrf-token': 'raw-csrf-token',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(revokedToken, 'raw-session-token');
|
||||
assert.match(response.headers['set-cookie'].join('\n'), /Max-Age=0/);
|
||||
});
|
||||
|
||||
test('rejects logout without a session-bound CSRF token', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async authorizeUnsafeRequest() {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
|
||||
test('limits login attempts by client before credential verification', async (testContext) => {
|
||||
let loginCalls = 0;
|
||||
const attemptsByKey = new Map();
|
||||
const redisClient = {
|
||||
async eval(script, { arguments: scriptArguments, keys }) {
|
||||
assert.match(script, /INCR/);
|
||||
assert.equal(scriptArguments[0], String(15 * 60 * 1000));
|
||||
const key = keys[0];
|
||||
const count = (attemptsByKey.get(key) ?? 0) + 1;
|
||||
attemptsByKey.set(key, count);
|
||||
return [count, 15 * 60 * 1000];
|
||||
},
|
||||
};
|
||||
const app = createTestApp(
|
||||
{
|
||||
async login() {
|
||||
loginCalls += 1;
|
||||
throw new Error('Invalid credentials');
|
||||
},
|
||||
},
|
||||
{
|
||||
loginRateLimiter: createRedisLoginRateLimiter({
|
||||
maxAttempts: 2,
|
||||
redisClient,
|
||||
}),
|
||||
},
|
||||
);
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const request = {
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@example.test', password: 'incorrect-password' },
|
||||
remoteAddress: '198.51.100.8',
|
||||
};
|
||||
|
||||
const firstResponse = await app.inject(request);
|
||||
const secondResponse = await app.inject(request);
|
||||
const blockedResponse = await app.inject(request);
|
||||
const otherClientResponse = await app.inject({
|
||||
...request,
|
||||
remoteAddress: '198.51.100.9',
|
||||
});
|
||||
|
||||
assert.equal(firstResponse.statusCode, 500);
|
||||
assert.equal(secondResponse.statusCode, 500);
|
||||
assert.equal(blockedResponse.statusCode, 429);
|
||||
assert.equal(blockedResponse.json().error.code, 'AUTH_RATE_LIMITED');
|
||||
assert.equal(blockedResponse.headers['retry-after'], '900');
|
||||
assert.equal(otherClientResponse.statusCode, 500);
|
||||
assert.equal(loginCalls, 3);
|
||||
});
|
||||
|
||||
test('lists active sessions without exposing credential material', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async listActiveSessions(sessionToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
return {
|
||||
currentSessionId: '3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
||||
sessions: [
|
||||
{
|
||||
id: '3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
expiresAt: new Date('2026-09-08T00:00:00.000Z'),
|
||||
lastActivityAt: new Date('2026-09-07T00:30:00.000Z'),
|
||||
ipAddress: '198.51.100.8',
|
||||
userAgent: 'Test Browser',
|
||||
sessionTokenHash: 'must-not-be-serialized',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/sessions',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data[0].isCurrent, true);
|
||||
assert.equal(response.json().data[0].sessionTokenHash, undefined);
|
||||
});
|
||||
|
||||
test('requires CSRF validation to revoke a session and clears current-session cookies', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
assert.equal(csrfToken, 'raw-csrf-token');
|
||||
},
|
||||
async revokeSession(sessionToken, sessionId) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
assert.equal(sessionId, '3d0a3713-5b8f-4080-af25-b2e6eb4150b1');
|
||||
return { currentSessionRevoked: true };
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/auth/sessions/3d0a3713-5b8f-4080-af25-b2e6eb4150b1',
|
||||
headers: {
|
||||
cookie: 'pokemon_inventory_session=raw-session-token',
|
||||
'x-csrf-token': 'raw-csrf-token',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(response.headers['set-cookie'].join('\n'), /pokemon_inventory_session=; Max-Age=0/);
|
||||
assert.match(response.headers['set-cookie'].join('\n'), /pokemon_inventory_csrf=; Max-Age=0/);
|
||||
});
|
||||
|
||||
test('revokes other sessions while retaining the current session', async (testContext) => {
|
||||
let revokedOtherSessions = false;
|
||||
const app = createTestApp({
|
||||
async authorizeUnsafeRequest() {},
|
||||
async revokeOtherSessions(sessionToken) {
|
||||
assert.equal(sessionToken, 'raw-session-token');
|
||||
revokedOtherSessions = true;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/auth/sessions',
|
||||
headers: {
|
||||
cookie: 'pokemon_inventory_session=raw-session-token',
|
||||
'x-csrf-token': 'raw-csrf-token',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(revokedOtherSessions, true);
|
||||
assert.equal(response.headers['set-cookie'], undefined);
|
||||
});
|
||||
|
||||
test('accepts password reset requests without returning a reset token', async (testContext) => {
|
||||
let resetRequest;
|
||||
const app = createTestApp(
|
||||
{},
|
||||
{
|
||||
passwordRecoveryService: {
|
||||
async requestReset(input) {
|
||||
resetRequest = input;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: 'admin@example.test' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 202);
|
||||
assert.equal(response.json().data, null);
|
||||
assert.equal('token' in response.json(), false);
|
||||
assert.equal(resetRequest.email, 'admin@example.test');
|
||||
});
|
||||
|
||||
test('resets a password only with a valid-shaped recovery token', async (testContext) => {
|
||||
let resetInput;
|
||||
const app = createTestApp(
|
||||
{},
|
||||
{
|
||||
passwordRecoveryService: {
|
||||
async resetPassword(input) {
|
||||
resetInput = input;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reset-password',
|
||||
payload: { token: 'a'.repeat(32), password: 'new-password-value' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(resetInput.password, 'new-password-value');
|
||||
});
|
||||
|
||||
test('limits password reset requests independently from login attempts', async (testContext) => {
|
||||
const app = createTestApp(
|
||||
{},
|
||||
{
|
||||
passwordRecoveryService: { async requestReset() {} },
|
||||
passwordResetRateLimiter: {
|
||||
async consume() {
|
||||
return { allowed: false, retryAfterSeconds: 60 };
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/forgot-password',
|
||||
payload: { email: 'admin@example.test' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 429);
|
||||
assert.equal(response.json().error.code, 'AUTH_RATE_LIMITED');
|
||||
assert.equal(response.headers['retry-after'], '60');
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
import { createListingService } from '../../src/modules/listings/services/listing-service.js';
|
||||
|
||||
function createTestApp({ authenticateAdmin, authorizeUnsafeRequest, listing } = {}) {
|
||||
const app = buildApp({
|
||||
authenticateAdmin,
|
||||
authorizeUnsafeRequest,
|
||||
listingService: createListingService({
|
||||
listingRepository: {
|
||||
async create(listing) {
|
||||
return {
|
||||
id: 'listing-1',
|
||||
...listing,
|
||||
createdAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-07T00:00:00.000Z'),
|
||||
};
|
||||
},
|
||||
async findByIdForSeller() {
|
||||
return listing ?? {
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-1',
|
||||
conditionId: 'condition-1',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
};
|
||||
},
|
||||
async listBySellerId() {
|
||||
return [{
|
||||
id: 'listing-1',
|
||||
inventoryItemId: 'inventory-1',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-1',
|
||||
conditionId: 'condition-1',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
}];
|
||||
},
|
||||
async updateStatus(id, status) {
|
||||
return {
|
||||
...(listing ?? {
|
||||
id,
|
||||
inventoryItemId: 'inventory-1',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'category-1',
|
||||
conditionId: 'condition-1',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
}),
|
||||
id,
|
||||
status,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
inventoryImageService: {
|
||||
async create(input) {
|
||||
return {
|
||||
id: 'image-1',
|
||||
...input,
|
||||
processingStatus: 'PENDING',
|
||||
createdAt: new Date('2026-09-08T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-08T00:00:00.000Z'),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
test('creates a draft listing for the authenticated seller', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/listings',
|
||||
payload: {
|
||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
notes: ' Near mint ',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.status, 'DRAFT');
|
||||
assert.equal(response.json().data.productName, 'Charizard ex');
|
||||
});
|
||||
|
||||
test('rejects a listing write without CSRF validation', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/listings',
|
||||
headers: { cookie: 'pokemon_inventory_session=raw-session-token' },
|
||||
payload: {
|
||||
inventoryItemId: 'd2f67d74-9647-48d5-b3a4-74d3b16ea9f8',
|
||||
productName: 'Charizard ex',
|
||||
categoryId: 'ab8150c7-96c1-4b55-8d69-7d0b77d48958',
|
||||
conditionId: '21eb218b-7a95-4f80-a9e3-e306f7ca1f2e',
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
|
||||
test('publishes a listing through the authenticated, CSRF-protected lifecycle route', async (testContext) => {
|
||||
let csrfChecks = 0;
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => { csrfChecks += 1; },
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/listings/listing-1/publish',
|
||||
headers: { 'x-csrf-token': 'csrf-token' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.status, 'AVAILABLE');
|
||||
assert.equal(csrfChecks, 1);
|
||||
});
|
||||
|
||||
test('reads seller-owned listings without requiring CSRF validation', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
authorizeUnsafeRequest: async () => {
|
||||
throw new Error('CSRF should not be checked for reads.');
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const collectionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/listings',
|
||||
});
|
||||
const detailResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/listings/listing-1',
|
||||
});
|
||||
|
||||
assert.equal(collectionResponse.statusCode, 200);
|
||||
assert.equal(collectionResponse.json().data[0].id, 'listing-1');
|
||||
assert.equal(detailResponse.statusCode, 200);
|
||||
assert.equal(detailResponse.json().data.status, 'DRAFT');
|
||||
});
|
||||
|
||||
test('adds inventory image metadata for the authenticated seller', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/inventory/inventory-1/images',
|
||||
payload: {
|
||||
storageKey: 'inventory/inventory-1/front.jpg',
|
||||
originalFilename: 'front.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: 12000,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
altText: 'Front of Charizard ex card',
|
||||
isPrimary: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.inventoryItemId, 'inventory-1');
|
||||
assert.equal(response.json().data.processingStatus, 'PENDING');
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildApp } from '../../src/app/build-app.js';
|
||||
|
||||
const userId = '464e9b49-8617-4d00-af3c-78b3294e1d70';
|
||||
const methodId = '6833868a-2a1b-4d33-90d1-fc9f2b7be869';
|
||||
const headers = {
|
||||
cookie: 'pokemon_inventory_session=session-token',
|
||||
'x-csrf-token': 'csrf-token',
|
||||
};
|
||||
|
||||
function createTestApp(mfaService) {
|
||||
return buildApp({
|
||||
authenticationService: {
|
||||
async authorizeUnsafeRequest(sessionToken, csrfToken) {
|
||||
assert.equal(sessionToken, 'session-token');
|
||||
assert.equal(csrfToken, 'csrf-token');
|
||||
return { userId };
|
||||
},
|
||||
},
|
||||
loginRateLimiter: { async consume() { return { allowed: true }; } },
|
||||
mfaService,
|
||||
});
|
||||
}
|
||||
|
||||
test('enrolls MFA only for the authenticated user and returns its provisioning URI', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async enroll(input) {
|
||||
assert.deepEqual(input, { userId, name: 'Authenticator' });
|
||||
return { id: methodId, provisioningUri: 'otpauth://totp/example' };
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/mfa/enroll',
|
||||
headers,
|
||||
payload: { name: 'Authenticator' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.json().data.provisioningUri, 'otpauth://totp/example');
|
||||
});
|
||||
|
||||
test('verifies MFA with CSRF protection and does not expose secret material', async (testContext) => {
|
||||
const app = createTestApp({
|
||||
async verifyEnrollment(input) {
|
||||
assert.deepEqual(input, { userId, methodId, code: '123456' });
|
||||
return { id: methodId, name: 'Authenticator', type: 'TOTP', secretCiphertext: 'hidden' };
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/mfa/verify',
|
||||
headers,
|
||||
payload: { methodId, code: '123456' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.secretCiphertext, undefined);
|
||||
assert.equal(response.json().data.type, 'TOTP');
|
||||
});
|
||||
|
||||
test('rejects MFA enrollment without CSRF validation', async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticationService: {
|
||||
async authorizeUnsafeRequest() {
|
||||
const error = new Error('CSRF validation failed.');
|
||||
error.code = 'FORBIDDEN';
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
loginRateLimiter: { async consume() { return { allowed: true }; } },
|
||||
mfaService: { async enroll() {} },
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/mfa/enroll',
|
||||
payload: { name: 'Authenticator' },
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 403);
|
||||
assert.equal(response.json().error.code, 'FORBIDDEN');
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after, beforeEach } from 'node:test';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { createDatabase } from '../../src/infrastructure/database/client.js';
|
||||
import {
|
||||
categories,
|
||||
conditionProfiles,
|
||||
conditions,
|
||||
mfaMethods,
|
||||
passwordResetTokens,
|
||||
securityAuditEvents,
|
||||
sellers,
|
||||
sessions,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
import { createSessionRepository } from '../../src/modules/auth/repositories/session-repository.js';
|
||||
import { createSecurityAuditRepository } from '../../src/modules/auth/repositories/security-audit-repository.js';
|
||||
import { createUserRepository } from '../../src/modules/auth/repositories/user-repository.js';
|
||||
import { createPasswordResetRepository } from '../../src/modules/auth/repositories/password-reset-repository.js';
|
||||
import { createMfaMethodRepository } from '../../src/modules/auth/repositories/mfa-method-repository.js';
|
||||
import { createInventoryItemRepository } from '../../src/modules/inventory/repositories/inventory-item-repository.js';
|
||||
import { createProductRepository } from '../../src/modules/products/repositories/product-repository.js';
|
||||
|
||||
const connectionString = process.env.TEST_DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error('TEST_DATABASE_URL is required to run integration tests.');
|
||||
}
|
||||
|
||||
if (!new URL(connectionString).pathname.endsWith('_test')) {
|
||||
throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".');
|
||||
}
|
||||
|
||||
const database = createDatabase(connectionString);
|
||||
const productRepository = createProductRepository(database.db);
|
||||
const inventoryItemRepository = createInventoryItemRepository(database.db);
|
||||
const sessionRepository = createSessionRepository(database.db);
|
||||
const passwordResetRepository = createPasswordResetRepository(database.db);
|
||||
const mfaMethodRepository = createMfaMethodRepository(database.db);
|
||||
const securityAuditRepository = createSecurityAuditRepository(database.db);
|
||||
const userRepository = createUserRepository(database.db);
|
||||
|
||||
async function createCatalogPrerequisites() {
|
||||
const [user] = await database.db
|
||||
.insert(users)
|
||||
.values({ email: 'admin@example.test', passwordHash: 'not-a-real-password' })
|
||||
.returning();
|
||||
const [seller] = await database.db
|
||||
.insert(sellers)
|
||||
.values({ userId: user.id, displayName: 'Catalog Owner' })
|
||||
.returning();
|
||||
const [category] = await database.db
|
||||
.insert(categories)
|
||||
.values({ name: 'Trading Cards', slug: 'trading-cards' })
|
||||
.returning();
|
||||
const [conditionProfile] = await database.db
|
||||
.insert(conditionProfiles)
|
||||
.values({ categoryId: category.id, name: 'Card' })
|
||||
.returning();
|
||||
const [condition] = await database.db
|
||||
.insert(conditions)
|
||||
.values({
|
||||
conditionProfileId: conditionProfile.id,
|
||||
name: 'Near Mint',
|
||||
code: 'NM',
|
||||
qualityRank: 1,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { user, seller, category, condition };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await database.db.execute(
|
||||
sql`TRUNCATE security_audit_events, password_reset_tokens, mfa_methods, sessions, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`,
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
test('persists distinct products and physical inventory copies', async () => {
|
||||
const { seller, category, condition } = await createCatalogPrerequisites();
|
||||
const product = await productRepository.create({
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Charizard ex',
|
||||
slug: 'charizard-ex-sv151-199',
|
||||
setName: 'Scarlet & Violet 151',
|
||||
cardNumber: '199/165',
|
||||
});
|
||||
const inventoryItem = await inventoryItemRepository.create({
|
||||
productId: product.id,
|
||||
conditionId: condition.id,
|
||||
notes: 'Clean front and back.',
|
||||
});
|
||||
|
||||
assert.equal(inventoryItem.productId, product.id);
|
||||
assert.equal(inventoryItem.status, 'ACTIVE');
|
||||
assert.equal(
|
||||
(await productRepository.listBySellerId(seller.id)).map(({ id }) => id).join(),
|
||||
product.id,
|
||||
);
|
||||
assert.equal(
|
||||
(await inventoryItemRepository.listByProductId(product.id)).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('enforces seller-scoped product slugs', async () => {
|
||||
const { seller, category } = await createCatalogPrerequisites();
|
||||
const product = {
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Pikachu Plush',
|
||||
slug: 'pikachu-plush',
|
||||
};
|
||||
|
||||
await productRepository.create(product);
|
||||
|
||||
await assert.rejects(
|
||||
() => productRepository.create(product),
|
||||
(error) => error.cause?.code === '23505',
|
||||
);
|
||||
});
|
||||
|
||||
test('finds a product by both its seller and slug', async () => {
|
||||
const { seller, category } = await createCatalogPrerequisites();
|
||||
const product = await productRepository.create({
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Pikachu Plush',
|
||||
slug: 'pikachu-plush',
|
||||
});
|
||||
const [otherUser] = await database.db
|
||||
.insert(users)
|
||||
.values({ email: 'other-admin@example.test', passwordHash: 'not-a-real-password' })
|
||||
.returning();
|
||||
const [otherSeller] = await database.db
|
||||
.insert(sellers)
|
||||
.values({ userId: otherUser.id, displayName: 'Other Catalog Owner' })
|
||||
.returning();
|
||||
|
||||
assert.equal(
|
||||
(await productRepository.findBySellerIdAndSlug(seller.id, product.slug))?.id,
|
||||
product.id,
|
||||
);
|
||||
assert.equal(
|
||||
await productRepository.findBySellerIdAndSlug(otherSeller.id, product.slug),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('retrieves only non-revoked, non-expired sessions', async () => {
|
||||
const { user, seller } = await createCatalogPrerequisites();
|
||||
const tokenHash = 'a hashed session token';
|
||||
const expiresAt = new Date('2026-09-08T00:00:00.000Z');
|
||||
|
||||
const createdSession = await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash: tokenHash,
|
||||
csrfTokenHash: 'a hashed CSRF token',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
const activeSession = await sessionRepository.findActiveAuthenticationSession(
|
||||
tokenHash,
|
||||
new Date('2026-09-07T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
assert.equal(activeSession.id, createdSession.id);
|
||||
assert.equal(activeSession.userId, user.id);
|
||||
assert.equal(activeSession.role, 'ADMIN');
|
||||
assert.equal(activeSession.sellerId, seller.id);
|
||||
assert.equal(activeSession.csrfTokenHash, 'a hashed CSRF token');
|
||||
assert.ok(activeSession.lastActivityAt instanceof Date);
|
||||
|
||||
await sessionRepository.revokeByTokenHash(
|
||||
tokenHash,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await sessionRepository.findActiveAuthenticationSession(
|
||||
tokenHash,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('provisions only one initial administrator with a linked seller', async () => {
|
||||
const createdAdmin = await userRepository.createInitialAdmin({
|
||||
email: 'admin@example.test',
|
||||
passwordHash: 'scrypt$test-hash',
|
||||
sellerName: 'Catalog Owner',
|
||||
});
|
||||
|
||||
assert.equal(createdAdmin.user.email, 'admin@example.test');
|
||||
assert.equal(createdAdmin.seller.userId, createdAdmin.user.id);
|
||||
assert.equal(
|
||||
await userRepository.createInitialAdmin({
|
||||
email: 'other@example.test',
|
||||
passwordHash: 'scrypt$test-hash',
|
||||
sellerName: 'Other Owner',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('lists active sessions and revokes all except the current session', async () => {
|
||||
const { user } = await createCatalogPrerequisites();
|
||||
const expiresAt = new Date('2026-09-08T00:00:00.000Z');
|
||||
const currentSession = await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash: 'current session hash',
|
||||
csrfTokenHash: 'current CSRF hash',
|
||||
expiresAt,
|
||||
});
|
||||
const otherSession = await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash: 'other session hash',
|
||||
csrfTokenHash: 'other CSRF hash',
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
(await sessionRepository.listActiveByUserId(
|
||||
user.id,
|
||||
new Date('2026-09-07T00:00:00.000Z'),
|
||||
))
|
||||
.map((session) => session.id)
|
||||
.sort(),
|
||||
[currentSession.id, otherSession.id].sort(),
|
||||
);
|
||||
|
||||
await sessionRepository.revokeOtherByUserId(
|
||||
user.id,
|
||||
currentSession.id,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
(
|
||||
await sessionRepository.listActiveByUserId(
|
||||
user.id,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
)
|
||||
).map((session) => session.id),
|
||||
[currentSession.id],
|
||||
);
|
||||
});
|
||||
|
||||
test('records private authentication audit events without credentials', async () => {
|
||||
const { user } = await createCatalogPrerequisites();
|
||||
const event = await securityAuditRepository.record({
|
||||
userId: user.id,
|
||||
event: 'LOGIN_SUCCESS',
|
||||
ipAddress: '198.51.100.8',
|
||||
userAgent: 'Test Browser',
|
||||
});
|
||||
|
||||
const [storedEvent] = await database.db
|
||||
.select()
|
||||
.from(securityAuditEvents)
|
||||
.where(sql`${securityAuditEvents.id} = ${event.id}`);
|
||||
|
||||
assert.equal(storedEvent.userId, user.id);
|
||||
assert.equal(storedEvent.event, 'LOGIN_SUCCESS');
|
||||
assert.equal(storedEvent.ipAddress, '198.51.100.8');
|
||||
assert.ok(storedEvent.createdAt instanceof Date);
|
||||
assert.deepEqual(storedEvent.metadata, {});
|
||||
assert.equal('sessionTokenHash' in storedEvent, false);
|
||||
assert.equal('passwordHash' in storedEvent, false);
|
||||
});
|
||||
|
||||
test('consumes a password reset token once, replaces the password, and revokes sessions', async () => {
|
||||
const { user } = await createCatalogPrerequisites();
|
||||
const now = new Date('2026-09-07T00:00:00.000Z');
|
||||
const tokenHash = 'reset token hash';
|
||||
await passwordResetRepository.issue({
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiresAt: new Date('2026-09-07T01:00:00.000Z'),
|
||||
});
|
||||
await sessionRepository.create({
|
||||
userId: user.id,
|
||||
sessionTokenHash: 'active session hash',
|
||||
csrfTokenHash: 'active CSRF hash',
|
||||
expiresAt: new Date('2026-09-08T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await passwordResetRepository.consume({
|
||||
tokenHash,
|
||||
passwordHash: 'replacement password hash',
|
||||
now,
|
||||
}),
|
||||
user.id,
|
||||
);
|
||||
assert.equal(
|
||||
await passwordResetRepository.consume({
|
||||
tokenHash,
|
||||
passwordHash: 'another password hash',
|
||||
now,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
const updatedUser = await userRepository.findAuthenticationUserByEmail('admin@example.test');
|
||||
const [resetToken] = await database.db
|
||||
.select()
|
||||
.from(passwordResetTokens)
|
||||
.where(sql`${passwordResetTokens.tokenHash} = ${tokenHash}`);
|
||||
|
||||
assert.equal(updatedUser.passwordHash, 'replacement password hash');
|
||||
assert.ok(resetToken.usedAt instanceof Date);
|
||||
assert.equal(
|
||||
await sessionRepository.findActiveAuthenticationSession(
|
||||
'active session hash',
|
||||
now,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('persists, enables, and disables an owned MFA method', async () => {
|
||||
const { user } = await createCatalogPrerequisites();
|
||||
const method = await mfaMethodRepository.create({
|
||||
userId: user.id,
|
||||
type: 'TOTP',
|
||||
name: 'Authenticator',
|
||||
secretCiphertext: 'encrypted-secret',
|
||||
});
|
||||
|
||||
assert.equal(method.isEnabled, false);
|
||||
assert.equal(
|
||||
(
|
||||
await mfaMethodRepository.findActiveByIdAndUserId(method.id, user.id)
|
||||
).secretCiphertext,
|
||||
'encrypted-secret',
|
||||
);
|
||||
|
||||
const enabledMethod = await mfaMethodRepository.enable(
|
||||
method.id,
|
||||
new Date('2026-09-07T00:00:00.000Z'),
|
||||
);
|
||||
const disabledMethod = await mfaMethodRepository.disable(
|
||||
method.id,
|
||||
new Date('2026-09-07T01:00:00.000Z'),
|
||||
);
|
||||
|
||||
assert.equal(enabledMethod.isEnabled, true);
|
||||
assert.equal(disabledMethod.isEnabled, false);
|
||||
assert.equal(
|
||||
await mfaMethodRepository.findActiveByIdAndUserId(method.id, user.id),
|
||||
null,
|
||||
);
|
||||
assert.equal((await database.db.select().from(mfaMethods)).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { after, beforeEach } from 'node:test';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { createDatabase } from '../../src/infrastructure/database/client.js';
|
||||
import {
|
||||
categories,
|
||||
conditionProfiles,
|
||||
conditions,
|
||||
inventoryItems,
|
||||
listings,
|
||||
products,
|
||||
sellers,
|
||||
users,
|
||||
} from '../../src/infrastructure/database/schema.js';
|
||||
import { createInventoryImageRepository } from '../../src/modules/inventory/repositories/inventory-image-repository.js';
|
||||
import { createListingRepository } from '../../src/modules/listings/repositories/listing-repository.js';
|
||||
|
||||
const connectionString = process.env.TEST_DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error('TEST_DATABASE_URL is required to run integration tests.');
|
||||
}
|
||||
|
||||
if (!new URL(connectionString).pathname.endsWith('_test')) {
|
||||
throw new Error('TEST_DATABASE_URL must reference a database whose name ends in "_test".');
|
||||
}
|
||||
|
||||
const database = createDatabase(connectionString);
|
||||
const listingRepository = createListingRepository(database.db);
|
||||
const inventoryImageRepository = createInventoryImageRepository(database.db);
|
||||
|
||||
async function createInventoryFixture() {
|
||||
const [user] = await database.db
|
||||
.insert(users)
|
||||
.values({ email: 'listing-admin@example.test', passwordHash: 'hashed-password' })
|
||||
.returning();
|
||||
const [seller] = await database.db
|
||||
.insert(sellers)
|
||||
.values({ userId: user.id, displayName: 'Listing Owner' })
|
||||
.returning();
|
||||
const [category] = await database.db
|
||||
.insert(categories)
|
||||
.values({ name: 'Trading Cards', slug: 'trading-cards' })
|
||||
.returning();
|
||||
const [conditionProfile] = await database.db
|
||||
.insert(conditionProfiles)
|
||||
.values({ categoryId: category.id, name: 'Card' })
|
||||
.returning();
|
||||
const [condition] = await database.db
|
||||
.insert(conditions)
|
||||
.values({
|
||||
conditionProfileId: conditionProfile.id,
|
||||
name: 'Near Mint',
|
||||
code: 'NM',
|
||||
qualityRank: 1,
|
||||
})
|
||||
.returning();
|
||||
const [product] = await database.db
|
||||
.insert(products)
|
||||
.values({
|
||||
sellerId: seller.id,
|
||||
categoryId: category.id,
|
||||
name: 'Charizard ex',
|
||||
slug: 'charizard-ex-sv151-199',
|
||||
})
|
||||
.returning();
|
||||
const [inventoryItem] = await database.db
|
||||
.insert(inventoryItems)
|
||||
.values({
|
||||
productId: product.id,
|
||||
conditionId: condition.id,
|
||||
notes: 'Original box included',
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { seller, category, condition, product, inventoryItem };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await database.db.execute(
|
||||
sql`TRUNCATE listings, inventory_items, products, conditions, condition_profiles, categories, sellers, users CASCADE`,
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await database.close();
|
||||
});
|
||||
|
||||
test('persists a draft listing for a physical inventory item', async () => {
|
||||
const { category, condition, inventoryItem } = await createInventoryFixture();
|
||||
|
||||
const listing = await listingRepository.create({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
productName: 'Charizard ex',
|
||||
categoryId: category.id,
|
||||
conditionId: condition.id,
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
notes: 'Near mint',
|
||||
});
|
||||
|
||||
assert.equal(listing.inventoryItemId, inventoryItem.id);
|
||||
assert.equal(listing.status, 'DRAFT');
|
||||
assert.equal(listing.askingPriceMinor, 2499);
|
||||
assert.ok(listing.id);
|
||||
});
|
||||
|
||||
test('lists the most recent draft entries for an inventory item', async () => {
|
||||
const { category, condition, inventoryItem } = await createInventoryFixture();
|
||||
|
||||
await listingRepository.create({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
productName: 'Charizard ex',
|
||||
categoryId: category.id,
|
||||
conditionId: condition.id,
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
|
||||
const listingsForItem = await listingRepository.listByInventoryItemId(inventoryItem.id);
|
||||
|
||||
assert.equal(listingsForItem.length, 1);
|
||||
assert.equal(listingsForItem[0].inventoryItemId, inventoryItem.id);
|
||||
});
|
||||
|
||||
test('persists image metadata only for the owning seller and counts item images', async () => {
|
||||
const { seller, inventoryItem } = await createInventoryFixture();
|
||||
const image = await inventoryImageRepository.createForSeller({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
storageKey: 'inventory/front.jpg',
|
||||
originalFilename: 'front.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: 12000,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
altText: 'Front image',
|
||||
sortOrder: 0,
|
||||
isPrimary: true,
|
||||
}, seller.id);
|
||||
|
||||
assert.equal(image.processingStatus, 'PENDING');
|
||||
assert.equal(await inventoryImageRepository.countByInventoryItemId(inventoryItem.id), 1);
|
||||
assert.equal(
|
||||
await inventoryImageRepository.createForSeller({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
storageKey: 'inventory/other.jpg',
|
||||
originalFilename: 'other.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: 12000,
|
||||
width: 1200,
|
||||
height: 900,
|
||||
}, '00000000-0000-0000-0000-000000000000'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('reads and updates listings only through the owning seller scope', async () => {
|
||||
const { seller, category, condition, inventoryItem } = await createInventoryFixture();
|
||||
|
||||
const createdListing = await listingRepository.create({
|
||||
inventoryItemId: inventoryItem.id,
|
||||
productName: 'Charizard ex',
|
||||
categoryId: category.id,
|
||||
conditionId: condition.id,
|
||||
askingPriceMinor: 2499,
|
||||
imageCount: 2,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
|
||||
const ownedListing = await listingRepository.findByIdForSeller(createdListing.id, seller.id);
|
||||
const sellerListings = await listingRepository.listBySellerId(seller.id);
|
||||
|
||||
assert.equal(ownedListing.id, createdListing.id);
|
||||
assert.equal(ownedListing.inventoryStatus, 'ACTIVE');
|
||||
assert.equal(sellerListings.length, 1);
|
||||
assert.equal(
|
||||
await listingRepository.findByIdForSeller(
|
||||
createdListing.id,
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
const archivedListing = await listingRepository.updateStatus(createdListing.id, 'ARCHIVED');
|
||||
assert.equal(archivedListing.status, 'ARCHIVED');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createRedisConnection } from '../../src/infrastructure/redis/client.js';
|
||||
import { createRedisLoginRateLimiter } from '../../src/modules/auth/services/login-rate-limiter.js';
|
||||
|
||||
const redisUrl = process.env.TEST_REDIS_URL;
|
||||
|
||||
if (!redisUrl) {
|
||||
throw new Error('TEST_REDIS_URL is required to run integration tests.');
|
||||
}
|
||||
|
||||
test('shares login attempts across limiter instances', async () => {
|
||||
const redis = await createRedisConnection(redisUrl);
|
||||
const keyPrefix = `pokemon-inventory:test:auth:${randomUUID()}:`;
|
||||
|
||||
try {
|
||||
const firstLimiter = createRedisLoginRateLimiter({
|
||||
keyPrefix,
|
||||
maxAttempts: 2,
|
||||
redisClient: redis.client,
|
||||
});
|
||||
const secondLimiter = createRedisLoginRateLimiter({
|
||||
keyPrefix,
|
||||
maxAttempts: 2,
|
||||
redisClient: redis.client,
|
||||
});
|
||||
|
||||
assert.deepEqual(await firstLimiter.consume('198.51.100.8'), { allowed: true });
|
||||
assert.deepEqual(await secondLimiter.consume('198.51.100.8'), { allowed: true });
|
||||
|
||||
const blockedAttempt = await firstLimiter.consume('198.51.100.8');
|
||||
|
||||
assert.equal(blockedAttempt.allowed, false);
|
||||
assert.ok(blockedAttempt.retryAfterSeconds > 0);
|
||||
assert.deepEqual(await secondLimiter.consume('198.51.100.9'), { allowed: true });
|
||||
} finally {
|
||||
await redis.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-pokemon_inventory}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-pokemon_inventory}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5433}:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --appendonly yes
|
||||
ports:
|
||||
- "${REDIS_PORT:-6380}:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
@@ -0,0 +1,87 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TYPE "user_role" AS ENUM ('ADMIN');
|
||||
CREATE TYPE "inventory_status" AS ENUM ('ACTIVE', 'SOLD', 'ARCHIVED');
|
||||
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"role" "user_role" DEFAULT 'ADMIN' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "users_email_unique" ON "users" ("email");
|
||||
|
||||
CREATE TABLE "sellers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id"),
|
||||
"display_name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "sellers_user_id_unique" ON "sellers" ("user_id");
|
||||
|
||||
CREATE TABLE "categories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"sort" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "categories_name_unique" ON "categories" ("name");
|
||||
CREATE UNIQUE INDEX "categories_slug_unique" ON "categories" ("slug");
|
||||
|
||||
CREATE TABLE "condition_profiles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"category_id" uuid NOT NULL REFERENCES "categories"("id"),
|
||||
"name" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"sort" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "conditions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"condition_profile_id" uuid NOT NULL REFERENCES "condition_profiles"("id"),
|
||||
"name" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"quality_rank" integer NOT NULL,
|
||||
"description" text,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"sort" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "conditions_profile_code_unique" UNIQUE("condition_profile_id", "code")
|
||||
);
|
||||
|
||||
CREATE TABLE "products" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"seller_id" uuid NOT NULL REFERENCES "sellers"("id"),
|
||||
"category_id" uuid NOT NULL REFERENCES "categories"("id"),
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"description" text,
|
||||
"brand" text,
|
||||
"set_name" text,
|
||||
"card_number" text,
|
||||
"event_name" text,
|
||||
"release_year" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "products_release_year_valid" CHECK ("release_year" IS NULL OR "release_year" BETWEEN 1996 AND 9999),
|
||||
CONSTRAINT "products_seller_slug_unique" UNIQUE("seller_id", "slug")
|
||||
);
|
||||
|
||||
CREATE TABLE "inventory_items" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"product_id" uuid NOT NULL REFERENCES "products"("id"),
|
||||
"condition_id" uuid NOT NULL REFERENCES "conditions"("id"),
|
||||
"status" "inventory_status" DEFAULT 'ACTIVE' NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id"),
|
||||
"session_token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sessions_token_hash_unique" UNIQUE("session_token_hash")
|
||||
);
|
||||
CREATE INDEX "sessions_user_id_index" ON "sessions" ("user_id");
|
||||
CREATE INDEX "sessions_expires_at_index" ON "sessions" ("expires_at");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "sessions" ADD COLUMN "csrf_token_hash" text;
|
||||
|
||||
UPDATE "sessions" SET "csrf_token_hash" = '' WHERE "csrf_token_hash" IS NULL;
|
||||
|
||||
ALTER TABLE "sessions" ALTER COLUMN "csrf_token_hash" SET NOT NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "security_audit_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid REFERENCES "users"("id"),
|
||||
"event" text NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
CREATE INDEX "security_audit_events_user_id_index" ON "security_audit_events" ("user_id");
|
||||
CREATE INDEX "security_audit_events_event_created_at_index" ON "security_audit_events" ("event", "created_at");
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "password_reset_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id"),
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "password_reset_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
CREATE INDEX "password_reset_tokens_user_id_index" ON "password_reset_tokens" ("user_id");
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "mfa_methods" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id"),
|
||||
"type" text NOT NULL,
|
||||
"secret_ciphertext" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"is_enabled" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"disabled_at" timestamp with time zone
|
||||
);
|
||||
CREATE INDEX "mfa_methods_user_id_index" ON "mfa_methods" ("user_id");
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TYPE "listing_status" AS ENUM ('DRAFT', 'AVAILABLE', 'PENDING', 'CLOSED', 'ARCHIVED');
|
||||
|
||||
CREATE TABLE "listings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"inventory_item_id" uuid NOT NULL REFERENCES "inventory_items"("id"),
|
||||
"product_name" text NOT NULL,
|
||||
"category_id" uuid NOT NULL,
|
||||
"condition_id" uuid NOT NULL REFERENCES "conditions"("id"),
|
||||
"asking_price_minor" integer NOT NULL,
|
||||
"status" "listing_status" DEFAULT 'DRAFT' NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "listings_inventory_item_id_index" ON "listings" ("inventory_item_id");
|
||||
CREATE INDEX "listings_status_index" ON "listings" ("status");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "listings" ADD COLUMN "image_count" integer NOT NULL DEFAULT 1;
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "inventory_images" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"inventory_item_id" uuid NOT NULL REFERENCES "inventory_items"("id"),
|
||||
"storage_key" text NOT NULL,
|
||||
"original_filename" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"width" integer NOT NULL,
|
||||
"height" integer NOT NULL,
|
||||
"alt_text" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"is_primary" boolean DEFAULT false NOT NULL,
|
||||
"processing_status" text DEFAULT 'PENDING' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX "inventory_images_inventory_item_id_index" ON "inventory_images" ("inventory_item_id");
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1788710400000,
|
||||
"tag": "0000_core_inventory",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1788768000000,
|
||||
"tag": "0001_auth_sessions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1788854400000,
|
||||
"tag": "0002_session_csrf_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1788940800000,
|
||||
"tag": "0003_security_audit_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1789027200000,
|
||||
"tag": "0004_password_reset_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1789113600000,
|
||||
"tag": "0005_mfa_methods",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1789200000000,
|
||||
"tag": "0006_listings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1789286400000,
|
||||
"tag": "0007_listing_image_count",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1789372800000,
|
||||
"tag": "0008_inventory_images",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Accessibility
|
||||
|
||||
Target WCAG 2.2 Level AA.
|
||||
|
||||
## Required practices
|
||||
|
||||
- Semantic HTML.
|
||||
- Full keyboard access.
|
||||
- Visible focus indicators.
|
||||
- Logical focus order.
|
||||
- Accessible names for controls.
|
||||
- Labels for all form inputs.
|
||||
- Required/optional state communicated programmatically and visually.
|
||||
- Accessible validation and error messages.
|
||||
- No color-only meaning.
|
||||
- Sufficient contrast.
|
||||
- Responsive layouts.
|
||||
- Support browser zoom and text resizing.
|
||||
- Accessible dialogs and galleries.
|
||||
- Focus management for dynamic UI.
|
||||
- Appropriate live-region/status announcements for important asynchronous changes.
|
||||
- Meaningful image alt text.
|
||||
- Use ARIA only where native semantics are insufficient.
|
||||
|
||||
## Testing
|
||||
|
||||
Run automated accessibility testing in CI.
|
||||
|
||||
Serious accessibility regressions should fail CI.
|
||||
|
||||
Automated tests do not replace manual keyboard and assistive-technology review for important workflows.
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
# API
|
||||
|
||||
## Base
|
||||
|
||||
REST API under `/api/v1`.
|
||||
|
||||
Responses use:
|
||||
|
||||
```json
|
||||
{ "data": {} }
|
||||
```
|
||||
|
||||
Collections include pagination metadata.
|
||||
|
||||
## Public endpoints
|
||||
|
||||
```text
|
||||
GET /api/v1/public/search
|
||||
GET /api/v1/public/products/:slug
|
||||
GET /api/v1/public/inventory
|
||||
GET /api/v1/public/listings
|
||||
GET /api/v1/public/sellers
|
||||
GET /api/v1/public/currencies
|
||||
GET /api/v1/public/exchange-rates
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/logout
|
||||
POST /api/v1/auth/forgot-password
|
||||
POST /api/v1/auth/reset-password
|
||||
GET /api/v1/auth/me
|
||||
POST /api/v1/auth/mfa/enroll
|
||||
POST /api/v1/auth/mfa/verify
|
||||
POST /api/v1/auth/mfa/disable
|
||||
GET /api/v1/auth/sessions
|
||||
DELETE /api/v1/auth/sessions/:id
|
||||
DELETE /api/v1/auth/sessions
|
||||
```
|
||||
|
||||
`POST /api/v1/auth/forgot-password` always returns the same accepted response after validating an email-shaped request, whether or not an account exists. It sends an opaque, one-time reset token only through the configured mail adapter. `POST /api/v1/auth/reset-password` consumes that token, updates the password, and revokes all active sessions.
|
||||
|
||||
`GET /api/v1/auth/sessions` returns only the authenticated user's active sessions and marks the current session. It never returns session or CSRF token material. `DELETE /api/v1/auth/sessions/:id` revokes one session owned by the authenticated user. `DELETE /api/v1/auth/sessions` revokes all other active sessions while retaining the current session. Session revocation endpoints require CSRF validation.
|
||||
|
||||
## Admin endpoints
|
||||
|
||||
```text
|
||||
GET /api/v1/admin/products
|
||||
POST /api/v1/admin/products
|
||||
GET /api/v1/admin/products/:id
|
||||
PATCH /api/v1/admin/products/:id
|
||||
|
||||
GET /api/v1/admin/inventory
|
||||
POST /api/v1/admin/inventory
|
||||
GET /api/v1/admin/inventory/:id
|
||||
PATCH /api/v1/admin/inventory/:id
|
||||
POST /api/v1/admin/inventory/:id/list
|
||||
POST /api/v1/admin/inventory/:id/archive
|
||||
POST /api/v1/admin/inventory/:id/relist
|
||||
POST /api/v1/admin/inventory/:id/pricing/refresh
|
||||
|
||||
GET /api/v1/admin/listings
|
||||
GET /api/v1/admin/listings/:id
|
||||
PATCH /api/v1/admin/listings/:id
|
||||
POST /api/v1/admin/listings/:id/publish
|
||||
POST /api/v1/admin/listings/:id/unpublish
|
||||
POST /api/v1/admin/listings/:id/pending
|
||||
POST /api/v1/admin/listings/:id/archive
|
||||
|
||||
POST /api/v1/admin/pricing/refresh
|
||||
GET /api/v1/admin/jobs/:id
|
||||
|
||||
POST /api/v1/admin/inventory/:id/images
|
||||
PATCH /api/v1/admin/inventory/:id/images/order
|
||||
POST /api/v1/admin/images/:id/primary
|
||||
DELETE /api/v1/admin/images/:id
|
||||
|
||||
POST /api/v1/admin/sales
|
||||
POST /api/v1/admin/bulk
|
||||
|
||||
GET /api/v1/admin/dashboard
|
||||
|
||||
GET /api/v1/admin/saved-views
|
||||
POST /api/v1/admin/saved-views
|
||||
PATCH /api/v1/admin/saved-views/:id
|
||||
DELETE /api/v1/admin/saved-views/:id
|
||||
```
|
||||
|
||||
Listing creation and lifecycle writes require an authenticated admin session and
|
||||
the session-bound CSRF header. Publishing is seller-scoped and checks the
|
||||
persisted product, category, condition, asking price, physical inventory, and
|
||||
image prerequisites before changing a draft to `AVAILABLE`; sold and archived
|
||||
physical inventory cannot be published. Unpublish changes
|
||||
an available listing to `CLOSED`; pending changes are available from draft or
|
||||
available listings.
|
||||
|
||||
`POST /api/v1/admin/inventory/:id/images` stores validated image metadata for an
|
||||
owned physical inventory item. File bytes are handled by the future
|
||||
`ImageStorage` adapter; publication counts persisted inventory images rather
|
||||
than trusting a listing request field.
|
||||
|
||||
## Provider matching
|
||||
|
||||
The API must expose Product-level operations for:
|
||||
- searching configured providers for candidate matches
|
||||
- confirming a provider mapping
|
||||
- manually searching/refining a provider query
|
||||
- marking a provider not applicable
|
||||
- disabling/replacing a mapping
|
||||
|
||||
Exact route naming may be finalized during API implementation, but these operations must remain Product-scoped.
|
||||
|
||||
## Validation
|
||||
|
||||
Validate body, query, path, and response schemas.
|
||||
|
||||
Business validation occurs after request-shape validation.
|
||||
|
||||
Allowlist sortable fields.
|
||||
|
||||
## Errors
|
||||
|
||||
Use machine-readable codes, including:
|
||||
- `PRODUCT_NOT_FOUND`
|
||||
- `INVENTORY_NOT_FOUND`
|
||||
- `INVENTORY_EDIT_LOCKED`
|
||||
- `INVALID_STATUS_TRANSITION`
|
||||
- `LISTING_REQUIREMENTS_NOT_MET`
|
||||
- `SALE_PRICE_REQUIRED`
|
||||
- `SALE_ITEM_NOT_AVAILABLE`
|
||||
- `PRICING_PROVIDER_FAILED`
|
||||
- `IMAGE_LIMIT_EXCEEDED`
|
||||
- `UNAUTHORIZED`
|
||||
- `FORBIDDEN`
|
||||
|
||||
## Privacy
|
||||
|
||||
Public DTOs must never expose private acquisition, sale, buyer, internal, provider-debugging, pricing-history, or audit information.
|
||||
|
||||
## Pagination and query state
|
||||
|
||||
Large collections use pagination. Public/admin filters and searches should be represented in URL query state where useful for shareability, refresh, and back/forward navigation.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Architecture
|
||||
|
||||
## Architectural style
|
||||
|
||||
Modular monolith.
|
||||
|
||||
Do not split domains into microservices unless a future architecture decision explicitly requires it.
|
||||
|
||||
## Runtime topology
|
||||
|
||||
```text
|
||||
Public Web
|
||||
↓ HTTPS
|
||||
Reverse Proxy
|
||||
↓
|
||||
Fastify API ───── PostgreSQL
|
||||
│
|
||||
├────────────── Redis / BullMQ
|
||||
│ ↓
|
||||
│ Worker
|
||||
│
|
||||
└────────────── Image Storage
|
||||
```
|
||||
|
||||
Docker Compose is the baseline deployment mechanism.
|
||||
|
||||
## Applications
|
||||
|
||||
### web
|
||||
Vue 3/Vite public and admin frontend.
|
||||
|
||||
### api
|
||||
Fastify REST API. Owns authentication, authorization, domain use cases, persistence, and synchronous integrations.
|
||||
|
||||
### worker
|
||||
Processes asynchronous jobs including bulk pricing, image processing, and large exports.
|
||||
|
||||
## Modules
|
||||
|
||||
API modules:
|
||||
- auth
|
||||
- users
|
||||
- sellers
|
||||
- products
|
||||
- inventory
|
||||
- listings
|
||||
- pricing
|
||||
- sales
|
||||
- images
|
||||
- search
|
||||
- settings
|
||||
- audit
|
||||
|
||||
## Layering
|
||||
|
||||
`Vue Component → Composable → API Service → HTTP → Fastify Route → Validation → Application Service → Domain Logic → Repository → PostgreSQL`
|
||||
|
||||
## External boundaries
|
||||
|
||||
Pricing providers implement `PricingProvider`.
|
||||
|
||||
Image storage implements `ImageStorage`.
|
||||
|
||||
Search is accessed through `SearchService`.
|
||||
|
||||
Currency conversion is accessed through a currency/exchange-rate service.
|
||||
|
||||
## Shared packages
|
||||
|
||||
Only genuinely shared material belongs in `packages/*`:
|
||||
- API contracts
|
||||
- validation schemas
|
||||
- safe utilities
|
||||
|
||||
Server-side domain logic stays on the server.
|
||||
|
||||
## Search
|
||||
|
||||
PostgreSQL-backed initially. Keep search behind an abstraction so OpenSearch/Elasticsearch can be introduced later without changing product-facing behavior.
|
||||
|
||||
## Authentication
|
||||
|
||||
Use server-side sessions with secure HttpOnly SameSite cookies. Authentication tokens must not be stored in localStorage.
|
||||
|
||||
## Caching
|
||||
|
||||
Use selective caching for stable/repeated data such as exchange rates, public metadata, and configuration. Avoid aggressive caching of mutations, financial calculations, auth state, and sale state.
|
||||
@@ -0,0 +1,201 @@
|
||||
# Data Model
|
||||
|
||||
## Core relationship
|
||||
|
||||
```text
|
||||
User → Seller → Product → Inventory Item → Listing
|
||||
│
|
||||
├── Images
|
||||
├── Acquisition
|
||||
├── Pricing Observations
|
||||
├── Market Valuations
|
||||
└── Activity
|
||||
|
||||
Sale → Sale Items → Inventory Items
|
||||
```
|
||||
|
||||
## Product
|
||||
|
||||
Represents what the item is, independent of physical ownership.
|
||||
|
||||
Examples:
|
||||
- Charizard ex — SV151 — #199/165
|
||||
- Pokémon Center Worlds 2025 Pikachu Plush
|
||||
|
||||
Products persist after all inventory is sold.
|
||||
|
||||
## Inventory Item
|
||||
|
||||
Represents one physical copy.
|
||||
|
||||
Each copy has its own:
|
||||
- condition
|
||||
- images
|
||||
- acquisition
|
||||
- asking price/listing
|
||||
- market valuation
|
||||
- notes
|
||||
- status
|
||||
|
||||
Do not model multiple physical copies as a quantity field on one Inventory Item.
|
||||
|
||||
## Listing
|
||||
|
||||
Represents how an Inventory Item is currently offered publicly.
|
||||
|
||||
A Product can have many physical Inventory Items with different conditions and asking prices.
|
||||
|
||||
## Sale
|
||||
|
||||
A manual external transaction. One Sale can contain multiple Sale Items.
|
||||
|
||||
## Provider Product Mapping
|
||||
|
||||
Provider mappings are Product-level relationships.
|
||||
|
||||
Recommended fields:
|
||||
- id
|
||||
- product_id
|
||||
- provider_id
|
||||
- mapping_type (`PRODUCT` or `SEARCH`)
|
||||
- external_identifier
|
||||
- search_query / search parameters
|
||||
- source_url
|
||||
- status
|
||||
- metadata
|
||||
- created_at
|
||||
- updated_at
|
||||
- disabled_at
|
||||
|
||||
A provider can support one or both mapping types.
|
||||
|
||||
eBay supports both:
|
||||
- specific product/catalog mappings where appropriate
|
||||
- search/query mappings where no canonical product is appropriate
|
||||
|
||||
Mappings can be replaced without destroying historical records.
|
||||
|
||||
Only one active/default mapping per provider should be used for normal pricing unless a provider integration explicitly supports multiple active sources.
|
||||
|
||||
## Suggested pricing tables
|
||||
|
||||
### pricing_providers
|
||||
`id, name, code, is_enabled, priority, configuration, created_at, updated_at`
|
||||
|
||||
### product_pricing_providers
|
||||
`product_id, provider_id, is_enabled`
|
||||
|
||||
### pricing_provider_mappings
|
||||
As described above.
|
||||
|
||||
### pricing_observations
|
||||
`id, product_id, inventory_item_id, provider_id, source_identifier, source_url, condition_id, price_minor, currency, observed_at, quality_score, is_outlier, outlier_reason, raw_reference`
|
||||
|
||||
### market_valuations
|
||||
`id, inventory_item_id, value_minor, currency, strategy, confidence, source_count, calculated_at`
|
||||
|
||||
### market_value_overrides
|
||||
`id, inventory_item_id, value_minor, currency, reason, created_by, created_at, removed_at`
|
||||
|
||||
### pricing_jobs
|
||||
`id, job_type, status, requested_by, started_at, completed_at, total_items, processed_items, successful_items, failed_items, error_summary, created_at`
|
||||
|
||||
## Conditions
|
||||
|
||||
`condition_profiles`:
|
||||
- id
|
||||
- category_id
|
||||
- name
|
||||
- active
|
||||
- sort
|
||||
|
||||
`conditions`:
|
||||
- id
|
||||
- condition_profile_id
|
||||
- name
|
||||
- code
|
||||
- quality_rank
|
||||
- description
|
||||
- active
|
||||
- sort
|
||||
|
||||
## Images
|
||||
|
||||
`inventory_images`:
|
||||
- id
|
||||
- inventory_item_id
|
||||
- storage_key
|
||||
- original_filename
|
||||
- mime_type
|
||||
- file_size
|
||||
- width
|
||||
- height
|
||||
- alt_text
|
||||
- sort_order
|
||||
- is_primary
|
||||
- processing_status
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
## Acquisitions
|
||||
|
||||
`acquisitions`:
|
||||
- id
|
||||
- inventory_item_id
|
||||
- method
|
||||
- cost_minor
|
||||
- currency
|
||||
- date
|
||||
- source
|
||||
- location
|
||||
- shipping_minor
|
||||
- tax_minor
|
||||
- other_fees_minor
|
||||
- estimated_trade_value_minor
|
||||
- notes
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
## Sales
|
||||
|
||||
`sales`:
|
||||
- id
|
||||
- seller_id
|
||||
- sale_date
|
||||
- sale_location
|
||||
- sold_price_minor
|
||||
- currency
|
||||
- platform_fees_minor
|
||||
- payment_processing_fees_minor
|
||||
- shipping_cost_minor
|
||||
- taxes_minor
|
||||
- other_fees_minor
|
||||
- buyer_name
|
||||
- buyer_contact
|
||||
- notes
|
||||
- created_by
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
`sale_items`:
|
||||
- id
|
||||
- sale_id
|
||||
- inventory_item_id
|
||||
- sold_price_minor
|
||||
- cost_basis_minor
|
||||
|
||||
## Activity
|
||||
|
||||
Activity records retain:
|
||||
- affected Product/Inventory Item
|
||||
- event
|
||||
- previous value where applicable
|
||||
- new value
|
||||
- actor
|
||||
- timestamp
|
||||
|
||||
Security events are stored separately.
|
||||
|
||||
## Money
|
||||
|
||||
Store money as integer minor units. Negative values are prohibited; zero is valid. USD is canonical for stored/calculated market values unless a documented external representation requires another currency.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Architecture Decision: Provider Product Mapping
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Decision
|
||||
Provider mappings are first-class Product-level records.
|
||||
|
||||
During new Product creation, configured pricing providers are searched for candidate matches. The admin confirms the appropriate match, refines a search manually when necessary, or marks a provider Not Applicable.
|
||||
|
||||
Mappings are reused by all physical Inventory Items belonging to the Product.
|
||||
|
||||
## Mapping types
|
||||
|
||||
- `PRODUCT`: specific canonical provider/catalog entity.
|
||||
- `SEARCH`: provider search/query definition.
|
||||
|
||||
eBay supports both mapping types.
|
||||
|
||||
## Rationale
|
||||
|
||||
A provider relationship describes the identity of the Product, not a particular physical copy. Keeping it at Product level avoids repeating provider discovery for every copy while supporting multiple conditions and future copies.
|
||||
|
||||
A search mapping is necessary for providers such as eBay where a canonical product may not exist for every collectible or merchandise item.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user