Realtime connection

This commit is contained in:
Заид Омар Медхат | Zaid Omar Medhat 2026-07-10 15:04:36 +05:00
parent 1fda5394c3
commit 96d496bf26
38 changed files with 1039 additions and 150 deletions

View file

@ -29,6 +29,9 @@ CENTRIFUGO_API_URL=http://centrifugo:8000/api
# Lifetime of the Centrifugo connection token the backend mints.
CENTRIFUGO_TOKEN_TTL=1h
# --- CORS (comma-separated allowlist; credentials mode, no wildcard) ---
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
# --- Postgres ---
POSTGRES_HOST=postgres
POSTGRES_PORT=5432

View file

@ -14,6 +14,7 @@
},
"dependencies": {
"@altricade/core": "workspace:^",
"@fastify/cookie": "^11.1.1",
"@fastify/cors": "^11.3.0",
"@fastify/rate-limit": "^11.1.0",
"@fastify/swagger": "^9.8.0",

View file

@ -1,6 +1,7 @@
import Fastify from 'fastify';
import type { FastifyError, FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import { roomChannel } from '@altricade/core';
import { loadConfig } from './config';
import type { AppConfig } from './config';
@ -14,6 +15,7 @@ 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';
import { realtimeRoutes } from './modules/realtime';
declare module 'fastify' {
interface FastifyInstance {
@ -57,7 +59,8 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
});
// Infrastructure plugins.
await app.register(cors, { origin: true, credentials: true });
await app.register(cors, { origin: config.corsOrigins, credentials: true });
await app.register(cookie);
await app.register(dbPlugin);
await app.register(redisPlugin);
await app.register(minioPlugin);
@ -92,6 +95,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
await app.register(healthRoutes);
await app.register(authRoutes, { prefix: '/auth' });
await app.register(usersRoutes);
await app.register(realtimeRoutes);
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);

View file

@ -32,6 +32,7 @@ export interface AppConfig {
port: number;
databaseUrl: string;
redisUrl: string;
corsOrigins: string[];
minio: MinioConfig;
centrifugo: CentrifugoConfig;
auth: AuthConfig;
@ -82,6 +83,10 @@ export const loadConfig = (): AppConfig => ({
port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')),
databaseUrl: required('DATABASE_URL'),
redisUrl: required('REDIS_URL'),
corsOrigins: optional('CORS_ORIGINS', 'http://localhost:5173,http://localhost:8080')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0),
minio: {
endpoint: required('MINIO_ENDPOINT'),
port: parsePort('MINIO_PORT', optional('MINIO_PORT', '9000')),

View file

@ -1,27 +1,50 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import type { FastifyInstance, FastifyReply, 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';
import type { AuthResult, RegisterBody, LoginBody } from '@altricade/core';
import type { IssuedAuth, RequestContext } from './auth.service';
const REFRESH_COOKIE = 'refresh_token';
const context = (request: FastifyRequest): RequestContext => ({
userAgent: request.headers['user-agent'] ?? null,
ip: request.ip,
});
// Public response body — deliberately omits the refresh token (cookie only).
const publicResult = (issued: IssuedAuth): AuthResult => ({
user: issued.user,
accessToken: issued.accessToken,
accessTokenExpiresIn: issued.accessTokenExpiresIn,
});
// 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> => {
const secure = app.config.nodeEnv === 'production';
const setRefreshCookie = (reply: FastifyReply, token: string): void => {
reply.setCookie(REFRESH_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure,
path: '/',
maxAge: app.config.auth.refreshTtlSeconds,
});
};
const clearRefreshCookie = (reply: FastifyReply): void => {
reply.clearCookie(REFRESH_COOKIE, { path: '/' });
};
app.post<{ Body: RegisterBody }>(
'/register',
{
@ -34,8 +57,9 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
config: authRateLimit,
},
async (request, reply) => {
const result = await app.authService.register(request.body, context(request));
return reply.code(201).send(result);
const issued = await app.authService.register(request.body, context(request));
setRefreshCookie(reply, issued.refreshToken);
return reply.code(201).send(publicResult(issued));
},
);
@ -51,39 +75,42 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
config: authRateLimit,
},
async (request, reply) => {
const result = await app.authService.login(request.body, context(request));
return reply.send(result);
const issued = await app.authService.login(request.body, context(request));
setRefreshCookie(reply, issued.refreshToken);
return reply.send(publicResult(issued));
},
);
app.post<{ Body: RefreshBody }>(
app.post(
'/refresh',
{
schema: {
tags: ['auth'],
summary: 'Rotate tokens (with reuse detection)',
body: refreshBodySchema,
response: { 200: authResultSchema },
summary: 'Rotate tokens using the refresh cookie (reuse detection)',
response: { 200: authResultSchema, 401: errorSchema },
},
config: authRateLimit,
},
async (request, reply) => {
const result = await app.authService.refresh(request.body.refreshToken, context(request));
return reply.send(result);
const token = request.cookies[REFRESH_COOKIE];
if (token === undefined) {
return reply.code(401).send({ error: 'invalid_token', message: 'Missing refresh token' });
}
const issued = await app.authService.refresh(token, context(request));
setRefreshCookie(reply, issued.refreshToken);
return reply.send(publicResult(issued));
},
);
app.post<{ Body: LogoutBody }>(
app.post(
'/logout',
{
schema: {
tags: ['auth'],
summary: 'Revoke a single refresh token (this device)',
body: logoutBodySchema,
},
},
{ schema: { tags: ['auth'], summary: 'Revoke the current refresh token (this device)' } },
async (request, reply) => {
await app.authService.logout(request.body.refreshToken);
const token = request.cookies[REFRESH_COOKIE];
if (token !== undefined) {
await app.authService.logout(token);
}
clearRefreshCookie(reply);
return reply.code(204).send();
},
);
@ -104,6 +131,7 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
return reply.code(401).send({ error: 'unauthorized' });
}
await app.authService.logoutAll(user.id);
clearRefreshCookie(reply);
return reply.code(204).send();
},
);
@ -124,7 +152,7 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
if (user === undefined) {
return reply.code(401).send({ error: 'unauthorized' });
}
const sessions = await app.authService.listSessions(user.id, undefined);
const sessions = await app.authService.listSessions(user.id, request.cookies[REFRESH_COOKIE]);
return reply.send(sessions);
},
);

View file

@ -1,12 +1,5 @@
import { randomUUID } from 'node:crypto';
import type {
AuthResult,
AuthTokens,
CentrifugoToken,
RegisterBody,
LoginBody,
Session,
} from '@altricade/core';
import type { CentrifugoToken, RegisterBody, LoginBody, Session, User } from '@altricade/core';
import { HttpError } from '../../shared/http-error';
import { toUser } from '../users';
import type { UsersRepository, UserRow } from '../users';
@ -32,6 +25,15 @@ export interface RequestContext {
ip: string | null;
}
// Internal result: includes the raw refresh token so the route can set the
// httpOnly cookie. The refresh token is NEVER returned in a response body.
export interface IssuedAuth {
user: User;
accessToken: string;
accessTokenExpiresIn: number;
refreshToken: string;
}
export interface AuthServiceDeps {
users: UsersRepository;
tokens: RefreshTokensRepository;
@ -39,9 +41,9 @@ export interface AuthServiceDeps {
}
export interface AuthService {
register(body: RegisterBody, ctx: RequestContext): Promise<AuthResult>;
login(body: LoginBody, ctx: RequestContext): Promise<AuthResult>;
refresh(refreshToken: string, ctx: RequestContext): Promise<AuthResult>;
register(body: RegisterBody, ctx: RequestContext): Promise<IssuedAuth>;
login(body: LoginBody, ctx: RequestContext): Promise<IssuedAuth>;
refresh(refreshToken: string, ctx: RequestContext): Promise<IssuedAuth>;
logout(refreshToken: string): Promise<void>;
logoutAll(userId: string): Promise<void>;
listSessions(userId: string, currentRefreshToken: string | undefined): Promise<Session[]>;
@ -71,11 +73,7 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
// exist (mitigates user enumeration via response time).
const dummyHash = hashPassword(randomUUID());
const issueTokens = async (
user: UserRow,
ctx: RequestContext,
familyId: string,
): Promise<AuthTokens> => {
const issue = async (user: UserRow, ctx: RequestContext, familyId: string): Promise<IssuedAuth> => {
const accessToken = await signAccessToken(config.accessSecret, config.accessTtlSeconds, {
sub: user.id,
username: user.username,
@ -90,14 +88,14 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
ip: ctx.ip,
expiresAt,
});
return { accessToken, refreshToken, accessTokenExpiresIn: config.accessTtlSeconds };
return {
user: toUser(user),
accessToken,
accessTokenExpiresIn: config.accessTtlSeconds,
refreshToken,
};
};
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);
@ -109,7 +107,7 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
email: body.email ?? null,
phone: body.phone ?? null,
});
return await asResult(user, ctx, randomUUID());
return await issue(user, ctx, randomUUID());
} catch (error) {
if (isUniqueViolation(error)) {
throw new HttpError(409, 'username_taken', 'Username is already taken');
@ -121,7 +119,6 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
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');
}
@ -129,7 +126,7 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
if (!ok) {
throw new HttpError(401, 'invalid_credentials', 'Invalid credentials');
}
return asResult(user, ctx, randomUUID());
return issue(user, ctx, randomUUID());
},
refresh: async (refreshToken, ctx) => {
@ -138,7 +135,6 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
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');
}
@ -150,7 +146,7 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
}
await tokens.revokeById(row.id);
return asResult(user, ctx, row.family_id);
return issue(user, ctx, row.family_id);
},
logout: async (refreshToken) => {

View file

@ -6,6 +6,7 @@ export type {
AuthServiceConfig,
AuthServiceDeps,
RequestContext,
IssuedAuth,
} from './auth.service';
export { authPlugin } from './auth.plugin';
export type { AuthUser } from './auth.plugin';

View file

@ -0,0 +1,3 @@
export { authorizeSubscription } from './realtime.service';
export type { SubscribeDecision } from './realtime.service';
export { realtimeRoutes } from './realtime.routes';

View file

@ -0,0 +1,68 @@
import type { FastifyInstance } from 'fastify';
import { userChannel } from '@altricade/core';
import { authorizeSubscription } from './realtime.service';
// Centrifugo subscribe-proxy payload (subset we use); Centrifugo sends more fields.
const subscribeProxyBodySchema = {
type: 'object',
required: ['channel'],
additionalProperties: true,
properties: {
user: { type: 'string' },
channel: { type: 'string' },
},
} as const;
const echoBodySchema = {
type: 'object',
required: ['text'],
additionalProperties: false,
properties: { text: { type: 'string', minLength: 1, maxLength: 500 } },
} as const;
export const realtimeRoutes = (app: FastifyInstance): Promise<void> => {
// Called by Centrifugo on every subscribe. Internal network only (nginx never
// exposes /centrifugo/*). Must return 200 with {result:{}} to allow or
// {error:{code,message}} to deny — Centrifugo's proxy contract.
app.post<{ Body: { user?: string; channel: string } }>(
'/centrifugo/subscribe',
{ schema: { body: subscribeProxyBodySchema } },
(request, reply) => {
const { user, channel } = request.body;
const decision = authorizeSubscription(user ?? '', channel);
if (!decision.allowed) {
return reply.send({ error: { code: decision.errorCode, message: decision.errorMessage } });
}
return reply.send({ result: {} });
},
);
// Realtime smoke test: publish a debug event to the caller's personal channel,
// demonstrating the full backend → Centrifugo → client publish path.
app.post<{ Body: { text: string } }>(
'/realtime/echo',
{
schema: {
tags: ['system'],
summary: 'Publish a debug.echo event to your own personal channel',
security: [{ bearerAuth: [] }],
body: echoBodySchema,
},
preHandler: app.authenticate,
},
async (request, reply) => {
const authUser = request.authUser;
if (authUser === undefined) {
return reply.code(401).send({ error: 'unauthorized' });
}
await app.centrifugo.publish(userChannel(authUser.id), {
type: 'debug.echo',
text: request.body.text,
at: new Date().toISOString(),
});
return reply.code(202).send({ status: 'published' });
},
);
return Promise.resolve();
};

View file

@ -0,0 +1,21 @@
import { isUserChannel, isRoomChannel, userChannel } from '@altricade/core';
export type SubscribeDecision =
| { allowed: true }
| { allowed: false; errorCode: number; errorMessage: string };
// Subscription authorization for the Centrifugo subscribe-proxy.
// user:<id> → allowed only for that user's own personal channel.
// room:<id> → denied for now; DB membership check lands in Phase 3.
export const authorizeSubscription = (userId: string, channel: string): SubscribeDecision => {
if (isUserChannel(channel)) {
if (channel === userChannel(userId)) {
return { allowed: true };
}
return { allowed: false, errorCode: 103, errorMessage: 'permission denied' };
}
if (isRoomChannel(channel)) {
return { allowed: false, errorCode: 103, errorMessage: 'room membership not available yet' };
}
return { allowed: false, errorCode: 103, errorMessage: 'unknown channel' };
};

View file

@ -8,7 +8,9 @@
"./channels": "./src/channels/index.ts",
"./events": "./src/events/index.ts",
"./types": "./src/types/index.ts",
"./schemas": "./src/schemas/index.ts"
"./schemas": "./src/schemas/index.ts",
"./api": "./src/api/index.ts",
"./realtime": "./src/realtime/index.ts"
},
"scripts": {
"build": "tsc --noEmit",
@ -16,6 +18,9 @@
"lint": "eslint ."
},
"dependencies": {
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"centrifuge": "^5.7.0",
"json-schema-to-ts": "^3.1.1"
}
}

View file

@ -0,0 +1,53 @@
import {
authResultSchema,
userSchema,
publicUserSchema,
sessionListSchema,
centrifugoTokenSchema,
} from '../schemas/index';
import type { RegisterBody, LoginBody, UpdateMeBody } from '../schemas/index';
import type { AuthResult, CentrifugoToken, Session, User, PublicUser } from '../types/index';
import { compileValidator, parse, requestJson } from './http';
import type { ApiClientConfig } from './http';
const authResultV = compileValidator<AuthResult>(authResultSchema);
const userV = compileValidator<User>(userSchema);
const publicUserV = compileValidator<PublicUser>(publicUserSchema);
const sessionsV = compileValidator<Session[]>(sessionListSchema);
const centrifugoTokenV = compileValidator<CentrifugoToken>(centrifugoTokenSchema);
export const register = async (config: ApiClientConfig, body: RegisterBody): Promise<AuthResult> =>
parse(authResultV, await requestJson(config, 'POST', '/auth/register', body));
export const login = async (config: ApiClientConfig, body: LoginBody): Promise<AuthResult> =>
parse(authResultV, await requestJson(config, 'POST', '/auth/login', body));
// Uses the httpOnly refresh cookie — no body.
export const refresh = async (config: ApiClientConfig): Promise<AuthResult> =>
parse(authResultV, await requestJson(config, 'POST', '/auth/refresh'));
export const logout = async (config: ApiClientConfig): Promise<void> => {
await requestJson(config, 'POST', '/auth/logout');
};
export const logoutAll = async (config: ApiClientConfig): Promise<void> => {
await requestJson(config, 'POST', '/auth/logout-all');
};
export const getMe = async (config: ApiClientConfig): Promise<User> =>
parse(userV, await requestJson(config, 'GET', '/me'));
export const updateMe = async (config: ApiClientConfig, body: UpdateMeBody): Promise<User> =>
parse(userV, await requestJson(config, 'PATCH', '/me', body));
export const getPublicProfile = async (
config: ApiClientConfig,
username: string,
): Promise<PublicUser> =>
parse(publicUserV, await requestJson(config, 'GET', `/users/${encodeURIComponent(username)}`));
export const listSessions = async (config: ApiClientConfig): Promise<Session[]> =>
parse(sessionsV, await requestJson(config, 'GET', '/auth/sessions'));
export const getCentrifugoToken = async (config: ApiClientConfig): Promise<CentrifugoToken> =>
parse(centrifugoTokenV, await requestJson(config, 'POST', '/auth/centrifugo-token'));

View file

@ -0,0 +1,82 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import type { ValidateFunction } from 'ajv';
const ajv = new Ajv({ allErrors: false, coerceTypes: false });
addFormats(ajv);
export const compileValidator = <T>(schema: object): ValidateFunction<T> =>
ajv.compile<T>(schema);
export class ApiError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
export interface ApiClientConfig {
/** Base URL for the REST API, e.g. '/api' (same-origin) or 'http://host/api'. */
baseUrl: string;
/** Supplies the current access token for the Authorization header, if any. */
getAccessToken?: () => string | null;
}
const toApiError = (status: number, json: unknown): ApiError => {
if (typeof json === 'object' && json !== null && 'error' in json) {
const code = typeof json.error === 'string' ? json.error : 'error';
const message =
'message' in json && typeof json.message === 'string' ? json.message : code;
return new ApiError(status, code, message);
}
return new ApiError(status, 'error', `Request failed with status ${String(status)}`);
};
// Perform a credentialed request (cookies included) and return the parsed body
// as `unknown`. Throws ApiError on non-2xx.
export const requestJson = async (
config: ApiClientConfig,
method: string,
path: string,
body?: unknown,
): Promise<unknown> => {
const headers: Record<string, string> = { accept: 'application/json' };
if (body !== undefined) {
headers['content-type'] = 'application/json';
}
const token = config.getAccessToken?.() ?? null;
if (token !== null) {
headers['authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${config.baseUrl}${path}`, {
method,
headers,
credentials: 'include',
body: body === undefined ? null : JSON.stringify(body),
});
const text = await response.text();
let json: unknown;
if (text.length > 0) {
json = JSON.parse(text);
}
if (!response.ok) {
throw toApiError(response.status, json);
}
return json;
};
// Validate `data` against a compiled validator, narrowing to T (no assertions).
export const parse = <T>(validator: ValidateFunction<T>, data: unknown): T => {
if (!validator(data)) {
throw new ApiError(500, 'invalid_response', 'Server response failed schema validation');
}
return data;
};

View file

@ -0,0 +1,15 @@
export { ApiError } from './http';
export type { ApiClientConfig } from './http';
export {
register,
login,
refresh,
logout,
logoutAll,
getMe,
updateMe,
getPublicProfile,
listSessions,
getCentrifugoToken,
} from './auth';
export { sendEcho } from './realtime';

View file

@ -0,0 +1,8 @@
import { requestJson } from './http';
import type { ApiClientConfig } from './http';
// Publish a debug.echo event to the caller's own personal channel (realtime
// smoke test). Requires a valid access token.
export const sendEcho = async (config: ApiClientConfig, text: string): Promise<void> => {
await requestJson(config, 'POST', '/realtime/echo', { text });
};

View file

@ -0,0 +1,74 @@
import { Centrifuge } from 'centrifuge';
import type { Subscription } from 'centrifuge';
export type ConnectionState = 'disconnected' | 'connecting' | 'connected';
export interface RealtimeEvent {
channel: string;
data: unknown;
}
export interface RealtimeClientOptions {
/** Centrifugo WebSocket URL, e.g. '/connection/websocket' (same-origin). */
url: string;
/** Supplies a fresh Centrifugo connection token (called on connect + refresh). */
getToken: () => Promise<string>;
onState?: (state: ConnectionState) => void;
onEvent?: (event: RealtimeEvent) => void;
}
// UI-agnostic wrapper over the Centrifugo SDK. Receive-only: the app never
// publishes through it (sends go over REST). Uses the platform's global
// WebSocket (browser + React Native).
export class RealtimeClient {
private readonly centrifuge: Centrifuge;
private readonly options: RealtimeClientOptions;
private readonly subscriptions = new Map<string, Subscription>();
constructor(options: RealtimeClientOptions) {
this.options = options;
this.centrifuge = new Centrifuge(options.url, {
getToken: () => options.getToken(),
});
this.centrifuge.on('connecting', () => {
options.onState?.('connecting');
});
this.centrifuge.on('connected', () => {
options.onState?.('connected');
});
this.centrifuge.on('disconnected', () => {
options.onState?.('disconnected');
});
}
connect(): void {
this.centrifuge.connect();
}
disconnect(): void {
this.centrifuge.disconnect();
}
subscribe(channel: string): void {
if (this.subscriptions.has(channel)) {
return;
}
const subscription = this.centrifuge.newSubscription(channel);
subscription.on('publication', (ctx) => {
const data: unknown = ctx.data;
this.options.onEvent?.({ channel, data });
});
subscription.subscribe();
this.subscriptions.set(channel, subscription);
}
unsubscribe(channel: string): void {
const subscription = this.subscriptions.get(channel);
if (subscription === undefined) {
return;
}
subscription.unsubscribe();
this.centrifuge.removeSubscription(subscription);
this.subscriptions.delete(channel);
}
}

View file

@ -0,0 +1,2 @@
export { RealtimeClient } from './client';
export type { ConnectionState, RealtimeEvent, RealtimeClientOptions } from './client';

View file

@ -32,24 +32,6 @@ export const loginBodySchema = {
},
} 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,
@ -64,6 +46,4 @@ export const updateMeBodySchema = {
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

@ -39,24 +39,16 @@ export const userSchema = {
},
} 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;
// The refresh token is delivered as an httpOnly cookie, so it is deliberately
// absent from the response body.
export const authResultSchema = {
type: 'object',
additionalProperties: false,
required: ['user', 'tokens'],
required: ['user', 'accessToken', 'accessTokenExpiresIn'],
properties: {
user: userSchema,
tokens: authTokensSchema,
accessToken: { type: 'string' },
accessTokenExpiresIn: { type: 'integer' },
},
} as const;

View file

@ -1,21 +1,8 @@
export {
registerBodySchema,
loginBodySchema,
refreshBodySchema,
logoutBodySchema,
updateMeBodySchema,
} from './auth';
export type {
RegisterBody,
LoginBody,
RefreshBody,
LogoutBody,
UpdateMeBody,
} from './auth';
export { registerBodySchema, loginBodySchema, updateMeBodySchema } from './auth';
export type { RegisterBody, LoginBody, UpdateMeBody } from './auth';
export {
publicUserSchema,
userSchema,
authTokensSchema,
authResultSchema,
sessionSchema,
sessionListSchema,

View file

@ -1,16 +1,12 @@
import type { User } from './user';
export interface AuthTokens {
accessToken: string;
refreshToken: string;
/** Access-token lifetime in seconds. */
accessTokenExpiresIn: number;
}
// Returned by register / login / refresh.
// Returned by register / login / refresh. The refresh token is delivered ONLY
// as an httpOnly cookie (never in the body), so it never appears here.
export interface AuthResult {
user: User;
tokens: AuthTokens;
accessToken: string;
/** Access-token lifetime in seconds. */
accessTokenExpiresIn: number;
}
// An active login session (one per device), from GET /auth/sessions.

View file

@ -2,5 +2,5 @@
export const CORE_VERSION = '0.1.0';
export type { User, PublicUser } from './user';
export type { AuthTokens, AuthResult, Session, CentrifugoToken } from './auth';
export type { AuthResult, Session, CentrifugoToken } from './auth';
export type { Message } from './message';

View file

@ -1,7 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src"
"rootDir": "src",
// DOM provides the Fetch API + WebSocket types used by the isomorphic
// api/realtime client layers (available in browsers, RN, and Node 18+).
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}

View file

