Realtime connection
This commit is contained in:
parent
1fda5394c3
commit
96d496bf26
38 changed files with 1039 additions and 150 deletions
|
|
@ -29,6 +29,9 @@ CENTRIFUGO_API_URL=http://centrifugo:8000/api
|
||||||
# Lifetime of the Centrifugo connection token the backend mints.
|
# Lifetime of the Centrifugo connection token the backend mints.
|
||||||
CENTRIFUGO_TOKEN_TTL=1h
|
CENTRIFUGO_TOKEN_TTL=1h
|
||||||
|
|
||||||
|
# --- CORS (comma-separated allowlist; credentials mode, no wildcard) ---
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||||
|
|
||||||
# --- Postgres ---
|
# --- Postgres ---
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@altricade/core": "workspace:^",
|
"@altricade/core": "workspace:^",
|
||||||
|
"@fastify/cookie": "^11.1.1",
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
"@fastify/rate-limit": "^11.1.0",
|
"@fastify/rate-limit": "^11.1.0",
|
||||||
"@fastify/swagger": "^9.8.0",
|
"@fastify/swagger": "^9.8.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import type { FastifyError, FastifyInstance } from 'fastify';
|
import type { FastifyError, FastifyInstance } from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
import { roomChannel } from '@altricade/core';
|
import { roomChannel } from '@altricade/core';
|
||||||
import { loadConfig } from './config';
|
import { loadConfig } from './config';
|
||||||
import type { AppConfig } from './config';
|
import type { AppConfig } from './config';
|
||||||
|
|
@ -14,6 +15,7 @@ import { swaggerPlugin } from './plugins/swagger';
|
||||||
import { healthRoutes } from './routes/health';
|
import { healthRoutes } from './routes/health';
|
||||||
import { createUsersRepository, createUsersService, usersRoutes } from './modules/users';
|
import { createUsersRepository, createUsersService, usersRoutes } from './modules/users';
|
||||||
import { createRefreshTokensRepository, createAuthService, authPlugin, authRoutes } from './modules/auth';
|
import { createRefreshTokensRepository, createAuthService, authPlugin, authRoutes } from './modules/auth';
|
||||||
|
import { realtimeRoutes } from './modules/realtime';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
|
|
@ -57,7 +59,8 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
});
|
});
|
||||||
|
|
||||||
// Infrastructure plugins.
|
// 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(dbPlugin);
|
||||||
await app.register(redisPlugin);
|
await app.register(redisPlugin);
|
||||||
await app.register(minioPlugin);
|
await app.register(minioPlugin);
|
||||||
|
|
@ -92,6 +95,7 @@ export const buildApp = async (config: AppConfig = loadConfig()): Promise<Fastif
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(authRoutes, { prefix: '/auth' });
|
await app.register(authRoutes, { prefix: '/auth' });
|
||||||
await app.register(usersRoutes);
|
await app.register(usersRoutes);
|
||||||
|
await app.register(realtimeRoutes);
|
||||||
|
|
||||||
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);
|
app.log.info(`core wired — example channel: ${roomChannel('demo')}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export interface AppConfig {
|
||||||
port: number;
|
port: number;
|
||||||
databaseUrl: string;
|
databaseUrl: string;
|
||||||
redisUrl: string;
|
redisUrl: string;
|
||||||
|
corsOrigins: string[];
|
||||||
minio: MinioConfig;
|
minio: MinioConfig;
|
||||||
centrifugo: CentrifugoConfig;
|
centrifugo: CentrifugoConfig;
|
||||||
auth: AuthConfig;
|
auth: AuthConfig;
|
||||||
|
|
@ -82,6 +83,10 @@ export const loadConfig = (): AppConfig => ({
|
||||||
port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')),
|
port: parsePort('BACKEND_PORT', optional('BACKEND_PORT', '4000')),
|
||||||
databaseUrl: required('DATABASE_URL'),
|
databaseUrl: required('DATABASE_URL'),
|
||||||
redisUrl: required('REDIS_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: {
|
minio: {
|
||||||
endpoint: required('MINIO_ENDPOINT'),
|
endpoint: required('MINIO_ENDPOINT'),
|
||||||
port: parsePort('MINIO_PORT', optional('MINIO_PORT', '9000')),
|
port: parsePort('MINIO_PORT', optional('MINIO_PORT', '9000')),
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,50 @@
|
||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import {
|
import {
|
||||||
registerBodySchema,
|
registerBodySchema,
|
||||||
loginBodySchema,
|
loginBodySchema,
|
||||||
refreshBodySchema,
|
|
||||||
logoutBodySchema,
|
|
||||||
authResultSchema,
|
authResultSchema,
|
||||||
sessionListSchema,
|
sessionListSchema,
|
||||||
centrifugoTokenSchema,
|
centrifugoTokenSchema,
|
||||||
errorSchema,
|
errorSchema,
|
||||||
} from '@altricade/core';
|
} from '@altricade/core';
|
||||||
import type { RegisterBody, LoginBody, RefreshBody, LogoutBody } from '@altricade/core';
|
import type { AuthResult, RegisterBody, LoginBody } from '@altricade/core';
|
||||||
import type { RequestContext } from './auth.service';
|
import type { IssuedAuth, RequestContext } from './auth.service';
|
||||||
|
|
||||||
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
|
|
||||||
const context = (request: FastifyRequest): RequestContext => ({
|
const context = (request: FastifyRequest): RequestContext => ({
|
||||||
userAgent: request.headers['user-agent'] ?? null,
|
userAgent: request.headers['user-agent'] ?? null,
|
||||||
ip: request.ip,
|
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.
|
// Throttle credential endpoints to blunt stuffing / enumeration.
|
||||||
const authRateLimit = { rateLimit: { max: 10, timeWindow: '1 minute' } };
|
const authRateLimit = { rateLimit: { max: 10, timeWindow: '1 minute' } };
|
||||||
const bearerAuth = [{ bearerAuth: [] }];
|
const bearerAuth = [{ bearerAuth: [] }];
|
||||||
|
|
||||||
export const authRoutes = (app: FastifyInstance): Promise<void> => {
|
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 }>(
|
app.post<{ Body: RegisterBody }>(
|
||||||
'/register',
|
'/register',
|
||||||
{
|
{
|
||||||
|
|
@ -34,8 +57,9 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
config: authRateLimit,
|
config: authRateLimit,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const result = await app.authService.register(request.body, context(request));
|
const issued = await app.authService.register(request.body, context(request));
|
||||||
return reply.code(201).send(result);
|
setRefreshCookie(reply, issued.refreshToken);
|
||||||
|
return reply.code(201).send(publicResult(issued));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -51,39 +75,42 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
config: authRateLimit,
|
config: authRateLimit,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const result = await app.authService.login(request.body, context(request));
|
const issued = await app.authService.login(request.body, context(request));
|
||||||
return reply.send(result);
|
setRefreshCookie(reply, issued.refreshToken);
|
||||||
|
return reply.send(publicResult(issued));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.post<{ Body: RefreshBody }>(
|
app.post(
|
||||||
'/refresh',
|
'/refresh',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['auth'],
|
tags: ['auth'],
|
||||||
summary: 'Rotate tokens (with reuse detection)',
|
summary: 'Rotate tokens using the refresh cookie (reuse detection)',
|
||||||
body: refreshBodySchema,
|
response: { 200: authResultSchema, 401: errorSchema },
|
||||||
response: { 200: authResultSchema },
|
|
||||||
},
|
},
|
||||||
config: authRateLimit,
|
config: authRateLimit,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const result = await app.authService.refresh(request.body.refreshToken, context(request));
|
const token = request.cookies[REFRESH_COOKIE];
|
||||||
return reply.send(result);
|
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',
|
'/logout',
|
||||||
{
|
{ schema: { tags: ['auth'], summary: 'Revoke the current refresh token (this device)' } },
|
||||||
schema: {
|
|
||||||
tags: ['auth'],
|
|
||||||
summary: 'Revoke a single refresh token (this device)',
|
|
||||||
body: logoutBodySchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async (request, reply) => {
|
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();
|
return reply.code(204).send();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -104,6 +131,7 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
return reply.code(401).send({ error: 'unauthorized' });
|
return reply.code(401).send({ error: 'unauthorized' });
|
||||||
}
|
}
|
||||||
await app.authService.logoutAll(user.id);
|
await app.authService.logoutAll(user.id);
|
||||||
|
clearRefreshCookie(reply);
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -124,7 +152,7 @@ export const authRoutes = (app: FastifyInstance): Promise<void> => {
|
||||||
if (user === undefined) {
|
if (user === undefined) {
|
||||||
return reply.code(401).send({ error: 'unauthorized' });
|
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);
|
return reply.send(sessions);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type {
|
import type { CentrifugoToken, RegisterBody, LoginBody, Session, User } from '@altricade/core';
|
||||||
AuthResult,
|
|
||||||
AuthTokens,
|
|
||||||
CentrifugoToken,
|
|
||||||
RegisterBody,
|
|
||||||
LoginBody,
|
|
||||||
Session,
|
|
||||||
} from '@altricade/core';
|
|
||||||
import { HttpError } from '../../shared/http-error';
|
import { HttpError } from '../../shared/http-error';
|
||||||
import { toUser } from '../users';
|
import { toUser } from '../users';
|
||||||
import type { UsersRepository, UserRow } from '../users';
|
import type { UsersRepository, UserRow } from '../users';
|
||||||
|
|
@ -32,6 +25,15 @@ export interface RequestContext {
|
||||||
ip: string | null;
|
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 {
|
export interface AuthServiceDeps {
|
||||||
users: UsersRepository;
|
users: UsersRepository;
|
||||||
tokens: RefreshTokensRepository;
|
tokens: RefreshTokensRepository;
|
||||||
|
|
@ -39,9 +41,9 @@ export interface AuthServiceDeps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthService {
|
export interface AuthService {
|
||||||
register(body: RegisterBody, ctx: RequestContext): Promise<AuthResult>;
|
register(body: RegisterBody, ctx: RequestContext): Promise<IssuedAuth>;
|
||||||
login(body: LoginBody, ctx: RequestContext): Promise<AuthResult>;
|
login(body: LoginBody, ctx: RequestContext): Promise<IssuedAuth>;
|
||||||
refresh(refreshToken: string, ctx: RequestContext): Promise<AuthResult>;
|
refresh(refreshToken: string, ctx: RequestContext): Promise<IssuedAuth>;
|
||||||
logout(refreshToken: string): Promise<void>;
|
logout(refreshToken: string): Promise<void>;
|
||||||
logoutAll(userId: string): Promise<void>;
|
logoutAll(userId: string): Promise<void>;
|
||||||
listSessions(userId: string, currentRefreshToken: string | undefined): Promise<Session[]>;
|
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).
|
// exist (mitigates user enumeration via response time).
|
||||||
const dummyHash = hashPassword(randomUUID());
|
const dummyHash = hashPassword(randomUUID());
|
||||||
|
|
||||||
const issueTokens = async (
|
const issue = async (user: UserRow, ctx: RequestContext, familyId: string): Promise<IssuedAuth> => {
|
||||||
user: UserRow,
|
|
||||||
ctx: RequestContext,
|
|
||||||
familyId: string,
|
|
||||||
): Promise<AuthTokens> => {
|
|
||||||
const accessToken = await signAccessToken(config.accessSecret, config.accessTtlSeconds, {
|
const accessToken = await signAccessToken(config.accessSecret, config.accessTtlSeconds, {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|
@ -90,13 +88,13 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
||||||
ip: ctx.ip,
|
ip: ctx.ip,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
return { accessToken, refreshToken, accessTokenExpiresIn: config.accessTtlSeconds };
|
return {
|
||||||
};
|
|
||||||
|
|
||||||
const asResult = async (user: UserRow, ctx: RequestContext, familyId: string): Promise<AuthResult> => ({
|
|
||||||
user: toUser(user),
|
user: toUser(user),
|
||||||
tokens: await issueTokens(user, ctx, familyId),
|
accessToken,
|
||||||
});
|
accessTokenExpiresIn: config.accessTtlSeconds,
|
||||||
|
refreshToken,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
register: async (body, ctx) => {
|
register: async (body, ctx) => {
|
||||||
|
|
@ -109,7 +107,7 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
||||||
email: body.email ?? null,
|
email: body.email ?? null,
|
||||||
phone: body.phone ?? null,
|
phone: body.phone ?? null,
|
||||||
});
|
});
|
||||||
return await asResult(user, ctx, randomUUID());
|
return await issue(user, ctx, randomUUID());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUniqueViolation(error)) {
|
if (isUniqueViolation(error)) {
|
||||||
throw new HttpError(409, 'username_taken', 'Username is already taken');
|
throw new HttpError(409, 'username_taken', 'Username is already taken');
|
||||||
|
|
@ -121,7 +119,6 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
||||||
login: async (body, ctx) => {
|
login: async (body, ctx) => {
|
||||||
const user = await users.findByUsername(body.username);
|
const user = await users.findByUsername(body.username);
|
||||||
if (user === undefined) {
|
if (user === undefined) {
|
||||||
// Normalize timing against a dummy verify, then fail non-enumeratingly.
|
|
||||||
await verifyPassword(await dummyHash, body.password);
|
await verifyPassword(await dummyHash, body.password);
|
||||||
throw new HttpError(401, 'invalid_credentials', 'Invalid credentials');
|
throw new HttpError(401, 'invalid_credentials', 'Invalid credentials');
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +126,7 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new HttpError(401, 'invalid_credentials', 'Invalid credentials');
|
throw new HttpError(401, 'invalid_credentials', 'Invalid credentials');
|
||||||
}
|
}
|
||||||
return asResult(user, ctx, randomUUID());
|
return issue(user, ctx, randomUUID());
|
||||||
},
|
},
|
||||||
|
|
||||||
refresh: async (refreshToken, ctx) => {
|
refresh: async (refreshToken, ctx) => {
|
||||||
|
|
@ -138,7 +135,6 @@ export const createAuthService = (deps: AuthServiceDeps): AuthService => {
|
||||||
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
|
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
|
||||||
}
|
}
|
||||||
if (row.revoked_at !== null) {
|
if (row.revoked_at !== null) {
|
||||||
// Reuse of an already-rotated token → treat as theft: revoke the family.
|
|
||||||
await tokens.revokeFamily(row.family_id);
|
await tokens.revokeFamily(row.family_id);
|
||||||
throw new HttpError(401, 'token_reuse', 'Refresh token reuse detected');
|
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');
|
throw new HttpError(401, 'invalid_token', 'Invalid refresh token');
|
||||||
}
|
}
|
||||||
await tokens.revokeById(row.id);
|
await tokens.revokeById(row.id);
|
||||||
return asResult(user, ctx, row.family_id);
|
return issue(user, ctx, row.family_id);
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async (refreshToken) => {
|
logout: async (refreshToken) => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export type {
|
||||||
AuthServiceConfig,
|
AuthServiceConfig,
|
||||||
AuthServiceDeps,
|
AuthServiceDeps,
|
||||||
RequestContext,
|
RequestContext,
|
||||||
|
IssuedAuth,
|
||||||
} from './auth.service';
|
} from './auth.service';
|
||||||
export { authPlugin } from './auth.plugin';
|
export { authPlugin } from './auth.plugin';
|
||||||
export type { AuthUser } from './auth.plugin';
|
export type { AuthUser } from './auth.plugin';
|
||||||
|
|
|
||||||
3
packages/backend/src/modules/realtime/index.ts
Normal file
3
packages/backend/src/modules/realtime/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { authorizeSubscription } from './realtime.service';
|
||||||
|
export type { SubscribeDecision } from './realtime.service';
|
||||||
|
export { realtimeRoutes } from './realtime.routes';
|
||||||
68
packages/backend/src/modules/realtime/realtime.routes.ts
Normal file
68
packages/backend/src/modules/realtime/realtime.routes.ts
Normal 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();
|
||||||
|
};
|
||||||
21
packages/backend/src/modules/realtime/realtime.service.ts
Normal file
21
packages/backend/src/modules/realtime/realtime.service.ts
Normal 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' };
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
"./channels": "./src/channels/index.ts",
|
"./channels": "./src/channels/index.ts",
|
||||||
"./events": "./src/events/index.ts",
|
"./events": "./src/events/index.ts",
|
||||||
"./types": "./src/types/index.ts",
|
"./types": "./src/types/index.ts",
|
||||||
"./schemas": "./src/schemas/index.ts"
|
"./schemas": "./src/schemas/index.ts",
|
||||||
|
"./api": "./src/api/index.ts",
|
||||||
|
"./realtime": "./src/realtime/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --noEmit",
|
"build": "tsc --noEmit",
|
||||||
|
|
@ -16,6 +18,9 @@
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ajv": "^8.20.0",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"centrifuge": "^5.7.0",
|
||||||
"json-schema-to-ts": "^3.1.1"
|
"json-schema-to-ts": "^3.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
packages/core/src/api/auth.ts
Normal file
53
packages/core/src/api/auth.ts
Normal 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'));
|
||||||
82
packages/core/src/api/http.ts
Normal file
82
packages/core/src/api/http.ts
Normal 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;
|
||||||
|
};
|
||||||
15
packages/core/src/api/index.ts
Normal file
15
packages/core/src/api/index.ts
Normal 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';
|
||||||
8
packages/core/src/api/realtime.ts
Normal file
8
packages/core/src/api/realtime.ts
Normal 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 });
|
||||||
|
};
|
||||||
74
packages/core/src/realtime/client.ts
Normal file
74
packages/core/src/realtime/client.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/core/src/realtime/index.ts
Normal file
2
packages/core/src/realtime/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { RealtimeClient } from './client';
|
||||||
|
export type { ConnectionState, RealtimeEvent, RealtimeClientOptions } from './client';
|
||||||
|
|
@ -32,24 +32,6 @@ export const loginBodySchema = {
|
||||||
},
|
},
|
||||||
} as const;
|
} 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 = {
|
export const updateMeBodySchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
|
|
@ -64,6 +46,4 @@ export const updateMeBodySchema = {
|
||||||
|
|
||||||
export type RegisterBody = FromSchema<typeof registerBodySchema>;
|
export type RegisterBody = FromSchema<typeof registerBodySchema>;
|
||||||
export type LoginBody = FromSchema<typeof loginBodySchema>;
|
export type LoginBody = FromSchema<typeof loginBodySchema>;
|
||||||
export type RefreshBody = FromSchema<typeof refreshBodySchema>;
|
|
||||||
export type LogoutBody = FromSchema<typeof logoutBodySchema>;
|
|
||||||
export type UpdateMeBody = FromSchema<typeof updateMeBodySchema>;
|
export type UpdateMeBody = FromSchema<typeof updateMeBodySchema>;
|
||||||
|
|
|
||||||
|
|
@ -39,24 +39,16 @@ export const userSchema = {
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const authTokensSchema = {
|
// The refresh token is delivered as an httpOnly cookie, so it is deliberately
|
||||||
type: 'object',
|
// absent from the response body.
|
||||||
additionalProperties: false,
|
|
||||||
required: ['accessToken', 'refreshToken', 'accessTokenExpiresIn'],
|
|
||||||
properties: {
|
|
||||||
accessToken: { type: 'string' },
|
|
||||||
refreshToken: { type: 'string' },
|
|
||||||
accessTokenExpiresIn: { type: 'integer' },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const authResultSchema = {
|
export const authResultSchema = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['user', 'tokens'],
|
required: ['user', 'accessToken', 'accessTokenExpiresIn'],
|
||||||
properties: {
|
properties: {
|
||||||
user: userSchema,
|
user: userSchema,
|
||||||
tokens: authTokensSchema,
|
accessToken: { type: 'string' },
|
||||||
|
accessTokenExpiresIn: { type: 'integer' },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,8 @@
|
||||||
export {
|
export { registerBodySchema, loginBodySchema, updateMeBodySchema } from './auth';
|
||||||
registerBodySchema,
|
export type { RegisterBody, LoginBody, UpdateMeBody } from './auth';
|
||||||
loginBodySchema,
|
|
||||||
refreshBodySchema,
|
|
||||||
logoutBodySchema,
|
|
||||||
updateMeBodySchema,
|
|
||||||
} from './auth';
|
|
||||||
export type {
|
|
||||||
RegisterBody,
|
|
||||||
LoginBody,
|
|
||||||
RefreshBody,
|
|
||||||
LogoutBody,
|
|
||||||
UpdateMeBody,
|
|
||||||
} from './auth';
|
|
||||||
export {
|
export {
|
||||||
publicUserSchema,
|
publicUserSchema,
|
||||||
userSchema,
|
userSchema,
|
||||||
authTokensSchema,
|
|
||||||
authResultSchema,
|
authResultSchema,
|
||||||
sessionSchema,
|
sessionSchema,
|
||||||
sessionListSchema,
|
sessionListSchema,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
import type { User } from './user';
|
import type { User } from './user';
|
||||||
|
|
||||||
export interface AuthTokens {
|
// Returned by register / login / refresh. The refresh token is delivered ONLY
|
||||||
accessToken: string;
|
// as an httpOnly cookie (never in the body), so it never appears here.
|
||||||
refreshToken: string;
|
|
||||||
/** Access-token lifetime in seconds. */
|
|
||||||
accessTokenExpiresIn: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returned by register / login / refresh.
|
|
||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
user: User;
|
user: User;
|
||||||
tokens: AuthTokens;
|
accessToken: string;
|
||||||
|
/** Access-token lifetime in seconds. */
|
||||||
|
accessTokenExpiresIn: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An active login session (one per device), from GET /auth/sessions.
|
// An active login session (one per device), from GET /auth/sessions.
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@
|
||||||
export const CORE_VERSION = '0.1.0';
|
export const CORE_VERSION = '0.1.0';
|
||||||
|
|
||||||
export type { User, PublicUser } from './user';
|
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';
|
export type { Message } from './message';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"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"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,16 @@
|
||||||
import type { ReactElement } from 'react';
|
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 { useTheme } from '../shared/theme';
|
||||||
import type { ThemePreference } from '../shared/theme';
|
import type { ThemePreference } from '../shared/theme';
|
||||||
import { env } from '../shared/config';
|
|
||||||
|
|
||||||
const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'];
|
const PREFERENCES: readonly ThemePreference[] = ['light', 'dark', 'system'];
|
||||||
|
|
||||||
export const App = (): ReactElement => {
|
const ThemeSwitch = (): ReactElement => {
|
||||||
const { preference, resolved, setPreference } = useTheme();
|
const { preference, setPreference } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
|
||||||
<h1>Altricade</h1>
|
|
||||||
<p>Realtime chat & 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">
|
<div className="theme-switch">
|
||||||
{PREFERENCES.map((option) => (
|
{PREFERENCES.map((option) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -39,6 +25,52 @@ export const App = (): ReactElement => {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,74 @@ body {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
2
packages/web/src/entities/session/index.ts
Normal file
2
packages/web/src/entities/session/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { SessionProvider, useSession } from './model';
|
||||||
|
export type { SessionStatus, SessionContextValue } from './model';
|
||||||
98
packages/web/src/entities/session/model.tsx
Normal file
98
packages/web/src/entities/session/model.tsx
Normal 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;
|
||||||
|
};
|
||||||
1
packages/web/src/features/auth/index.ts
Normal file
1
packages/web/src/features/auth/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { AuthForm } from './ui/AuthForm';
|
||||||
87
packages/web/src/features/auth/ui/AuthForm.tsx
Normal file
87
packages/web/src/features/auth/ui/AuthForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
packages/web/src/features/realtime/index.ts
Normal file
3
packages/web/src/features/realtime/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { RealtimeProvider, useRealtime } from './model';
|
||||||
|
export type { RealtimeContextValue } from './model';
|
||||||
|
export { ConnectionPanel } from './ui/ConnectionPanel';
|
||||||
69
packages/web/src/features/realtime/model.tsx
Normal file
69
packages/web/src/features/realtime/model.tsx
Normal 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;
|
||||||
|
};
|
||||||
55
packages/web/src/features/realtime/ui/ConnectionPanel.tsx
Normal file
55
packages/web/src/features/realtime/ui/ConnectionPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
9
packages/web/src/shared/api/index.ts
Normal file
9
packages/web/src/shared/api/index.ts
Normal 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,
|
||||||
|
};
|
||||||
10
packages/web/src/shared/auth-token/index.ts
Normal file
10
packages/web/src/shared/auth-token/index.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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 {
|
export interface WebEnv {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
|
wsUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env: WebEnv = {
|
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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1
packages/web/src/vite-env.d.ts
vendored
1
packages/web/src/vite-env.d.ts
vendored
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL?: string;
|
readonly VITE_API_URL?: string;
|
||||||
|
readonly VITE_WS_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
'/connection/websocket': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
110
pnpm-lock.yaml
110
pnpm-lock.yaml
|
|
@ -41,6 +41,9 @@ importers:
|
||||||
'@altricade/core':
|
'@altricade/core':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../core
|
version: link:../core
|
||||||
|
'@fastify/cookie':
|
||||||
|
specifier: ^11.1.1
|
||||||
|
version: 11.1.1
|
||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: ^11.3.0
|
specifier: ^11.3.0
|
||||||
version: 11.3.0
|
version: 11.3.0
|
||||||
|
|
@ -102,6 +105,15 @@ importers:
|
||||||
|
|
||||||
packages/core:
|
packages/core:
|
||||||
dependencies:
|
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:
|
json-schema-to-ts:
|
||||||
specifier: ^3.1.1
|
specifier: ^3.1.1
|
||||||
version: 3.1.1
|
version: 3.1.1
|
||||||
|
|
@ -432,6 +444,9 @@ packages:
|
||||||
'@fastify/ajv-compiler@4.0.5':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||||
|
|
||||||
|
'@fastify/cookie@11.1.1':
|
||||||
|
resolution: {integrity: sha512-sJ0NXzGVYjUB4OynPZRsIcQ1mKSP4rW45xLCN0aelRq5Vl37xVVbz5kJ6Y0a9m2T0mCUjYCuvlUA9QlTafrZWw==}
|
||||||
|
|
||||||
'@fastify/cors@11.3.0':
|
'@fastify/cors@11.3.0':
|
||||||
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
|
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
|
||||||
|
|
||||||
|
|
@ -617,6 +632,33 @@ packages:
|
||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
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':
|
'@rolldown/binding-android-arm64@1.1.5':
|
||||||
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
|
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
|
@ -1054,6 +1096,9 @@ packages:
|
||||||
caniuse-lite@1.0.30001803:
|
caniuse-lite@1.0.30001803:
|
||||||
resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==}
|
resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==}
|
||||||
|
|
||||||
|
centrifuge@5.7.0:
|
||||||
|
resolution: {integrity: sha512-Ptx7ELyVc7/KgzpadVlISTtdTWsuzumze5/vo9sH4RsvtFulJJMhmKr/cNDg6se1eKKbS6ZywIBl4eSZxqY3fw==}
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||||
engines: {node: '>= 14.16.0'}
|
engines: {node: '>= 14.16.0'}
|
||||||
|
|
@ -1095,6 +1140,10 @@ packages:
|
||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cookie@2.0.1:
|
||||||
|
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
|
||||||
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -1213,6 +1262,10 @@ packages:
|
||||||
eventemitter3@5.0.4:
|
eventemitter3@5.0.4:
|
||||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
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:
|
fast-decode-uri-component@1.0.1:
|
||||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||||
|
|
||||||
|
|
@ -1523,6 +1576,9 @@ packages:
|
||||||
lodash@4.18.1:
|
lodash@4.18.1:
|
||||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
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:
|
lru-cache@11.5.2:
|
||||||
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
|
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
@ -1746,6 +1802,10 @@ packages:
|
||||||
process-warning@5.0.0:
|
process-warning@5.0.0:
|
||||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
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:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -2380,6 +2440,11 @@ snapshots:
|
||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 3.1.3
|
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':
|
'@fastify/cors@11.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fastify-plugin: 6.0.0
|
fastify-plugin: 6.0.0
|
||||||
|
|
@ -2565,6 +2630,26 @@ snapshots:
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@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':
|
'@rolldown/binding-android-arm64@1.1.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2912,6 +2997,11 @@ snapshots:
|
||||||
|
|
||||||
caniuse-lite@1.0.30001803: {}
|
caniuse-lite@1.0.30001803: {}
|
||||||
|
|
||||||
|
centrifuge@5.7.0:
|
||||||
|
dependencies:
|
||||||
|
events: 3.3.0
|
||||||
|
protobufjs: 7.6.5
|
||||||
|
|
||||||
chokidar@4.0.3:
|
chokidar@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
readdirp: 4.1.2
|
readdirp: 4.1.2
|
||||||
|
|
@ -2942,6 +3032,8 @@ snapshots:
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
cookie@2.0.1: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
|
|
@ -3086,6 +3178,8 @@ snapshots:
|
||||||
|
|
||||||
eventemitter3@5.0.4: {}
|
eventemitter3@5.0.4: {}
|
||||||
|
|
||||||
|
events@3.3.0: {}
|
||||||
|
|
||||||
fast-decode-uri-component@1.0.1: {}
|
fast-decode-uri-component@1.0.1: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
@ -3381,6 +3475,8 @@ snapshots:
|
||||||
|
|
||||||
lodash@4.18.1: {}
|
lodash@4.18.1: {}
|
||||||
|
|
||||||
|
long@5.3.2: {}
|
||||||
|
|
||||||
lru-cache@11.5.2: {}
|
lru-cache@11.5.2: {}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
|
|
@ -3587,6 +3683,20 @@ snapshots:
|
||||||
|
|
||||||
process-warning@5.0.0: {}
|
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: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
query-string@7.1.3:
|
query-string@7.1.3:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue