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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export type {
|
|||
AuthServiceConfig,
|
||||
AuthServiceDeps,
|
||||
RequestContext,
|
||||
IssuedAuth,
|
||||
} from './auth.service';
|
||||
export { authPlugin } 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",
|
||||
"./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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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 {
|
||||
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',
|
||||
};
|
||||
|
|
|
|||
1
packages/web/src/vite-env.d.ts
vendored
1
packages/web/src/vite-env.d.ts
vendored
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
110
pnpm-lock.yaml
110
pnpm-lock.yaml
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue