From c728016dec2583e414cb9064e4b2fd7524a09667 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 17:32:03 -0400 Subject: [PATCH] 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). --- .env.example | 17 + .github/copilot-instructions.md | 85 + .github/workflows/.gitkeep | 0 .gitignore | 7 + .local/project-ports.json | 27 + AGENTS.md | 54 + README.md | 84 + apps/api/package.json | 25 + apps/api/src/app/.gitkeep | 0 apps/api/src/app/build-app.js | 53 + apps/api/src/app/server.js | 97 + apps/api/src/infrastructure/.gitkeep | 0 .../api/src/infrastructure/database/client.js | 19 + .../api/src/infrastructure/database/schema.js | 285 ++ .../email/password-reset-notifier.js | 33 + .../infrastructure/images/image-storage.js | 9 + .../images/local-image-storage.js | 45 + apps/api/src/infrastructure/redis/client.js | 20 + apps/api/src/modules/.gitkeep | 0 apps/api/src/modules/auth/domain/password.js | 67 + .../modules/auth/domain/secret-protection.js | 49 + apps/api/src/modules/auth/domain/session.js | 23 + apps/api/src/modules/auth/domain/totp.js | 33 + .../repositories/mfa-method-repository.js | 67 + .../repositories/password-reset-repository.js | 62 + .../repositories/security-audit-repository.js | 14 + .../auth/repositories/session-repository.js | 100 + .../auth/repositories/user-repository.js | 47 + .../src/modules/auth/routes/auth-routes.js | 253 ++ .../auth/scripts/provision-initial-admin.js | 41 + .../auth/services/authentication-service.js | 233 ++ .../auth/services/initial-admin-service.js | 49 + .../auth/services/login-rate-limiter.js | 48 + .../src/modules/auth/services/mfa-service.js | 94 + .../services/password-recovery-service.js | 69 + .../inventory/domain/inventory-item.js | 56 + .../repositories/condition-repository.js | 26 + .../inventory-image-repository.js | 33 + .../repositories/inventory-item-repository.js | 24 + .../inventory/routes/admin-image-routes.js | 46 + .../routes/admin-inventory-routes.js | 57 + .../services/inventory-image-service.js | 18 + .../services/inventory-item-service.js | 35 + .../src/modules/listings/domain/listing.js | 131 + .../repositories/listing-repository.js | 57 + .../listings/routes/admin-listing-routes.js | 96 + .../listings/services/listing-service.js | 49 + .../src/modules/products/domain/product.js | 30 + .../repositories/product-repository.js | 38 + .../products/routes/admin-product-routes.js | 63 + .../products/services/product-service.js | 33 + apps/api/src/shared/.gitkeep | 0 .../src/shared/errors/application-error.js | 6 + apps/api/tests/.gitkeep | 0 .../authentication-service.test.js | 201 ++ .../tests/application/image-storage.test.js | 48 + .../application/initial-admin-service.test.js | 54 + .../inventory-item-service.test.js | 49 + .../tests/application/listing-service.test.js | 158 + .../api/tests/application/mfa-service.test.js | 126 + .../password-recovery-service.test.js | 79 + .../tests/application/product-service.test.js | 34 + apps/api/tests/domain/inventory-item.test.js | 34 + apps/api/tests/domain/listing.test.js | 74 + apps/api/tests/domain/product.test.js | 29 + .../tests/http/admin-product-routes.test.js | 173 ++ apps/api/tests/http/auth-routes.test.js | 340 +++ apps/api/tests/http/listing-routes.test.js | 200 ++ apps/api/tests/http/mfa-routes.test.js | 91 + .../integration/catalog-repositories.test.js | 363 +++ .../integration/listing-repository.test.js | 190 ++ .../redis-login-rate-limiter.test.js | 41 + apps/web/src/components/.gitkeep | 0 apps/web/src/composables/.gitkeep | 0 apps/web/src/pages/.gitkeep | 0 apps/web/src/router/.gitkeep | 0 apps/web/src/services/.gitkeep | 0 apps/web/src/stores/.gitkeep | 0 apps/web/src/utils/.gitkeep | 0 apps/web/tests/.gitkeep | 0 apps/worker/src/.gitkeep | 0 apps/worker/tests/.gitkeep | 0 compose.yaml | 33 + database/migrations/.gitkeep | 0 database/migrations/0000_core_inventory.sql | 87 + database/migrations/0001_auth_sessions.sql | 14 + .../migrations/0002_session_csrf_tokens.sql | 5 + .../migrations/0003_security_audit_events.sql | 11 + .../migrations/0004_password_reset_tokens.sql | 10 + database/migrations/0005_mfa_methods.sql | 12 + database/migrations/0006_listings.sql | 17 + .../migrations/0007_listing_image_count.sql | 1 + database/migrations/0008_inventory_images.sql | 18 + database/migrations/meta/_journal.json | 69 + database/seeds/.gitkeep | 0 docs/ACCESSIBILITY.md | 31 + docs/API.md | 144 + docs/ARCHITECTURE.md | 87 + docs/DATA-MODEL.md | 201 ++ docs/DECISIONS.md | 24 + docs/DEPLOYMENT.md | 58 + docs/DESIGN.md | 43 + docs/IMAGES.md | 95 + docs/IMPLEMENTATION-STATUS.md | 156 + docs/PRICING.md | 126 + docs/PRODUCT.md | 36 + docs/REQUIREMENTS.md | 176 ++ docs/SECURITY.md | 95 + docs/TESTING.md | 61 + docs/UX.md | 78 + drizzle.config.js | 17 + package-lock.json | 2660 +++++++++++++++++ package.json | 26 + packages/contracts/.gitkeep | 0 packages/contracts/auth.js | 23 + packages/contracts/image.js | 22 + packages/contracts/inventory.js | 15 + packages/contracts/listing.js | 19 + packages/contracts/package.json | 12 + packages/contracts/product.js | 21 + packages/utilities/.gitkeep | 0 packages/validation/.gitkeep | 0 packages/validation/auth.js | 34 + packages/validation/image.js | 13 + packages/validation/inventory.js | 9 + packages/validation/listing.js | 13 + packages/validation/package.json | 15 + packages/validation/product.js | 10 + 128 files changed, 9788 insertions(+) create mode 100644 .env.example create mode 100644 .github/copilot-instructions.md create mode 100644 .github/workflows/.gitkeep create mode 100644 .gitignore create mode 100644 .local/project-ports.json create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app/.gitkeep create mode 100644 apps/api/src/app/build-app.js create mode 100644 apps/api/src/app/server.js create mode 100644 apps/api/src/infrastructure/.gitkeep create mode 100644 apps/api/src/infrastructure/database/client.js create mode 100644 apps/api/src/infrastructure/database/schema.js create mode 100644 apps/api/src/infrastructure/email/password-reset-notifier.js create mode 100644 apps/api/src/infrastructure/images/image-storage.js create mode 100644 apps/api/src/infrastructure/images/local-image-storage.js create mode 100644 apps/api/src/infrastructure/redis/client.js create mode 100644 apps/api/src/modules/.gitkeep create mode 100644 apps/api/src/modules/auth/domain/password.js create mode 100644 apps/api/src/modules/auth/domain/secret-protection.js create mode 100644 apps/api/src/modules/auth/domain/session.js create mode 100644 apps/api/src/modules/auth/domain/totp.js create mode 100644 apps/api/src/modules/auth/repositories/mfa-method-repository.js create mode 100644 apps/api/src/modules/auth/repositories/password-reset-repository.js create mode 100644 apps/api/src/modules/auth/repositories/security-audit-repository.js create mode 100644 apps/api/src/modules/auth/repositories/session-repository.js create mode 100644 apps/api/src/modules/auth/repositories/user-repository.js create mode 100644 apps/api/src/modules/auth/routes/auth-routes.js create mode 100644 apps/api/src/modules/auth/scripts/provision-initial-admin.js create mode 100644 apps/api/src/modules/auth/services/authentication-service.js create mode 100644 apps/api/src/modules/auth/services/initial-admin-service.js create mode 100644 apps/api/src/modules/auth/services/login-rate-limiter.js create mode 100644 apps/api/src/modules/auth/services/mfa-service.js create mode 100644 apps/api/src/modules/auth/services/password-recovery-service.js create mode 100644 apps/api/src/modules/inventory/domain/inventory-item.js create mode 100644 apps/api/src/modules/inventory/repositories/condition-repository.js create mode 100644 apps/api/src/modules/inventory/repositories/inventory-image-repository.js create mode 100644 apps/api/src/modules/inventory/repositories/inventory-item-repository.js create mode 100644 apps/api/src/modules/inventory/routes/admin-image-routes.js create mode 100644 apps/api/src/modules/inventory/routes/admin-inventory-routes.js create mode 100644 apps/api/src/modules/inventory/services/inventory-image-service.js create mode 100644 apps/api/src/modules/inventory/services/inventory-item-service.js create mode 100644 apps/api/src/modules/listings/domain/listing.js create mode 100644 apps/api/src/modules/listings/repositories/listing-repository.js create mode 100644 apps/api/src/modules/listings/routes/admin-listing-routes.js create mode 100644 apps/api/src/modules/listings/services/listing-service.js create mode 100644 apps/api/src/modules/products/domain/product.js create mode 100644 apps/api/src/modules/products/repositories/product-repository.js create mode 100644 apps/api/src/modules/products/routes/admin-product-routes.js create mode 100644 apps/api/src/modules/products/services/product-service.js create mode 100644 apps/api/src/shared/.gitkeep create mode 100644 apps/api/src/shared/errors/application-error.js create mode 100644 apps/api/tests/.gitkeep create mode 100644 apps/api/tests/application/authentication-service.test.js create mode 100644 apps/api/tests/application/image-storage.test.js create mode 100644 apps/api/tests/application/initial-admin-service.test.js create mode 100644 apps/api/tests/application/inventory-item-service.test.js create mode 100644 apps/api/tests/application/listing-service.test.js create mode 100644 apps/api/tests/application/mfa-service.test.js create mode 100644 apps/api/tests/application/password-recovery-service.test.js create mode 100644 apps/api/tests/application/product-service.test.js create mode 100644 apps/api/tests/domain/inventory-item.test.js create mode 100644 apps/api/tests/domain/listing.test.js create mode 100644 apps/api/tests/domain/product.test.js create mode 100644 apps/api/tests/http/admin-product-routes.test.js create mode 100644 apps/api/tests/http/auth-routes.test.js create mode 100644 apps/api/tests/http/listing-routes.test.js create mode 100644 apps/api/tests/http/mfa-routes.test.js create mode 100644 apps/api/tests/integration/catalog-repositories.test.js create mode 100644 apps/api/tests/integration/listing-repository.test.js create mode 100644 apps/api/tests/integration/redis-login-rate-limiter.test.js create mode 100644 apps/web/src/components/.gitkeep create mode 100644 apps/web/src/composables/.gitkeep create mode 100644 apps/web/src/pages/.gitkeep create mode 100644 apps/web/src/router/.gitkeep create mode 100644 apps/web/src/services/.gitkeep create mode 100644 apps/web/src/stores/.gitkeep create mode 100644 apps/web/src/utils/.gitkeep create mode 100644 apps/web/tests/.gitkeep create mode 100644 apps/worker/src/.gitkeep create mode 100644 apps/worker/tests/.gitkeep create mode 100644 compose.yaml create mode 100644 database/migrations/.gitkeep create mode 100644 database/migrations/0000_core_inventory.sql create mode 100644 database/migrations/0001_auth_sessions.sql create mode 100644 database/migrations/0002_session_csrf_tokens.sql create mode 100644 database/migrations/0003_security_audit_events.sql create mode 100644 database/migrations/0004_password_reset_tokens.sql create mode 100644 database/migrations/0005_mfa_methods.sql create mode 100644 database/migrations/0006_listings.sql create mode 100644 database/migrations/0007_listing_image_count.sql create mode 100644 database/migrations/0008_inventory_images.sql create mode 100644 database/migrations/meta/_journal.json create mode 100644 database/seeds/.gitkeep create mode 100644 docs/ACCESSIBILITY.md create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATA-MODEL.md create mode 100644 docs/DECISIONS.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DESIGN.md create mode 100644 docs/IMAGES.md create mode 100644 docs/IMPLEMENTATION-STATUS.md create mode 100644 docs/PRICING.md create mode 100644 docs/PRODUCT.md create mode 100644 docs/REQUIREMENTS.md create mode 100644 docs/SECURITY.md create mode 100644 docs/TESTING.md create mode 100644 docs/UX.md create mode 100644 drizzle.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/contracts/.gitkeep create mode 100644 packages/contracts/auth.js create mode 100644 packages/contracts/image.js create mode 100644 packages/contracts/inventory.js create mode 100644 packages/contracts/listing.js create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/product.js create mode 100644 packages/utilities/.gitkeep create mode 100644 packages/validation/.gitkeep create mode 100644 packages/validation/auth.js create mode 100644 packages/validation/image.js create mode 100644 packages/validation/inventory.js create mode 100644 packages/validation/listing.js create mode 100644 packages/validation/package.json create mode 100644 packages/validation/product.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..10c2bcb --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..0c1fc11 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 diff --git a/.github/workflows/.gitkeep b/.github/workflows/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..743a385 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +.env.* +!.env.example +coverage/ +dist/ +*.log \ No newline at end of file diff --git a/.local/project-ports.json b/.local/project-ports.json new file mode 100644 index 0000000..6f06375 --- /dev/null +++ b/.local/project-ports.json @@ -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": "" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d81b285 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fb379a --- /dev/null +++ b/README.md @@ -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. diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..a064dd2 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} \ No newline at end of file diff --git a/apps/api/src/app/.gitkeep b/apps/api/src/app/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/app/build-app.js b/apps/api/src/app/build-app.js new file mode 100644 index 0000000..d5cc495 --- /dev/null +++ b/apps/api/src/app/build-app.js @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/app/server.js b/apps/api/src/app/server.js new file mode 100644 index 0000000..f1ac513 --- /dev/null +++ b/apps/api/src/app/server.js @@ -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; +}); \ No newline at end of file diff --git a/apps/api/src/infrastructure/.gitkeep b/apps/api/src/infrastructure/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/infrastructure/database/client.js b/apps/api/src/infrastructure/database/client.js new file mode 100644 index 0000000..1b1c4d1 --- /dev/null +++ b/apps/api/src/infrastructure/database/client.js @@ -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(), + }; +} \ No newline at end of file diff --git a/apps/api/src/infrastructure/database/schema.js b/apps/api/src/infrastructure/database/schema.js new file mode 100644 index 0000000..3a5d6f6 --- /dev/null +++ b/apps/api/src/infrastructure/database/schema.js @@ -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), +]); \ No newline at end of file diff --git a/apps/api/src/infrastructure/email/password-reset-notifier.js b/apps/api/src/infrastructure/email/password-reset-notifier.js new file mode 100644 index 0000000..522ad50 --- /dev/null +++ b/apps/api/src/infrastructure/email/password-reset-notifier.js @@ -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()}`, + }); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/infrastructure/images/image-storage.js b/apps/api/src/infrastructure/images/image-storage.js new file mode 100644 index 0000000..6489d32 --- /dev/null +++ b/apps/api/src/infrastructure/images/image-storage.js @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/infrastructure/images/local-image-storage.js b/apps/api/src/infrastructure/images/local-image-storage.js new file mode 100644 index 0000000..d533708 --- /dev/null +++ b/apps/api/src/infrastructure/images/local-image-storage.js @@ -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 }); + }, + }); +} \ No newline at end of file diff --git a/apps/api/src/infrastructure/redis/client.js b/apps/api/src/infrastructure/redis/client.js new file mode 100644 index 0000000..293a769 --- /dev/null +++ b/apps/api/src/infrastructure/redis/client.js @@ -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(), + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/.gitkeep b/apps/api/src/modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/modules/auth/domain/password.js b/apps/api/src/modules/auth/domain/password.js new file mode 100644 index 0000000..414adb1 --- /dev/null +++ b/apps/api/src/modules/auth/domain/password.js @@ -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; + } +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/domain/secret-protection.js b/apps/api/src/modules/auth/domain/secret-protection.js new file mode 100644 index 0000000..1b857df --- /dev/null +++ b/apps/api/src/modules/auth/domain/secret-protection.js @@ -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'); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/domain/session.js b/apps/api/src/modules/auth/domain/session.js new file mode 100644 index 0000000..dcebaff --- /dev/null +++ b/apps/api/src/modules/auth/domain/session.js @@ -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'); +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/domain/totp.js b/apps/api/src/modules/auth/domain/totp.js new file mode 100644 index 0000000..e41ae5c --- /dev/null +++ b/apps/api/src/modules/auth/domain/totp.js @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/repositories/mfa-method-repository.js b/apps/api/src/modules/auth/repositories/mfa-method-repository.js new file mode 100644 index 0000000..4c20f31 --- /dev/null +++ b/apps/api/src/modules/auth/repositories/mfa-method-repository.js @@ -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; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/repositories/password-reset-repository.js b/apps/api/src/modules/auth/repositories/password-reset-repository.js new file mode 100644 index 0000000..724a1c7 --- /dev/null +++ b/apps/api/src/modules/auth/repositories/password-reset-repository.js @@ -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; + }); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/repositories/security-audit-repository.js b/apps/api/src/modules/auth/repositories/security-audit-repository.js new file mode 100644 index 0000000..ba47be0 --- /dev/null +++ b/apps/api/src/modules/auth/repositories/security-audit-repository.js @@ -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; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/repositories/session-repository.js b/apps/api/src/modules/auth/repositories/session-repository.js new file mode 100644 index 0000000..9ee9265 --- /dev/null +++ b/apps/api/src/modules/auth/repositories/session-repository.js @@ -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), + ), + ); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/repositories/user-repository.js b/apps/api/src/modules/auth/repositories/user-repository.js new file mode 100644 index 0000000..ef8b78a --- /dev/null +++ b/apps/api/src/modules/auth/repositories/user-repository.js @@ -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; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/routes/auth-routes.js b/apps/api/src/modules/auth/routes/auth-routes.js new file mode 100644 index 0000000..ab83547 --- /dev/null +++ b/apps/api/src/modules/auth/routes/auth-routes.js @@ -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 }); + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/scripts/provision-initial-admin.js b/apps/api/src/modules/auth/scripts/provision-initial-admin.js new file mode 100644 index 0000000..7854c71 --- /dev/null +++ b/apps/api/src/modules/auth/scripts/provision-initial-admin.js @@ -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; +}); \ No newline at end of file diff --git a/apps/api/src/modules/auth/services/authentication-service.js b/apps/api/src/modules/auth/services/authentication-service.js new file mode 100644 index 0000000..b94dbfb --- /dev/null +++ b/apps/api/src/modules/auth/services/authentication-service.js @@ -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); +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/services/initial-admin-service.js b/apps/api/src/modules/auth/services/initial-admin-service.js new file mode 100644 index 0000000..72334f9 --- /dev/null +++ b/apps/api/src/modules/auth/services/initial-admin-service.js @@ -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, + }; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/services/login-rate-limiter.js b/apps/api/src/modules/auth/services/login-rate-limiter.js new file mode 100644 index 0000000..5561ffd --- /dev/null +++ b/apps/api/src/modules/auth/services/login-rate-limiter.js @@ -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 }; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/services/mfa-service.js b/apps/api/src/modules/auth/services/mfa-service.js new file mode 100644 index 0000000..7b47009 --- /dev/null +++ b/apps/api/src/modules/auth/services/mfa-service.js @@ -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 }; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/auth/services/password-recovery-service.js b/apps/api/src/modules/auth/services/password-recovery-service.js new file mode 100644 index 0000000..1b0f1bb --- /dev/null +++ b/apps/api/src/modules/auth/services/password-recovery-service.js @@ -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, + }); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/domain/inventory-item.js b/apps/api/src/modules/inventory/domain/inventory-item.js new file mode 100644 index 0000000..09bca0f --- /dev/null +++ b/apps/api/src/modules/inventory/domain/inventory-item.js @@ -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() } + : {}), + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/repositories/condition-repository.js b/apps/api/src/modules/inventory/repositories/condition-repository.js new file mode 100644 index 0000000..1778dbc --- /dev/null +++ b/apps/api/src/modules/inventory/repositories/condition-repository.js @@ -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; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/repositories/inventory-image-repository.js b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js new file mode 100644 index 0000000..67092e0 --- /dev/null +++ b/apps/api/src/modules/inventory/repositories/inventory-image-repository.js @@ -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; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/repositories/inventory-item-repository.js b/apps/api/src/modules/inventory/repositories/inventory-item-repository.js new file mode 100644 index 0000000..d4f2e19 --- /dev/null +++ b/apps/api/src/modules/inventory/repositories/inventory-item-repository.js @@ -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)); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/routes/admin-image-routes.js b/apps/api/src/modules/inventory/routes/admin-image-routes.js new file mode 100644 index 0000000..8064d52 --- /dev/null +++ b/apps/api/src/modules/inventory/routes/admin-image-routes.js @@ -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) }); + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/routes/admin-inventory-routes.js b/apps/api/src/modules/inventory/routes/admin-inventory-routes.js new file mode 100644 index 0000000..f6eec71 --- /dev/null +++ b/apps/api/src/modules/inventory/routes/admin-inventory-routes.js @@ -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) }); + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/services/inventory-image-service.js b/apps/api/src/modules/inventory/services/inventory-image-service.js new file mode 100644 index 0000000..8332710 --- /dev/null +++ b/apps/api/src/modules/inventory/services/inventory-image-service.js @@ -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; + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/inventory/services/inventory-item-service.js b/apps/api/src/modules/inventory/services/inventory-item-service.js new file mode 100644 index 0000000..ec2ab15 --- /dev/null +++ b/apps/api/src/modules/inventory/services/inventory-item-service.js @@ -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); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/listings/domain/listing.js b/apps/api/src/modules/listings/domain/listing.js new file mode 100644 index 0000000..286de94 --- /dev/null +++ b/apps/api/src/modules/listings/domain/listing.js @@ -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; +} diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js new file mode 100644 index 0000000..4695cc4 --- /dev/null +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -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; + }, + }; +} diff --git a/apps/api/src/modules/listings/routes/admin-listing-routes.js b/apps/api/src/modules/listings/routes/admin-listing-routes.js new file mode 100644 index 0000000..28c4e0b --- /dev/null +++ b/apps/api/src/modules/listings/routes/admin-listing-routes.js @@ -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)); +} diff --git a/apps/api/src/modules/listings/services/listing-service.js b/apps/api/src/modules/listings/services/listing-service.js new file mode 100644 index 0000000..91cfe2b --- /dev/null +++ b/apps/api/src/modules/listings/services/listing-service.js @@ -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); + }, + }; +} diff --git a/apps/api/src/modules/products/domain/product.js b/apps/api/src/modules/products/domain/product.js new file mode 100644 index 0000000..ccd9c5f --- /dev/null +++ b/apps/api/src/modules/products/domain/product.js @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/modules/products/repositories/product-repository.js b/apps/api/src/modules/products/repositories/product-repository.js new file mode 100644 index 0000000..f08cd37 --- /dev/null +++ b/apps/api/src/modules/products/repositories/product-repository.js @@ -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)); + }, + }; +} \ No newline at end of file diff --git a/apps/api/src/modules/products/routes/admin-product-routes.js b/apps/api/src/modules/products/routes/admin-product-routes.js new file mode 100644 index 0000000..e423571 --- /dev/null +++ b/apps/api/src/modules/products/routes/admin-product-routes.js @@ -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) }); + }); +} \ No newline at end of file diff --git a/apps/api/src/modules/products/services/product-service.js b/apps/api/src/modules/products/services/product-service.js new file mode 100644 index 0000000..5572f85 --- /dev/null +++ b/apps/api/src/modules/products/services/product-service.js @@ -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; +} \ No newline at end of file diff --git a/apps/api/src/shared/.gitkeep b/apps/api/src/shared/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/shared/errors/application-error.js b/apps/api/src/shared/errors/application-error.js new file mode 100644 index 0000000..65c659d --- /dev/null +++ b/apps/api/src/shared/errors/application-error.js @@ -0,0 +1,6 @@ +export function createApplicationError(code, message, statusCode = 400) { + const error = new Error(message); + error.code = code; + error.statusCode = statusCode; + return error; +} \ No newline at end of file diff --git a/apps/api/tests/.gitkeep b/apps/api/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/application/authentication-service.test.js b/apps/api/tests/application/authentication-service.test.js new file mode 100644 index 0000000..08c7095 --- /dev/null +++ b/apps/api/tests/application/authentication-service.test.js @@ -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 }); +}); \ No newline at end of file diff --git a/apps/api/tests/application/image-storage.test.js b/apps/api/tests/application/image-storage.test.js new file mode 100644 index 0000000..1a739a0 --- /dev/null +++ b/apps/api/tests/application/image-storage.test.js @@ -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\(\)/, + ); +}); \ No newline at end of file diff --git a/apps/api/tests/application/initial-admin-service.test.js b/apps/api/tests/application/initial-admin-service.test.js new file mode 100644 index 0000000..5834934 --- /dev/null +++ b/apps/api/tests/application/initial-admin-service.test.js @@ -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', + ); +}); \ No newline at end of file diff --git a/apps/api/tests/application/inventory-item-service.test.js b/apps/api/tests/application/inventory-item-service.test.js new file mode 100644 index 0000000..5b80721 --- /dev/null +++ b/apps/api/tests/application/inventory-item-service.test.js @@ -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', + ); +}); \ No newline at end of file diff --git a/apps/api/tests/application/listing-service.test.js b/apps/api/tests/application/listing-service.test.js new file mode 100644 index 0000000..08bb73a --- /dev/null +++ b/apps/api/tests/application/listing-service.test.js @@ -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); +}); diff --git a/apps/api/tests/application/mfa-service.test.js b/apps/api/tests/application/mfa-service.test.js new file mode 100644 index 0000000..2ee2eee --- /dev/null +++ b/apps/api/tests/application/mfa-service.test.js @@ -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'); +}); \ No newline at end of file diff --git a/apps/api/tests/application/password-recovery-service.test.js b/apps/api/tests/application/password-recovery-service.test.js new file mode 100644 index 0000000..5b94e47 --- /dev/null +++ b/apps/api/tests/application/password-recovery-service.test.js @@ -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', + ); +}); \ No newline at end of file diff --git a/apps/api/tests/application/product-service.test.js b/apps/api/tests/application/product-service.test.js new file mode 100644 index 0000000..05272d4 --- /dev/null +++ b/apps/api/tests/application/product-service.test.js @@ -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'); +}); \ No newline at end of file diff --git a/apps/api/tests/domain/inventory-item.test.js b/apps/api/tests/domain/inventory-item.test.js new file mode 100644 index 0000000..86d829c --- /dev/null +++ b/apps/api/tests/domain/inventory-item.test.js @@ -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)); +}); \ No newline at end of file diff --git a/apps/api/tests/domain/listing.test.js b/apps/api/tests/domain/listing.test.js new file mode 100644 index 0000000..64d333b --- /dev/null +++ b/apps/api/tests/domain/listing.test.js @@ -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); +}); diff --git a/apps/api/tests/domain/product.test.js b/apps/api/tests/domain/product.test.js new file mode 100644 index 0000000..a456aea --- /dev/null +++ b/apps/api/tests/domain/product.test.js @@ -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/, + ); +}); \ No newline at end of file diff --git a/apps/api/tests/http/admin-product-routes.test.js b/apps/api/tests/http/admin-product-routes.test.js new file mode 100644 index 0000000..8cd2e5f --- /dev/null +++ b/apps/api/tests/http/admin-product-routes.test.js @@ -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'); +}); \ No newline at end of file diff --git a/apps/api/tests/http/auth-routes.test.js b/apps/api/tests/http/auth-routes.test.js new file mode 100644 index 0000000..29e4d9a --- /dev/null +++ b/apps/api/tests/http/auth-routes.test.js @@ -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'); +}); \ No newline at end of file diff --git a/apps/api/tests/http/listing-routes.test.js b/apps/api/tests/http/listing-routes.test.js new file mode 100644 index 0000000..5bd785e --- /dev/null +++ b/apps/api/tests/http/listing-routes.test.js @@ -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'); +}); diff --git a/apps/api/tests/http/mfa-routes.test.js b/apps/api/tests/http/mfa-routes.test.js new file mode 100644 index 0000000..4c34a86 --- /dev/null +++ b/apps/api/tests/http/mfa-routes.test.js @@ -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'); +}); \ No newline at end of file diff --git a/apps/api/tests/integration/catalog-repositories.test.js b/apps/api/tests/integration/catalog-repositories.test.js new file mode 100644 index 0000000..3955eb7 --- /dev/null +++ b/apps/api/tests/integration/catalog-repositories.test.js @@ -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); +}); \ No newline at end of file diff --git a/apps/api/tests/integration/listing-repository.test.js b/apps/api/tests/integration/listing-repository.test.js new file mode 100644 index 0000000..9a3d122 --- /dev/null +++ b/apps/api/tests/integration/listing-repository.test.js @@ -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'); +}); diff --git a/apps/api/tests/integration/redis-login-rate-limiter.test.js b/apps/api/tests/integration/redis-login-rate-limiter.test.js new file mode 100644 index 0000000..82de6f0 --- /dev/null +++ b/apps/api/tests/integration/redis-login-rate-limiter.test.js @@ -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(); + } +}); \ No newline at end of file diff --git a/apps/web/src/components/.gitkeep b/apps/web/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/composables/.gitkeep b/apps/web/src/composables/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/pages/.gitkeep b/apps/web/src/pages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/router/.gitkeep b/apps/web/src/router/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/services/.gitkeep b/apps/web/src/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/stores/.gitkeep b/apps/web/src/stores/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/src/utils/.gitkeep b/apps/web/src/utils/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/tests/.gitkeep b/apps/web/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/worker/src/.gitkeep b/apps/worker/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/worker/tests/.gitkeep b/apps/worker/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..3496dfa --- /dev/null +++ b/compose.yaml @@ -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: \ No newline at end of file diff --git a/database/migrations/.gitkeep b/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/database/migrations/0000_core_inventory.sql b/database/migrations/0000_core_inventory.sql new file mode 100644 index 0000000..9e1045d --- /dev/null +++ b/database/migrations/0000_core_inventory.sql @@ -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 +); \ No newline at end of file diff --git a/database/migrations/0001_auth_sessions.sql b/database/migrations/0001_auth_sessions.sql new file mode 100644 index 0000000..ef720c9 --- /dev/null +++ b/database/migrations/0001_auth_sessions.sql @@ -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"); \ No newline at end of file diff --git a/database/migrations/0002_session_csrf_tokens.sql b/database/migrations/0002_session_csrf_tokens.sql new file mode 100644 index 0000000..fae7ff2 --- /dev/null +++ b/database/migrations/0002_session_csrf_tokens.sql @@ -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; \ No newline at end of file diff --git a/database/migrations/0003_security_audit_events.sql b/database/migrations/0003_security_audit_events.sql new file mode 100644 index 0000000..309e07f --- /dev/null +++ b/database/migrations/0003_security_audit_events.sql @@ -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"); \ No newline at end of file diff --git a/database/migrations/0004_password_reset_tokens.sql b/database/migrations/0004_password_reset_tokens.sql new file mode 100644 index 0000000..c99788a --- /dev/null +++ b/database/migrations/0004_password_reset_tokens.sql @@ -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"); \ No newline at end of file diff --git a/database/migrations/0005_mfa_methods.sql b/database/migrations/0005_mfa_methods.sql new file mode 100644 index 0000000..722f12f --- /dev/null +++ b/database/migrations/0005_mfa_methods.sql @@ -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"); \ No newline at end of file diff --git a/database/migrations/0006_listings.sql b/database/migrations/0006_listings.sql new file mode 100644 index 0000000..23f4e15 --- /dev/null +++ b/database/migrations/0006_listings.sql @@ -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"); diff --git a/database/migrations/0007_listing_image_count.sql b/database/migrations/0007_listing_image_count.sql new file mode 100644 index 0000000..4f29b32 --- /dev/null +++ b/database/migrations/0007_listing_image_count.sql @@ -0,0 +1 @@ +ALTER TABLE "listings" ADD COLUMN "image_count" integer NOT NULL DEFAULT 1; \ No newline at end of file diff --git a/database/migrations/0008_inventory_images.sql b/database/migrations/0008_inventory_images.sql new file mode 100644 index 0000000..b9e57d9 --- /dev/null +++ b/database/migrations/0008_inventory_images.sql @@ -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"); \ No newline at end of file diff --git a/database/migrations/meta/_journal.json b/database/migrations/meta/_journal.json new file mode 100644 index 0000000..635323b --- /dev/null +++ b/database/migrations/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/database/seeds/.gitkeep b/database/seeds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md new file mode 100644 index 0000000..afe119c --- /dev/null +++ b/docs/ACCESSIBILITY.md @@ -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. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..ed9a156 --- /dev/null +++ b/docs/API.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..86db49d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md new file mode 100644 index 0000000..596d51e --- /dev/null +++ b/docs/DATA-MODEL.md @@ -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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..47770e6 --- /dev/null +++ b/docs/DECISIONS.md @@ -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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..fd90190 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,58 @@ +# Deployment + +## Environments + +- development +- test/CI +- production + +## Docker Compose + +Baseline services: +- frontend +- API +- worker +- PostgreSQL +- Redis +- reverse proxy + +Persistent data: +- PostgreSQL data +- Redis data where needed +- image storage + +## Reverse proxy + +Responsible for: +- HTTPS/TLS +- routing +- compression +- security headers +- rate limiting where appropriate + +## Configuration + +Environment variables/secrets: +- database connection +- Redis connection +- authentication/session secrets +- provider credentials +- email credentials +- storage credentials + +Application settings such as pricing strategy and site settings belong in the database. + +## Backups + +Back up: +- PostgreSQL +- image storage +- required environment/configuration information + +Retention policy and restore procedures must be documented for production. + +Disaster recovery should allow reconstruction elsewhere from database backup, image backup, and deployment/environment configuration. + +## Future hosting + +Initial deployment targets a home server/NAS. The architecture should allow later movement to an online server or managed infrastructure without redesigning the domain model. diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..c1a1923 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,43 @@ +# Design System Direction + +## Principles + +- Minimal +- Clean +- Information-dense without clutter +- Accessible +- Responsive +- Consistent +- Component-driven + +## Component architecture + +Build atomic/reusable Vue components. + +Examples: +- SearchBar +- ProductCard +- InventoryGroup +- InventoryCopyRow +- PriceDisplay +- ConditionBadge +- StatusBadge +- ImageGallery +- FilterPanel +- SortControl +- Modal/Dialog +- FormField +- ValidationMessage +- DataTable +- EmptyState +- LoadingState +- ErrorState +- ActivityTimeline + +Components should have clear responsibilities and avoid embedding domain business rules. + +## Visual decisions + +Exact color palette, typography, spacing tokens, and final visual styling can evolve during implementation, but must preserve accessibility and the documented information hierarchy. + +No color-only indication of state or condition. diff --git a/docs/IMAGES.md b/docs/IMAGES.md new file mode 100644 index 0000000..8928311 --- /dev/null +++ b/docs/IMAGES.md @@ -0,0 +1,95 @@ +# Image Management + +## Requirements + +Multiple images are core functionality. + +Use cases include: +- card front/back +- corners +- edges/whitening +- holographic details +- merchandise angles +- defects/details + +## Workflow + +`Upload → Validate → Store Original → Queue Processing → Generate Derivatives → Complete` + +Support: +- multiple-file upload +- drag/drop +- preview +- reorder +- primary image +- delete +- add images later + +Image count is configurable. + +## Storage + +Images are not stored in PostgreSQL. + +Use an `ImageStorage` abstraction. + +Initial: +- Docker-mounted local storage + +Future: +- S3-compatible object storage + +The API uses an `ImageStorage` contract with `put`, `get`, and `delete` +operations. The current local adapter rejects absolute and traversal paths and +does not overwrite an existing key. It stores bytes outside PostgreSQL; image +metadata remains in `inventory_images`. + +## Processing + +Validate: +- MIME type +- file signature where practical +- size +- image dimensions +- configured image count + +Generate: +- thumbnail +- medium +- large/responsive derivatives + +Modern formats such as WebP/AVIF may be used where appropriate. + +Retain originals where practical. + +## Metadata + +Store: +- storage key +- original filename +- MIME type +- file size +- dimensions +- alt text +- order +- primary flag +- processing status +- timestamps + +## Alt text + +Generate an initial suggestion automatically, but require the seller to be able to review/edit it. + +Do not invent details not visible or supplied by the seller. + +## Accessibility + +Public galleries must support: +- keyboard operation +- accessible controls +- visible focus +- meaningful names +- focus management +- screen-reader-friendly state + +Primary images power cards, search results, homepage previews, and social/share metadata. diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md new file mode 100644 index 0000000..396a88b --- /dev/null +++ b/docs/IMPLEMENTATION-STATUS.md @@ -0,0 +1,156 @@ +# Implementation Status + +Last updated: 2026-09-08 + +## Resume Here + +Before changing code, read this document, `AGENTS.md`, and the linked source-of-truth documentation. Then confirm the current baseline with: + +```sh +npm test +``` + +For persistence or Redis-backed changes, also run the isolated integration suite after setting `TEST_DATABASE_URL` to the `*_test` database and `TEST_REDIS_URL` to the test Redis instance: + +```sh +TEST_DATABASE_URL='postgresql://...' TEST_REDIS_URL='redis://...' npm run test:integration +``` + +The current local container runtime is OrbStack. If Docker commands point at a stale host, run: + +```sh +unset DOCKER_HOST +docker context use orbstack +``` + +## Completed + +### Workspace And Database + +- Npm workspace with the API and shared contracts/validation packages. +- PostgreSQL Compose service on local port `5433`; API port `3000` is registered in `.local/project-ports.json`. +- Redis Compose service on local port `6380` supports distributed login throttling. +- Drizzle schema and migrations: + - `0000_core_inventory`: users, sellers, categories, condition profiles, conditions, products, and physical inventory items. + - `0001_auth_sessions`: hashed server-side sessions, expiry, revocation, IP address, and user agent. + - `0002_session_csrf_tokens`: session-bound hashed CSRF tokens. + - `0003_security_audit_events`: private append-only authentication security events. + - `0004_password_reset_tokens`: hashed, one-time password recovery tokens. + - `0005_mfa_methods`: protected TOTP method records and lifecycle state. + - `0006_listings`: listing records and lifecycle enum. + - `0007_listing_image_count`: persisted image prerequisite for publication. + - `0008_inventory_images`: item-owned image metadata and processing state. +- Isolated integration tests require a database name ending in `_test` and truncate only that database's tables. + - Local-only `npm run provision:admin` creates the first Admin User and linked Seller atomically. It requires `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD`, and `INITIAL_SELLER_NAME`, hashes the password with scrypt, and refuses once any User exists. + +### Domain And Persistence + +- Product identity requires name, category, and seller. +- Products receive seller-scoped, normalized slugs; duplicate names receive a numeric suffix. +- Each Inventory Item is a separate physical copy. +- Inventory condition must belong to the Product category. +- Inventory lifecycle supports `ACTIVE`, `SOLD`, and `ARCHIVED`; sold and archived items are locked against ordinary edits. +- Repositories for products, inventory items, conditions, users, and sessions use Drizzle/PostgreSQL. +- Listing persistence supports draft creation, seller-scoped lookup, status updates, and inventory-item history. + +### API And Authentication + +- Fastify app bootstrap and `GET /health` response. +- Admin Product endpoints: + - `GET /api/v1/admin/products` + - `POST /api/v1/admin/products` +- Admin Inventory endpoint: + - `POST /api/v1/admin/inventory` +- Authentication endpoints: + - `POST /api/v1/auth/login` + - `POST /api/v1/auth/logout` + - `GET /api/v1/auth/me` +- Login uses asynchronous scrypt password verification. Session tokens are random, stored only as SHA-256 hashes, and never returned in JSON. +- Session cookie settings are `HttpOnly`, `SameSite=Strict`, path `/`, and `Secure` in production. +- Login is limited to five attempts per client IP every 15 minutes and returns `429 AUTH_RATE_LIMITED` with `Retry-After` once exhausted. Redis atomically coordinates the limit across API processes. +- Login sets a separate same-site CSRF cookie. Authenticated state-changing routes require its matching `x-csrf-token` header, validated against the session-bound hash. +- `GET /api/v1/auth/sessions` returns safe metadata for the current user's active sessions and marks the current session. `DELETE /api/v1/auth/sessions/:id` revokes an owned session; `DELETE /api/v1/auth/sessions` revokes all other sessions while retaining the current one. Revoking the current session clears both cookies. +- Active sessions renew `last_activity_at` after five minutes, avoiding a database write on every authenticated request. +- Authentication events are retained separately from inventory activity. `LOGIN_SUCCESS`, `LOGIN_FAILURE`, `SESSION_CREATED`, and `SESSION_REVOKED` include the known actor where available, plus safe request context. Passwords and session/CSRF tokens or hashes are never recorded. +- Password recovery uses generic `POST /api/v1/auth/forgot-password` responses to prevent account enumeration. Opaque tokens are delivered only through the SMTP adapter, stored only as hashes, expire after one hour, are consumed once, and revoke existing sessions after a password reset. +- API startup requires `SMTP_URL`, `SMTP_FROM`, and `PASSWORD_RESET_URL`; configure these local values before starting the server. +- Existing admin routes validate the authenticated session and derive seller ownership server-side. +- Request schemas live in `packages/validation`; admin response DTOs live in `packages/contracts`. +- MFA enrollment, verification, disable, protected secret handling, login verification, and audit events are implemented. +- Admin listing creation and lifecycle routes are implemented: + - `POST /api/v1/admin/listings` + - `GET /api/v1/admin/listings` + - `GET /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` +- Listing publication is seller-scoped, CSRF-protected, and gated by persisted identity, price, physical inventory, condition, and image prerequisites. +- Listing publication rejects physical inventory in `SOLD` or `ARCHIVED` status with a conflict response. +- Listing reads are seller-scoped and return only admin listing DTO fields. +- Item-owned image metadata can be added through a seller-scoped, CSRF-protected admin route; publication uses the persisted image count. +- `ImageStorage` and a traversal-safe local filesystem adapter are implemented; upload transport, signature validation, and derivative processing remain. + +### Verified Baseline + +| Suite | Current coverage | +| --- | --- | +| Domain | Product requirements and inventory lifecycle rules | +| Application | Product slug allocation, condition compatibility, password verification/recovery, session issuance, activity renewal, management, and audit events | +| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | +| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | + +Latest result: `npm test` passed 62 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. + +## Next Milestone + +Complete the inventory/listing integrity boundary before starting pricing and acquisition. + +1. Add upload transport, MIME/signature/size validation, and processing/derivative lifecycle for persisted image metadata. +2. Remove the temporary listing image-count snapshot after storage-backed image counts are fully established. +3. Begin acquisition and pricing foundations after the listing/image boundary is complete. + +Acceptance criteria: listing reads and lifecycle writes preserve seller ownership, CSRF, inventory status, and publication prerequisites. + +## Overall Roadmap + +Work through one bounded vertical slice at a time. The linked documentation remains authoritative when this roadmap is less detailed. + +1. Authentication hardening. +2. Listings and image prerequisites: keep Listing separate from Inventory Item; enforce publish requirements from [REQUIREMENTS.md](REQUIREMENTS.md). +3. Acquisition, pricing provider mappings, observations, valuations, and private/public DTO boundaries from [DATA-MODEL.md](DATA-MODEL.md) and [PRICING.md](PRICING.md). +4. Sales workflow, sold-item locking, corrections, cost basis, and audit history. +5. SearchService, public catalog/search endpoints, filtering, pagination, and public product URLs. +6. Image storage abstraction, upload validation, processing worker, responsive variants, and alt-text editing. +7. Vue admin workflows and public catalog, following [DESIGN.md](DESIGN.md), [UX.md](UX.md), and [ACCESSIBILITY.md](ACCESSIBILITY.md). +8. Dashboard, bulk operations, exports, settings, workers, and provider adapters. +9. CI, Docker deployment completion, backups, end-to-end/accessibility coverage, and operational documentation. + +## Known Gaps + +- No initial User/Seller is provisioned in the development database yet. Run `npm run provision:admin` after setting its required local `.env` variables. +- Listings are partially implemented: draft creation, seller-scoped reads, publication gating, lifecycle writes, archive behavior, sold/archived inventory checks, and item-owned image metadata are complete; storage-backed processing and removal of the legacy listing snapshot remain. +- Sales, acquisitions, pricing, image processing, search, worker jobs, web UI, CI, and deployment services remain unimplemented. +- `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes. + +## Local Commands + +```sh +npm install +npm run db:up +npm run db:migrate +npm run provision:admin +npm test +``` + +The Compose database requires a local `.env` created from `.env.example`. To prepare the test database once after bringing PostgreSQL up: + +```sh +docker compose exec -T postgres sh -c 'createdb -U "$POSTGRES_USER" pokemon_inventory_test' +``` + +Then migrate it using its `TEST_DATABASE_URL` value as `DATABASE_URL`, and run integration tests with both `TEST_DATABASE_URL` and `TEST_REDIS_URL` set. See [README.md](../README.md) for the environment-file prerequisites. + +## Suggested Resume Prompt + +`Read docs/IMPLEMENTATION-STATUS.md and continue the next bounded milestone. Verify the existing baseline first, follow AGENTS.md, and keep the change focused with tests.` \ No newline at end of file diff --git a/docs/PRICING.md b/docs/PRICING.md new file mode 100644 index 0000000..294ba6b --- /dev/null +++ b/docs/PRICING.md @@ -0,0 +1,126 @@ +# Pricing Architecture + +## Goals + +Provide a defensible calculated market value while retaining provider provenance and uncertainty. + +## Provider abstraction + +All providers implement a common conceptual interface: + +`PricingProvider` + +A provider returns normalized observations containing: +- provider/source +- Product or Inventory Item context +- price +- currency +- condition +- timestamp +- source URL/identifier +- quality/confidence information + +Provider adapters own provider-specific API behavior and normalization. + +Use officially supported APIs or permitted sources. Do not invent APIs or bypass restrictions. + +## Provider Product Mapping + +Mapping occurs during new Product creation. + +Workflow: + +`Create Product → Search Providers → Candidate Matches → Admin Confirms Mapping` + +The admin can also search/refine manually. + +Mappings are persistent Product-level records and are reused for future physical copies. + +Provider mapping is not required. + +A provider can be: +- linked to a canonical product +- linked to a search/query +- marked Not Applicable +- left unlinked + +### Mapping types + +`PRODUCT` +- specific provider/catalog entity + +`SEARCH` +- provider search/query definition + +eBay supports both. + +## Pricing pipeline + +```text +Raw Provider Data + ↓ +Normalized Observations + ↓ +Quality / Confidence Analysis + ↓ +Valuation Strategy + ↓ +Calculated Market Value + ↓ +Optional Manual Override + ↓ +Display Currency Conversion +``` + +## Valuation + +Default strategy: median. + +Supported strategies should include: +- average +- median +- weighted average + +The strategy is configurable. + +Outliers remain in the underlying observations and are flagged with a reason. The valuation engine may account for outlier status according to the selected strategy, but raw observations are not silently discarded. + +One valid source can produce a market value with low confidence. + +No reliable condition-specific value means unavailable; never fabricate a value. + +## Refresh + +Refresh: +- one Inventory Item +- Product-level copies +- bulk selected inventory + +Bulk refresh runs through BullMQ in the worker. + +Provider failure should not invalidate other valid observations. The failed provider is recorded for admin diagnostics. + +## Overrides + +Admin can manually override market value. + +Override requires: +- value +- currency +- private reason +- actor +- timestamp + +Overrides are never exposed publicly as provider-level information. + +## Currency + +Canonical stored/calculated market values use USD. + +Visitor currency conversion occurs at presentation time using cached exchange rates. + +The original USD value remains authoritative. + +## Staleness + +Pricing freshness is configurable. Dashboard and admin inventory can identify stale values. The exact default threshold is a configuration decision during implementation, not a hard-coded domain invariant. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..a0db14d --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,36 @@ +# Product Definition + +## Purpose + +Provide a personal catalog for Pokémon products the seller owns, allowing the seller to maintain inventory, understand market pricing, publish asking prices, and record completed external sales. + +## Primary users + +### Public visitor +Can search and browse available inventory, inspect item details, view calculated market value and asking price, and contact the seller. + +### Admin/seller +Can manage products, physical inventory, listings, images, pricing, acquisitions, sales, settings, and history. + +MVP has one seller/admin. The model is multi-seller ready. + +## Non-goals + +The application does not provide: +- shopping cart +- checkout +- payment processing +- shipping fulfillment +- customer accounts +- order management +- in-app e-commerce transactions + +## Product principles + +1. Inventory accuracy over convenience. +2. Preserve history. +3. Keep private seller data private. +4. Automate repetitive pricing work without hiding its provenance. +5. Allow unusual products that have no provider match. +6. Prefer reusable domain services over duplicated behavior. +7. Accessibility is a product requirement, not a later enhancement. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md new file mode 100644 index 0000000..21a8b24 --- /dev/null +++ b/docs/REQUIREMENTS.md @@ -0,0 +1,176 @@ +# Functional Requirements + +## Public + +### Homepage +- Minimal, search-first landing page. +- Search updates as the visitor types. +- Recently added available items appear below search. +- No-match state: `No relevant matches found.` + +### Search +Search may include product name, Pokémon name, set, card number, category, description, event, year, brand, and tags. + +Public search must use the same underlying search abstraction as admin search while respecting public visibility. + +### Inventory +Filters include status, newly added, category, set, condition, price range, and event. Available filter values are derived from current data where appropriate. + +Sorting includes: +- Recently Added +- Recently Updated +- Name A–Z +- Name Z–A +- Price Low→High +- Price High→Low +- Market Value Low→High +- Market Value High→Low + +Sold is hidden by default but can be included through an explicit public filter/search. + +Pending listings are public and clearly marked. + +### Item details +Show images, title, product details, condition, asking price, market value where available, market update time, seller description, and configured seller contact methods. + +Public pages have stable, shareable URLs. + +## Inventory creation + +Guided flow: + +`Find/Create Product → Link Market Sources → Inventory Details → Images → Pricing → Review → List` + +Required for an item to become Available: +- Product name +- Category +- Asking price +- Images +- Physical inventory record +- Condition + +Provider matching is not mandatory. + +### Existing Product +Admin can add another physical copy, reuse prior item data as a starting point, or create a new physical record after a previous copy sold. + +### Provider matching +When creating a new Product, the system searches applicable configured providers and presents candidate matches. Admin can refine/search manually and confirm a mapping. + +Mappings belong to Products and are reused by physical copies. + +A provider may be marked Not Applicable. A Product may exist without any provider mapping. + +## Inventory lifecycle + +Inventory status: +- `ACTIVE` +- `SOLD` +- `ARCHIVED` + +Listing status: +- `DRAFT` +- `AVAILABLE` +- `PENDING` +- `CLOSED` +- `ARCHIVED` + +Owned items may have no active listing and may be designated for sale, personal collection, display, or other purposes. + +Pending and sold items are locked against ordinary edits. Controlled corrections remain possible with audit history. + +Relisting creates a new physical Inventory Item rather than reopening the sold physical record. + +## Conditions + +Card profile: +- NM +- LP +- MP +- HP +- Damaged + +Merchandise profile: +- New +- Like New +- Used +- Damaged + +Profiles and labels are configurable. + +## Pricing + +- Multiple providers supported. +- Median is the default valuation strategy. +- Outliers are retained and flagged. +- One valid source may produce a low-confidence value. +- Manual market-value overrides require a private reason. +- Provider errors are retained for admin visibility. +- Public sees only calculated/overridden market value, not source-level observations. +- Canonical currency is USD. +- Condition-specific values are used only when reliable data exists. +- No fabricated values. +- Pricing can refresh by item, Product, or bulk. +- Bulk refresh is asynchronous. + +## Images + +- Multiple images per physical item. +- Multiple-file upload and drag/drop. +- Reorder, primary selection, deletion, and later additions. +- Configurable image count limit. +- Automatic validation and optimization. +- Original retained where practical. +- Responsive derivatives generated. +- Seller can edit generated alt-text suggestions. + +## Acquisition + +Private acquisition information includes cost, date, source, location, shipping, tax, fees, trade value, and notes. + +Total cost basis is calculated from component costs. + +## Sales + +Record Sale requires sold price and supports date, location/platform, fees, shipping, taxes, buyer information, and notes. + +Multiple inventory items may belong to one sale. + +Sale correction/undo is controlled and audited. + +## Bulk operations + +Supported: +- status change +- archive +- pricing refresh +- tags + +Mark Sold uses the dedicated sale workflow and cannot bypass sold-price validation. + +## Dashboard + +Dashboard should meaningfully present: +- inventory count +- available listings +- sold count +- total acquisition cost +- total asking value +- total market value +- potential profit +- recently added items +- stale pricing +- missing images +- missing pricing +- provider errors +- recent activity + +Actionable alerts link to filtered management views. + +## Exports + +Admin-only CSV, Excel, and JSON exports. Large exports may run asynchronously. + +## Settings + +General, inventory, pricing, and security settings are configurable as described in the supporting documentation. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..f9659d1 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,95 @@ +# Security + +## Authentication + +MVP: +- email/password +- MFA using TOTP or passkey-capable architecture +- password recovery +- remember-me option +- configurable session expiry +- active session/device management +- revoke one or all sessions + +Use secure server-side sessions with: +- HttpOnly +- Secure in production +- appropriate SameSite policy + +Never store auth tokens in localStorage. + +## Authorization + +MVP has Admin role. + +Design authorization so future roles can be added without rewriting domain logic. + +All authorization is enforced server-side. + +## CSRF + +Implement an appropriate CSRF defense for cookie-based authentication. + +## Input and API security + +- Validate all inputs. +- Parameterize database queries through Drizzle. +- Allowlist sort fields. +- Apply pagination. +- Rate-limit sensitive/auth endpoints. +- Use security headers. +- Do not leak private fields through serialization. + +## Upload security + +- Validate MIME type and file signatures where practical. +- Limit size and dimensions. +- Limit image count. +- Process in controlled jobs. +- Treat filenames as untrusted. +- Do not execute uploaded content. + +## Sessions + +Suggested fields: +`id, user_id, session_token_hash, created_at, expires_at, last_activity_at, ip_address, user_agent, revoked_at` + +## MFA + +Suggested fields: +`id, user_id, type, secret_reference, name, is_enabled, created_at, last_used_at` + +Secrets must be stored using appropriate secret protection; do not expose raw MFA secrets in logs. + +## Security audit log + +Separate from inventory activity. + +Events include: +- LOGIN_SUCCESS +- LOGIN_FAILURE +- PASSWORD_RESET +- MFA_ENROLLED +- MFA_FAILURE +- SESSION_CREATED +- SESSION_REVOKED +- ACCOUNT_UPDATED + +## Secrets + +Credentials, API keys, database URLs, and encryption secrets belong in environment/secret management, never in source control. + +## Privacy boundary + +Private: +- acquisition data +- sale data +- buyer information +- profit +- internal notes +- inventory IDs +- provider observations and errors +- pricing methodology/confidence/history +- activity/audit logs + +Public data must be exposed through purpose-built DTOs. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..4060d0f --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,61 @@ +# Testing Strategy + +Testing begins with the first implementation. + +## Unit + +Test domain rules and pure services: +- status transitions +- listing requirements +- cost basis +- profit +- valuation strategies +- outlier handling +- condition rules + +## API / integration + +Test: +- authentication +- authorization +- repositories +- API validation +- Product/provider mapping +- inventory lifecycle +- sales transactions +- public/private DTO boundaries + +## Component + +Test important Vue components and composables. + +## E2E + +Core workflows: +1. Login +2. Add Product +3. Find/link provider mappings +4. Add Inventory Item +5. Upload images +6. Calculate pricing +7. Publish listing +8. Search public site +9. Record sale +10. View dashboard + +## Accessibility + +Run automated accessibility checks and maintain manual keyboard/assistive technology coverage for critical workflows. + +## CI + +GitHub Actions should run: +- linting +- unit tests +- integration/API tests +- component tests +- E2E tests as appropriate +- accessibility checks +- build validation + +Do not weaken or skip tests to obtain a passing build. diff --git a/docs/UX.md b/docs/UX.md new file mode 100644 index 0000000..9bb95a0 --- /dev/null +++ b/docs/UX.md @@ -0,0 +1,78 @@ +# UX Requirements + +## Design direction + +Clean, minimal, catalog-focused, and responsive. + +The homepage is intentionally search-first and visually similar in simplicity to a search engine landing page. + +## Public information hierarchy + +For cards: +1. Primary image +2. Product/item name +3. Market value +4. Asking price + +The asking price is the primary seller decision value; market value provides context. + +## Product grouping + +Multiple physical copies of one Product are grouped together. + +Example: + +```text +Charizard ex #199/165 + Near Mint $115 asking $120 market + Lightly Played $95 asking $90 market + + 1 More Available +``` + +Copies are sorted by condition quality, best first. + +Selecting the group can reveal all copies. + +Do not create separate Product groups solely because condition differs. + +## Inventory management + +Admin uses search plus filters and saved views. + +Recommended saved views: +- Needs Attention +- Recently Added +- High Value +- Sold This Month + +Bulk selection should be available where safe. + +## Guided creation + +`Find/Create Product → Link Market Sources → Inventory Details → Images → Pricing → Review → List` + +The workflow should make it clear whether the user is editing the Product or the physical Inventory Item. + +## Locked states + +Pending and Sold items are visibly locked against ordinary edits. Controlled actions such as correcting a sale remain available through explicit workflows. + +## Public sold items + +Sold items show Sold status and no asking price. + +## Responsive behavior + +Support desktop, tablet, and mobile. Layouts should adapt rather than simply shrink desktop controls. + +## Feedback + +Dynamic actions must provide accessible status feedback, including: +- loading +- success +- validation error +- provider failure +- image processing +- background job progress + +Avoid relying on color alone. diff --git a/drizzle.config.js b/drizzle.config.js new file mode 100644 index 0000000..14eac95 --- /dev/null +++ b/drizzle.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'drizzle-kit'; +import 'dotenv/config'; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error('DATABASE_URL is required to manage database migrations.'); +} + +export default defineConfig({ + dialect: 'postgresql', + schema: './apps/api/src/infrastructure/database/schema.js', + out: './database/migrations', + dbCredentials: { + url: databaseUrl, + }, +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ac4620f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2660 @@ +{ + "name": "pokemon-inventory", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pokemon-inventory", + "workspaces": [ + "apps/*", + "packages/*" + ], + "devDependencies": { + "dotenv": "^16.6.1", + "drizzle-kit": "^0.31.8" + } + }, + "apps/api": { + "name": "@pokemon-inventory/api", + "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" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.6.tgz", + "integrity": "sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@pokemon-inventory/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/@pokemon-inventory/contracts": { + "resolved": "packages/contracts", + "link": true + }, + "node_modules/@pokemon-inventory/validation": { + "resolved": "packages/validation", + "link": true + }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.44.7", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.7.tgz", + "integrity": "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.3", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz", + "integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", + "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/otpauth": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.2.tgz", + "integrity": "sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "funding": { + "url": "https://github.com/hectorm/otpauth?sponsor=1" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/contracts": { + "name": "@pokemon-inventory/contracts" + }, + "packages/validation": { + "name": "@pokemon-inventory/validation", + "dependencies": { + "zod": "^4.1.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7185f9e --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "pokemon-inventory", + "private": true, + "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "db:down": "docker compose down", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:up": "docker compose up -d postgres redis", + "dev:api": "npm run start --workspace=@pokemon-inventory/api", + "provision:admin": "npm run provision:admin --workspace=@pokemon-inventory/api", + "test:application": "npm run test:application --workspace=@pokemon-inventory/api", + "test:domain": "npm run test:domain --workspace=@pokemon-inventory/api", + "test:http": "npm run test:http --workspace=@pokemon-inventory/api", + "test:integration": "npm run test:integration --workspace=@pokemon-inventory/api", + "test": "npm run test:domain && npm run test:application && npm run test:http" + }, + "devDependencies": { + "dotenv": "^16.6.1", + "drizzle-kit": "^0.31.8" + } +} \ No newline at end of file diff --git a/packages/contracts/.gitkeep b/packages/contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/contracts/auth.js b/packages/contracts/auth.js new file mode 100644 index 0000000..e40d4e8 --- /dev/null +++ b/packages/contracts/auth.js @@ -0,0 +1,23 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toSessionDto(session, currentSessionId) { + return { + id: session.id, + createdAt: toIsoString(session.createdAt), + expiresAt: toIsoString(session.expiresAt), + lastActivityAt: toIsoString(session.lastActivityAt), + ipAddress: session.ipAddress, + userAgent: session.userAgent, + isCurrent: session.id === currentSessionId, + }; +} + +export function toMfaMethodDto(method) { + return { + id: method.id, + name: method.name, + type: method.type, + }; +} \ No newline at end of file diff --git a/packages/contracts/image.js b/packages/contracts/image.js new file mode 100644 index 0000000..e5983ab --- /dev/null +++ b/packages/contracts/image.js @@ -0,0 +1,22 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toAdminInventoryImageDto(image) { + return { + id: image.id, + inventoryItemId: image.inventoryItemId, + storageKey: image.storageKey, + originalFilename: image.originalFilename, + mimeType: image.mimeType, + fileSize: image.fileSize, + width: image.width, + height: image.height, + altText: image.altText ?? null, + sortOrder: image.sortOrder, + isPrimary: image.isPrimary, + processingStatus: image.processingStatus, + createdAt: toIsoString(image.createdAt), + updatedAt: toIsoString(image.updatedAt), + }; +} \ No newline at end of file diff --git a/packages/contracts/inventory.js b/packages/contracts/inventory.js new file mode 100644 index 0000000..49cb4c0 --- /dev/null +++ b/packages/contracts/inventory.js @@ -0,0 +1,15 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toAdminInventoryItemDto(inventoryItem) { + return { + id: inventoryItem.id, + productId: inventoryItem.productId, + conditionId: inventoryItem.conditionId, + status: inventoryItem.status, + notes: inventoryItem.notes ?? null, + createdAt: toIsoString(inventoryItem.createdAt), + updatedAt: toIsoString(inventoryItem.updatedAt), + }; +} \ No newline at end of file diff --git a/packages/contracts/listing.js b/packages/contracts/listing.js new file mode 100644 index 0000000..ee63050 --- /dev/null +++ b/packages/contracts/listing.js @@ -0,0 +1,19 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toAdminListingDto(listing) { + return { + id: listing.id, + inventoryItemId: listing.inventoryItemId, + productName: listing.productName, + categoryId: listing.categoryId, + conditionId: listing.conditionId, + askingPriceMinor: listing.askingPriceMinor, + imageCount: listing.imageCount, + status: listing.status, + notes: listing.notes ?? null, + createdAt: toIsoString(listing.createdAt), + updatedAt: toIsoString(listing.updatedAt), + }; +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..42a7245 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,12 @@ +{ + "name": "@pokemon-inventory/contracts", + "private": true, + "type": "module", + "exports": { + "./auth": "./auth.js", + "./inventory": "./inventory.js", + "./image": "./image.js", + "./listing": "./listing.js", + "./product": "./product.js" + } +} \ No newline at end of file diff --git a/packages/contracts/product.js b/packages/contracts/product.js new file mode 100644 index 0000000..b4bfa6e --- /dev/null +++ b/packages/contracts/product.js @@ -0,0 +1,21 @@ +function toIsoString(value) { + return value instanceof Date ? value.toISOString() : value; +} + +export function toAdminProductDto(product) { + return { + id: product.id, + sellerId: product.sellerId, + categoryId: product.categoryId, + name: product.name, + slug: product.slug, + description: product.description ?? null, + brand: product.brand ?? null, + setName: product.setName ?? null, + cardNumber: product.cardNumber ?? null, + eventName: product.eventName ?? null, + releaseYear: product.releaseYear ?? null, + createdAt: toIsoString(product.createdAt), + updatedAt: toIsoString(product.updatedAt), + }; +} \ No newline at end of file diff --git a/packages/utilities/.gitkeep b/packages/utilities/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/validation/.gitkeep b/packages/validation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/validation/auth.js b/packages/validation/auth.js new file mode 100644 index 0000000..58b22f9 --- /dev/null +++ b/packages/validation/auth.js @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const loginSchema = z + .object({ + email: z.string().trim().email().max(320), + password: z.string().min(1).max(1024), + mfaCode: z.string().regex(/^\d{6}$/).optional(), + rememberMe: z.boolean().optional(), + }) + .strict(); + +export const sessionIdSchema = z.uuid(); + +export const requestPasswordResetSchema = z + .object({ email: z.string().trim().email().max(320) }) + .strict(); + +export const resetPasswordSchema = z + .object({ + token: z.string().min(32).max(512), + password: z.string().min(12).max(1024), + }) + .strict(); + +export const enrollMfaSchema = z + .object({ name: z.string().trim().min(1).max(100) }) + .strict(); + +export const verifyMfaSchema = z + .object({ + methodId: z.uuid(), + code: z.string().regex(/^\d{6}$/), + }) + .strict(); \ No newline at end of file diff --git a/packages/validation/image.js b/packages/validation/image.js new file mode 100644 index 0000000..cbecde8 --- /dev/null +++ b/packages/validation/image.js @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const createInventoryImageSchema = z.object({ + storageKey: z.string().trim().min(1).max(500), + originalFilename: z.string().trim().min(1).max(255), + mimeType: z.enum(['image/jpeg', 'image/png', 'image/webp']), + fileSize: z.int().positive(), + width: z.int().positive(), + height: z.int().positive(), + altText: z.string().trim().max(1000).optional(), + sortOrder: z.int().nonnegative().optional(), + isPrimary: z.boolean().optional(), +}).strict(); \ No newline at end of file diff --git a/packages/validation/inventory.js b/packages/validation/inventory.js new file mode 100644 index 0000000..dddee49 --- /dev/null +++ b/packages/validation/inventory.js @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const createInventoryItemSchema = z + .object({ + productId: z.uuid(), + conditionId: z.uuid(), + notes: z.string().trim().max(10000).optional(), + }) + .strict(); \ No newline at end of file diff --git a/packages/validation/listing.js b/packages/validation/listing.js new file mode 100644 index 0000000..4aab809 --- /dev/null +++ b/packages/validation/listing.js @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const createListingSchema = z + .object({ + inventoryItemId: z.uuid(), + productName: z.string().trim().min(1).max(255), + categoryId: z.uuid(), + conditionId: z.uuid(), + askingPriceMinor: z.int().positive(), + imageCount: z.int().positive(), + notes: z.string().trim().max(10000).optional(), + }) + .strict(); diff --git a/packages/validation/package.json b/packages/validation/package.json new file mode 100644 index 0000000..d476922 --- /dev/null +++ b/packages/validation/package.json @@ -0,0 +1,15 @@ +{ + "name": "@pokemon-inventory/validation", + "private": true, + "type": "module", + "exports": { + "./auth": "./auth.js", + "./inventory": "./inventory.js", + "./image": "./image.js", + "./listing": "./listing.js", + "./product": "./product.js" + }, + "dependencies": { + "zod": "^4.1.8" + } +} \ No newline at end of file diff --git a/packages/validation/product.js b/packages/validation/product.js new file mode 100644 index 0000000..16f6120 --- /dev/null +++ b/packages/validation/product.js @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +const identifier = z.uuid(); + +export const createProductSchema = z + .object({ + name: z.string().trim().min(1).max(255), + categoryId: identifier, + }) + .strict(); \ No newline at end of file