From 96d496bf268674eb368edddcc7f6b81e65ab6f4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82=20=7C=20Zaid=20Omar=20Medh?= =?UTF-8?q?at?= Date: Fri, 10 Jul 2026 15:04:36 +0500 Subject: [PATCH] Realtime connection --- .env.example | 3 + packages/backend/package.json | 1 + packages/backend/src/app.ts | 6 +- packages/backend/src/config.ts | 5 + .../backend/src/modules/auth/auth.routes.ts | 78 +++++++++---- .../backend/src/modules/auth/auth.service.ts | 50 ++++---- packages/backend/src/modules/auth/index.ts | 1 + .../backend/src/modules/realtime/index.ts | 3 + .../src/modules/realtime/realtime.routes.ts | 68 +++++++++++ .../src/modules/realtime/realtime.service.ts | 21 ++++ packages/core/package.json | 7 +- packages/core/src/api/auth.ts | 53 +++++++++ packages/core/src/api/http.ts | 82 +++++++++++++ packages/core/src/api/index.ts | 15 +++ packages/core/src/api/realtime.ts | 8 ++ packages/core/src/realtime/client.ts | 74 ++++++++++++ packages/core/src/realtime/index.ts | 2 + packages/core/src/schemas/auth.ts | 20 ---- packages/core/src/schemas/entities.ts | 18 +-- packages/core/src/schemas/index.ts | 17 +-- packages/core/src/types/auth.ts | 14 +-- packages/core/src/types/index.ts | 2 +- packages/core/tsconfig.json | 5 +- packages/web/src/app/App.tsx | 102 ++++++++++------ packages/web/src/app/index.css | 71 +++++++++++ packages/web/src/entities/session/index.ts | 2 + packages/web/src/entities/session/model.tsx | 98 ++++++++++++++++ packages/web/src/features/auth/index.ts | 1 + .../web/src/features/auth/ui/AuthForm.tsx | 87 ++++++++++++++ packages/web/src/features/realtime/index.ts | 3 + packages/web/src/features/realtime/model.tsx | 69 +++++++++++ .../features/realtime/ui/ConnectionPanel.tsx | 55 +++++++++ packages/web/src/shared/api/index.ts | 9 ++ packages/web/src/shared/auth-token/index.ts | 10 ++ packages/web/src/shared/config/env.ts | 7 +- packages/web/src/vite-env.d.ts | 1 + packages/web/vite.config.ts | 11 ++ pnpm-lock.yaml | 110 ++++++++++++++++++ 38 files changed, 1039 insertions(+), 150 deletions(-) create mode 100644 packages/backend/src/modules/realtime/index.ts create mode 100644 packages/backend/src/modules/realtime/realtime.routes.ts create mode 100644 packages/backend/src/modules/realtime/realtime.service.ts create mode 100644 packages/core/src/api/auth.ts create mode 100644 packages/core/src/api/http.ts create mode 100644 packages/core/src/api/index.ts create mode 100644 packages/core/src/api/realtime.ts create mode 100644 packages/core/src/realtime/client.ts create mode 100644 packages/core/src/realtime/index.ts create mode 100644 packages/web/src/entities/session/index.ts create mode 100644 packages/web/src/entities/session/model.tsx create mode 100644 packages/web/src/features/auth/index.ts create mode 100644 packages/web/src/features/auth/ui/AuthForm.tsx create mode 100644 packages/web/src/features/realtime/index.ts create mode 100644 packages/web/src/features/realtime/model.tsx create mode 100644 packages/web/src/features/realtime/ui/ConnectionPanel.tsx create mode 100644 packages/web/src/shared/api/index.ts create mode 100644 packages/web/src/shared/auth-token/index.ts diff --git a/.env.example b/.env.example index 956c709..30cba81 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/packages/backend/package.json b/packages/backend/package.json index 4b8b37d..d3a3425 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -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", diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index d745518..93d242a 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -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 ({ 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')), diff --git a/packages/backend/src/modules/auth/auth.routes.ts b/packages/backend/src/modules/auth/auth.routes.ts index 21a27e3..294792b 100644 --- a/packages/backend/src/modules/auth/auth.routes.ts +++ b/packages/backend/src/modules/auth/auth.routes.ts @@ -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 => { + 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 => { 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 => { 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 => { 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 => { 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); }, ); diff --git a/packages/backend/src/modules/auth/auth.service.ts b/packages/backend/src/modules/auth/auth.service.ts index b0ef69b..3078729 100644 --- a/packages/backend/src/modules/auth/auth.service.ts +++ b/packages/backend/src/modules/auth/auth.service.ts @@ -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; - login(body: LoginBody, ctx: RequestContext): Promise; - refresh(refreshToken: string, ctx: RequestContext): Promise; + register(body: RegisterBody, ctx: RequestContext): Promise; + login(body: LoginBody, ctx: RequestContext): Promise; + refresh(refreshToken: string, ctx: RequestContext): Promise; logout(refreshToken: string): Promise; logoutAll(userId: string): Promise; listSessions(userId: string, currentRefreshToken: string | undefined): Promise; @@ -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 => { + const issue = async (user: UserRow, ctx: RequestContext, familyId: string): Promise => { 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 => ({ - 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) => { diff --git a/packages/backend/src/modules/auth/index.ts b/packages/backend/src/modules/auth/index.ts index 74f4c53..4392e10 100644 --- a/packages/backend/src/modules/auth/index.ts +++ b/packages/backend/src/modules/auth/index.ts @@ -6,6 +6,7 @@ export type { AuthServiceConfig, AuthServiceDeps, RequestContext, + IssuedAuth, } from './auth.service'; export { authPlugin } from './auth.plugin'; export type { AuthUser } from './auth.plugin'; diff --git a/packages/backend/src/modules/realtime/index.ts b/packages/backend/src/modules/realtime/index.ts new file mode 100644 index 0000000..87d341b --- /dev/null +++ b/packages/backend/src/modules/realtime/index.ts @@ -0,0 +1,3 @@ +export { authorizeSubscription } from './realtime.service'; +export type { SubscribeDecision } from './realtime.service'; +export { realtimeRoutes } from './realtime.routes'; diff --git a/packages/backend/src/modules/realtime/realtime.routes.ts b/packages/backend/src/modules/realtime/realtime.routes.ts new file mode 100644 index 0000000..4c5c639 --- /dev/null +++ b/packages/backend/src/modules/realtime/realtime.routes.ts @@ -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 => { + // 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(); +}; diff --git a/packages/backend/src/modules/realtime/realtime.service.ts b/packages/backend/src/modules/realtime/realtime.service.ts new file mode 100644 index 0000000..68fcfb2 --- /dev/null +++ b/packages/backend/src/modules/realtime/realtime.service.ts @@ -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: → allowed only for that user's own personal channel. +// room: → 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' }; +}; diff --git a/packages/core/package.json b/packages/core/package.json index 62d5c25..9c5987f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" } } diff --git a/packages/core/src/api/auth.ts b/packages/core/src/api/auth.ts new file mode 100644 index 0000000..9666325 --- /dev/null +++ b/packages/core/src/api/auth.ts @@ -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(authResultSchema); +const userV = compileValidator(userSchema); +const publicUserV = compileValidator(publicUserSchema); +const sessionsV = compileValidator(sessionListSchema); +const centrifugoTokenV = compileValidator(centrifugoTokenSchema); + +export const register = async (config: ApiClientConfig, body: RegisterBody): Promise => + parse(authResultV, await requestJson(config, 'POST', '/auth/register', body)); + +export const login = async (config: ApiClientConfig, body: LoginBody): Promise => + parse(authResultV, await requestJson(config, 'POST', '/auth/login', body)); + +// Uses the httpOnly refresh cookie — no body. +export const refresh = async (config: ApiClientConfig): Promise => + parse(authResultV, await requestJson(config, 'POST', '/auth/refresh')); + +export const logout = async (config: ApiClientConfig): Promise => { + await requestJson(config, 'POST', '/auth/logout'); +}; + +export const logoutAll = async (config: ApiClientConfig): Promise => { + await requestJson(config, 'POST', '/auth/logout-all'); +}; + +export const getMe = async (config: ApiClientConfig): Promise => + parse(userV, await requestJson(config, 'GET', '/me')); + +export const updateMe = async (config: ApiClientConfig, body: UpdateMeBody): Promise => + parse(userV, await requestJson(config, 'PATCH', '/me', body)); + +export const getPublicProfile = async ( + config: ApiClientConfig, + username: string, +): Promise => + parse(publicUserV, await requestJson(config, 'GET', `/users/${encodeURIComponent(username)}`)); + +export const listSessions = async (config: ApiClientConfig): Promise => + parse(sessionsV, await requestJson(config, 'GET', '/auth/sessions')); + +export const getCentrifugoToken = async (config: ApiClientConfig): Promise => + parse(centrifugoTokenV, await requestJson(config, 'POST', '/auth/centrifugo-token')); diff --git a/packages/core/src/api/http.ts b/packages/core/src/api/http.ts new file mode 100644 index 0000000..c87c424 --- /dev/null +++ b/packages/core/src/api/http.ts @@ -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 = (schema: object): ValidateFunction => + ajv.compile(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 => { + const headers: Record = { 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 = (validator: ValidateFunction, data: unknown): T => { + if (!validator(data)) { + throw new ApiError(500, 'invalid_response', 'Server response failed schema validation'); + } + return data; +}; diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts new file mode 100644 index 0000000..992e2d1 --- /dev/null +++ b/packages/core/src/api/index.ts @@ -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'; diff --git a/packages/core/src/api/realtime.ts b/packages/core/src/api/realtime.ts new file mode 100644 index 0000000..a2ea4ff --- /dev/null +++ b/packages/core/src/api/realtime.ts @@ -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 => { + await requestJson(config, 'POST', '/realtime/echo', { text }); +}; diff --git a/packages/core/src/realtime/client.ts b/packages/core/src/realtime/client.ts new file mode 100644 index 0000000..122eda1 --- /dev/null +++ b/packages/core/src/realtime/client.ts @@ -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; + 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(); + + 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); + } +} diff --git a/packages/core/src/realtime/index.ts b/packages/core/src/realtime/index.ts new file mode 100644 index 0000000..2dda14a --- /dev/null +++ b/packages/core/src/realtime/index.ts @@ -0,0 +1,2 @@ +export { RealtimeClient } from './client'; +export type { ConnectionState, RealtimeEvent, RealtimeClientOptions } from './client'; diff --git a/packages/core/src/schemas/auth.ts b/packages/core/src/schemas/auth.ts index cce82ee..5cae719 100644 --- a/packages/core/src/schemas/auth.ts +++ b/packages/core/src/schemas/auth.ts @@ -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; export type LoginBody = FromSchema; -export type RefreshBody = FromSchema; -export type LogoutBody = FromSchema; export type UpdateMeBody = FromSchema; diff --git a/packages/core/src/schemas/entities.ts b/packages/core/src/schemas/entities.ts index 366acd8..3b0ce3c 100644 --- a/packages/core/src/schemas/entities.ts +++ b/packages/core/src/schemas/entities.ts @@ -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; diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 83b016a..997a6ab 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -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, diff --git a/packages/core/src/types/auth.ts b/packages/core/src/types/auth.ts index 2d8045f..2aabadf 100644 --- a/packages/core/src/types/auth.ts +++ b/packages/core/src/types/auth.ts @@ -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. diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 338720d..471d54f 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -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'; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index da8829f..0097eca 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -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"] } diff --git a/packages/web/src/app/App.tsx b/packages/web/src/app/App.tsx index bf224d2..351918f 100644 --- a/packages/web/src/app/App.tsx +++ b/packages/web/src/app/App.tsx @@ -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 ( -
-

Altricade

-

Realtime chat & calls — Phase 0 skeleton.

-
-
core version
-
{CORE_VERSION}
-
example channel
-
{roomChannel('demo')}
-
API base
-
{env.apiUrl}
-
theme
-
- {resolved} (preference: {preference}) -
-
-
- {PREFERENCES.map((option) => ( - - ))} -
-
+
+ {PREFERENCES.map((option) => ( + + ))} +
); }; + +const Dashboard = ({ user, onLogout }: { user: User; onLogout: () => void }): ReactElement => ( +
+

Altricade

+

+ Signed in as @{user.username} ({user.displayName}) +

+ +
+ +
+ +
+); + +const Shell = (): ReactElement => { + const { status, user, logout } = useSession(); + + if (status === 'loading') { + return ( +
+

Loading…

+
+ ); + } + if (user === null) { + return ; + } + return ( + + { + void logout(); + }} + /> + + ); +}; + +export const App = (): ReactElement => ( + + + +); diff --git a/packages/web/src/app/index.css b/packages/web/src/app/index.css index b37726b..b1f7100 100644 --- a/packages/web/src/app/index.css +++ b/packages/web/src/app/index.css @@ -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; +} diff --git a/packages/web/src/entities/session/index.ts b/packages/web/src/entities/session/index.ts new file mode 100644 index 0000000..4075ddc --- /dev/null +++ b/packages/web/src/entities/session/index.ts @@ -0,0 +1,2 @@ +export { SessionProvider, useSession } from './model'; +export type { SessionStatus, SessionContextValue } from './model'; diff --git a/packages/web/src/entities/session/model.tsx b/packages/web/src/entities/session/model.tsx new file mode 100644 index 0000000..3acf75e --- /dev/null +++ b/packages/web/src/entities/session/model.tsx @@ -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; + login: (body: LoginBody) => Promise; + logout: () => Promise; +} + +const SessionContext = createContext(null); + +export const SessionProvider = ({ children }: { children: ReactNode }): ReactElement => { + const [status, setStatus] = useState('loading'); + const [user, setUser] = useState(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 => { + 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 => { + applyAuth(await apiRegister(apiConfig, body)); + }, + [applyAuth], + ); + + const login = useCallback( + async (body: LoginBody): Promise => { + applyAuth(await apiLogin(apiConfig, body)); + }, + [applyAuth], + ); + + const logout = useCallback(async (): Promise => { + try { + await apiLogout(apiConfig); + } finally { + clear(); + } + }, [clear]); + + const value = useMemo( + () => ({ status, user, register, login, logout }), + [status, user, register, login, logout], + ); + + return {children}; +}; + +export const useSession = (): SessionContextValue => { + const context = useContext(SessionContext); + if (context === null) { + throw new Error('useSession must be used within a SessionProvider'); + } + return context; +}; diff --git a/packages/web/src/features/auth/index.ts b/packages/web/src/features/auth/index.ts new file mode 100644 index 0000000..607257b --- /dev/null +++ b/packages/web/src/features/auth/index.ts @@ -0,0 +1 @@ +export { AuthForm } from './ui/AuthForm'; diff --git a/packages/web/src/features/auth/ui/AuthForm.tsx b/packages/web/src/features/auth/ui/AuthForm.tsx new file mode 100644 index 0000000..b25abc0 --- /dev/null +++ b/packages/web/src/features/auth/ui/AuthForm.tsx @@ -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('login'); + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async (event: SyntheticEvent): Promise => { + 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 ( +
+

Altricade

+

{mode === 'login' ? 'Log in' : 'Create an account'}

+
{ + void submit(event); + }} + > + { + setUsername(event.target.value); + }} + /> + {mode === 'register' ? ( + { + setDisplayName(event.target.value); + }} + /> + ) : null} + { + setPassword(event.target.value); + }} + /> + {error !== null ?

{error}

: null} + +
+ +
+ ); +}; diff --git a/packages/web/src/features/realtime/index.ts b/packages/web/src/features/realtime/index.ts new file mode 100644 index 0000000..2f4f35e --- /dev/null +++ b/packages/web/src/features/realtime/index.ts @@ -0,0 +1,3 @@ +export { RealtimeProvider, useRealtime } from './model'; +export type { RealtimeContextValue } from './model'; +export { ConnectionPanel } from './ui/ConnectionPanel'; diff --git a/packages/web/src/features/realtime/model.tsx b/packages/web/src/features/realtime/model.tsx new file mode 100644 index 0000000..3517ced --- /dev/null +++ b/packages/web/src/features/realtime/model.tsx @@ -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(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('disconnected'); + const [events, setEvents] = useState([]); + + 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( + () => ({ state, events, channel }), + [state, events, channel], + ); + + return {children}; +}; + +export const useRealtime = (): RealtimeContextValue => { + const context = useContext(RealtimeContext); + if (context === null) { + throw new Error('useRealtime must be used within a RealtimeProvider'); + } + return context; +}; diff --git a/packages/web/src/features/realtime/ui/ConnectionPanel.tsx b/packages/web/src/features/realtime/ui/ConnectionPanel.tsx new file mode 100644 index 0000000..7896214 --- /dev/null +++ b/packages/web/src/features/realtime/ui/ConnectionPanel.tsx @@ -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(null); + + const echo = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + setError(null); + try { + await sendEcho(apiConfig, text); + } catch (caught) { + setError(caught instanceof ApiError ? caught.message : 'Echo failed'); + } + }; + + return ( +
+
+
socket
+
{state}
+
channel
+
{channel ?? '—'}
+
+
{ + void echo(event); + }} + > + { + setText(event.target.value); + }} + /> + +
+ {error !== null ?

{error}

: null} +

Events on {channel ?? 'your channel'}

+
    + {events.map((event, index) => ( +
  • {JSON.stringify(event.data)}
  • + ))} +
+
+ ); +}; diff --git a/packages/web/src/shared/api/index.ts b/packages/web/src/shared/api/index.ts new file mode 100644 index 0000000..b0c13e8 --- /dev/null +++ b/packages/web/src/shared/api/index.ts @@ -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, +}; diff --git a/packages/web/src/shared/auth-token/index.ts b/packages/web/src/shared/auth-token/index.ts new file mode 100644 index 0000000..b0f46b9 --- /dev/null +++ b/packages/web/src/shared/auth-token/index.ts @@ -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; +}; diff --git a/packages/web/src/shared/config/env.ts b/packages/web/src/shared/config/env.ts index 9166c1d..30cab3d 100644 --- a/packages/web/src/shared/config/env.ts +++ b/packages/web/src/shared/config/env.ts @@ -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', }; diff --git a/packages/web/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts index c57d674..90bedce 100644 --- a/packages/web/src/vite-env.d.ts +++ b/packages/web/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string; + readonly VITE_WS_URL?: string; } interface ImportMeta { diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 90ac88c..293a416 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -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, + }, + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74dbab6..d72411a 100644 --- a/pnpm-lock.yaml +++ b/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: