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
|
||||
# Internal URL of the Centrifugo HTTP API (service name on the compose network).
|
||||
CENTRIFUGO_API_URL=http://centrifugo:8000/api
|
||||
# Lifetime of the Centrifugo connection token the backend mints.
|
||||
CENTRIFUGO_TOKEN_TTL=1h
|
||||
|
||||
# --- Postgres ---
|
||||
POSTGRES_HOST=postgres
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -56,11 +56,24 @@ pnpm --filter @altricade/web dev
|
|||
Endpoints (via the dev override):
|
||||
|
||||
- Gateway (nginx): http://localhost:8080
|
||||
- **API docs (Swagger UI): http://localhost:8080/docs** (or http://localhost:4000/docs)
|
||||
- API health: http://localhost:8080/api/health → `{ "status": "ok" }`
|
||||
- API readiness: http://localhost:8080/api/ready → 200 only when Postgres + Redis + MinIO are reachable
|
||||
- Web client: http://localhost:5173
|
||||
- MinIO console: http://localhost:9001
|
||||
|
||||
### Testing the API with Swagger
|
||||
|
||||
Open the Swagger UI, then:
|
||||
|
||||
1. `POST /auth/register` (or `/auth/login`) and copy `tokens.accessToken` from the response.
|
||||
2. Click **Authorize** (top right), paste the access token, and authorize.
|
||||
3. Protected endpoints (`/me`, `/auth/sessions`, `/auth/centrifugo-token`, …) now work from "Try it out".
|
||||
|
||||
> Dev note: the backend runs in Docker with `node_modules` baked into the image.
|
||||
> After changing backend dependencies, recreate the container so it picks them up:
|
||||
> `docker compose up -d --build --force-recreate backend`.
|
||||
|
||||
## Quality gates (enforced mechanically — a violation fails the build)
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ http {
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
# Emit path-only redirects so the externally-mapped port isn't dropped.
|
||||
absolute_redirect off;
|
||||
|
||||
# REST API — strip the /api prefix before proxying to the backend.
|
||||
location /api/ {
|
||||
|
|
@ -26,6 +28,20 @@ http {
|
|||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Swagger UI + OpenAPI spec — served by the backend at /docs (HTML, static
|
||||
# assets and /docs/json all live under this prefix; pass through unmodified).
|
||||
# Redirect the slashless form so the UI's relative asset paths resolve.
|
||||
location = /docs {
|
||||
return 301 /docs/;
|
||||
}
|
||||
location /docs {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Realtime WebSocket — Centrifugo (receive-only client socket).
|
||||
location /connection/websocket {
|
||||
proxy_pass http://centrifugo;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-boundaries": "^7.0.2",
|
||||
"globals": "^17.7.0",
|
||||
"prettier": "^3.9.5",
|
||||
"turbo": "^2.10.4",
|
||||
|
|
|
|||
|
|
@ -1,45 +1,34 @@
|
|||
import { base } from '../../eslint.config.mjs';
|
||||
import boundaries from 'eslint-plugin-boundaries';
|
||||
|
||||
// Backend architecture layering. The folder-based layers are classified and the
|
||||
// import direction between them is enforced:
|
||||
// routes → may import plugins, db
|
||||
// plugins → may import db
|
||||
// db → leaf (types only)
|
||||
// (src/app.ts, src/server.ts, src/config.ts are the composition root + leaf
|
||||
// config — unclassified in Phase 0.) The full per-domain module structure with
|
||||
// route → service → repository layering is introduced in Phase 1.
|
||||
// Backend architecture layering (route → service → repository → db), enforced
|
||||
// with path-based no-restricted-imports. Boundaries' element model is folder-
|
||||
// oriented and our feature modules are flat files, so we enforce the key edges
|
||||
// directly and reliably:
|
||||
// - routes may NOT import repositories or the db layer (must go via a service)
|
||||
// - services may NOT import the db layer (must go via a repository)
|
||||
// - only repositories (and the db plugin) touch src/db
|
||||
const noDbLayer = {
|
||||
group: ['**/db', '**/db/*'],
|
||||
message: 'Only repositories may access the db layer — go through a repository.',
|
||||
};
|
||||
const noRepository = {
|
||||
group: ['**/*.repository'],
|
||||
message: 'Routes must go through a service, not a repository directly.',
|
||||
};
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/**', 'migrations/**'] },
|
||||
...base,
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
plugins: { boundaries },
|
||||
settings: {
|
||||
'boundaries/include': ['src/**/*'],
|
||||
'boundaries/elements': [
|
||||
{ type: 'plugins', pattern: 'src/plugins/*' },
|
||||
{ type: 'routes', pattern: 'src/routes/*' },
|
||||
{ type: 'db', pattern: 'src/db/*' },
|
||||
],
|
||||
},
|
||||
files: ['src/modules/*/*.routes.ts'],
|
||||
rules: {
|
||||
'boundaries/dependencies': [
|
||||
'error',
|
||||
{
|
||||
default: 'disallow',
|
||||
policies: [
|
||||
{
|
||||
from: { element: { types: 'routes' } },
|
||||
allow: { to: { element: { types: { anyOf: ['plugins', 'db'] } } } },
|
||||
},
|
||||
{
|
||||
from: { element: { types: 'plugins' } },
|
||||
allow: { to: { element: { types: 'db' } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': ['error', { patterns: [noRepository, noDbLayer] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/modules/*/*.service.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', { patterns: [noDbLayer] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
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": {
|
||||
"@altricade/core": "workspace:^",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/rate-limit": "^11.1.0",
|
||||
"@fastify/swagger": "^9.8.0",
|
||||
"@fastify/swagger-ui": "^6.1.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"fastify": "^5.10.0",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"jose": "^6.2.3",
|
||||
"kysely": "^0.29.3",
|
||||
"minio": "^8.0.7",
|
||||
"node-pg-migrate": "^8.0.4",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import Fastify from 'fastify';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifyError, FastifyInstance } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { roomChannel } from '@altricade/core';
|
||||
import { loadConfig } from './config';
|
||||
import type { AppConfig } from './config';
|
||||
import { HttpError } from './shared/http-error';
|
||||
import { dbPlugin } from './plugins/db';
|
||||
import { redisPlugin } from './plugins/redis';
|
||||
import { minioPlugin } from './plugins/minio';
|
||||
import { centrifugoPlugin } from './plugins/centrifugo';
|
||||
import { rateLimitPlugin } from './plugins/rate-limit';
|
||||
import { swaggerPlugin } from './plugins/swagger';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createUsersRepository, createUsersService, usersRoutes } from './modules/users';
|
||||
import { createRefreshTokensRepository, createAuthService, authPlugin, authRoutes } from './modules/auth';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
|
|
@ -16,7 +21,7 @@ declare module 'fastify' {
|
|||
}
|
||||
}
|
||||
|
||||
// Composition root: assemble the Fastify app from config + plugins + routes.
|
||||
// Composition root: assemble the Fastify app from config + plugins + modules.
|
||||
// Kept side-effect-free (no listen) so it can be reused by tests later.
|
||||
export const buildApp = async (config: AppConfig = loadConfig()): Promise<FastifyInstance> => {
|
||||
const app = Fastify({
|
||||
|
|
@ -25,14 +30,69 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
|||
|
||||
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(dbPlugin);
|
||||
await app.register(redisPlugin);
|
||||
await app.register(minioPlugin);
|
||||
await app.register(centrifugoPlugin);
|
||||
await app.register(healthRoutes);
|
||||
await app.register(rateLimitPlugin);
|
||||
// Registered before routes so it can collect their schemas into the OpenAPI doc.
|
||||
await app.register(swaggerPlugin);
|
||||
|
||||
// Modules: build repositories + services now that `db` is available, decorate.
|
||||
const usersRepository = createUsersRepository(app.db);
|
||||
const refreshTokens = createRefreshTokensRepository(app.db);
|
||||
app.decorate('usersService', createUsersService(usersRepository));
|
||||
app.decorate(
|
||||
'authService',
|
||||
createAuthService({
|
||||
users: usersRepository,
|
||||
tokens: refreshTokens,
|
||||
config: {
|
||||
accessSecret: config.auth.accessSecret,
|
||||
accessTtlSeconds: config.auth.accessTtlSeconds,
|
||||
refreshTtlSeconds: config.auth.refreshTtlSeconds,
|
||||
centrifugoSecret: config.centrifugo.tokenHmacSecret,
|
||||
centrifugoTtlSeconds: config.centrifugo.tokenTtlSeconds,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Auth preHandler decorator must exist before routes that use it register.
|
||||
await app.register(authPlugin);
|
||||
|
||||
// Routes.
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes, { prefix: '/auth' });
|
||||
await app.register(usersRoutes);
|
||||
|
||||
// Proves the @altricade/core shared kernel is imported and callable.
|
||||
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);
|
||||
|
||||
return app;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export interface CentrifugoConfig {
|
|||
apiUrl: string;
|
||||
apiKey: string;
|
||||
tokenHmacSecret: string;
|
||||
tokenTtlSeconds: number;
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
accessSecret: string;
|
||||
accessTtlSeconds: number;
|
||||
refreshTtlSeconds: number;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
|
|
@ -27,6 +34,7 @@ export interface AppConfig {
|
|||
redisUrl: string;
|
||||
minio: MinioConfig;
|
||||
centrifugo: CentrifugoConfig;
|
||||
auth: AuthConfig;
|
||||
}
|
||||
|
||||
const required = (name: string): string => {
|
||||
|
|
@ -55,6 +63,20 @@ const parsePort = (name: string, raw: string): number => {
|
|||
|
||||
const parseBoolean = (raw: string): boolean => raw.toLowerCase() === 'true';
|
||||
|
||||
// Parse a duration like "900", "15s", "15m", "1h", "30d" into seconds.
|
||||
const DURATION_UNITS: Record<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 => ({
|
||||
nodeEnv: optional('NODE_ENV', 'development'),
|
||||
port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')),
|
||||
|
|
@ -75,5 +97,11 @@ export const loadConfig = (): AppConfig => ({
|
|||
apiUrl: required('CENTRIFUGO_API_URL'),
|
||||
apiKey: required('CENTRIFUGO_API_KEY'),
|
||||
tokenHmacSecret: required('CENTRIFUGO_TOKEN_HMAC_SECRET'),
|
||||
tokenTtlSeconds: parseDurationSeconds('CENTRIFUGO_TOKEN_TTL', optional('CENTRIFUGO_TOKEN_TTL', '1h')),
|
||||
},
|
||||
auth: {
|
||||
accessSecret: required('JWT_ACCESS_SECRET'),
|
||||
accessTtlSeconds: parseDurationSeconds('ACCESS_TOKEN_TTL', optional('ACCESS_TOKEN_TTL', '15m')),
|
||||
refreshTtlSeconds: parseDurationSeconds('REFRESH_TOKEN_TTL', optional('REFRESH_TOKEN_TTL', '30d')),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,34 @@
|
|||
// The Kysely database registry: one property per table. Empty in Phase 0 —
|
||||
// tables (users, refresh_tokens, rooms, messages, ...) are added here in lockstep
|
||||
// with node-pg-migrate migrations from Phase 1 onward.
|
||||
export type Database = Record<string, never>;
|
||||
import type { ColumnType, Generated } from 'kysely';
|
||||
|
||||
// Kysely database registry: one interface per table. Grows with each migration.
|
||||
|
||||
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).
|
||||
// /ready — readiness: every backing service is reachable (pg + redis + minio).
|
||||
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([
|
||||
withTimeout(checkPostgres(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",
|
||||
"./channels": "./src/channels/index.ts",
|
||||
"./events": "./src/events/index.ts",
|
||||
"./types": "./src/types/index.ts"
|
||||
"./types": "./src/types/index.ts",
|
||||
"./schemas": "./src/schemas/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
export * from './channels/index';
|
||||
export * from './events/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 /
|
||||
// user / API types (and their JSON Schemas) are filled in per feature phase.
|
||||
// Bumped as `core` evolves; clients can log/verify the shared contract version.
|
||||
export const CORE_VERSION = '0.1.0';
|
||||
|
||||
/** Bumped as `core` evolves; clients can log/verify the shared contract version. */
|
||||
export const CORE_VERSION = '0.0.0';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
export type { User, PublicUser } from './user';
|
||||
export type { AuthTokens, AuthResult, Session, CentrifugoToken } from './auth';
|
||||
export type { Message } from './message';
|
||||
|
|
|
|||
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 boundaries from 'eslint-plugin-boundaries';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
|
||||
// Strict Feature-Sliced Design: a layer may import only from itself and the
|
||||
// layers below it. Upward imports (e.g. shared → features) are build errors.
|
||||
// layers below it. Upward imports are build errors. Enforced with path-based
|
||||
// no-restricted-imports (reliable across relative imports), one override per
|
||||
// layer forbidding every higher layer.
|
||||
const FSD_LAYERS = ['app', 'pages', 'widgets', 'features', 'entities', 'shared'];
|
||||
|
||||
const fsdPolicies = FSD_LAYERS.map((layer, index) => ({
|
||||
from: { element: { types: layer } },
|
||||
allow: { to: { element: { types: { anyOf: FSD_LAYERS.slice(index) } } } },
|
||||
}));
|
||||
const fsdLayerOverrides = FSD_LAYERS.flatMap((layer, index) => {
|
||||
const higherLayers = FSD_LAYERS.slice(0, index);
|
||||
if (higherLayers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
files: [`src/${layer}/**/*.{ts,tsx}`],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: higherLayers.map((higher) => ({
|
||||
group: [`**/${higher}`, `**/${higher}/**`],
|
||||
message: `FSD violation: layer '${layer}' may not import from the higher layer '${higher}'.`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/**'] },
|
||||
|
|
@ -21,24 +40,12 @@ export default [
|
|||
globals: { ...globals.browser },
|
||||
},
|
||||
plugins: {
|
||||
boundaries,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
settings: {
|
||||
'boundaries/include': ['src/**/*'],
|
||||
'boundaries/elements': [
|
||||
{ type: 'app', pattern: 'src/app/*' },
|
||||
{ type: 'pages', pattern: 'src/pages/*' },
|
||||
{ type: 'widgets', pattern: 'src/widgets/*' },
|
||||
{ type: 'features', pattern: 'src/features/*' },
|
||||
{ type: 'entities', pattern: 'src/entities/*' },
|
||||
{ type: 'shared', pattern: 'src/shared/*' },
|
||||
],
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'boundaries/dependencies': ['error', { default: 'disallow', policies: fsdPolicies }],
|
||||
},
|
||||
},
|
||||
...fsdLayerOverrides,
|
||||
];
|
||||
|
|
|
|||
646
pnpm-lock.yaml
646
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue