diff --git a/.env.example b/.env.example index 62d5244..956c709 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,8 @@ CENTRIFUGO_TOKEN_HMAC_SECRET=dev-CHANGE-ME-centrifugo-hmac-not-for-prod CENTRIFUGO_API_KEY=dev-CHANGE-ME-centrifugo-api-key # Internal URL of the Centrifugo HTTP API (service name on the compose network). CENTRIFUGO_API_URL=http://centrifugo:8000/api +# Lifetime of the Centrifugo connection token the backend mints. +CENTRIFUGO_TOKEN_TTL=1h # --- Postgres --- POSTGRES_HOST=postgres diff --git a/README.md b/README.md index 709cce2..2053435 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,24 @@ pnpm --filter @altricade/web dev Endpoints (via the dev override): - Gateway (nginx): http://localhost:8080 +- **API docs (Swagger UI): http://localhost:8080/docs** (or http://localhost:4000/docs) - API health: http://localhost:8080/api/health → `{ "status": "ok" }` - API readiness: http://localhost:8080/api/ready → 200 only when Postgres + Redis + MinIO are reachable - Web client: http://localhost:5173 - MinIO console: http://localhost:9001 +### Testing the API with Swagger + +Open the Swagger UI, then: + +1. `POST /auth/register` (or `/auth/login`) and copy `tokens.accessToken` from the response. +2. Click **Authorize** (top right), paste the access token, and authorize. +3. Protected endpoints (`/me`, `/auth/sessions`, `/auth/centrifugo-token`, …) now work from "Try it out". + +> Dev note: the backend runs in Docker with `node_modules` baked into the image. +> After changing backend dependencies, recreate the container so it picks them up: +> `docker compose up -d --build --force-recreate backend`. + ## Quality gates (enforced mechanically — a violation fails the build) ```bash diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf index 77ef535..afc20b3 100644 --- a/infra/nginx/nginx.conf +++ b/infra/nginx/nginx.conf @@ -16,6 +16,8 @@ http { server { listen 80; server_name _; + # Emit path-only redirects so the externally-mapped port isn't dropped. + absolute_redirect off; # REST API — strip the /api prefix before proxying to the backend. location /api/ { @@ -26,6 +28,20 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # Swagger UI + OpenAPI spec — served by the backend at /docs (HTML, static + # assets and /docs/json all live under this prefix; pass through unmodified). + # Redirect the slashless form so the UI's relative asset paths resolve. + location = /docs { + return 301 /docs/; + } + location /docs { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Realtime WebSocket — Centrifugo (receive-only client socket). location /connection/websocket { proxy_pass http://centrifugo; diff --git a/package.json b/package.json index 5f784b4..bfd5d7c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "@eslint/js": "^10.0.1", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-boundaries": "^7.0.2", "globals": "^17.7.0", "prettier": "^3.9.5", "turbo": "^2.10.4", diff --git a/packages/backend/eslint.config.mjs b/packages/backend/eslint.config.mjs index eaab6fa..6fadc55 100644 --- a/packages/backend/eslint.config.mjs +++ b/packages/backend/eslint.config.mjs @@ -1,45 +1,34 @@ import { base } from '../../eslint.config.mjs'; -import boundaries from 'eslint-plugin-boundaries'; -// Backend architecture layering. The folder-based layers are classified and the -// import direction between them is enforced: -// routes → may import plugins, db -// plugins → may import db -// db → leaf (types only) -// (src/app.ts, src/server.ts, src/config.ts are the composition root + leaf -// config — unclassified in Phase 0.) The full per-domain module structure with -// route → service → repository layering is introduced in Phase 1. +// Backend architecture layering (route → service → repository → db), enforced +// with path-based no-restricted-imports. Boundaries' element model is folder- +// oriented and our feature modules are flat files, so we enforce the key edges +// directly and reliably: +// - routes may NOT import repositories or the db layer (must go via a service) +// - services may NOT import the db layer (must go via a repository) +// - only repositories (and the db plugin) touch src/db +const noDbLayer = { + group: ['**/db', '**/db/*'], + message: 'Only repositories may access the db layer — go through a repository.', +}; +const noRepository = { + group: ['**/*.repository'], + message: 'Routes must go through a service, not a repository directly.', +}; + export default [ { ignores: ['dist/**', 'migrations/**'] }, ...base, { - files: ['src/**/*.ts'], - plugins: { boundaries }, - settings: { - 'boundaries/include': ['src/**/*'], - 'boundaries/elements': [ - { type: 'plugins', pattern: 'src/plugins/*' }, - { type: 'routes', pattern: 'src/routes/*' }, - { type: 'db', pattern: 'src/db/*' }, - ], - }, + files: ['src/modules/*/*.routes.ts'], rules: { - 'boundaries/dependencies': [ - 'error', - { - default: 'disallow', - policies: [ - { - from: { element: { types: 'routes' } }, - allow: { to: { element: { types: { anyOf: ['plugins', 'db'] } } } }, - }, - { - from: { element: { types: 'plugins' } }, - allow: { to: { element: { types: 'db' } } }, - }, - ], - }, - ], + 'no-restricted-imports': ['error', { patterns: [noRepository, noDbLayer] }], + }, + }, + { + files: ['src/modules/*/*.service.ts'], + rules: { + 'no-restricted-imports': ['error', { patterns: [noDbLayer] }], }, }, ]; diff --git a/packages/backend/migrations/1720000000001_auth.cjs b/packages/backend/migrations/1720000000001_auth.cjs new file mode 100644 index 0000000..c338ff5 --- /dev/null +++ b/packages/backend/migrations/1720000000001_auth.cjs @@ -0,0 +1,49 @@ +// Phase 1 — auth schema: users + refresh_tokens (sessions). + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + // Case-insensitive text for the unique username (and non-unique email). + pgm.createExtension('citext', { ifNotExists: true }); + + pgm.createTable('users', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + username: { type: 'citext', notNull: true, unique: true }, + display_name: { type: 'text', notNull: true }, + // Email is optional and NOT unique (contact field only, not a login credential). + email: { type: 'citext' }, + phone: { type: 'text' }, + avatar_ref: { type: 'text' }, + password_hash: { type: 'text', notNull: true }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + last_seen_at: { type: 'timestamptz' }, + }); + + pgm.createTable('refresh_tokens', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') }, + user_id: { + type: 'uuid', + notNull: true, + references: 'users', + onDelete: 'CASCADE', + }, + // Rotation lineage: reuse of any token in a family revokes the whole family. + family_id: { type: 'uuid', notNull: true }, + token_hash: { type: 'text', notNull: true, unique: true }, + user_agent: { type: 'text' }, + ip: { type: 'text' }, + created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + last_used_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }, + expires_at: { type: 'timestamptz', notNull: true }, + revoked_at: { type: 'timestamptz' }, + }); + + pgm.createIndex('refresh_tokens', 'user_id'); + pgm.createIndex('refresh_tokens', 'family_id'); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.dropTable('refresh_tokens'); + pgm.dropTable('users'); +}; diff --git a/packages/backend/package.json b/packages/backend/package.json index 296c6f4..4b8b37d 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -15,9 +15,15 @@ "dependencies": { "@altricade/core": "workspace:^", "@fastify/cors": "^11.3.0", + "@fastify/rate-limit": "^11.1.0", + "@fastify/swagger": "^9.8.0", + "@fastify/swagger-ui": "^6.1.0", + "@node-rs/argon2": "^2.0.2", + "ajv-formats": "^3.0.1", "fastify": "^5.10.0", "fastify-plugin": "^6.0.0", "ioredis": "^5.11.1", + "jose": "^6.2.3", "kysely": "^0.29.3", "minio": "^8.0.7", "node-pg-migrate": "^8.0.4", diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index b81b872..d745518 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,14 +1,19 @@ import Fastify from 'fastify'; -import type { FastifyInstance } from 'fastify'; +import type { FastifyError, FastifyInstance } from 'fastify'; import cors from '@fastify/cors'; import { roomChannel } from '@altricade/core'; import { loadConfig } from './config'; import type { AppConfig } from './config'; +import { HttpError } from './shared/http-error'; import { dbPlugin } from './plugins/db'; import { redisPlugin } from './plugins/redis'; import { minioPlugin } from './plugins/minio'; import { centrifugoPlugin } from './plugins/centrifugo'; +import { rateLimitPlugin } from './plugins/rate-limit'; +import { swaggerPlugin } from './plugins/swagger'; import { healthRoutes } from './routes/health'; +import { createUsersRepository, createUsersService, usersRoutes } from './modules/users'; +import { createRefreshTokensRepository, createAuthService, authPlugin, authRoutes } from './modules/auth'; declare module 'fastify' { interface FastifyInstance { @@ -16,7 +21,7 @@ declare module 'fastify' { } } -// Composition root: assemble the Fastify app from config + plugins + routes. +// Composition root: assemble the Fastify app from config + plugins + modules. // Kept side-effect-free (no listen) so it can be reused by tests later. export const buildApp = async (config: AppConfig = loadConfig()): Promise => { const app = Fastify({ @@ -25,14 +30,69 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise { + const text = typeof body === 'string' ? body : body.toString('utf8'); + if (text.trim().length === 0) { + done(null, undefined); + return; + } + void defaultJsonParser(request, text, done); + }); + + app.setErrorHandler((error: FastifyError, request, reply) => { + if (error instanceof HttpError) { + return reply.code(error.statusCode).send({ error: error.code, message: error.message }); + } + if (error.validation !== undefined) { + return reply.code(400).send({ error: 'validation_error', message: error.message }); + } + if (error.statusCode !== undefined && error.statusCode < 500) { + return reply.code(error.statusCode).send({ error: 'request_error', message: error.message }); + } + request.log.error(error); + return reply.code(500).send({ error: 'internal_error', message: 'Internal server error' }); + }); + + // Infrastructure plugins. await app.register(cors, { origin: true, credentials: true }); await app.register(dbPlugin); await app.register(redisPlugin); await app.register(minioPlugin); await app.register(centrifugoPlugin); - await app.register(healthRoutes); + await app.register(rateLimitPlugin); + // Registered before routes so it can collect their schemas into the OpenAPI doc. + await app.register(swaggerPlugin); + + // Modules: build repositories + services now that `db` is available, decorate. + const usersRepository = createUsersRepository(app.db); + const refreshTokens = createRefreshTokensRepository(app.db); + app.decorate('usersService', createUsersService(usersRepository)); + app.decorate( + 'authService', + createAuthService({ + users: usersRepository, + tokens: refreshTokens, + config: { + accessSecret: config.auth.accessSecret, + accessTtlSeconds: config.auth.accessTtlSeconds, + refreshTtlSeconds: config.auth.refreshTtlSeconds, + centrifugoSecret: config.centrifugo.tokenHmacSecret, + centrifugoTtlSeconds: config.centrifugo.tokenTtlSeconds, + }, + }), + ); + + // Auth preHandler decorator must exist before routes that use it register. + await app.register(authPlugin); + + // Routes. + await app.register(healthRoutes); + await app.register(authRoutes, { prefix: '/auth' }); + await app.register(usersRoutes); - // Proves the @altricade/core shared kernel is imported and callable. app.log.info(`core wired — example channel: ${roomChannel('demo')}`); return app; diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c8b7b60..f74b204 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -18,6 +18,13 @@ export interface CentrifugoConfig { apiUrl: string; apiKey: string; tokenHmacSecret: string; + tokenTtlSeconds: number; +} + +export interface AuthConfig { + accessSecret: string; + accessTtlSeconds: number; + refreshTtlSeconds: number; } export interface AppConfig { @@ -27,6 +34,7 @@ export interface AppConfig { redisUrl: string; minio: MinioConfig; centrifugo: CentrifugoConfig; + auth: AuthConfig; } const required = (name: string): string => { @@ -55,6 +63,20 @@ const parsePort = (name: string, raw: string): number => { const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true'; +// Parse a duration like "900", "15s", "15m", "1h", "30d" into seconds. +const DURATION_UNITS: Record = { s: 1, m: 60, h: 3600, d: 86400 }; + +const parseDurationSeconds = (name: string, raw: string): number => { + const match = /^(\d+)([smhd])?$/.exec(raw); + if (match === null) { + throw new Error(`Environment variable ${name} is not a valid duration: ${raw}`); + } + const amount = Number.parseInt(match[1] ?? '', 10); + const unit = match[2]; + const multiplier = unit === undefined ? 1 : (DURATION_UNITS[unit] ?? 1); + return amount * multiplier; +}; + export const loadConfig = (): AppConfig => ({ nodeEnv: optional('NODE_ENV', 'development'), port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')), @@ -75,5 +97,11 @@ export const loadConfig = (): AppConfig => ({ apiUrl: required('CENTRIFUGO_API_URL'), apiKey: required('CENTRIFUGO_API_KEY'), tokenHmacSecret: required('CENTRIFUGO_TOKEN_HMAC_SECRET'), + tokenTtlSeconds: parseDurationSeconds('CENTRIFUGO_TOKEN_TTL', optional('CENTRIFUGO_TOKEN_TTL', '1h')), + }, + auth: { + accessSecret: required('JWT_ACCESS_SECRET'), + accessTtlSeconds: parseDurationSeconds('ACCESS_TOKEN_TTL', optional('ACCESS_TOKEN_TTL', '15m')), + refreshTtlSeconds: parseDurationSeconds('REFRESH_TOKEN_TTL', optional('REFRESH_TOKEN_TTL', '30d')), }, }); diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts index 6649188..4fe859a 100644 --- a/packages/backend/src/db/schema.ts +++ b/packages/backend/src/db/schema.ts @@ -1,4 +1,34 @@ -// The Kysely database registry: one property per table. Empty in Phase 0 — -// tables (users, refresh_tokens, rooms, messages, ...) are added here in lockstep -// with node-pg-migrate migrations from Phase 1 onward. -export type Database = Record; +import type { ColumnType, Generated } from 'kysely'; + +// Kysely database registry: one interface per table. Grows with each migration. + +export interface UsersTable { + id: Generated; + username: string; + display_name: string; + email: string | null; + phone: string | null; + avatar_ref: string | null; + password_hash: string; + created_at: Generated; + updated_at: ColumnType; + last_seen_at: Date | null; +} + +export interface RefreshTokensTable { + id: Generated; + user_id: string; + family_id: string; + token_hash: string; + user_agent: string | null; + ip: string | null; + created_at: Generated; + last_used_at: ColumnType; + expires_at: Date; + revoked_at: Date | null; +} + +export interface Database { + users: UsersTable; + refresh_tokens: RefreshTokensTable; +} diff --git a/packages/backend/src/modules/auth/auth.plugin.ts b/packages/backend/src/modules/auth/auth.plugin.ts new file mode 100644 index 0000000..4f2d2c4 --- /dev/null +++ b/packages/backend/src/modules/auth/auth.plugin.ts @@ -0,0 +1,49 @@ +import fp from 'fastify-plugin'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { verifyAccessToken } from './tokens'; + +export interface AuthUser { + id: string; + username: string; +} + +declare module 'fastify' { + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } + interface FastifyRequest { + authUser?: AuthUser; + } +} + +const BEARER_PREFIX = 'Bearer '; + +// Registers `app.authenticate`, a preHandler that verifies the Bearer access +// token and attaches `request.authUser`. Replies 401 on any failure. +export const authPlugin = fp( + (app) => { + const secret = app.config.auth.accessSecret; + + app.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => { + const header = request.headers.authorization; + if (header === undefined) { + await reply.code(401).send({ error: 'unauthorized' }); + return; + } + if (!header.startsWith(BEARER_PREFIX)) { + await reply.code(401).send({ error: 'unauthorized' }); + return; + } + const token = header.slice(BEARER_PREFIX.length); + try { + const claims = await verifyAccessToken(secret, token); + request.authUser = { id: claims.sub, username: claims.username }; + } catch { + await reply.code(401).send({ error: 'unauthorized' }); + } + }); + + return Promise.resolve(); + }, + { name: 'authenticate' }, +); diff --git a/packages/backend/src/modules/auth/auth.repository.ts b/packages/backend/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..0262cf6 --- /dev/null +++ b/packages/backend/src/modules/auth/auth.repository.ts @@ -0,0 +1,80 @@ +import type { Kysely, Selectable } from 'kysely'; +import type { Database, RefreshTokensTable } from '../../db/schema'; + +export type RefreshTokenRow = Selectable; + +export interface NewRefreshToken { + userId: string; + familyId: string; + tokenHash: string; + userAgent: string | null; + ip: string | null; + expiresAt: Date; +} + +export interface RefreshTokensRepository { + insert(input: NewRefreshToken): Promise; + findByHash(tokenHash: string): Promise; + revokeById(id: string): Promise; + revokeFamily(familyId: string): Promise; + revokeAllForUser(userId: string): Promise; + listActiveForUser(userId: string): Promise; +} + +export const createRefreshTokensRepository = ( + db: Kysely, +): RefreshTokensRepository => ({ + insert: (input) => + db + .insertInto('refresh_tokens') + .values({ + user_id: input.userId, + family_id: input.familyId, + token_hash: input.tokenHash, + user_agent: input.userAgent, + ip: input.ip, + expires_at: input.expiresAt, + }) + .returningAll() + .executeTakeFirstOrThrow(), + + findByHash: (tokenHash) => + db.selectFrom('refresh_tokens').selectAll().where('token_hash', '=', tokenHash).executeTakeFirst(), + + revokeById: async (id) => { + await db + .updateTable('refresh_tokens') + .set({ revoked_at: new Date() }) + .where('id', '=', id) + .where('revoked_at', 'is', null) + .execute(); + }, + + revokeFamily: async (familyId) => { + await db + .updateTable('refresh_tokens') + .set({ revoked_at: new Date() }) + .where('family_id', '=', familyId) + .where('revoked_at', 'is', null) + .execute(); + }, + + revokeAllForUser: async (userId) => { + await db + .updateTable('refresh_tokens') + .set({ revoked_at: new Date() }) + .where('user_id', '=', userId) + .where('revoked_at', 'is', null) + .execute(); + }, + + listActiveForUser: (userId) => + db + .selectFrom('refresh_tokens') + .selectAll() + .where('user_id', '=', userId) + .where('revoked_at', 'is', null) + .where('expires_at', '>', new Date()) + .orderBy('last_used_at', 'desc') + .execute(), +}); diff --git a/packages/backend/src/modules/auth/auth.routes.ts b/packages/backend/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..21a27e3 --- /dev/null +++ b/packages/backend/src/modules/auth/auth.routes.ts @@ -0,0 +1,154 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { + registerBodySchema, + loginBodySchema, + refreshBodySchema, + logoutBodySchema, + authResultSchema, + sessionListSchema, + centrifugoTokenSchema, + errorSchema, +} from '@altricade/core'; +import type { RegisterBody, LoginBody, RefreshBody, LogoutBody } from '@altricade/core'; +import type { RequestContext } from './auth.service'; + +const context = (request: FastifyRequest): RequestContext => ({ + userAgent: request.headers['user-agent'] ?? null, + ip: request.ip, +}); + +// Throttle credential endpoints to blunt stuffing / enumeration. +const authRateLimit = { rateLimit: { max: 10, timeWindow: '1 minute' } }; +const bearerAuth = [{ bearerAuth: [] }]; + +export const authRoutes = (app: FastifyInstance): Promise => { + app.post<{ Body: RegisterBody }>( + '/register', + { + schema: { + tags: ['auth'], + summary: 'Register a new account', + body: registerBodySchema, + response: { 201: authResultSchema }, + }, + config: authRateLimit, + }, + async (request, reply) => { + const result = await app.authService.register(request.body, context(request)); + return reply.code(201).send(result); + }, + ); + + app.post<{ Body: LoginBody }>( + '/login', + { + schema: { + tags: ['auth'], + summary: 'Log in with username + password', + body: loginBodySchema, + response: { 200: authResultSchema }, + }, + config: authRateLimit, + }, + async (request, reply) => { + const result = await app.authService.login(request.body, context(request)); + return reply.send(result); + }, + ); + + app.post<{ Body: RefreshBody }>( + '/refresh', + { + schema: { + tags: ['auth'], + summary: 'Rotate tokens (with reuse detection)', + body: refreshBodySchema, + response: { 200: authResultSchema }, + }, + config: authRateLimit, + }, + async (request, reply) => { + const result = await app.authService.refresh(request.body.refreshToken, context(request)); + return reply.send(result); + }, + ); + + app.post<{ Body: LogoutBody }>( + '/logout', + { + schema: { + tags: ['auth'], + summary: 'Revoke a single refresh token (this device)', + body: logoutBodySchema, + }, + }, + async (request, reply) => { + await app.authService.logout(request.body.refreshToken); + return reply.code(204).send(); + }, + ); + + app.post( + '/logout-all', + { + schema: { + tags: ['auth'], + summary: 'Revoke all sessions for the current user', + security: bearerAuth, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) { + return reply.code(401).send({ error: 'unauthorized' }); + } + await app.authService.logoutAll(user.id); + return reply.code(204).send(); + }, + ); + + app.get( + '/sessions', + { + schema: { + tags: ['auth'], + summary: 'List active sessions', + security: bearerAuth, + response: { 200: sessionListSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) { + return reply.code(401).send({ error: 'unauthorized' }); + } + const sessions = await app.authService.listSessions(user.id, undefined); + return reply.send(sessions); + }, + ); + + app.post( + '/centrifugo-token', + { + schema: { + tags: ['auth'], + summary: 'Mint a short-lived Centrifugo connection token', + security: bearerAuth, + response: { 200: centrifugoTokenSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) { + return reply.code(401).send({ error: 'unauthorized' }); + } + const token = await app.authService.centrifugoToken(user.id); + return reply.send(token); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..b0ef69b --- /dev/null +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -0,0 +1,189 @@ +import { randomUUID } from 'node:crypto'; +import type { + AuthResult, + AuthTokens, + CentrifugoToken, + RegisterBody, + LoginBody, + Session, +} from '@altricade/core'; +import { HttpError } from '../../shared/http-error'; +import { toUser } from '../users'; +import type { UsersRepository, UserRow } from '../users'; +import { hashPassword, verifyPassword } from './password'; +import { + generateRefreshToken, + hashRefreshToken, + signAccessToken, + signCentrifugoToken, +} from './tokens'; +import type { RefreshTokensRepository, RefreshTokenRow } from './auth.repository'; + +export interface AuthServiceConfig { + accessSecret: string; + accessTtlSeconds: number; + refreshTtlSeconds: number; + centrifugoSecret: string; + centrifugoTtlSeconds: number; +} + +export interface RequestContext { + userAgent: string | null; + ip: string | null; +} + +export interface AuthServiceDeps { + users: UsersRepository; + tokens: RefreshTokensRepository; + config: AuthServiceConfig; +} + +export interface AuthService { + register(body: RegisterBody, ctx: RequestContext): Promise; + login(body: LoginBody, ctx: RequestContext): Promise; + refresh(refreshToken: string, ctx: RequestContext): Promise; + logout(refreshToken: string): Promise; + logoutAll(userId: string): Promise; + listSessions(userId: string, currentRefreshToken: string | undefined): Promise; + centrifugoToken(userId: string): Promise; +} + +const isUniqueViolation = (error: unknown): boolean => { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return false; + } + return error.code === '23505'; +}; + +const toSession = (row: RefreshTokenRow, currentHash: string | undefined): Session => ({ + id: row.id, + userAgent: row.user_agent, + ip: row.ip, + createdAt: row.created_at.toISOString(), + lastUsedAt: row.last_used_at.toISOString(), + current: currentHash !== undefined && row.token_hash === currentHash, +}); + +export const createAuthService = (deps: AuthServiceDeps): AuthService => { + const { users, tokens, config } = deps; + + // Precomputed hash used to equalize login timing when a username does not + // exist (mitigates user enumeration via response time). + const dummyHash = hashPassword(randomUUID()); + + const issueTokens = async ( + user: UserRow, + ctx: RequestContext, + familyId: string, + ): Promise => { + const accessToken = await signAccessToken(config.accessSecret, config.accessTtlSeconds, { + sub: user.id, + username: user.username, + }); + const refreshToken = generateRefreshToken(); + const expiresAt = new Date(Date.now() + config.refreshTtlSeconds * 1000); + await tokens.insert({ + userId: user.id, + familyId, + tokenHash: hashRefreshToken(refreshToken), + userAgent: ctx.userAgent, + ip: ctx.ip, + expiresAt, + }); + return { accessToken, refreshToken, accessTokenExpiresIn: config.accessTtlSeconds }; + }; + + const asResult = async (user: UserRow, ctx: RequestContext, familyId: string): Promise => ({ + user: toUser(user), + tokens: await issueTokens(user, ctx, familyId), + }); + + return { + register: async (body, ctx) => { + const passwordHash = await hashPassword(body.password); + try { + const user = await users.create({ + username: body.username, + displayName: body.displayName, + passwordHash, + email: body.email ?? null, + phone: body.phone ?? null, + }); + return await asResult(user, ctx, randomUUID()); + } catch (error) { + if (isUniqueViolation(error)) { + throw new HttpError(409, 'username_taken', 'Username is already taken'); + } + throw error; + } + }, + + login: async (body, ctx) => { + const user = await users.findByUsername(body.username); + if (user === undefined) { + // Normalize timing against a dummy verify, then fail non-enumeratingly. + await verifyPassword(await dummyHash, body.password); + throw new HttpError(401, 'invalid_credentials', 'Invalid credentials'); + } + const ok = await verifyPassword(user.password_hash, body.password); + if (!ok) { + throw new HttpError(401, 'invalid_credentials', 'Invalid credentials'); + } + return asResult(user, ctx, randomUUID()); + }, + + refresh: async (refreshToken, ctx) => { + const row = await tokens.findByHash(hashRefreshToken(refreshToken)); + if (row === undefined) { + throw new HttpError(401, 'invalid_token', 'Invalid refresh token'); + } + if (row.revoked_at !== null) { + // Reuse of an already-rotated token → treat as theft: revoke the family. + await tokens.revokeFamily(row.family_id); + throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected'); + } + if (row.expires_at.getTime() <= Date.now()) { + throw new HttpError(401, 'invalid_token', 'Refresh token expired'); + } + const user = await users.findById(row.user_id); + if (user === undefined) { + throw new HttpError(401, 'invalid_token', 'Invalid refresh token'); + } + await tokens.revokeById(row.id); + return asResult(user, ctx, row.family_id); + }, + + logout: async (refreshToken) => { + const row = await tokens.findByHash(hashRefreshToken(refreshToken)); + if (row !== undefined) { + await tokens.revokeById(row.id); + } + }, + + logoutAll: async (userId) => { + await tokens.revokeAllForUser(userId); + }, + + listSessions: async (userId, currentRefreshToken) => { + const rows = await tokens.listActiveForUser(userId); + const currentHash = + currentRefreshToken === undefined ? undefined : hashRefreshToken(currentRefreshToken); + return rows.map((row) => toSession(row, currentHash)); + }, + + centrifugoToken: async (userId) => { + const token = await signCentrifugoToken( + config.centrifugoSecret, + config.centrifugoTtlSeconds, + userId, + ); + return { token, expiresIn: config.centrifugoTtlSeconds }; + }, + }; +}; + +declare module 'fastify' { + interface FastifyInstance { + authService: AuthService; + } +} diff --git a/packages/backend/src/modules/auth/index.ts b/packages/backend/src/modules/auth/index.ts new file mode 100644 index 0000000..74f4c53 --- /dev/null +++ b/packages/backend/src/modules/auth/index.ts @@ -0,0 +1,12 @@ +export { createRefreshTokensRepository } from './auth.repository'; +export type { RefreshTokensRepository, RefreshTokenRow, NewRefreshToken } from './auth.repository'; +export { createAuthService } from './auth.service'; +export type { + AuthService, + AuthServiceConfig, + AuthServiceDeps, + RequestContext, +} from './auth.service'; +export { authPlugin } from './auth.plugin'; +export type { AuthUser } from './auth.plugin'; +export { authRoutes } from './auth.routes'; diff --git a/packages/backend/src/modules/auth/password.ts b/packages/backend/src/modules/auth/password.ts new file mode 100644 index 0000000..52ffb2c --- /dev/null +++ b/packages/backend/src/modules/auth/password.ts @@ -0,0 +1,8 @@ +import { hash, verify } from '@node-rs/argon2'; + +// argon2id password hashing via @node-rs/argon2 (prebuilt binaries, incl. musl). +// Defaults are argon2id with sound cost parameters — no custom crypto. +export const hashPassword = (password: string): Promise => hash(password); + +export const verifyPassword = (passwordHash: string, password: string): Promise => + verify(passwordHash, password); diff --git a/packages/backend/src/modules/auth/tokens.ts b/packages/backend/src/modules/auth/tokens.ts new file mode 100644 index 0000000..c0b5e86 --- /dev/null +++ b/packages/backend/src/modules/auth/tokens.ts @@ -0,0 +1,59 @@ +import { SignJWT, jwtVerify } from 'jose'; +import { randomBytes, createHash } from 'node:crypto'; + +export interface AccessTokenClaims { + sub: string; + username: string; +} + +const encoder = new TextEncoder(); + +const keyFrom = (secret: string): Uint8Array => encoder.encode(secret); + +// --- App access token (JWT, HS256, short-lived) --- + +export const signAccessToken = ( + secret: string, + ttlSeconds: number, + claims: AccessTokenClaims, +): Promise => + new SignJWT({ username: claims.username }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(claims.sub) + .setIssuedAt() + .setExpirationTime(`${String(ttlSeconds)}s`) + .sign(keyFrom(secret)); + +export const verifyAccessToken = async ( + secret: string, + token: string, +): Promise => { + const { payload } = await jwtVerify(token, keyFrom(secret), { algorithms: ['HS256'] }); + const sub = payload.sub; + const username = payload['username']; + if (typeof sub !== 'string' || typeof username !== 'string') { + throw new Error('Invalid access token claims'); + } + return { sub, username }; +}; + +// --- Centrifugo connection token (separate secret, HS256) --- + +export const signCentrifugoToken = ( + secret: string, + ttlSeconds: number, + userId: string, +): Promise => + new SignJWT({}) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(userId) + .setIssuedAt() + .setExpirationTime(`${String(ttlSeconds)}s`) + .sign(keyFrom(secret)); + +// --- Opaque refresh token (random, stored only as a SHA-256 hash) --- + +export const generateRefreshToken = (): string => randomBytes(32).toString('base64url'); + +export const hashRefreshToken = (token: string): string => + createHash('sha256').update(token).digest('hex'); diff --git a/packages/backend/src/modules/users/index.ts b/packages/backend/src/modules/users/index.ts new file mode 100644 index 0000000..ca91d65 --- /dev/null +++ b/packages/backend/src/modules/users/index.ts @@ -0,0 +1,11 @@ +export { createUsersRepository } from './users.repository'; +export type { + UsersRepository, + UserRow, + NewUser, + ProfilePatch, +} from './users.repository'; +export { createUsersService } from './users.service'; +export type { UsersService } from './users.service'; +export { toUser, toPublicUser } from './users.mapper'; +export { usersRoutes } from './users.routes'; diff --git a/packages/backend/src/modules/users/users.mapper.ts b/packages/backend/src/modules/users/users.mapper.ts new file mode 100644 index 0000000..a3232f4 --- /dev/null +++ b/packages/backend/src/modules/users/users.mapper.ts @@ -0,0 +1,22 @@ +import type { User, PublicUser } from '@altricade/core'; +import type { UserRow } from './users.repository'; + +// Map DB rows to the shared core shapes (dates → ISO strings, private fields +// stripped for the public shape). +export const toUser = (row: UserRow): User => ({ + id: row.id, + username: row.username, + displayName: row.display_name, + avatarUrl: row.avatar_ref, + email: row.email, + phone: row.phone, + createdAt: row.created_at.toISOString(), + lastSeenAt: row.last_seen_at === null ? null : row.last_seen_at.toISOString(), +}); + +export const toPublicUser = (row: UserRow): PublicUser => ({ + id: row.id, + username: row.username, + displayName: row.display_name, + avatarUrl: row.avatar_ref, +}); diff --git a/packages/backend/src/modules/users/users.repository.ts b/packages/backend/src/modules/users/users.repository.ts new file mode 100644 index 0000000..36be400 --- /dev/null +++ b/packages/backend/src/modules/users/users.repository.ts @@ -0,0 +1,69 @@ +import type { Kysely, Selectable, Updateable } from 'kysely'; +import type { Database, UsersTable } from '../../db/schema'; + +export type UserRow = Selectable; + +export interface NewUser { + username: string; + displayName: string; + passwordHash: string; + email: string | null; + phone: string | null; +} + +export interface ProfilePatch { + displayName?: string; + email?: string | null; + phone?: string | null; +} + +export interface UsersRepository { + create(input: NewUser): Promise; + findByUsername(username: string): Promise; + findById(id: string): Promise; + updateProfile(id: string, patch: ProfilePatch): Promise; + touchLastSeen(id: string): Promise; +} + +export const createUsersRepository = (db: Kysely): UsersRepository => ({ + create: (input) => + db + .insertInto('users') + .values({ + username: input.username, + display_name: input.displayName, + password_hash: input.passwordHash, + email: input.email, + phone: input.phone, + }) + .returningAll() + .executeTakeFirstOrThrow(), + + findByUsername: (username) => + db.selectFrom('users').selectAll().where('username', '=', username).executeTakeFirst(), + + findById: (id) => db.selectFrom('users').selectAll().where('id', '=', id).executeTakeFirst(), + + updateProfile: (id, patch) => { + const values: Updateable = { updated_at: new Date() }; + if (patch.displayName !== undefined) { + values.display_name = patch.displayName; + } + if (patch.email !== undefined) { + values.email = patch.email; + } + if (patch.phone !== undefined) { + values.phone = patch.phone; + } + return db + .updateTable('users') + .set(values) + .where('id', '=', id) + .returningAll() + .executeTakeFirst(); + }, + + touchLastSeen: async (id) => { + await db.updateTable('users').set({ last_seen_at: new Date() }).where('id', '=', id).execute(); + }, +}); diff --git a/packages/backend/src/modules/users/users.routes.ts b/packages/backend/src/modules/users/users.routes.ts new file mode 100644 index 0000000..488f21c --- /dev/null +++ b/packages/backend/src/modules/users/users.routes.ts @@ -0,0 +1,74 @@ +import type { FastifyInstance } from 'fastify'; +import { updateMeBodySchema, userSchema, publicUserSchema, errorSchema } from '@altricade/core'; +import type { UpdateMeBody } from '@altricade/core'; + +const usernameParamsSchema = { + type: 'object', + required: ['username'], + properties: { username: { type: 'string' } }, +} as const; + +const bearerAuth = [{ bearerAuth: [] }]; + +export const usersRoutes = (app: FastifyInstance): Promise => { + app.get( + '/me', + { + schema: { + tags: ['users'], + summary: 'Get the current user profile', + security: bearerAuth, + response: { 200: userSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) { + return reply.code(401).send({ error: 'unauthorized' }); + } + const me = await app.usersService.getMe(user.id); + return reply.send(me); + }, + ); + + app.patch<{ Body: UpdateMeBody }>( + '/me', + { + schema: { + tags: ['users'], + summary: 'Update the current user profile', + security: bearerAuth, + body: updateMeBodySchema, + response: { 200: userSchema, 401: errorSchema }, + }, + preHandler: app.authenticate, + }, + async (request, reply) => { + const user = request.authUser; + if (user === undefined) { + return reply.code(401).send({ error: 'unauthorized' }); + } + const updated = await app.usersService.updateProfile(user.id, request.body); + return reply.send(updated); + }, + ); + + app.get<{ Params: { username: string } }>( + '/users/:username', + { + schema: { + tags: ['users'], + summary: 'Get a public user profile by username', + params: usernameParamsSchema, + response: { 200: publicUserSchema }, + }, + }, + async (request, reply) => { + const profile = await app.usersService.getPublicProfile(request.params.username); + return reply.send(profile); + }, + ); + + return Promise.resolve(); +}; diff --git a/packages/backend/src/modules/users/users.service.ts b/packages/backend/src/modules/users/users.service.ts new file mode 100644 index 0000000..4815820 --- /dev/null +++ b/packages/backend/src/modules/users/users.service.ts @@ -0,0 +1,42 @@ +import type { User, PublicUser, UpdateMeBody } from '@altricade/core'; +import { HttpError } from '../../shared/http-error'; +import type { UsersRepository } from './users.repository'; +import { toUser, toPublicUser } from './users.mapper'; + +export interface UsersService { + getMe(userId: string): Promise; + getPublicProfile(username: string): Promise; + updateProfile(userId: string, patch: UpdateMeBody): Promise; +} + +export const createUsersService = (users: UsersRepository): UsersService => ({ + getMe: async (userId) => { + const row = await users.findById(userId); + if (row === undefined) { + throw new HttpError(404, 'not_found', 'User not found'); + } + return toUser(row); + }, + + getPublicProfile: async (username) => { + const row = await users.findByUsername(username); + if (row === undefined) { + throw new HttpError(404, 'not_found', 'User not found'); + } + return toPublicUser(row); + }, + + updateProfile: async (userId, patch) => { + const row = await users.updateProfile(userId, patch); + if (row === undefined) { + throw new HttpError(404, 'not_found', 'User not found'); + } + return toUser(row); + }, +}); + +declare module 'fastify' { + interface FastifyInstance { + usersService: UsersService; + } +} diff --git a/packages/backend/src/plugins/rate-limit.ts b/packages/backend/src/plugins/rate-limit.ts new file mode 100644 index 0000000..b67bfb3 --- /dev/null +++ b/packages/backend/src/plugins/rate-limit.ts @@ -0,0 +1,14 @@ +import fp from 'fastify-plugin'; +import rateLimit from '@fastify/rate-limit'; + +// Redis-backed rate limiting (works across backend replicas). Registered with +// `global: false` — it only applies to routes that opt in via `config.rateLimit`. +export const rateLimitPlugin = fp( + async (app) => { + await app.register(rateLimit, { + global: false, + redis: app.redis, + }); + }, + { name: 'rate-limit', dependencies: ['redis'] }, +); diff --git a/packages/backend/src/plugins/swagger.ts b/packages/backend/src/plugins/swagger.ts new file mode 100644 index 0000000..4905d6b --- /dev/null +++ b/packages/backend/src/plugins/swagger.ts @@ -0,0 +1,41 @@ +import fp from 'fastify-plugin'; +import swagger from '@fastify/swagger'; +import swaggerUi from '@fastify/swagger-ui'; + +// OpenAPI docs + Swagger UI at /docs. Must be registered before routes so it can +// collect their schemas. The "Authorize" button uses the bearerAuth scheme — +// paste an access token from /auth/login or /auth/register to call protected +// endpoints. +export const swaggerPlugin = fp( + async (app) => { + await app.register(swagger, { + openapi: { + info: { + title: 'Altricade API', + version: '0.1.0', + description: 'Realtime chat & calls backend.', + }, + servers: [ + { url: 'http://localhost:8080/api', description: 'nginx gateway' }, + { url: 'http://localhost:4000', description: 'backend direct (dev)' }, + ], + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + }, + }, + tags: [ + { name: 'auth', description: 'Authentication, tokens & sessions' }, + { name: 'users', description: 'User profiles' }, + { name: 'system', description: 'Health & readiness' }, + ], + }, + }); + + await app.register(swaggerUi, { + routePrefix: '/docs', + uiConfig: { docExpansion: 'list', deepLinking: true, persistAuthorization: true }, + }); + }, + { name: 'swagger' }, +); diff --git a/packages/backend/src/routes/health.ts b/packages/backend/src/routes/health.ts index dbd1b8b..b63230a 100644 --- a/packages/backend/src/routes/health.ts +++ b/packages/backend/src/routes/health.ts @@ -64,9 +64,11 @@ const checkMinio = async (app: FastifyInstance): Promise => { // /health — liveness: the process is up and serving (used by Docker HEALTHCHECK). // /ready — readiness: every backing service is reachable (pg + redis + minio). export const healthRoutes = (app: FastifyInstance): Promise => { - app.get('/health', () => ({ status: 'ok' })); + app.get('/health', { schema: { tags: ['system'], summary: 'Liveness probe' } }, () => ({ + status: 'ok', + })); - app.get('/ready', async (_request, reply) => { + app.get('/ready', { schema: { tags: ['system'], summary: 'Readiness probe' } }, async (_request, reply) => { const [postgres, redis, minio] = await Promise.all([ withTimeout(checkPostgres(app)), withTimeout(checkRedis(app)), diff --git a/packages/backend/src/shared/http-error.ts b/packages/backend/src/shared/http-error.ts new file mode 100644 index 0000000..9ae3f34 --- /dev/null +++ b/packages/backend/src/shared/http-error.ts @@ -0,0 +1,13 @@ +// Domain error carrying an HTTP status + stable machine code. Services throw +// these; the app's error handler maps them to responses. +export class HttpError extends Error { + readonly statusCode: number; + readonly code: string; + + constructor(statusCode: number, code: string, message: string) { + super(message); + this.name = 'HttpError'; + this.statusCode = statusCode; + this.code = code; + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 87d7a1a..62d5c25 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,11 +7,15 @@ ".": "./src/index.ts", "./channels": "./src/channels/index.ts", "./events": "./src/events/index.ts", - "./types": "./src/types/index.ts" + "./types": "./src/types/index.ts", + "./schemas": "./src/schemas/index.ts" }, "scripts": { "build": "tsc --noEmit", "typecheck": "tsc --noEmit", "lint": "eslint ." + }, + "dependencies": { + "json-schema-to-ts": "^3.1.1" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6675655..598d01d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,3 +2,4 @@ export * from './channels/index'; export * from './events/index'; export * from './types/index'; +export * from './schemas/index'; diff --git a/packages/core/src/schemas/auth.ts b/packages/core/src/schemas/auth.ts new file mode 100644 index 0000000..cce82ee --- /dev/null +++ b/packages/core/src/schemas/auth.ts @@ -0,0 +1,69 @@ +// JSON Schemas for auth payloads — the single source of truth. The backend +// validates requests against these (Fastify), and the payload TS types are +// derived from them via `FromSchema` (see ./types), so there is no drift. + +import type { FromSchema } from 'json-schema-to-ts'; + +// Username: public handle, case-insensitive-unique. Letters/digits/underscore. +const USERNAME_PATTERN = '^[a-zA-Z0-9_]{3,32}$'; +// E.164 phone, e.g. +14155552671. +const PHONE_PATTERN = '^\\+[1-9]\\d{1,14}$'; + +export const registerBodySchema = { + type: 'object', + additionalProperties: false, + required: ['username', 'displayName', 'password'], + properties: { + username: { type: 'string', pattern: USERNAME_PATTERN }, + displayName: { type: 'string', minLength: 1, maxLength: 64 }, + password: { type: 'string', minLength: 8, maxLength: 128 }, + email: { type: 'string', format: 'email', maxLength: 254 }, + phone: { type: 'string', pattern: PHONE_PATTERN }, + }, +} as const; + +export const loginBodySchema = { + type: 'object', + additionalProperties: false, + required: ['username', 'password'], + properties: { + username: { type: 'string', pattern: USERNAME_PATTERN }, + password: { type: 'string', minLength: 1, maxLength: 128 }, + }, +} as const; + +export const refreshBodySchema = { + type: 'object', + additionalProperties: false, + required: ['refreshToken'], + properties: { + refreshToken: { type: 'string', minLength: 1 }, + }, +} as const; + +export const logoutBodySchema = { + type: 'object', + additionalProperties: false, + required: ['refreshToken'], + properties: { + refreshToken: { type: 'string', minLength: 1 }, + }, +} as const; + +export const updateMeBodySchema = { + type: 'object', + additionalProperties: false, + minProperties: 1, + properties: { + displayName: { type: 'string', minLength: 1, maxLength: 64 }, + // `null` clears the value; a string sets it. + email: { type: ['string', 'null'], format: 'email', maxLength: 254 }, + phone: { type: ['string', 'null'], pattern: PHONE_PATTERN }, + }, +} as const; + +export type RegisterBody = FromSchema; +export type LoginBody = FromSchema; +export type RefreshBody = FromSchema; +export type LogoutBody = FromSchema; +export type UpdateMeBody = FromSchema; diff --git a/packages/core/src/schemas/entities.ts b/packages/core/src/schemas/entities.ts new file mode 100644 index 0000000..366acd8 --- /dev/null +++ b/packages/core/src/schemas/entities.ts @@ -0,0 +1,99 @@ +// Response JSON Schemas mirroring the core types. Shared by the backend for +// OpenAPI docs and response serialization, so the documented contract cannot +// drift from what the API returns. + +export const publicUserSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'username', 'displayName', 'avatarUrl'], + properties: { + id: { type: 'string', format: 'uuid' }, + username: { type: 'string' }, + displayName: { type: 'string' }, + avatarUrl: { type: ['string', 'null'] }, + }, +} as const; + +export const userSchema = { + type: 'object', + additionalProperties: false, + required: [ + 'id', + 'username', + 'displayName', + 'avatarUrl', + 'email', + 'phone', + 'createdAt', + 'lastSeenAt', + ], + properties: { + id: { type: 'string', format: 'uuid' }, + username: { type: 'string' }, + displayName: { type: 'string' }, + avatarUrl: { type: ['string', 'null'] }, + email: { type: ['string', 'null'] }, + phone: { type: ['string', 'null'] }, + createdAt: { type: 'string', format: 'date-time' }, + lastSeenAt: { type: ['string', 'null'], format: 'date-time' }, + }, +} as const; + +export const authTokensSchema = { + type: 'object', + additionalProperties: false, + required: ['accessToken', 'refreshToken', 'accessTokenExpiresIn'], + properties: { + accessToken: { type: 'string' }, + refreshToken: { type: 'string' }, + accessTokenExpiresIn: { type: 'integer' }, + }, +} as const; + +export const authResultSchema = { + type: 'object', + additionalProperties: false, + required: ['user', 'tokens'], + properties: { + user: userSchema, + tokens: authTokensSchema, + }, +} as const; + +export const sessionSchema = { + type: 'object', + additionalProperties: false, + required: ['id', 'userAgent', 'ip', 'createdAt', 'lastUsedAt', 'current'], + properties: { + id: { type: 'string', format: 'uuid' }, + userAgent: { type: ['string', 'null'] }, + ip: { type: ['string', 'null'] }, + createdAt: { type: 'string', format: 'date-time' }, + lastUsedAt: { type: 'string', format: 'date-time' }, + current: { type: 'boolean' }, + }, +} as const; + +export const sessionListSchema = { + type: 'array', + items: sessionSchema, +} as const; + +export const centrifugoTokenSchema = { + type: 'object', + additionalProperties: false, + required: ['token', 'expiresIn'], + properties: { + token: { type: 'string' }, + expiresIn: { type: 'integer' }, + }, +} as const; + +export const errorSchema = { + type: 'object', + required: ['error'], + properties: { + error: { type: 'string' }, + message: { type: 'string' }, + }, +} as const; diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts new file mode 100644 index 0000000..83b016a --- /dev/null +++ b/packages/core/src/schemas/index.ts @@ -0,0 +1,24 @@ +export { + registerBodySchema, + loginBodySchema, + refreshBodySchema, + logoutBodySchema, + updateMeBodySchema, +} from './auth'; +export type { + RegisterBody, + LoginBody, + RefreshBody, + LogoutBody, + UpdateMeBody, +} from './auth'; +export { + publicUserSchema, + userSchema, + authTokensSchema, + authResultSchema, + sessionSchema, + sessionListSchema, + centrifugoTokenSchema, + errorSchema, +} from './entities'; diff --git a/packages/core/src/types/auth.ts b/packages/core/src/types/auth.ts new file mode 100644 index 0000000..2d8045f --- /dev/null +++ b/packages/core/src/types/auth.ts @@ -0,0 +1,30 @@ +import type { User } from './user'; + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + /** Access-token lifetime in seconds. */ + accessTokenExpiresIn: number; +} + +// Returned by register / login / refresh. +export interface AuthResult { + user: User; + tokens: AuthTokens; +} + +// An active login session (one per device), from GET /auth/sessions. +export interface Session { + id: string; + userAgent: string | null; + ip: string | null; + createdAt: string; + lastUsedAt: string; + current: boolean; +} + +// Short-lived token the client uses to open the Centrifugo connection. +export interface CentrifugoToken { + token: string; + expiresIn: number; +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index dc975e0..338720d 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,20 +1,6 @@ -// Shared payload/domain types. Placeholder shapes for Phase 0 — real message / -// user / API types (and their JSON Schemas) are filled in per feature phase. +// Bumped as `core` evolves; clients can log/verify the shared contract version. +export const CORE_VERSION = '0.1.0'; -/** Bumped as `core` evolves; clients can log/verify the shared contract version. */ -export const CORE_VERSION = '0.0.0'; - -export interface User { - id: string; - username: string; - displayName: string; - avatarUrl: string | null; -} - -export interface Message { - id: string; - conversationId: string; - senderId: string; - body: string; - createdAt: string; -} +export type { User, PublicUser } from './user'; +export type { AuthTokens, AuthResult, Session, CentrifugoToken } from './auth'; +export type { Message } from './message'; diff --git a/packages/core/src/types/message.ts b/packages/core/src/types/message.ts new file mode 100644 index 0000000..03bcfa9 --- /dev/null +++ b/packages/core/src/types/message.ts @@ -0,0 +1,9 @@ +// Placeholder message shape — the full model (server-assigned seq, dedupe id, +// media refs, edits, reactions) is defined in Phases 3–5. +export interface Message { + id: string; + conversationId: string; + senderId: string; + body: string; + createdAt: string; +} diff --git a/packages/core/src/types/user.ts b/packages/core/src/types/user.ts new file mode 100644 index 0000000..009e4de --- /dev/null +++ b/packages/core/src/types/user.ts @@ -0,0 +1,20 @@ +// The authenticated user's own profile (includes private contact fields). +export interface User { + id: string; + username: string; + displayName: string; + avatarUrl: string | null; + email: string | null; + phone: string | null; + createdAt: string; + lastSeenAt: string | null; +} + +// A user's public profile (no private contact fields) — returned by +// GET /users/:username and embedded in messages/members. +export interface PublicUser { + id: string; + username: string; + displayName: string; + avatarUrl: string | null; +} diff --git a/packages/web/eslint.config.mjs b/packages/web/eslint.config.mjs index 75b21b0..5fa12fc 100644 --- a/packages/web/eslint.config.mjs +++ b/packages/web/eslint.config.mjs @@ -1,16 +1,35 @@ import { base } from '../../eslint.config.mjs'; -import boundaries from 'eslint-plugin-boundaries'; import reactHooks from 'eslint-plugin-react-hooks'; import globals from 'globals'; // Strict Feature-Sliced Design: a layer may import only from itself and the -// layers below it. Upward imports (e.g. shared → features) are build errors. +// layers below it. Upward imports are build errors. Enforced with path-based +// no-restricted-imports (reliable across relative imports), one override per +// layer forbidding every higher layer. const FSD_LAYERS = ['app', 'pages', 'widgets', 'features', 'entities', 'shared']; -const fsdPolicies = FSD_LAYERS.map((layer, index) => ({ - from: { element: { types: layer } }, - allow: { to: { element: { types: { anyOf: FSD_LAYERS.slice(index) } } } }, -})); +const fsdLayerOverrides = FSD_LAYERS.flatMap((layer, index) => { + const higherLayers = FSD_LAYERS.slice(0, index); + if (higherLayers.length === 0) { + return []; + } + return [ + { + files: [`src/${layer}/**/*.{ts,tsx}`], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: higherLayers.map((higher) => ({ + group: [`**/${higher}`, `**/${higher}/**`], + message: `FSD violation: layer '${layer}' may not import from the higher layer '${higher}'.`, + })), + }, + ], + }, + }, + ]; +}); export default [ { ignores: ['dist/**'] }, @@ -21,24 +40,12 @@ export default [ globals: { ...globals.browser }, }, plugins: { - boundaries, 'react-hooks': reactHooks, }, - settings: { - 'boundaries/include': ['src/**/*'], - 'boundaries/elements': [ - { type: 'app', pattern: 'src/app/*' }, - { type: 'pages', pattern: 'src/pages/*' }, - { type: 'widgets', pattern: 'src/widgets/*' }, - { type: 'features', pattern: 'src/features/*' }, - { type: 'entities', pattern: 'src/entities/*' }, - { type: 'shared', pattern: 'src/shared/*' }, - ], - }, rules: { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', - 'boundaries/dependencies': ['error', { default: 'disallow', policies: fsdPolicies }], }, }, + ...fsdLayerOverrides, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95441f2..74dbab6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@10.6.0) - eslint-plugin-boundaries: - specifier: ^7.0.2 - version: 7.0.2(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@5.9.3))(eslint@10.6.0) globals: specifier: ^17.7.0 version: 17.7.0 @@ -47,6 +44,21 @@ importers: '@fastify/cors': specifier: ^11.3.0 version: 11.3.0 + '@fastify/rate-limit': + specifier: ^11.1.0 + version: 11.1.0 + '@fastify/swagger': + specifier: ^9.8.0 + version: 9.8.0 + '@fastify/swagger-ui': + specifier: ^6.1.0 + version: 6.1.0 + '@node-rs/argon2': + specifier: ^2.0.2 + version: 2.0.2 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) fastify: specifier: ^5.10.0 version: 5.10.0 @@ -56,6 +68,9 @@ importers: ioredis: specifier: ^5.11.1 version: 5.11.1 + jose: + specifier: ^6.2.3 + version: 6.2.3 kysely: specifier: ^0.29.3 version: 0.29.3 @@ -77,7 +92,7 @@ importers: version: 8.20.0 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) tsx: specifier: ^4.23.0 version: 4.23.0 @@ -85,7 +100,11 @@ importers: specifier: ^5.9.3 version: 5.9.3 - packages/core: {} + packages/core: + dependencies: + json-schema-to-ts: + specifier: ^3.1.1 + version: 3.1.1 packages/desktop: dependencies: @@ -119,7 +138,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.3 - version: 6.0.3(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)) + version: 6.0.3(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0)) eslint-plugin-react-hooks: specifier: ^7.1.1 version: 7.1.1(eslint@10.6.0) @@ -128,7 +147,7 @@ importers: version: 5.9.3 vite: specifier: ^8.1.4 - version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0) + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0) packages: @@ -187,6 +206,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -199,10 +222,6 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@boundaries/elements@3.0.1': - resolution: {integrity: sha512-T53UueJVRIn1B2G5FWo6T3rqAA1aHcuypRweQcbW6Z/leUygsHW54Gezkm/8uxB9Fw8ewE+izfKKmA9XVv7HdQ==} - engines: {node: '>=18.18'} - '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -407,6 +426,9 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -428,6 +450,21 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@11.1.0': + resolution: {integrity: sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.3.0': + resolution: {integrity: sha512-9YMYRpCOtMBrqKYWcqiw7ykOrn4D0jogHpJrFS0KGeSuOwzKMM5/mjj7B0CFLVoQ6htqKYw//Zs7APn9DBq05w==} + + '@fastify/swagger-ui@6.1.0': + resolution: {integrity: sha512-vbhHlJvzXujGco+6yumjt4cwptzb+0ZezPvApFSI+62IdJCfHtjpJrFIT+ln3BCB+KVFiUkI1Y+L9adIGmvdHQ==} + + '@fastify/swagger@9.8.0': + resolution: {integrity: sha512-GdRkUboXu++nChrmWM22SNDkabB529TL7cQ4RDsPDP76ro1kSW1cLIEZ9S8ZUbJKYUciHgLAgiX8SHzCnqZdnQ==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -471,6 +508,13 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -480,6 +524,93 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@node-rs/argon2-android-arm-eabi@2.0.2': + resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/argon2-android-arm64@2.0.2': + resolution: {integrity: sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/argon2-darwin-arm64@2.0.2': + resolution: {integrity: sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/argon2-darwin-x64@2.0.2': + resolution: {integrity: sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/argon2-freebsd-x64@2.0.2': + resolution: {integrity: sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + resolution: {integrity: sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + resolution: {integrity: sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + resolution: {integrity: sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + resolution: {integrity: sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/argon2-linux-x64-musl@2.0.2': + resolution: {integrity: sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/argon2-wasm32-wasi@2.0.2': + resolution: {integrity: sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + resolution: {integrity: sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + resolution: {integrity: sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + resolution: {integrity: sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/argon2@2.0.2': + resolution: {integrity: sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==} + engines: {node: '>= 10'} + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -898,10 +1029,6 @@ packages: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - browser-or-node@2.1.1: resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} @@ -927,10 +1054,6 @@ packages: caniuse-lite@1.0.30001803: resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -961,6 +1084,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -975,14 +1102,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1003,6 +1122,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1017,10 +1140,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -1030,6 +1149,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1040,36 +1162,6 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-boundaries@7.0.2: - resolution: {integrity: sha512-VLaPMvNh+ONw6F/S0gpkS0/QDudqVjcaL1DoGo+8sJqZmxxabOtrFZ24PDI1jQLg3pyqujzJ3/4Cq7Bk249gjQ==} - engines: {node: '>=18.18'} - peerDependencies: - eslint: '>=6.0.0' - eslint-plugin-react-hooks@7.1.1: resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} @@ -1174,10 +1266,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - filter-obj@1.1.0: resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} engines: {node: '>=0.10.0'} @@ -1209,9 +1297,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1230,29 +1315,24 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@17.7.0: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} - handlebars@4.7.9: - resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} - engines: {node: '>=0.4.7'} - hasBin: true - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1276,14 +1356,6 @@ packages: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1296,10 +1368,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-unsafe@1.0.1: resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} @@ -1310,6 +1378,9 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1328,6 +1399,14 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1454,10 +1533,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1466,13 +1541,15 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minio@8.0.7: resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==} engines: {node: ^16 || ^18 || >=20} @@ -1498,9 +1575,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - node-pg-migrate@8.0.4: resolution: {integrity: sha512-HTlJ6fOT/2xHhAUtsqSN85PGMAqSbfGJNRwQF8+ZwQ1+sVGNUTl/ZGEshPsOI3yV22tPIyHXrKXr3S0JxeYLrg==} engines: {node: '>=20.11.0'} @@ -1524,6 +1598,9 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1551,9 +1628,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -1598,10 +1672,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -1731,11 +1801,6 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} - resolve@1.22.12: - resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} - engines: {node: '>= 0.4'} - hasBin: true - ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -1790,6 +1855,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1809,10 +1877,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -1828,6 +1892,10 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + stream-chain@2.2.5: resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} @@ -1857,14 +1925,6 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1886,18 +1946,21 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - toad-cache@3.7.4: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1957,11 +2020,6 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} - uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} - engines: {node: '>=0.8.0'} - hasBin: true - undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -2029,9 +2087,6 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2059,6 +2114,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -2159,6 +2219,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -2182,20 +2244,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@boundaries/elements@3.0.1(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@5.9.3))(eslint@10.6.0)': - dependencies: - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.6.0) - handlebars: 4.7.9 - is-core-module: 2.16.1 - micromatch: 4.0.8 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -2324,6 +2372,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@fastify/accept-negotiator@2.0.1': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.20.0 @@ -2352,6 +2402,47 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@fastify/rate-limit@11.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 6.0.0 + toad-cache: 3.7.4 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.3.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 6.0.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@6.1.0': + dependencies: + '@fastify/static': 9.3.0 + fastify-plugin: 6.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.8.0': + dependencies: + fastify-plugin: 6.0.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2391,6 +2482,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/ms@2.0.2': {} + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -2400,6 +2500,67 @@ snapshots: '@nodable/entities@2.2.0': {} + '@node-rs/argon2-android-arm-eabi@2.0.2': + optional: true + + '@node-rs/argon2-android-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-arm64@2.0.2': + optional: true + + '@node-rs/argon2-darwin-x64@2.0.2': + optional: true + + '@node-rs/argon2-freebsd-x64@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm-gnueabihf@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-arm64-musl@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-gnu@2.0.2': + optional: true + + '@node-rs/argon2-linux-x64-musl@2.0.2': + optional: true + + '@node-rs/argon2-wasm32-wasi@2.0.2': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/argon2-win32-arm64-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-ia32-msvc@2.0.2': + optional: true + + '@node-rs/argon2-win32-x64-msvc@2.0.2': + optional: true + + '@node-rs/argon2@2.0.2': + optionalDependencies: + '@node-rs/argon2-android-arm-eabi': 2.0.2 + '@node-rs/argon2-android-arm64': 2.0.2 + '@node-rs/argon2-darwin-arm64': 2.0.2 + '@node-rs/argon2-darwin-x64': 2.0.2 + '@node-rs/argon2-freebsd-x64': 2.0.2 + '@node-rs/argon2-linux-arm-gnueabihf': 2.0.2 + '@node-rs/argon2-linux-arm64-gnu': 2.0.2 + '@node-rs/argon2-linux-arm64-musl': 2.0.2 + '@node-rs/argon2-linux-x64-gnu': 2.0.2 + '@node-rs/argon2-linux-x64-musl': 2.0.2 + '@node-rs/argon2-wasm32-wasi': 2.0.2 + '@node-rs/argon2-win32-arm64-msvc': 2.0.2 + '@node-rs/argon2-win32-ia32-msvc': 2.0.2 + '@node-rs/argon2-win32-x64-msvc': 2.0.2 + '@oxc-project/types@0.139.0': {} '@pinojs/redact@0.4.0': {} @@ -2668,10 +2829,10 @@ snapshots: '@typescript-eslint/types': 8.63.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0))': + '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0) + vite: 8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0) abstract-logging@2.0.1: {} @@ -2730,10 +2891,6 @@ snapshots: dependencies: balanced-match: 4.0.4 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - browser-or-node@2.1.1: {} browserslist@4.28.5: @@ -2755,11 +2912,6 @@ snapshots: caniuse-lite@1.0.30001803: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2784,6 +2936,8 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} @@ -2796,10 +2950,6 @@ snapshots: csstype@3.2.3: {} - debug@3.2.7: - dependencies: - ms: 2.1.3 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -2810,6 +2960,8 @@ snapshots: denque@2.1.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -2818,8 +2970,6 @@ snapshots: emoji-regex@8.0.0: {} - es-errors@1.3.0: {} - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -2851,45 +3001,14 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@10.6.0): dependencies: eslint: 10.6.0 - eslint-import-resolver-node@0.3.9: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.2 - resolve: 1.22.12 - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.6.0): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.63.0(eslint@10.6.0)(typescript@5.9.3) - eslint: 10.6.0 - eslint-import-resolver-node: 0.3.9 - transitivePeerDependencies: - - supports-color - - eslint-plugin-boundaries@7.0.2(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@5.9.3))(eslint@10.6.0): - dependencies: - '@boundaries/elements': 3.0.1(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@5.9.3))(eslint@10.6.0) - chalk: 4.1.2 - eslint: 10.6.0 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@10.6.0) - handlebars: 4.7.9 - micromatch: 4.0.8 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - eslint-plugin-react-hooks@7.1.1(eslint@10.6.0): dependencies: '@babel/core': 7.29.7 @@ -3038,10 +3157,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - filter-obj@1.1.0: {} find-my-way@9.6.0: @@ -3076,8 +3191,6 @@ snapshots: fsevents@2.3.3: optional: true - function-bind@1.1.2: {} - gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -3095,29 +3208,28 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@17.7.0: {} - handlebars@4.7.9: - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - - has-flag@4.0.0: {} - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - hermes-estree@0.25.1: {} hermes-parser@0.25.1: dependencies: hermes-estree: 0.25.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3140,14 +3252,6 @@ snapshots: ipaddr.js@2.4.0: {} - is-core-module@2.16.1: - dependencies: - hasown: 2.0.4 - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.4 - is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -3156,8 +3260,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-number@7.0.0: {} - is-unsafe@1.0.1: {} isexe@2.0.0: {} @@ -3166,6 +3268,8 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 + jose@6.2.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -3178,6 +3282,19 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.3 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -3274,23 +3391,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - mime-db@1.52.0: {} mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime@3.0.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 - minimist@1.2.8: {} - minio@8.0.7: dependencies: async: 3.2.6 @@ -3328,8 +3440,6 @@ snapshots: natural-compare@1.4.0: {} - neo-async@2.6.2: {} - node-pg-migrate@8.0.4(@types/pg@8.20.0)(pg@8.22.0): dependencies: glob: 11.1.0 @@ -3344,6 +3454,8 @@ snapshots: on-exit-leak-free@2.1.2: {} + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3369,8 +3481,6 @@ snapshots: path-key@3.1.1: {} - path-parse@1.0.7: {} - path-scurry@2.0.2: dependencies: lru-cache: 11.5.2 @@ -3415,8 +3525,6 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.2: {} - picomatch@4.0.5: {} pino-abstract-transport@3.0.0: @@ -3447,12 +3555,13 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - postcss-load-config@6.0.1(postcss@8.5.16)(tsx@4.23.0): + postcss-load-config@6.0.1(postcss@8.5.16)(tsx@4.23.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.16 tsx: 4.23.0 + yaml: 2.9.0 postcss@8.5.16: dependencies: @@ -3520,13 +3629,6 @@ snapshots: resolve-from@5.0.0: {} - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - ret@0.5.0: {} reusify@1.1.0: {} @@ -3605,6 +3707,8 @@ snapshots: set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3619,8 +3723,6 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.6.1: {} - source-map@0.7.6: {} split-on-first@1.1.0: {} @@ -3629,6 +3731,8 @@ snapshots: standard-as-callback@2.1.0: {} + statuses@2.0.2: {} + stream-chain@2.2.5: {} stream-json@1.9.1: @@ -3665,12 +3769,6 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -3694,14 +3792,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - toad-cache@3.7.4: {} + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3711,7 +3809,7 @@ snapshots: tslib@2.8.1: optional: true - tsup@8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 @@ -3722,7 +3820,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.16)(tsx@4.23.0) + postcss-load-config: 6.0.1(postcss@8.5.16)(tsx@4.23.0)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.62.2 source-map: 0.7.6 @@ -3773,9 +3871,6 @@ snapshots: ufo@1.6.4: {} - uglify-js@3.19.3: - optional: true - undici-types@8.3.0: {} update-browserslist-db@1.2.3(browserslist@4.28.5): @@ -3790,7 +3885,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0): + vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.1)(tsx@4.23.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -3802,6 +3897,7 @@ snapshots: esbuild: 0.28.1 fsevents: 2.3.3 tsx: 4.23.0 + yaml: 2.9.0 which@2.0.2: dependencies: @@ -3809,8 +3905,6 @@ snapshots: word-wrap@1.2.5: {} - wordwrap@1.0.0: {} - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -3832,6 +3926,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.3: