auth
This commit is contained in:
parent
62f20f8843
commit
1fda5394c3
37 changed files with 1736 additions and 360 deletions
|
|
@ -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
|
CENTRIFUGO_API_KEY=dev-CHANGE-ME-centrifugo-api-key
|
||||||
# Internal URL of the Centrifugo HTTP API (service name on the compose network).
|
# Internal URL of the Centrifugo HTTP API (service name on the compose network).
|
||||||
CENTRIFUGO_API_URL=http://centrifugo:8000/api
|
CENTRIFUGO_API_URL=http://centrifugo:8000/api
|
||||||
|
# Lifetime of the Centrifugo connection token the backend mints.
|
||||||
|
CENTRIFUGO_TOKEN_TTL=1h
|
||||||
|
|
||||||
# --- Postgres ---
|
# --- Postgres ---
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
|
|
|
||||||
13
README.md
13
README.md
|
|
@ -56,11 +56,24 @@ pnpm --filter @altricade/web dev
|
||||||
Endpoints (via the dev override):
|
Endpoints (via the dev override):
|
||||||
|
|
||||||
- Gateway (nginx): http://localhost:8080
|
- 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 health: http://localhost:8080/api/health → `{ "status": "ok" }`
|
||||||
- API readiness: http://localhost:8080/api/ready → 200 only when Postgres + Redis + MinIO are reachable
|
- API readiness: http://localhost:8080/api/ready → 200 only when Postgres + Redis + MinIO are reachable
|
||||||
- Web client: http://localhost:5173
|
- Web client: http://localhost:5173
|
||||||
- MinIO console: http://localhost:9001
|
- 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)
|
## Quality gates (enforced mechanically — a violation fails the build)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ http {
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
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.
|
# REST API — strip the /api prefix before proxying to the backend.
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
|
@ -26,6 +28,20 @@ http {
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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).
|
# Realtime WebSocket — Centrifugo (receive-only client socket).
|
||||||
location /connection/websocket {
|
location /connection/websocket {
|
||||||
proxy_pass http://centrifugo;
|
proxy_pass http://centrifugo;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-boundaries": "^7.0.2",
|
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
"prettier": "^3.9.5",
|
"prettier": "^3.9.5",
|
||||||
"turbo": "^2.10.4",
|
"turbo": "^2.10.4",
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,34 @@
|
||||||
import { base } from '../../eslint.config.mjs';
|
import { base } from '../../eslint.config.mjs';
|
||||||
import boundaries from 'eslint-plugin-boundaries';
|
|
||||||
|
|
||||||
// Backend architecture layering. The folder-based layers are classified and the
|
// Backend architecture layering (route → service → repository → db), enforced
|
||||||
// import direction between them is enforced:
|
// with path-based no-restricted-imports. Boundaries' element model is folder-
|
||||||
// routes → may import plugins, db
|
// oriented and our feature modules are flat files, so we enforce the key edges
|
||||||
// plugins → may import db
|
// directly and reliably:
|
||||||
// db → leaf (types only)
|
// - routes may NOT import repositories or the db layer (must go via a service)
|
||||||
// (src/app.ts, src/server.ts, src/config.ts are the composition root + leaf
|
// - services may NOT import the db layer (must go via a repository)
|
||||||
// config — unclassified in Phase 0.) The full per-domain module structure with
|
// - only repositories (and the db plugin) touch src/db
|
||||||
// route → service → repository layering is introduced in Phase 1.
|
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 [
|
export default [
|
||||||
{ ignores: ['dist/**', 'migrations/**'] },
|
{ ignores: ['dist/**', 'migrations/**'] },
|
||||||
...base,
|
...base,
|
||||||
{
|
{
|
||||||
files: ['src/**/*.ts'],
|
files: ['src/modules/*/*.routes.ts'],
|
||||||
plugins: { boundaries },
|
|
||||||
settings: {
|
|
||||||
'boundaries/include': ['src/**/*'],
|
|
||||||
'boundaries/elements': [
|
|
||||||
{ type: 'plugins', pattern: 'src/plugins/*' },
|
|
||||||
{ type: 'routes', pattern: 'src/routes/*' },
|
|
||||||
{ type: 'db', pattern: 'src/db/*' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
rules: {
|
rules: {
|
||||||
'boundaries/dependencies': [
|
'no-restricted-imports': ['error', { patterns: [noRepository, noDbLayer] }],
|
||||||
'error',
|
},
|
||||||
{
|
|
||||||
default: 'disallow',
|
|
||||||
policies: [
|
|
||||||
{
|
|
||||||
from: { element: { types: 'routes' } },
|
|
||||||
allow: { to: { element: { types: { anyOf: ['plugins', 'db'] } } } },
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: { element: { types: 'plugins' } },
|
files: ['src/modules/*/*.service.ts'],
|
||||||
allow: { to: { element: { types: 'db' } } },
|
rules: {
|
||||||
},
|
'no-restricted-imports': ['error', { patterns: [noDbLayer] }],
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
49
packages/backend/migrations/1720000000001_auth.cjs
Normal file
49
packages/backend/migrations/1720000000001_auth.cjs
Normal file
|
|
@ -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');
|
||||||
|
};
|
||||||
|
|
@ -15,9 +15,15 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@altricade/core": "workspace:^",
|
"@altricade/core": "workspace:^",
|
||||||
"@fastify/cors": "^11.3.0",
|
"@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": "^5.10.0",
|
||||||
"fastify-plugin": "^6.0.0",
|
"fastify-plugin": "^6.0.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"kysely": "^0.29.3",
|
"kysely": "^0.29.3",
|
||||||
"minio": "^8.0.7",
|
"minio": "^8.0.7",
|
||||||
"node-pg-migrate": "^8.0.4",
|
"node-pg-migrate": "^8.0.4",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyError, FastifyInstance } from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import { roomChannel } from '@altricade/core';
|
import { roomChannel } from '@altricade/core';
|
||||||
import { loadConfig } from './config';
|
import { loadConfig } from './config';
|
||||||
import type { AppConfig } from './config';
|
import type { AppConfig } from './config';
|
||||||
|
import { HttpError } from './shared/http-error';
|
||||||
import { dbPlugin } from './plugins/db';
|
import { dbPlugin } from './plugins/db';
|
||||||
import { redisPlugin } from './plugins/redis';
|
import { redisPlugin } from './plugins/redis';
|
||||||
import { minioPlugin } from './plugins/minio';
|
import { minioPlugin } from './plugins/minio';
|
||||||
import { centrifugoPlugin } from './plugins/centrifugo';
|
import { centrifugoPlugin } from './plugins/centrifugo';
|
||||||
|
import { rateLimitPlugin } from './plugins/rate-limit';
|
||||||
|
import { swaggerPlugin } from './plugins/swagger';
|
||||||
import { healthRoutes } from './routes/health';
|
import { healthRoutes } from './routes/health';
|
||||||
|
import { createUsersRepository, createUsersService, usersRoutes } from './modules/users';
|
||||||
|
import { createRefreshTokensRepository, createAuthService, authPlugin, authRoutes } from './modules/auth';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
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.
|
// Kept side-effect-free (no listen) so it can be reused by tests later.
|
||||||
export const buildApp = async (config: AppConfig = loadConfig()): Promise<FastifyInstance> => {
|
export const buildApp = async (config: AppConfig = loadConfig()): Promise<FastifyInstance> => {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
|
@ -25,14 +30,69 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
|
|
||||||
app.decorate('config', config);
|
app.decorate('config', config);
|
||||||
|
|
||||||
|
// Tolerate a bodyless POST that still carries `Content-Type: application/json`
|
||||||
|
// (common client behavior) — treat an empty body as no body instead of 400.
|
||||||
|
const defaultJsonParser = app.getDefaultJsonParser('error', 'ignore');
|
||||||
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (request, body, done) => {
|
||||||
|
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(cors, { origin: true, credentials: true });
|
||||||
await app.register(dbPlugin);
|
await app.register(dbPlugin);
|
||||||
await app.register(redisPlugin);
|
await app.register(redisPlugin);
|
||||||
await app.register(minioPlugin);
|
await app.register(minioPlugin);
|
||||||
await app.register(centrifugoPlugin);
|
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')}`);
|
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ export interface CentrifugoConfig {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
tokenHmacSecret: string;
|
tokenHmacSecret: string;
|
||||||
|
tokenTtlSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthConfig {
|
||||||
|
accessSecret: string;
|
||||||
|
accessTtlSeconds: number;
|
||||||
|
refreshTtlSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
|
|
@ -27,6 +34,7 @@ export interface AppConfig {
|
||||||
redisUrl: string;
|
redisUrl: string;
|
||||||
minio: MinioConfig;
|
minio: MinioConfig;
|
||||||
centrifugo: CentrifugoConfig;
|
centrifugo: CentrifugoConfig;
|
||||||
|
auth: AuthConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = (name: string): string => {
|
const required = (name: string): string => {
|
||||||
|
|
@ -55,6 +63,20 @@ const parsePort = (name: string, raw: string): number => {
|
||||||
|
|
||||||
const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true';
|
const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true';
|
||||||
|
|
||||||
|
// Parse a duration like "900", "15s", "15m", "1h", "30d" into seconds.
|
||||||
|
const DURATION_UNITS: Record<string, number> = { 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 => ({
|
export const loadConfig = (): AppConfig => ({
|
||||||
nodeEnv: optional('NODE_ENV', 'development'),
|
nodeEnv: optional('NODE_ENV', 'development'),
|
||||||
port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')),
|
port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')),
|
||||||
|
|
@ -75,5 +97,11 @@ export const loadConfig = (): AppConfig => ({
|
||||||
apiUrl: required('CENTRIFUGO_API_URL'),
|
apiUrl: required('CENTRIFUGO_API_URL'),
|
||||||
apiKey: required('CENTRIFUGO_API_KEY'),
|
apiKey: required('CENTRIFUGO_API_KEY'),
|
||||||
tokenHmacSecret: required('CENTRIFUGO_TOKEN_HMAC_SECRET'),
|
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')),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,34 @@
|
||||||
// The Kysely database registry: one property per table. Empty in Phase 0 —
|
import type { ColumnType, Generated } from 'kysely';
|
||||||
// tables (users, refresh_tokens, rooms, messages, ...) are added here in lockstep
|
|
||||||
// with node-pg-migrate migrations from Phase 1 onward.
|
// Kysely database registry: one interface per table. Grows with each migration.
|
||||||
export type Database = Record<string, never>;
|
|
||||||
|
export interface UsersTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
avatar_ref: string | null;
|
||||||
|
password_hash: string;
|
||||||
|
created_at: Generated<Date>;
|
||||||
|
updated_at: ColumnType<Date, Date | undefined, Date | undefined>;
|
||||||
|
last_seen_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokensTable {
|
||||||
|
id: Generated<string>;
|
||||||
|
user_id: string;
|
||||||
|
family_id: string;
|
||||||
|
token_hash: string;
|
||||||
|
user_agent: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
created_at: Generated<Date>;
|
||||||
|
last_used_at: ColumnType<Date, Date | undefined, Date | undefined>;
|
||||||
|
expires_at: Date;
|
||||||
|
revoked_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
users: UsersTable;
|
||||||
|
refresh_tokens: RefreshTokensTable;
|
||||||
|
}
|
||||||
|
|
|
||||||
49
packages/backend/src/modules/auth/auth.plugin.ts
Normal file
49
packages/backend/src/modules/auth/auth.plugin.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
}
|
||||||
|
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' },
|
||||||
|
);
|
||||||
80
packages/backend/src/modules/auth/auth.repository.ts
Normal file
80
packages/backend/src/modules/auth/auth.repository.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import type { Kysely, Selectable } from 'kysely';
|
||||||
|
import type { Database, RefreshTokensTable } from '../../db/schema';
|
||||||
|
|
||||||
|
export type RefreshTokenRow = Selectable<RefreshTokensTable>;
|
||||||
|
|
||||||
|
export interface NewRefreshToken {
|
||||||
|
userId: string;
|
||||||
|
familyId: string;
|
||||||
|
tokenHash: string;
|
||||||
|
userAgent: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokensRepository {
|
||||||
|
insert(input: NewRefreshToken): Promise<RefreshTokenRow>;
|
||||||
|
findByHash(tokenHash: string): Promise<RefreshTokenRow | undefined>;
|
||||||
|
revokeById(id: string): Promise<void>;
|
||||||
|
revokeFamily(familyId: string): Promise<void>;
|
||||||
|
revokeAllForUser(userId: string): Promise<void>;
|
||||||
|
listActiveForUser(userId: string): Promise<RefreshTokenRow[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRefreshTokensRepository = (
|
||||||
|
db: Kysely<Database>,
|
||||||
|
): 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(),
|
||||||
|
});
|
||||||
154
packages/backend/src/modules/auth/auth.routes.ts
Normal file
154
packages/backend/src/modules/auth/auth.routes.ts
Normal file
|
|
@ -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<void> => {
|
||||||
|
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();
|
||||||
|
};
|
||||||
189
packages/backend/src/modules/auth/auth.service.ts
Normal file
189
packages/backend/src/modules/auth/auth.service.ts
Normal file
|
|
@ -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<AuthResult>;
|
||||||
|
login(body: LoginBody, ctx: RequestContext): Promise<AuthResult>;
|
||||||
|
refresh(refreshToken: string, ctx: RequestContext): Promise<AuthResult>;
|
||||||
|
logout(refreshToken: string): Promise<void>;
|
||||||
|
logoutAll(userId: string): Promise<void>;
|
||||||
|
listSessions(userId: string, currentRefreshToken: string | undefined): Promise<Session[]>;
|
||||||
|
centrifugoToken(userId: string): Promise<CentrifugoToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AuthTokens> => {
|
||||||
|
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<AuthResult> => ({
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/backend/src/modules/auth/index.ts
Normal file
12
packages/backend/src/modules/auth/index.ts
Normal file
|
|
@ -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';
|
||||||
8
packages/backend/src/modules/auth/password.ts
Normal file
8
packages/backend/src/modules/auth/password.ts
Normal file
|
|
@ -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<string> => hash(password);
|
||||||
|
|
||||||
|
export const verifyPassword = (passwordHash: string, password: string): Promise<boolean> =>
|
||||||
|
verify(passwordHash, password);
|
||||||
59
packages/backend/src/modules/auth/tokens.ts
Normal file
59
packages/backend/src/modules/auth/tokens.ts
Normal file
|
|
@ -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<string> =>
|
||||||
|
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<AccessTokenClaims> => {
|
||||||
|
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<string> =>
|
||||||
|
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');
|
||||||
11
packages/backend/src/modules/users/index.ts
Normal file
11
packages/backend/src/modules/users/index.ts
Normal file
|
|
@ -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';
|
||||||
22
packages/backend/src/modules/users/users.mapper.ts
Normal file
22
packages/backend/src/modules/users/users.mapper.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
69
packages/backend/src/modules/users/users.repository.ts
Normal file
69
packages/backend/src/modules/users/users.repository.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { Kysely, Selectable, Updateable } from 'kysely';
|
||||||
|
import type { Database, UsersTable } from '../../db/schema';
|
||||||
|
|
||||||
|
export type UserRow = Selectable<UsersTable>;
|
||||||
|
|
||||||
|
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<UserRow>;
|
||||||
|
findByUsername(username: string): Promise<UserRow | undefined>;
|
||||||
|
findById(id: string): Promise<UserRow | undefined>;
|
||||||
|
updateProfile(id: string, patch: ProfilePatch): Promise<UserRow | undefined>;
|
||||||
|
touchLastSeen(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUsersRepository = (db: Kysely<Database>): 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<UsersTable> = { 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();
|
||||||
|
},
|
||||||
|
});
|
||||||
74
packages/backend/src/modules/users/users.routes.ts
Normal file
74
packages/backend/src/modules/users/users.routes.ts
Normal file
|
|
@ -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<void> => {
|
||||||
|
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();
|
||||||
|
};
|
||||||
42
packages/backend/src/modules/users/users.service.ts
Normal file
42
packages/backend/src/modules/users/users.service.ts
Normal file
|
|
@ -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<User>;
|
||||||
|
getPublicProfile(username: string): Promise<PublicUser>;
|
||||||
|
updateProfile(userId: string, patch: UpdateMeBody): Promise<User>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/backend/src/plugins/rate-limit.ts
Normal file
14
packages/backend/src/plugins/rate-limit.ts
Normal file
|
|
@ -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'] },
|
||||||
|
);
|
||||||
41
packages/backend/src/plugins/swagger.ts
Normal file
41
packages/backend/src/plugins/swagger.ts
Normal file
|
|
@ -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' },
|
||||||
|
);
|
||||||
|
|
@ -64,9 +64,11 @@ const checkMinio = async (app: FastifyInstance): Promise<boolean> => {
|
||||||
// /health — liveness: the process is up and serving (used by Docker HEALTHCHECK).
|
// /health — liveness: the process is up and serving (used by Docker HEALTHCHECK).
|
||||||
// /ready — readiness: every backing service is reachable (pg + redis + minio).
|
// /ready — readiness: every backing service is reachable (pg + redis + minio).
|
||||||
export const healthRoutes = (app: FastifyInstance): Promise<void> => {
|
export const healthRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
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([
|
const [postgres, redis, minio] = await Promise.all([
|
||||||
withTimeout(checkPostgres(app)),
|
withTimeout(checkPostgres(app)),
|
||||||
withTimeout(checkRedis(app)),
|
withTimeout(checkRedis(app)),
|
||||||
|
|
|
||||||
13
packages/backend/src/shared/http-error.ts
Normal file
13
packages/backend/src/shared/http-error.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,15 @@
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./channels": "./src/channels/index.ts",
|
"./channels": "./src/channels/index.ts",
|
||||||
"./events": "./src/events/index.ts",
|
"./events": "./src/events/index.ts",
|
||||||
"./types": "./src/types/index.ts"
|
"./types": "./src/types/index.ts",
|
||||||
|
"./schemas": "./src/schemas/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --noEmit",
|
"build": "tsc --noEmit",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
export * from './channels/index';
|
export * from './channels/index';
|
||||||
export * from './events/index';
|
export * from './events/index';
|
||||||
export * from './types/index';
|
export * from './types/index';
|
||||||
|
export * from './schemas/index';
|
||||||
|
|
|
||||||
69
packages/core/src/schemas/auth.ts
Normal file
69
packages/core/src/schemas/auth.ts
Normal file
|
|
@ -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<typeof registerBodySchema>;
|
||||||
|
export type LoginBody = FromSchema<typeof loginBodySchema>;
|
||||||
|
export type RefreshBody = FromSchema<typeof refreshBodySchema>;
|
||||||
|
export type LogoutBody = FromSchema<typeof logoutBodySchema>;
|
||||||
|
export type UpdateMeBody = FromSchema<typeof updateMeBodySchema>;
|
||||||
99
packages/core/src/schemas/entities.ts
Normal file
99
packages/core/src/schemas/entities.ts
Normal file
|
|
@ -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;
|
||||||
24
packages/core/src/schemas/index.ts
Normal file
24
packages/core/src/schemas/index.ts
Normal file
|
|
@ -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';
|
||||||
30
packages/core/src/types/auth.ts
Normal file
30
packages/core/src/types/auth.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,6 @@
|
||||||
// Shared payload/domain types. Placeholder shapes for Phase 0 — real message /
|
// Bumped as `core` evolves; clients can log/verify the shared contract version.
|
||||||
// user / API types (and their JSON Schemas) are filled in per feature phase.
|
export const CORE_VERSION = '0.1.0';
|
||||||
|
|
||||||
/** Bumped as `core` evolves; clients can log/verify the shared contract version. */
|
export type { User, PublicUser } from './user';
|
||||||
export const CORE_VERSION = '0.0.0';
|
export type { AuthTokens, AuthResult, Session, CentrifugoToken } from './auth';
|
||||||
|
export type { Message } from './message';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
9
packages/core/src/types/message.ts
Normal file
9
packages/core/src/types/message.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
20
packages/core/src/types/user.ts
Normal file
20
packages/core/src/types/user.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,35 @@
|
||||||
import { base } from '../../eslint.config.mjs';
|
import { base } from '../../eslint.config.mjs';
|
||||||
import boundaries from 'eslint-plugin-boundaries';
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
// Strict Feature-Sliced Design: a layer may import only from itself and the
|
// 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 FSD_LAYERS = ['app', 'pages', 'widgets', 'features', 'entities', 'shared'];
|
||||||
|
|
||||||
const fsdPolicies = FSD_LAYERS.map((layer, index) => ({
|
const fsdLayerOverrides = FSD_LAYERS.flatMap((layer, index) => {
|
||||||
from: { element: { types: layer } },
|
const higherLayers = FSD_LAYERS.slice(0, index);
|
||||||
allow: { to: { element: { types: { anyOf: FSD_LAYERS.slice(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 [
|
export default [
|
||||||
{ ignores: ['dist/**'] },
|
{ ignores: ['dist/**'] },
|
||||||
|
|
@ -21,24 +40,12 @@ export default [
|
||||||
globals: { ...globals.browser },
|
globals: { ...globals.browser },
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
boundaries,
|
|
||||||
'react-hooks': reactHooks,
|
'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: {
|
rules: {
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
'boundaries/dependencies': ['error', { default: 'disallow', policies: fsdPolicies }],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...fsdLayerOverrides,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
646
pnpm-lock.yaml
646
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue