This commit is contained in:
Заид Омар Медхат | Zaid Omar Medhat 2026-07-10 14:16:10 +05:00
parent 62f20f8843
commit 1fda5394c3
37 changed files with 1736 additions and 360 deletions

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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",

View file

@ -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] }],
],
},
],
}, },
}, },
]; ];

View 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');
};

View file

@ -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",

View file

@ -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;

View file

@ -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')),
}, },
}); });

View file

@ -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;
}

View 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' },
);

View 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(),
});

View 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();
};

View 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;
}
}

View 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';

View 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);

View 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');

View 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';

View 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,
});

View 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();
},
});

View 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();
};

View 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;
}
}

View 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'] },
);

View 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' },
);

View file

@ -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)),

View 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;
}
}

View file

@ -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"
} }
} }

View file

@ -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';

View 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>;

View 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;

View 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';

View 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;
}

View file

@ -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;
}

View file

@ -0,0 +1,9 @@
// Placeholder message shape — the full model (server-assigned seq, dedupe id,
// media refs, edits, reactions) is defined in Phases 35.
export interface Message {
id: string;
conversationId: string;
senderId: string;
body: string;
createdAt: string;
}

View 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;
}

View file

@ -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,
]; ];

File diff suppressed because it is too large Load diff