@ -1,44 +1,76 @@
import type { ReactElement } from 'react';
import { CORE_VERSION, roomChannel } from '@altricade/core';
import type { User } from '@altricade/core';
import { SessionProvider, useSession } from '../entities/session';
import { AuthForm } from '../features/auth';
import { RealtimeProvider, ConnectionPanel } from '../features/realtime';
import { useTheme } from '../shared/theme';
import type { ThemePreference } from '../shared/theme';
import { env } from '../shared/config';
const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'];
export const App = (): ReactElement => {
const { preference, resolved, setPreference } = useTheme();
const ThemeSwitch = (): ReactElement => {
const { preference, setPreference } = useTheme();
return (
<main className="app">
<h1>Altricade</h1>
<p>Realtime chat &amp; calls Phase 0 skeleton.</p>
<dl>
<dt>core version</dt>
<dd>{CORE_VERSION}</dd>
<dt>example channel</dt>
<dd>{roomChannel('demo')}</dd>
<dt>API base</dt>
<dd>{env.apiUrl}</dd>
<dt>theme</dt>
<dd>
{resolved} (preference: {preference})
</dd>
</dl>
<div className="theme-switch">
{PREFERENCES.map((option) => (
<button
key={option}
type="button"
aria-pressed={preference === option}
onClick={() => {
setPreference(option);
}}
>
{option}
</button>
))}
</div>
</main>
<div className="theme-switch">
{PREFERENCES.map((option) => (
<button
key={option}
type="button"
aria-pressed={preference === option}
onClick={() => {
setPreference(option);
}}
>
{option}
</button>
))}
</div>
);
};
const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => (
<main className="app">
<h1>Altricade</h1>
<p>
Signed in as <strong>@{user.username}</strong> ({user.displayName})
</p>
<ConnectionPanel />
<div className="actions">
<button type="button" onClick={onLogout}>
Log out
</button>
</div>
<ThemeSwitch />
</main>
);
const Shell = (): ReactElement => {
const { status, user, logout } = useSession();
if (status === 'loading') {
return (
<main className="app">
<p>Loading</p>
</main>
);
}
if (user === null) {
return <AuthForm />;
}
return (
<RealtimeProvider>
<Dashboard
user={user}
onLogout={() => {
void logout();
}}
/>
</RealtimeProvider>
);
};
export const App = (): ReactElement => (
<SessionProvider>
<Shell />
</SessionProvider>
);

View file

@ -73,3 +73,74 @@ body {
border-color: var(--color-accent);
color: var(--color-accent);
}
.auth-form,
.echo-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 320px;
margin: 1rem 0;
}
.echo-form {
flex-direction: row;
}
.auth-form input,
.echo-form input {
padding: 0.5rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
}
.auth-form button,
.echo-form button,
.actions button {
padding: 0.5rem 1rem;
border-radius: 8px;
border: 1px solid var(--color-accent);
background: var(--color-accent);
color: #fff;
cursor: pointer;
}
.auth-form button:disabled,
.echo-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-toggle {
background: none;
border: none;
color: var(--color-accent);
cursor: pointer;
padding: 0;
}
.auth-error {
color: #e5484d;
}
.realtime dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 1rem;
}
.event-log {
font-family: ui-monospace, monospace;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.75rem 1.25rem;
max-height: 240px;
overflow-y: auto;
}
.actions {
margin: 1rem 0;
}

View file

@ -0,0 +1,2 @@
export { SessionProvider, useSession } from './model';
export type { SessionStatus, SessionContextValue } from './model';

View file

@ -0,0 +1,98 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import type { AuthResult, User, RegisterBody, LoginBody } from '@altricade/core';
import {
register as apiRegister,
login as apiLogin,
refresh as apiRefresh,
logout as apiLogout,
} from '@altricade/core/api';
import { apiConfig } from '../../shared/api';
import { setAccessToken } from '../../shared/auth-token';
export type SessionStatus = 'loading' | 'anonymous' | 'authenticated';
export interface SessionContextValue {
status: SessionStatus;
user: User | null;
register: (body: RegisterBody) => Promise<void>;
login: (body: LoginBody) => Promise<void>;
logout: () => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
export const SessionProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [status, setStatus] = useState<SessionStatus>('loading');
const [user, setUser] = useState<User | null>(null);
const applyAuth = useCallback((result: AuthResult): void => {
setAccessToken(result.accessToken);
setUser(result.user);
setStatus('authenticated');
}, []);
const clear = useCallback((): void => {
setAccessToken(null);
setUser(null);
setStatus('anonymous');
}, []);
// On load, try to restore the session from the httpOnly refresh cookie.
useEffect(() => {
let cancelled = false;
const bootstrap = async (): Promise<void> => {
try {
const result = await apiRefresh(apiConfig);
if (!cancelled) {
applyAuth(result);
}
} catch {
if (!cancelled) {
clear();
}
}
};
void bootstrap();
return () => {
cancelled = true;
};
}, [applyAuth, clear]);
const register = useCallback(
async (body: RegisterBody): Promise<void> => {
applyAuth(await apiRegister(apiConfig, body));
},
[applyAuth],
);
const login = useCallback(
async (body: LoginBody): Promise<void> => {
applyAuth(await apiLogin(apiConfig, body));
},
[applyAuth],
);
const logout = useCallback(async (): Promise<void> => {
try {
await apiLogout(apiConfig);
} finally {
clear();
}
}, [clear]);
const value = useMemo<SessionContextValue>(
() => ({ status, user, register, login, logout }),
[status, user, register, login, logout],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
};
export const useSession = (): SessionContextValue => {
const context = useContext(SessionContext);
if (context === null) {
throw new Error('useSession must be used within a SessionProvider');
}
return context;
};

View file

@ -0,0 +1 @@
export { AuthForm } from './ui/AuthForm';

View file

@ -0,0 +1,87 @@
import { useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react';
import { ApiError } from '@altricade/core/api';
import { useSession } from '../../../entities/session';
type Mode = 'login' | 'register';
export const AuthForm = (): ReactElement => {
const { login, register } = useSession();
const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (event: SyntheticEvent): Promise<void> => {
event.preventDefault();
setBusy(true);
setError(null);
try {
if (mode === 'login') {
await login({ username, password });
} else {
await register({ username, displayName, password });
}
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : 'Something went wrong');
} finally {
setBusy(false);
}
};
return (
<main className="app">
<h1>Altricade</h1>
<p>{mode === 'login' ? 'Log in' : 'Create an account'}</p>
<form
className="auth-form"
onSubmit={(event) => {
void submit(event);
}}
>
<input
placeholder="username"
autoComplete="username"
value={username}
onChange={(event) => {
setUsername(event.target.value);
}}
/>
{mode === 'register' ? (
<input
placeholder="display name"
value={displayName}
onChange={(event) => {
setDisplayName(event.target.value);
}}
/>
) : null}
<input
type="password"
placeholder="password"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
value={password}
onChange={(event) => {
setPassword(event.target.value);
}}
/>
{error !== null ? <p className="auth-error">{error}</p> : null}
<button type="submit" disabled={busy}>
{mode === 'login' ? 'Log in' : 'Register'}
</button>
</form>
<button
type="button"
className="auth-toggle"
onClick={() => {
setMode(mode === 'login' ? 'register' : 'login');
setError(null);
}}
>
{mode === 'login' ? 'Need an account? Register' : 'Have an account? Log in'}
</button>
</main>
);
};

View file

@ -0,0 +1,3 @@
export { RealtimeProvider, useRealtime } from './model';
export type { RealtimeContextValue } from './model';
export { ConnectionPanel } from './ui/ConnectionPanel';

View file

@ -0,0 +1,69 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { RealtimeClient } from '@altricade/core/realtime';
import type { ConnectionState, RealtimeEvent } from '@altricade/core/realtime';
import { getCentrifugoToken } from '@altricade/core/api';
import { userChannel } from '@altricade/core';
import { apiConfig } from '../../shared/api';
import { env } from '../../shared/config';
import { useSession } from '../../entities/session';
export interface RealtimeContextValue {
state: ConnectionState;
events: RealtimeEvent[];
channel: string | null;
}
const RealtimeContext = createContext<RealtimeContextValue | null>(null);
// Turn a same-origin path into an absolute ws/wss URL (Centrifugo needs a full URL).
const resolveWsUrl = (raw: string): string => {
if (raw.startsWith('ws://') || raw.startsWith('wss://')) {
return raw;
}
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${scheme}://${window.location.host}${raw}`;
};
export const RealtimeProvider = ({ children }: { children: ReactNode }): ReactElement => {
const { status, user } = useSession();
const [state, setState] = useState<ConnectionState>('disconnected');
const [events, setEvents] = useState<RealtimeEvent[]>([]);
const userId = user?.id ?? null;
const channel = userId === null ? null : userChannel(userId);
useEffect(() => {
if (status !== 'authenticated' || userId === null) {
return undefined;
}
const client = new RealtimeClient({
url: resolveWsUrl(env.wsUrl),
getToken: async () => (await getCentrifugoToken(apiConfig)).token,
onState: setState,
onEvent: (event) => {
setEvents((prev) => [...prev, event]);
},
});
client.connect();
client.subscribe(userChannel(userId));
return () => {
client.disconnect();
};
}, [status, userId]);
const value = useMemo<RealtimeContextValue>(
() => ({ state, events, channel }),
[state, events, channel],
);
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
};
export const useRealtime = (): RealtimeContextValue => {
const context = useContext(RealtimeContext);
if (context === null) {
throw new Error('useRealtime must be used within a RealtimeProvider');
}
return context;
};

View file

@ -0,0 +1,55 @@
import { useState } from 'react';
import type { ReactElement, SyntheticEvent } from 'react';
import { sendEcho, ApiError } from '@altricade/core/api';
import { apiConfig } from '../../../shared/api';
import { useRealtime } from '../model';
export const ConnectionPanel = (): ReactElement => {
const { state, events, channel } = useRealtime();
const [text, setText] = useState('hello');
const [error, setError] = useState<string | null>(null);
const echo = async (event: SyntheticEvent): Promise<void> => {
event.preventDefault();
setError(null);
try {
await sendEcho(apiConfig, text);
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : 'Echo failed');
}
};
return (
<section className="realtime">
<dl>
<dt>socket</dt>
<dd>{state}</dd>
<dt>channel</dt>
<dd>{channel ?? '—'}</dd>
</dl>
<form
className="echo-form"
onSubmit={(event) => {
void echo(event);
}}
>
<input
value={text}
onChange={(event) => {
setText(event.target.value);
}}
/>
<button type="submit" disabled={state !== 'connected'}>
Send echo
</button>
</form>
{error !== null ? <p className="auth-error">{error}</p> : null}
<h3>Events on {channel ?? 'your channel'}</h3>
<ul className="event-log">
{events.map((event, index) => (
<li key={index}>{JSON.stringify(event.data)}</li>
))}
</ul>
</section>
);
};

View file

@ -0,0 +1,9 @@
import type { ApiClientConfig } from '@altricade/core/api';
import { env } from '../config';
import { getAccessToken } from '../auth-token';
// Single API client config: same-origin base + the in-memory access token.
export const apiConfig: ApiClientConfig = {
baseUrl: env.apiUrl,
getAccessToken,
};

View file

@ -0,0 +1,10 @@
// In-memory holder for the current access token. The refresh token lives only
// in an httpOnly cookie; the access token is intentionally NOT persisted (it is
// re-obtained via /auth/refresh on load).
let accessToken: string | null = null;
export const getAccessToken = (): string | null => accessToken;
export const setAccessToken = (token: string | null): void => {
accessToken = token;
};

View file

@ -1,9 +1,12 @@
// Web runtime config. Vite inlines `import.meta.env.VITE_*` at build time.
// Web runtime config. Same-origin by default (Vite proxies /api and the
// WebSocket to the gateway), which keeps the refresh cookie working.
export interface WebEnv {
apiUrl: string;
wsUrl: string;
}
export const env: WebEnv = {
apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:8080',
apiUrl: import.meta.env.VITE_API_URL ?? '/api',
wsUrl: import.meta.env.VITE_WS_URL ?? '/connection/websocket',
};

View file

@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_WS_URL?: string;
}
interface ImportMeta {

View file

@ -1,10 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Proxy API + realtime through the dev server so the browser talks to a single
// origin (localhost:5173). This lets the httpOnly refresh cookie work over http
// with SameSite=Lax instead of requiring cross-origin SameSite=None; Secure.
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true },
'/connection/websocket': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
},
});

View file

@ -41,6 +41,9 @@ importers:
'@altricade/core':
specifier: workspace:^
version: link:../core
'@fastify/cookie':
specifier: ^11.1.1
version: 11.1.1
'@fastify/cors':
specifier: ^11.3.0
version: 11.3.0
@ -102,6 +105,15 @@ importers:
packages/core:
dependencies:
ajv:
specifier: ^8.20.0
version: 8.20.0
ajv-formats:
specifier: ^3.0.1
version: 3.0.1(ajv@8.20.0)
centrifuge:
specifier: ^5.7.0
version: 5.7.0
json-schema-to-ts:
specifier: ^3.1.1
version: 3.1.1
@ -432,6 +444,9 @@ packages:
'@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/cookie@11.1.1':
resolution: {integrity: sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==}
'@fastify/cors@11.3.0':
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
@ -617,6 +632,33 @@ packages:
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.5':
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
'@protobufjs/eventemitter@1.1.1':
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
'@protobufjs/fetch@1.1.1':
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.2':
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
'@rolldown/binding-android-arm64@1.1.5':
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1054,6 +1096,9 @@ packages:
caniuse-lite@1.0.30001803:
resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==}
centrifuge@5.7.0:
resolution: {integrity: sha512-Ptx7ELyVc7/KgzpadVlISTtdTWsuzumze5/vo9sH4RsvtFulJJMhmKr/cNDg6se1eKKbS6ZywIBl4eSZxqY3fw==}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@ -1095,6 +1140,10 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cookie@2.0.1:
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
engines: {node: '>=22'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -1213,6 +1262,10 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
fast-decode-uri-component@1.0.1:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
@ -1523,6 +1576,9 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
@ -1746,6 +1802,10 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
protobufjs@7.6.5:
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
engines: {node: '>=12.0.0'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@ -2380,6 +2440,11 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 3.1.3
'@fastify/cookie@11.1.1':
dependencies:
cookie: 2.0.1
fastify-plugin: 6.0.0
'@fastify/cors@11.3.0':
dependencies:
fastify-plugin: 6.0.0
@ -2565,6 +2630,26 @@ snapshots:
'@pinojs/redact@0.4.0': {}
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.5': {}
'@protobufjs/eventemitter@1.1.1': {}
'@protobufjs/fetch@1.1.1':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/float@1.0.2': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.2': {}
'@rolldown/binding-android-arm64@1.1.5':
optional: true
@ -2912,6 +2997,11 @@ snapshots:
caniuse-lite@1.0.30001803: {}
centrifuge@5.7.0:
dependencies:
events: 3.3.0
protobufjs: 7.6.5
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@ -2942,6 +3032,8 @@ snapshots:
cookie@1.1.1: {}
cookie@2.0.1: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -3086,6 +3178,8 @@ snapshots:
eventemitter3@5.0.4: {}
events@3.3.0: {}
fast-decode-uri-component@1.0.1: {}
fast-deep-equal@3.1.3: {}
@ -3381,6 +3475,8 @@ snapshots:
lodash@4.18.1: {}
long@5.3.2: {}
lru-cache@11.5.2: {}
lru-cache@5.1.1:
@ -3587,6 +3683,20 @@ snapshots:
process-warning@5.0.0: {}
protobufjs@7.6.5:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.5
'@protobufjs/eventemitter': 1.1.1
'@protobufjs/fetch': 1.1.1
'@protobufjs/float': 1.0.2
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.2
'@types/node': 26.1.1
long: 5.3.2
punycode@2.3.1: {}
query-string@7.1.3: