diff --git a/app/game-api/package.json b/app/game-api/package.json index ee882da..4c8416b 100644 --- a/app/game-api/package.json +++ b/app/game-api/package.json @@ -20,6 +20,7 @@ "@sammo-ts/infra": "workspace:*", "@trpc/server": "^11.4.4", "fastify": "^5.3.3", + "redis": "^4.7.0", "zod": "^4.2.1" } } diff --git a/app/game-api/tsconfig.json b/app/game-api/tsconfig.json index 53aba2b..bfd6a06 100644 --- a/app/game-api/tsconfig.json +++ b/app/game-api/tsconfig.json @@ -5,5 +5,8 @@ "rootDir": "src", "composite": true }, - "include": ["src"] + "include": ["src"], + "references": [ + { "path": "../../packages/infra" } + ] } diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index a28255f..34fc291 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -7,10 +7,20 @@ "build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/gateway-api", "dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/gateway-api --watch", "lint": "node -e \"console.log('lint not configured')\"", - "test": "node -e \"console.log('test not configured')\"", + "test": "vitest run --config vitest.config.ts", "typecheck": "tsc --noEmit" }, "devDependencies": { - "tsdown": "^0.18.3" + "tsdown": "^0.18.3", + "vite-tsconfig-paths": "^6.0.0", + "vitest": "^4.0.16" + }, + "dependencies": { + "@fastify/cors": "^10.0.1", + "@sammo-ts/infra": "workspace:*", + "@trpc/server": "^11.4.4", + "fastify": "^5.3.3", + "redis": "^4.7.0", + "zod": "^4.2.1" } } diff --git a/app/gateway-api/src/auth/inMemorySessionService.ts b/app/gateway-api/src/auth/inMemorySessionService.ts new file mode 100644 index 0000000..55f3b69 --- /dev/null +++ b/app/gateway-api/src/auth/inMemorySessionService.ts @@ -0,0 +1,123 @@ +import { randomUUID } from 'node:crypto'; + +import type { + GameSessionInfo, + GatewaySessionConfig, + GatewaySessionInfo, + GatewaySessionService, + SessionRevocationOptions, +} from './sessionService.js'; +import type { UserRecord } from './userRepository.js'; + +interface StoredSession { + info: GatewaySessionInfo; + expiresAt: number; +} + +interface StoredGameSession { + info: GameSessionInfo; + expiresAt: number; +} + +const buildGameKey = (profile: string, gameToken: string): string => `${profile}:${gameToken}`; + +// 세션 TTL 동작을 테스트할 수 있도록 메모리 기반 구현을 제공한다. +export class InMemoryGatewaySessionService implements GatewaySessionService { + private readonly sessions = new Map(); + private readonly gameSessions = new Map(); + private readonly sessionGames = new Map>(); + private readonly sessionTtlMs: number; + private readonly gameSessionTtlMs: number; + + constructor(config: GatewaySessionConfig) { + this.sessionTtlMs = config.sessionTtlSeconds * 1000; + this.gameSessionTtlMs = config.gameSessionTtlSeconds * 1000; + } + + async createSession(user: UserRecord): Promise { + const sessionToken = randomUUID(); + const info: GatewaySessionInfo = { + sessionToken, + userId: user.id, + username: user.username, + displayName: user.displayName, + issuedAt: new Date().toISOString(), + }; + this.sessions.set(sessionToken, { + info, + expiresAt: Date.now() + this.sessionTtlMs, + }); + return info; + } + + async getSession(sessionToken: string): Promise { + const stored = this.sessions.get(sessionToken); + if (!stored) { + return null; + } + if (Date.now() > stored.expiresAt) { + this.sessions.delete(sessionToken); + this.sessionGames.delete(sessionToken); + return null; + } + return stored.info; + } + + async revokeSession( + sessionToken: string, + options: SessionRevocationOptions = { revokeGames: true } + ): Promise { + if (options.revokeGames ?? true) { + const gameKeys = this.sessionGames.get(sessionToken); + if (gameKeys) { + for (const key of gameKeys) { + this.gameSessions.delete(key); + } + } + this.sessionGames.delete(sessionToken); + } + this.sessions.delete(sessionToken); + } + + async createGameSession( + sessionToken: string, + profile: string + ): Promise { + const session = await this.getSession(sessionToken); + if (!session) { + return null; + } + const gameToken = randomUUID(); + const info: GameSessionInfo = { + profile, + gameToken, + sessionToken, + userId: session.userId, + username: session.username, + displayName: session.displayName, + issuedAt: new Date().toISOString(), + }; + const key = buildGameKey(profile, gameToken); + this.gameSessions.set(key, { + info, + expiresAt: Date.now() + this.gameSessionTtlMs, + }); + const set = this.sessionGames.get(sessionToken) ?? new Set(); + set.add(key); + this.sessionGames.set(sessionToken, set); + return info; + } + + async getGameSession(profile: string, gameToken: string): Promise { + const key = buildGameKey(profile, gameToken); + const stored = this.gameSessions.get(key); + if (!stored) { + return null; + } + if (Date.now() > stored.expiresAt) { + this.gameSessions.delete(key); + return null; + } + return stored.info; + } +} diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts new file mode 100644 index 0000000..ce9c602 --- /dev/null +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -0,0 +1,36 @@ +import { randomUUID } from 'node:crypto'; + +import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; +import type { CreateUserInput, UserRecord, UserRepository } from './userRepository.js'; + +// 유저 데이터 저장소를 메모리로 대체한 임시 구현. +export const createInMemoryUserRepository = ( + hasher: PasswordHasher = createSimplePasswordHasher() +): UserRepository => { + const usersByName = new Map(); + + return { + async findByUsername(username: string): Promise { + return usersByName.get(username) ?? null; + }, + async createUser(input: CreateUserInput): Promise { + if (usersByName.has(input.username)) { + throw new Error('User already exists.'); + } + const salt = hasher.createSalt(); + const user: UserRecord = { + id: randomUUID(), + username: input.username, + displayName: input.displayName ?? input.username, + passwordSalt: salt, + passwordHash: hasher.hash(input.password, salt), + createdAt: new Date().toISOString(), + }; + usersByName.set(input.username, user); + return user; + }, + async verifyPassword(user: UserRecord, password: string): Promise { + return hasher.hash(password, user.passwordSalt) === user.passwordHash; + }, + }; +}; diff --git a/app/gateway-api/src/auth/passwordHasher.ts b/app/gateway-api/src/auth/passwordHasher.ts new file mode 100644 index 0000000..0a4c803 --- /dev/null +++ b/app/gateway-api/src/auth/passwordHasher.ts @@ -0,0 +1,13 @@ +import { createHash, randomBytes } from 'node:crypto'; + +export interface PasswordHasher { + createSalt(): string; + hash(password: string, salt: string): string; +} + +// 비밀번호 해싱은 임시 구현이므로 이후 안전한 KDF로 교체한다. +export const createSimplePasswordHasher = (): PasswordHasher => ({ + createSalt: () => randomBytes(16).toString('hex'), + hash: (password: string, salt: string) => + createHash('sha256').update(`${salt}:${password}`).digest('hex'), +}); diff --git a/app/gateway-api/src/auth/redisKeys.ts b/app/gateway-api/src/auth/redisKeys.ts new file mode 100644 index 0000000..e6df5ba --- /dev/null +++ b/app/gateway-api/src/auth/redisKeys.ts @@ -0,0 +1,12 @@ +export interface GatewayRedisKeyBuilder { + sessionKey(sessionToken: string): string; + sessionGameSetKey(sessionToken: string): string; + gameSessionKey(profile: string, gameToken: string): string; +} + +export const createGatewayRedisKeyBuilder = (prefix: string): GatewayRedisKeyBuilder => ({ + sessionKey: (sessionToken: string) => `${prefix}:session:${sessionToken}`, + sessionGameSetKey: (sessionToken: string) => `${prefix}:session-games:${sessionToken}`, + gameSessionKey: (profile: string, gameToken: string) => + `${prefix}:game-session:${profile}:${gameToken}`, +}); diff --git a/app/gateway-api/src/auth/redisSessionService.ts b/app/gateway-api/src/auth/redisSessionService.ts new file mode 100644 index 0000000..b6daa8f --- /dev/null +++ b/app/gateway-api/src/auth/redisSessionService.ts @@ -0,0 +1,134 @@ +import { randomUUID } from 'node:crypto'; + +import { createGatewayRedisKeyBuilder } from './redisKeys.js'; +import type { + GameSessionInfo, + GatewaySessionConfig, + GatewaySessionInfo, + GatewaySessionService, + SessionRevocationOptions, +} from './sessionService.js'; +import type { UserRecord } from './userRepository.js'; + +interface RedisGatewaySessionOptions extends GatewaySessionConfig { + keyPrefix: string; +} + +interface RedisPipeline { + set(key: string, value: string, options?: { EX?: number }): RedisPipeline; + sAdd(key: string, member: string): RedisPipeline; + expire(key: string, seconds: number): RedisPipeline; + del(key: string): RedisPipeline; + exec(): Promise; +} + +interface RedisClientLike { + get(key: string): Promise; + set(key: string, value: string, options?: { EX?: number }): Promise; + sMembers(key: string): Promise; + multi(): RedisPipeline; + del(key: string): Promise; +} + +const parseJson = (raw: string | null): T | null => { + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +}; + +// Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다. +export class RedisGatewaySessionService implements GatewaySessionService { + private readonly client: RedisClientLike; + private readonly keys: ReturnType; + private readonly sessionTtlSeconds: number; + private readonly gameSessionTtlSeconds: number; + + constructor(client: RedisClientLike, options: RedisGatewaySessionOptions) { + this.client = client; + this.keys = createGatewayRedisKeyBuilder(options.keyPrefix); + this.sessionTtlSeconds = options.sessionTtlSeconds; + this.gameSessionTtlSeconds = options.gameSessionTtlSeconds; + } + + async createSession(user: UserRecord): Promise { + const sessionToken = randomUUID(); + const info: GatewaySessionInfo = { + sessionToken, + userId: user.id, + username: user.username, + displayName: user.displayName, + issuedAt: new Date().toISOString(), + }; + await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), { + EX: this.sessionTtlSeconds, + }); + return info; + } + + async getSession(sessionToken: string): Promise { + const raw = await this.client.get(this.keys.sessionKey(sessionToken)); + return parseJson(raw); + } + + async revokeSession( + sessionToken: string, + options: SessionRevocationOptions = { revokeGames: true } + ): Promise { + const key = this.keys.sessionKey(sessionToken); + if (options.revokeGames ?? true) { + const gameSetKey = this.keys.sessionGameSetKey(sessionToken); + const games = await this.client.sMembers(gameSetKey); + if (games.length > 0) { + const pipeline = this.client.multi(); + for (const entry of games) { + pipeline.del(entry); + } + pipeline.del(gameSetKey); + pipeline.del(key); + await pipeline.exec(); + return; + } + await this.client.del(gameSetKey); + } + await this.client.del(key); + } + + async createGameSession( + sessionToken: string, + profile: string + ): Promise { + const session = await this.getSession(sessionToken); + if (!session) { + return null; + } + const gameToken = randomUUID(); + const info: GameSessionInfo = { + profile, + gameToken, + sessionToken, + userId: session.userId, + username: session.username, + displayName: session.displayName, + issuedAt: new Date().toISOString(), + }; + const gameKey = this.keys.gameSessionKey(profile, gameToken); + const gameSetKey = this.keys.sessionGameSetKey(sessionToken); + await this.client + .multi() + .set(gameKey, JSON.stringify(info), { EX: this.gameSessionTtlSeconds }) + .sAdd(gameSetKey, gameKey) + .expire(gameSetKey, this.sessionTtlSeconds) + .exec(); + return info; + } + + async getGameSession(profile: string, gameToken: string): Promise { + const raw = await this.client.get(this.keys.gameSessionKey(profile, gameToken)); + return parseJson(raw); + } +} diff --git a/app/gateway-api/src/auth/sessionService.ts b/app/gateway-api/src/auth/sessionService.ts new file mode 100644 index 0000000..e4ecfd5 --- /dev/null +++ b/app/gateway-api/src/auth/sessionService.ts @@ -0,0 +1,36 @@ +import type { UserRecord } from './userRepository.js'; + +export interface GatewaySessionInfo { + sessionToken: string; + userId: string; + username: string; + displayName: string; + issuedAt: string; +} + +export interface GameSessionInfo { + profile: string; + gameToken: string; + sessionToken: string; + userId: string; + username: string; + displayName: string; + issuedAt: string; +} + +export interface GatewaySessionConfig { + sessionTtlSeconds: number; + gameSessionTtlSeconds: number; +} + +export interface SessionRevocationOptions { + revokeGames?: boolean; +} + +export interface GatewaySessionService { + createSession(user: UserRecord): Promise; + getSession(sessionToken: string): Promise; + revokeSession(sessionToken: string, options?: SessionRevocationOptions): Promise; + createGameSession(sessionToken: string, profile: string): Promise; + getGameSession(profile: string, gameToken: string): Promise; +} diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts new file mode 100644 index 0000000..73ca052 --- /dev/null +++ b/app/gateway-api/src/auth/userRepository.ts @@ -0,0 +1,34 @@ +export interface UserRecord { + id: string; + username: string; + displayName: string; + passwordHash: string; + passwordSalt: string; + createdAt: string; +} + +export interface PublicUser { + id: string; + username: string; + displayName: string; + createdAt: string; +} + +export const toPublicUser = (user: UserRecord): PublicUser => ({ + id: user.id, + username: user.username, + displayName: user.displayName, + createdAt: user.createdAt, +}); + +export interface CreateUserInput { + username: string; + password: string; + displayName?: string; +} + +export interface UserRepository { + findByUsername(username: string): Promise; + createUser(input: CreateUserInput): Promise; + verifyPassword(user: UserRecord, password: string): Promise; +} diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts new file mode 100644 index 0000000..863ac30 --- /dev/null +++ b/app/gateway-api/src/config.ts @@ -0,0 +1,36 @@ +export interface GatewayApiConfig { + host: string; + port: number; + trpcPath: string; + redisKeyPrefix: string; + sessionTtlSeconds: number; + gameSessionTtlSeconds: number; +} + +const parseNumber = (value: string | undefined, fallback: number, label: string): number => { + if (!value) { + return fallback; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + throw new Error(`${label} must be a number.`); + } + return parsed; +}; + +export const resolveGatewayApiConfigFromEnv = ( + env: NodeJS.ProcessEnv = process.env +): GatewayApiConfig => { + return { + host: env.GATEWAY_API_HOST ?? '0.0.0.0', + port: parseNumber(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT'), + trpcPath: env.TRPC_PATH ?? '/trpc', + redisKeyPrefix: env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway', + sessionTtlSeconds: parseNumber(env.SESSION_TTL_SECONDS, 60 * 60 * 24 * 7, 'SESSION_TTL_SECONDS'), + gameSessionTtlSeconds: parseNumber( + env.GAME_SESSION_TTL_SECONDS, + 60 * 60 * 6, + 'GAME_SESSION_TTL_SECONDS' + ), + }; +}; diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts new file mode 100644 index 0000000..88a2d0e --- /dev/null +++ b/app/gateway-api/src/context.ts @@ -0,0 +1,15 @@ +import type { GatewaySessionService } from './auth/sessionService.js'; +import type { UserRepository } from './auth/userRepository.js'; + +export interface GatewayApiContext { + users: UserRepository; + sessions: GatewaySessionService; +} + +export const createGatewayApiContext = (options: { + users: UserRepository; + sessions: GatewaySessionService; +}): GatewayApiContext => ({ + users: options.users, + sessions: options.sessions, +}); diff --git a/app/gateway-api/src/index.ts b/app/gateway-api/src/index.ts index cb0ff5c..d0ced8e 100644 --- a/app/gateway-api/src/index.ts +++ b/app/gateway-api/src/index.ts @@ -1 +1,30 @@ -export {}; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { runGatewayApiServer } from './server.js'; + +export * from './config.js'; +export * from './context.js'; +export * from './router.js'; +export * from './server.js'; +export * from './auth/userRepository.js'; +export * from './auth/passwordHasher.js'; +export * from './auth/inMemoryUserRepository.js'; +export * from './auth/sessionService.js'; +export * from './auth/inMemorySessionService.js'; +export * from './auth/redisSessionService.js'; +export * from './auth/redisKeys.js'; + +const isMain = (): boolean => { + if (!process.argv[1]) { + return false; + } + return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +}; + +if (isMain()) { + runGatewayApiServer().catch((error) => { + console.error('[gateway-api] failed to start', error); + process.exitCode = 1; + }); +} diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts new file mode 100644 index 0000000..f27016c --- /dev/null +++ b/app/gateway-api/src/router.ts @@ -0,0 +1,168 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { procedure, router } from './trpc.js'; +import { toPublicUser } from './auth/userRepository.js'; + +const zUsername = z.string().min(2).max(32); +const zPassword = z.string().min(6).max(128); +const zProfile = z.string().min(1).max(64); + +export const appRouter = router({ + health: router({ + ping: procedure.query(() => ({ + ok: true, + now: new Date().toISOString(), + })), + }), + auth: router({ + register: procedure + .input( + z.object({ + username: zUsername, + password: zPassword, + displayName: z.string().min(2).max(40).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const existing = await ctx.users.findByUsername(input.username); + if (existing) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Username already exists.', + }); + } + let created = null; + try { + created = await ctx.users.createUser(input); + } catch (error) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Username already exists.', + cause: error, + }); + } + const session = await ctx.sessions.createSession(created); + return { + user: toPublicUser(created), + sessionToken: session.sessionToken, + issuedAt: session.issuedAt, + }; + }), + login: procedure + .input( + z.object({ + username: zUsername, + password: zPassword, + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.users.findByUsername(input.username); + if (!user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid username or password.', + }); + } + const ok = await ctx.users.verifyPassword(user, input.password); + if (!ok) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid username or password.', + }); + } + const session = await ctx.sessions.createSession(user); + return { + user: toPublicUser(user), + sessionToken: session.sessionToken, + issuedAt: session.issuedAt, + }; + }), + me: procedure + .input( + z.object({ + sessionToken: z.string().min(1), + }) + ) + .query(async ({ ctx, input }) => { + const session = await ctx.sessions.getSession(input.sessionToken); + if (!session) { + return null; + } + return { + user: { + id: session.userId, + username: session.username, + displayName: session.displayName, + }, + issuedAt: session.issuedAt, + }; + }), + logout: procedure + .input( + z.object({ + sessionToken: z.string().min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + await ctx.sessions.revokeSession(input.sessionToken, { revokeGames: true }); + return { ok: true }; + }), + issueGameSession: procedure + .input( + z.object({ + sessionToken: z.string().min(1), + profile: zProfile, + }) + ) + .mutation(async ({ ctx, input }) => { + const gameSession = await ctx.sessions.createGameSession( + input.sessionToken, + input.profile + ); + if (!gameSession) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Session is not valid.', + }); + } + return { + profile: gameSession.profile, + gameToken: gameSession.gameToken, + issuedAt: gameSession.issuedAt, + }; + }), + validateGameSession: procedure + .input( + z.object({ + profile: zProfile, + gameToken: z.string().min(1), + }) + ) + .query(async ({ ctx, input }) => { + const gameSession = await ctx.sessions.getGameSession( + input.profile, + input.gameToken + ); + if (!gameSession) { + return null; + } + const session = await ctx.sessions.getSession(gameSession.sessionToken); + if (!session) { + return null; + } + return { + profile: gameSession.profile, + sessionToken: gameSession.sessionToken, + user: { + id: gameSession.userId, + username: gameSession.username, + displayName: gameSession.displayName, + }, + issuedAt: gameSession.issuedAt, + }; + }), + }), +}); + +export type AppRouter = typeof appRouter; diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts new file mode 100644 index 0000000..412db1e --- /dev/null +++ b/app/gateway-api/src/server.ts @@ -0,0 +1,65 @@ +import fastify from 'fastify'; +import cors from '@fastify/cors'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; + +import { resolveGatewayApiConfigFromEnv } from './config.js'; +import { createGatewayApiContext } from './context.js'; +import { createInMemoryUserRepository } from './auth/inMemoryUserRepository.js'; +import { RedisGatewaySessionService } from './auth/redisSessionService.js'; +import { appRouter } from './router.js'; + +export const createGatewayApiServer = async () => { + const config = resolveGatewayApiConfigFromEnv(); + const redis = createRedisConnector(resolveRedisConfigFromEnv()); + await redis.connect(); + + const users = createInMemoryUserRepository(); + const sessions = new RedisGatewaySessionService(redis.client, { + keyPrefix: config.redisKeyPrefix, + sessionTtlSeconds: config.sessionTtlSeconds, + gameSessionTtlSeconds: config.gameSessionTtlSeconds, + }); + + const app = fastify({ + logger: true, + }); + + await app.register(cors, { + origin: true, + credentials: true, + }); + + await app.register(fastifyTRPCPlugin, { + prefix: config.trpcPath, + trpcOptions: { + router: appRouter, + createContext: () => + createGatewayApiContext({ + users, + sessions, + }), + }, + }); + + app.get('/healthz', async () => ({ + ok: true, + })); + + app.addHook('onClose', async () => { + await redis.disconnect(); + }); + + return { + app, + config, + }; +}; + +export const runGatewayApiServer = async (): Promise => { + const { app, config } = await createGatewayApiServer(); + await app.listen({ + host: config.host, + port: config.port, + }); +}; diff --git a/app/gateway-api/src/trpc.ts b/app/gateway-api/src/trpc.ts new file mode 100644 index 0000000..315a247 --- /dev/null +++ b/app/gateway-api/src/trpc.ts @@ -0,0 +1,8 @@ +import { initTRPC } from '@trpc/server'; + +import type { GatewayApiContext } from './context.js'; + +const t = initTRPC.context().create(); + +export const router = t.router; +export const procedure = t.procedure; diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts new file mode 100644 index 0000000..4e45c1d --- /dev/null +++ b/app/gateway-api/test/authFlow.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; +import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; +import { createGatewayApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const buildCaller = () => { + const users = createInMemoryUserRepository(); + const sessions = new InMemoryGatewaySessionService({ + sessionTtlSeconds: 3600, + gameSessionTtlSeconds: 600, + }); + return appRouter.createCaller(createGatewayApiContext({ users, sessions })); +}; + +describe('gateway auth flow', () => { + it('registers and issues a game session', async () => { + const caller = buildCaller(); + const register = await caller.auth.register({ + username: 'tester', + password: 'secretpass', + displayName: 'Tester', + }); + + expect(register.user.username).toBe('tester'); + expect(register.sessionToken).toBeTruthy(); + + const issued = await caller.auth.issueGameSession({ + sessionToken: register.sessionToken, + profile: 'che:default', + }); + + expect(issued.profile).toBe('che:default'); + expect(issued.gameToken).toBeTruthy(); + + const validated = await caller.auth.validateGameSession({ + profile: 'che:default', + gameToken: issued.gameToken, + }); + + expect(validated?.user.username).toBe('tester'); + }); +}); diff --git a/app/gateway-api/tsconfig.json b/app/gateway-api/tsconfig.json index 53aba2b..bfd6a06 100644 --- a/app/gateway-api/tsconfig.json +++ b/app/gateway-api/tsconfig.json @@ -5,5 +5,8 @@ "rootDir": "src", "composite": true }, - "include": ["src"] + "include": ["src"], + "references": [ + { "path": "../../packages/infra" } + ] } diff --git a/app/gateway-api/vitest.config.ts b/app/gateway-api/vitest.config.ts new file mode 100644 index 0000000..400c273 --- /dev/null +++ b/app/gateway-api/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; + +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: [path.resolve(__dirname, '../../tsconfig.paths.json')], + }), + ], + test: { + environment: 'node', + globals: true, + include: ['test/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2219e19..f3048be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: fastify: specifier: ^5.3.3 version: 5.6.2 + redis: + specifier: ^4.7.0 + version: 4.7.1 zod: specifier: ^4.2.1 version: 4.2.1 @@ -77,10 +80,35 @@ importers: app/game-frontend: {} app/gateway-api: + dependencies: + '@fastify/cors': + specifier: ^10.0.1 + version: 10.1.0 + '@sammo-ts/infra': + specifier: workspace:* + version: link:../../packages/infra + '@trpc/server': + specifier: ^11.4.4 + version: 11.8.1(typescript@5.9.3) + fastify: + specifier: ^5.3.3 + version: 5.6.2 + redis: + specifier: ^4.7.0 + version: 4.7.1 + zod: + specifier: ^4.2.1 + version: 4.2.1 devDependencies: tsdown: specifier: ^0.18.3 version: 0.18.3(typescript@5.9.3) + vite-tsconfig-paths: + specifier: ^6.0.0 + version: 6.0.3(typescript@5.9.3)(vite@7.3.0(@types/node@20.19.27)(jiti@2.6.1)) + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@20.19.27)(jiti@2.6.1) app/gateway-frontend: {}