diff --git a/api/src/audit/audit.integration.spec.ts b/api/src/audit/audit.integration.spec.ts index b2b54d6..96ded21 100644 --- a/api/src/audit/audit.integration.spec.ts +++ b/api/src/audit/audit.integration.spec.ts @@ -33,7 +33,7 @@ jest.mock("bcrypt", () => ({ describe("Login audit (integration)", () => { let app: INestApplication - const auditService = { log: jest.fn().mockResolvedValue(undefined) } + const auditService = { logSafely: jest.fn().mockResolvedValue(undefined) } const usersRepository = { findByEmail: jest.fn(), findByUsername: jest.fn(), @@ -58,6 +58,7 @@ describe("Login audit (integration)", () => { password_hash: "$2b$10$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ12", created_at: new Date("2026-01-01T00:00:00Z"), + is_admin: false, } beforeAll(async () => { @@ -96,8 +97,8 @@ describe("Login audit (integration)", () => { .send({ email: user.email, password: "correctPassword" }) expect(res.status).toBe(200) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( user.id, AuditAction.AUTH_LOGIN_SUCCESS, { email: user.email }, @@ -114,8 +115,8 @@ describe("Login audit (integration)", () => { .send({ email: user.email, password: "wrongPassword" }) expect(res.status).toBe(401) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( user.id, AuditAction.AUTH_LOGIN_FAILURE, { reason: "invalid_password", email: user.email }, @@ -131,8 +132,8 @@ describe("Login audit (integration)", () => { .send({ email: "nobody@example.com", password: "whatever" }) expect(res.status).toBe(401) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( null, AuditAction.AUTH_LOGIN_FAILURE, { reason: "user_not_found", email: "nobody@example.com" }, diff --git a/api/src/audit/audit.interceptor.spec.ts b/api/src/audit/audit.interceptor.spec.ts index c1a2975..b03b19a 100644 --- a/api/src/audit/audit.interceptor.spec.ts +++ b/api/src/audit/audit.interceptor.spec.ts @@ -5,11 +5,11 @@ import { AuditInterceptor } from "./audit.interceptor" import { AuditService } from "./audit.service" describe("AuditInterceptor", () => { - let auditService: { log: jest.Mock } + let auditService: { logSafely: jest.Mock } let interceptor: AuditInterceptor beforeEach(() => { - auditService = { log: jest.fn().mockResolvedValue(undefined) } + auditService = { logSafely: jest.fn().mockResolvedValue(undefined) } interceptor = new AuditInterceptor(auditService as unknown as AuditService) }) @@ -55,8 +55,8 @@ describe("AuditInterceptor", () => { it("captures PATCH /users/me as PROFILE_UPDATE with the acting user id", async () => { await runThroughInterceptor("PATCH", "/users/me", { auth: { userId: 42 } }) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( 42, AuditAction.PROFILE_UPDATE, {}, @@ -69,8 +69,8 @@ describe("AuditInterceptor", () => { auth: { userId: 42 }, }) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( 42, AuditAction.PASSWORD_CHANGE, {}, @@ -84,11 +84,11 @@ describe("AuditInterceptor", () => { params: { id: "7" }, }) - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( 3, AuditAction.STREAM_DELETE, - { streamId: 7 }, + {}, "203.0.113.7", ) }) @@ -99,10 +99,10 @@ describe("AuditInterceptor", () => { params: { id: "7" }, }) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledWith( 3, AuditAction.STREAM_DELETE, - { streamId: 7 }, + {}, expect.any(String), ) }) @@ -110,7 +110,7 @@ describe("AuditInterceptor", () => { it("does not capture POST /auth/login: AuthService owns login auditing", async () => { await runThroughInterceptor("POST", "/auth/login") - expect(auditService.log).not.toHaveBeenCalled() + expect(auditService.logSafely).not.toHaveBeenCalled() }) it("does not capture routes that do not exist", async () => { @@ -119,7 +119,7 @@ describe("AuditInterceptor", () => { // The real route is DELETE /streams/:id; the id-less path is not a route. await runThroughInterceptor("DELETE", "/streams") - expect(auditService.log).not.toHaveBeenCalled() + expect(auditService.logSafely).not.toHaveBeenCalled() }) it("does not capture DELETE /streams/:id when the id is non-numeric", async () => { @@ -127,7 +127,7 @@ describe("AuditInterceptor", () => { params: { id: "abc" }, }) - expect(auditService.log).not.toHaveBeenCalled() + expect(auditService.logSafely).not.toHaveBeenCalled() }) it("writes no audit row when the request handler fails", async () => { @@ -140,14 +140,14 @@ describe("AuditInterceptor", () => { ), ).rejects.toThrow("boom") - expect(auditService.log).not.toHaveBeenCalled() + expect(auditService.logSafely).not.toHaveBeenCalled() }) it("records a NULL user id when the request carries no actor", async () => { await runThroughInterceptor("PATCH", "/users/me") - expect(auditService.log).toHaveBeenCalledTimes(1) - expect(auditService.log).toHaveBeenCalledWith( + expect(auditService.logSafely).toHaveBeenCalledTimes(1) + expect(auditService.logSafely).toHaveBeenCalledWith( null, AuditAction.PROFILE_UPDATE, {}, diff --git a/api/src/audit/audit.module.ts b/api/src/audit/audit.module.ts index dba1d67..9ceaa2b 100644 --- a/api/src/audit/audit.module.ts +++ b/api/src/audit/audit.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common" import { APP_INTERCEPTOR } from "@nestjs/core" -import { AdminAuditController } from "./admin-audit.controller" +import { AdminAuditController } from "../admin/admin-audit.controller" import { AuditInterceptor } from "./audit.interceptor" import { AuditService } from "./audit.service" import { MetricsModule } from "../metrics/metrics.module" diff --git a/api/src/auth/auth.controller.spec.ts b/api/src/auth/auth.controller.spec.ts index 9c76eef..781040c 100644 --- a/api/src/auth/auth.controller.spec.ts +++ b/api/src/auth/auth.controller.spec.ts @@ -1,4 +1,5 @@ import { UnauthorizedException } from "@nestjs/common" + import { AuthController } from "./auth.controller" import { AuthResponse, AuthService } from "./auth.service" @@ -23,10 +24,10 @@ function makeController(service: MockAuthService): AuthController { function authResponse(): AuthResponse { return { user: { - id: 1, + id: "1", username: "testuser", email: "test@example.com", - createdAt: new Date("2026-01-01T00:00:00Z"), + createdAt: "2026-01-01T00:00:00.000Z", }, accessToken: "access.token", refreshToken: "refresh.token", diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index a4ce928..49105d3 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -6,8 +6,8 @@ import { Post, Req, Res, + UnauthorizedException, } from "@nestjs/common" -import { Throttle } from "@nestjs/throttler" import { ApiCreatedResponse, ApiNoContentResponse, @@ -15,13 +15,17 @@ import { ApiOperation, ApiTags, } from "@nestjs/swagger" -import type { Request, Response } from "express" +import { Throttle } from "@nestjs/throttler" + + import { AuthResponse, AuthService } from "./auth.service" +import { ForgotPasswordDto } from "./dto/forgot-password.dto" import { LoginDto } from "./dto/login.dto" import { RegisterDto } from "./dto/register.dto" -import { ForgotPasswordDto } from "./dto/forgot-password.dto" import { ResetPasswordDto } from "./dto/reset-password.dto" +import type { Request, Response } from "express" + const REFRESH_COOKIE_NAME = "refresh_token" const COOKIE_OPTIONS = { httpOnly: true, @@ -84,17 +88,24 @@ export class AuthController { @Post("refresh") @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: "Refresh the access token", + summary: "Refresh an expired access token", description: - "Reads the refresh token from the httpOnly cookie and returns a new access token.", + "Accepts a refresh token via the request body (`refreshToken`) or via " + + "the `refresh_token` httpOnly cookie. Returns a fresh access token, " + + "refresh token, and the user profile.", }) @ApiOkResponse({ - description: "Access token refreshed.", + description: "Token refresh successful. New token pair returned.", }) - async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise { - const result = await this.authService.refresh(req) - res.cookie(REFRESH_COOKIE_NAME, result.refreshToken, COOKIE_OPTIONS) - return result + refresh( + @Body("refreshToken") bodyToken?: string, + @Req() req?: { cookies?: Record }, + ): Promise { + const token = bodyToken ?? req?.cookies?.refresh_token + if (!token) { + throw new UnauthorizedException("refresh token is required") + } + return this.authService.refresh(token) } @Post("logout") @@ -160,26 +171,4 @@ export class AuthController { } } - @Post("refresh") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Refresh an expired access token", - description: - "Accepts a refresh token via the request body (`refreshToken`) or via " + - "the `refresh_token` httpOnly cookie. Returns a fresh access token, " + - "refresh token, and the user profile.", - }) - @ApiOkResponse({ - description: "Token refresh successful. New token pair returned.", - }) - refresh( - @Body("refreshToken") bodyToken?: string, - @Req() req?: { cookies?: Record }, - ): Promise { - const token = bodyToken ?? req?.cookies?.refresh_token - if (!token) { - throw new UnauthorizedException("refresh token is required") - } - return this.authService.refresh(token) - } } diff --git a/api/src/auth/auth.service.spec.ts b/api/src/auth/auth.service.spec.ts index 2a0d664..4c725cc 100644 --- a/api/src/auth/auth.service.spec.ts +++ b/api/src/auth/auth.service.spec.ts @@ -121,7 +121,7 @@ describe("AuthService", () => { refreshJwt = mockJwtService() users = mockUsersRepository() passwordReset = mockPasswordResetService() - tokenDenylist = { revoke: jest.fn() } + tokenDenylist = { revoke: jest.fn(), decodeJti: jest.fn(), isRevoked: jest.fn() } audit = { log: jest.fn(), logSafely: jest.fn() } service = makeService( accessJwt, @@ -178,6 +178,7 @@ describe("AuthService", () => { email: dto.email, username: dto.username, passwordChangedAt: expect.any(Number), + isAdmin: false, jti: expect.any(String), }) expect(refreshJwt.sign).toHaveBeenCalledWith({ @@ -373,6 +374,7 @@ describe("AuthService", () => { email: user.email, username: user.username, passwordChangedAt: expect.any(Number), + isAdmin: false, jti: expect.any(String), }) expect(refreshJwt.sign).toHaveBeenCalledWith({ @@ -516,6 +518,7 @@ describe("AuthService", () => { email: user.email, username: user.username, passwordChangedAt: expect.any(Number), + isAdmin: false, jti: expect.any(String), }) expect(refreshJwt.sign).toHaveBeenCalledWith({ @@ -644,9 +647,7 @@ describe("AuthService", () => { refreshJwt.sign.mockReturnValue("new.refresh.token") tokenDenylist.isRevoked.mockResolvedValue(false) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: { refresh_token: refreshToken } } as any - const result = await service.refresh(req) + const result = await service.refresh(refreshToken) expect(result.accessToken).toBe("new.access.token") expect(result.refreshToken).toBe("new.refresh.token") @@ -661,28 +662,21 @@ describe("AuthService", () => { refreshJwt.decode.mockReturnValue({ sub: 1, jti: "revoked-refresh-jti" }) tokenDenylist.isRevoked.mockResolvedValue(true) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: { refresh_token: refreshToken } } as any - - await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) - await expect(service.refresh(req)).rejects.toThrow( + await expect(service.refresh(refreshToken)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh(refreshToken)).rejects.toThrow( "refresh token has been revoked", ) expect(users.findById).not.toHaveBeenCalled() }) it("throws UnauthorizedException when refresh token is missing", async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: {} } as any - await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh("")).rejects.toThrow(UnauthorizedException) }) it("throws UnauthorizedException when refresh token is invalid", async () => { refreshJwt.verifyAsync.mockRejectedValue(new Error("invalid")) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: { refresh_token: refreshToken } } as any - await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) + await expect(service.refresh(refreshToken)).rejects.toThrow(UnauthorizedException) }) it("throws UnauthorizedException when user is not found", async () => { @@ -690,78 +684,7 @@ describe("AuthService", () => { refreshJwt.decode.mockReturnValue({ sub: 999 }) users.findById.mockResolvedValue(null) - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial request stub for test - const req = { cookies: { refresh_token: refreshToken } } as any - await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException) - }) - }) - - // -- refresh ----------------------------------------------------------- - - describe("refresh", () => { - it("returns new token pair for a valid refresh token", async () => { - const user = dummyUser() - jwt.verify.mockReturnValue({ sub: user.id }) - users.findById.mockResolvedValue(user) - jwt.sign.mockReturnValueOnce("new.access.token").mockReturnValueOnce("new.refresh.token") - - const result = await service.refresh("valid.refresh.token") - - expect(jwt.verify).toHaveBeenCalledWith("valid.refresh.token") - expect(users.findById).toHaveBeenCalledWith(user.id) - expect(result.accessToken).toBe("new.access.token") - expect(result.refreshToken).toBe("new.refresh.token") - expect(result.user).toEqual({ - id: user.id, - username: user.username, - email: user.email, - createdAt: user.created_at, - }) - }) - - it("throws UnauthorizedException when the refresh token is invalid or expired", async () => { - jwt.verify.mockImplementation(() => { - throw new Error("jwt expired") - }) - - await expect(service.refresh("expired.token")).rejects.toThrow( - UnauthorizedException, - ) - expect(users.findById).not.toHaveBeenCalled() - }) - - it("throws UnauthorizedException when the user no longer exists", async () => { - jwt.verify.mockReturnValue({ sub: 999 }) - users.findById.mockResolvedValue(null) - - await expect(service.refresh("valid.for.deleted.user")).rejects.toThrow( - UnauthorizedException, - ) - expect(jwt.sign).not.toHaveBeenCalled() - }) - - it("signs the access token with the standard short-lived payload", async () => { - const user = dummyUser() - jwt.verify.mockReturnValue({ sub: user.id }) - users.findById.mockResolvedValue(user) - jwt.sign - .mockReturnValueOnce("access") - .mockReturnValueOnce("refresh") - - await service.refresh("token") - - // First call: access token (short-lived, full claims) - expect(jwt.sign).toHaveBeenNthCalledWith(1, { - sub: user.id, - email: user.email, - username: user.username, - }) - // Second call: refresh token (long-lived, sub only) - expect(jwt.sign).toHaveBeenNthCalledWith( - 2, - { sub: user.id }, - { expiresIn: "7d" }, - ) + await expect(service.refresh(refreshToken)).rejects.toThrow(UnauthorizedException) }) }) }) diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 1df0b71..494f881 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -15,7 +15,7 @@ import { LoginDto } from "./dto/login.dto" import { RegisterDto } from "./dto/register.dto" import { ResetPasswordDto } from "./dto/reset-password.dto" import { PasswordResetService } from "./password-reset.service" -import { TokenDenylistService } from "./token-denylist.service" +import { TokenDenylistService, TokenJti } from "./token-denylist.service" import { User, UsersRepository } from "./users.repository" import { AuditAction } from "../audit/audit-action.enum" import { AuditService } from "../audit/audit.service" @@ -155,8 +155,15 @@ export class AuthService { } } - async refresh(req: Request): Promise { - const refreshToken = req.cookies?.refresh_token + /** + * Refresh an access token using a valid refresh token. + * + * The refresh token is extracted by the controller from either the request + * body (`refreshToken`) or the `refresh_token` httpOnly cookie. This method + * validates the token, consults the denylist for its `jti` (issue #510), + * and returns a fresh token pair. + */ + async refresh(refreshToken: string): Promise { if (!refreshToken) { throw new UnauthorizedException("missing refresh token") } @@ -314,6 +321,7 @@ export class AuthService { username: user.username, passwordChangedAt: user.password_changed_at?.getTime() ?? user.created_at.getTime(), + isAdmin: user.is_admin === true, jti: randomUUID(), }) } @@ -329,14 +337,6 @@ export class AuthService { jti: randomUUID(), }) } - - /** Create a long-lived JWT refresh token for the given user. */ - private signRefreshToken(user: User): string { - return this.jwtService.sign( - { sub: user.id }, - { expiresIn: "7d" }, - ) - } } /** Strip the password hash from a user row before returning to clients. */ diff --git a/api/src/auth/users.repository.ts b/api/src/auth/users.repository.ts index a8ef4bb..531ae4b 100644 --- a/api/src/auth/users.repository.ts +++ b/api/src/auth/users.repository.ts @@ -32,14 +32,6 @@ export class UsersRepository { return rows[0] ?? null } - async findById(id: number): Promise { - const { rows } = await this.pool.query( - "SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1", - [id], - ) - return rows[0] ?? null - } - async findByUsername(username: string): Promise { const { rows } = await this.pool.query( "SELECT id, username, email, password_hash, created_at, password_changed_at, is_admin FROM users WHERE username = $1", diff --git a/api/src/common/guards/jwt-extractor.service.spec.ts b/api/src/common/guards/jwt-extractor.service.spec.ts index 357a799..677c781 100644 --- a/api/src/common/guards/jwt-extractor.service.spec.ts +++ b/api/src/common/guards/jwt-extractor.service.spec.ts @@ -42,14 +42,20 @@ describe("JwtExtractorService", () => { jwtService.verifyAsync.mockResolvedValue({ sub: 1, jti: JTI }) denylist.isRevoked.mockResolvedValue(false) - await expect(extractor.authenticate("Bearer a.b.c")).resolves.toBe(1) + await expect(extractor.authenticate("Bearer a.b.c")).resolves.toEqual({ + userId: 1, + isAdmin: false, + }) expect(denylist.isRevoked).toHaveBeenCalledWith(JTI) }) it("skips the denylist lookup for legacy tokens without a jti", async () => { jwtService.verifyAsync.mockResolvedValue({ sub: 1 }) - await expect(extractor.authenticate("Bearer a.b.c")).resolves.toBe(1) + await expect(extractor.authenticate("Bearer a.b.c")).resolves.toEqual({ + userId: 1, + isAdmin: false, + }) expect(denylist.isRevoked).not.toHaveBeenCalled() }) @@ -78,7 +84,10 @@ describe("JwtExtractorService", () => { created_at: new Date("2026-01-01T00:00:00Z"), }) - await expect(extractor.authenticate("Bearer a.b.c")).resolves.toBe(1) + await expect(extractor.authenticate("Bearer a.b.c")).resolves.toEqual({ + userId: 1, + isAdmin: false, + }) }) it("throws UnauthorizedException for a missing or malformed header", async () => { diff --git a/api/src/contract-provider.spec.ts b/api/src/contract-provider.spec.ts index 07d6394..275028e 100644 --- a/api/src/contract-provider.spec.ts +++ b/api/src/contract-provider.spec.ts @@ -101,7 +101,11 @@ describe("Contract provider verification (api)", () => { let existingDeliveryId: string beforeAll(async () => { - streamsRepository = new StreamsRepository() + // The in-memory StreamsRepository consults the same TagsRepository + // instance the service uses, so the `tag` list filter (issue #532) + // resolves against the associations seeded below. + const tagsRepository = new TagsRepository() + streamsRepository = new StreamsRepository(tagsRepository) subscriptionsRepository = new WebhookSubscriptionsRepository() deliveriesRepository = new WebhookDeliveriesRepository() @@ -132,7 +136,7 @@ describe("Contract provider verification (api)", () => { providers: [ StreamsService, TagsService, - TagsRepository, + { provide: TagsRepository, useValue: tagsRepository }, { provide: StreamsRepository, useValue: streamsRepository }, WebhooksService, { @@ -218,8 +222,8 @@ describe("Contract provider verification (api)", () => { existingStreamId = String(stream.id) // Attach one tag so `list-stream-tags` exercises a non-empty - // response (the schema pins the Tag shape, not just the empty case). - const tagsRepository = moduleFixture.get(TagsRepository) + // response (the schema pins the Tag shape, not just the empty case) + // and so `list-streams-by-tag` has a real association to filter on. const seededTag = await tagsRepository.upsertBySlug( "Live Streaming", "live-streaming", diff --git a/api/src/database.integration.spec.ts b/api/src/database.integration.spec.ts index 4b9f6f9..7e2df49 100644 --- a/api/src/database.integration.spec.ts +++ b/api/src/database.integration.spec.ts @@ -370,7 +370,7 @@ describe("Database Integration Tests", () => { expect(pending.timestamp).toBe("2026-08-01T00:00:00.000Z") // The row is visible to the worker's poll source. - const { data } = await streamsDb.getPendingEvents(100, 0) + const { data } = await streamsDb.getPendingEvents(100, null) expect(data).toHaveLength(1) expect(data[0]).toEqual(pending) }) @@ -474,16 +474,20 @@ describe("Database Integration Tests", () => { } if (rows.length < BATCH) break - // Fetch the actual timestamp from the last row we saw. + // Fetch the actual timestamp from the last row we saw. The cursor is + // serialized as `timestamp::text` (microsecond precision) rather than + // a JS `Date`/ISO string: node-postgres truncates `timestamptz` to + // milliseconds, and with a burst of inserts sharing one millisecond + // the truncated cursor would re-fetch rows already seen. const lastRow = rows[rows.length - 1] const { rows: tsRows } = await pool.query<{ - timestamp: Date + timestamp: string }>( - `SELECT timestamp FROM stream_data WHERE id = $1`, + `SELECT timestamp::text AS timestamp FROM stream_data WHERE id = $1`, [lastRow.id], ) cursor = JSON.stringify({ - timestamp: tsRows[0].timestamp.toISOString(), + timestamp: tsRows[0].timestamp, id: lastRow.id, }) } diff --git a/api/src/gateways/streams.gateway.spec.ts b/api/src/gateways/streams.gateway.spec.ts index 310c963..80d4b92 100644 --- a/api/src/gateways/streams.gateway.spec.ts +++ b/api/src/gateways/streams.gateway.spec.ts @@ -148,7 +148,7 @@ describe("StreamsGateway", () => { const socket = makeSocket({ handshake: { auth: { token: "valid-token" } }, }) - authExtractor.authenticate.mockResolvedValue(42) + authExtractor.authenticate.mockResolvedValue({ userId: 42, isAdmin: false }) await gateway.handleConnection(socket as unknown as any) @@ -221,7 +221,7 @@ describe("StreamsGateway", () => { headers: { authorization: "Bearer header-token" }, }, }) - authExtractor.authenticate.mockResolvedValue(99) + authExtractor.authenticate.mockResolvedValue({ userId: 99, isAdmin: false }) await gateway.handleConnection(socket as unknown as any) @@ -265,7 +265,7 @@ describe("StreamsGateway", () => { headers: { authorization: "Bearer header-token" }, }, }) - authExtractor.authenticate.mockResolvedValue(7) + authExtractor.authenticate.mockResolvedValue({ userId: 7, isAdmin: false }) await gateway.handleConnection(socket as unknown as any) @@ -288,7 +288,7 @@ describe("StreamsGateway", () => { query: { token: "decoy-token" }, }, }) - authExtractor.authenticate.mockResolvedValue(11) + authExtractor.authenticate.mockResolvedValue({ userId: 11, isAdmin: false }) await gateway.handleConnection(socket as unknown as any) diff --git a/api/src/gateways/streams.gateway.ts b/api/src/gateways/streams.gateway.ts index c11eb12..b16babc 100644 --- a/api/src/gateways/streams.gateway.ts +++ b/api/src/gateways/streams.gateway.ts @@ -146,7 +146,7 @@ export class StreamsGateway // revoked tokens and tokens minted before the user's last password // change are rejected here too — the JWT's `jti` is checked against // the denylist inside authenticate(). - const userId = await this.jwtExtractorService.authenticate( + const { userId } = await this.jwtExtractorService.authenticate( `Bearer ${token}`, ) client.data.userId = userId diff --git a/api/src/main.ts b/api/src/main.ts index 7aeecf2..fcaf3e8 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -5,7 +5,6 @@ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger" import compression from "compression" import cookieParser from "cookie-parser" import helmet from "helmet" -import * as cookieParser from "cookie-parser" import { AppModule } from "./app.module" import { SanitizeStringsPipe } from "./common/sanitization/sanitize-strings.pipe" import { ThrottlerExceptionFilter } from "./throttler-exception.filter" diff --git a/api/src/streams/dto/create-stream.dto.ts b/api/src/streams/dto/create-stream.dto.ts index f6fa983..06c5f12 100644 --- a/api/src/streams/dto/create-stream.dto.ts +++ b/api/src/streams/dto/create-stream.dto.ts @@ -1,5 +1,6 @@ import { Transform } from "class-transformer" import { IsOptional, IsString, Length, MaxLength } from "class-validator" + import { IsOptionalStreamVisibility, type StreamVisibility, diff --git a/api/src/streams/dto/list-streams.query.dto.spec.ts b/api/src/streams/dto/list-streams.query.dto.spec.ts new file mode 100644 index 0000000..1f915ab --- /dev/null +++ b/api/src/streams/dto/list-streams.query.dto.spec.ts @@ -0,0 +1,67 @@ +import { validate } from "class-validator" + +import { ListStreamsQueryDto } from "./list-streams.query.dto" + +async function validateDto(overrides: Partial) { + const dto = new ListStreamsQueryDto() + Object.assign(dto, overrides) + return validate(dto) +} + +describe("ListStreamsQueryDto — search & tag params (issue #532)", () => { + it("accepts a valid q", async () => { + const errors = await validateDto({ q: "football" }) + expect(errors).toHaveLength(0) + }) + + it("rejects q longer than 200 characters", async () => { + const errors = await validateDto({ q: "x".repeat(201) }) + expect(errors).toHaveLength(1) + expect(errors[0]?.property).toBe("q") + expect(errors[0]?.constraints?.maxLength).toMatch(/at most 200/) + }) + + it("accepts a 200-character q", async () => { + const errors = await validateDto({ q: "x".repeat(200) }) + expect(errors).toHaveLength(0) + }) + + it("accepts a valid slug tag", async () => { + const errors = await validateDto({ tag: "live-streaming" }) + expect(errors).toHaveLength(0) + }) + + it("accepts a numeric tag id", async () => { + const errors = await validateDto({ tag: "42" }) + expect(errors).toHaveLength(0) + }) + + it("rejects a tag that is neither a slug nor an id", async () => { + const errors = await validateDto({ tag: "Live Streaming" }) + expect(errors).toHaveLength(1) + expect(errors[0]?.property).toBe("tag") + expect(errors[0]?.constraints?.matches).toMatch(/slug/) + }) + + it("rejects a tag with uppercase characters", async () => { + const errors = await validateDto({ tag: "Live" }) + expect(errors).toHaveLength(1) + expect(errors[0]?.property).toBe("tag") + }) + + it("rejects a tag with a leading dash", async () => { + const errors = await validateDto({ tag: "-live" }) + expect(errors).toHaveLength(1) + }) + + it("rejects a non-string q", async () => { + const errors = await validateDto({ q: 123 as unknown as string }) + expect(errors).toHaveLength(1) + expect(errors[0]?.property).toBe("q") + }) + + it("treats absent q and tag as valid (no filter)", async () => { + const errors = await validateDto({}) + expect(errors).toHaveLength(0) + }) +}) diff --git a/api/src/streams/dto/list-streams.query.dto.ts b/api/src/streams/dto/list-streams.query.dto.ts index 27fe554..3d86d90 100644 --- a/api/src/streams/dto/list-streams.query.dto.ts +++ b/api/src/streams/dto/list-streams.query.dto.ts @@ -1,11 +1,19 @@ import { ApiPropertyOptional } from "@nestjs/swagger" import { Transform } from "class-transformer" -import { IsBoolean, IsIn, IsOptional, IsString } from "class-validator" -import { PaginationQueryDto } from "../../common/dto/pagination.dto" +import { + IsBoolean, + IsIn, + IsOptional, + IsString, + MaxLength, + Matches, +} from "class-validator" + import { STREAM_VISIBILITY_VALUES, type StreamVisibility, } from "./visibility" +import { PaginationQueryDto } from "../../common/dto/pagination.dto" /** * Query parameters for `GET /streams`. @@ -68,4 +76,40 @@ export class ListStreamsQueryDto extends PaginationQueryDto { }) @IsBoolean({ message: "ownerOnly must be a boolean" }) ownerOnly?: boolean + + @ApiPropertyOptional({ + description: + "Case-insensitive substring search over stream name and description. '%' and '_' are matched literally, never as wildcards.", + example: "football", + }) + @IsOptional() + @Transform(({ value }) => + value === undefined || value === null || value === "" + ? undefined + : typeof value === "string" + ? value.trim() + : value, + ) + @IsString({ message: "q must be a string" }) + @MaxLength(200, { message: "q must be at most 200 characters" }) + q?: string + + @ApiPropertyOptional({ + description: + "Restrict results to streams carrying this tag. Accepts a tag slug (e.g. 'live') or a numeric tag id. Unknown tags return an empty page.", + example: "live", + }) + @IsOptional() + @Transform(({ value }) => + value === undefined || value === null || value === "" + ? undefined + : typeof value === "string" + ? value.trim() + : value, + ) + @IsString({ message: "tag must be a string" }) + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: "tag must be a slug (lowercase alphanumeric with hyphens) or a numeric id", + }) + tag?: string } diff --git a/api/src/streams/dto/update-stream.dto.ts b/api/src/streams/dto/update-stream.dto.ts index 5e920b5..8b71452 100644 --- a/api/src/streams/dto/update-stream.dto.ts +++ b/api/src/streams/dto/update-stream.dto.ts @@ -1,4 +1,5 @@ import { IsIn, IsOptional, IsString, Length, MaxLength } from "class-validator" + import { IsOptionalStreamVisibility, type StreamVisibility, diff --git a/api/src/streams/repository/streams-db.repository.spec.ts b/api/src/streams/repository/streams-db.repository.spec.ts new file mode 100644 index 0000000..03ee104 --- /dev/null +++ b/api/src/streams/repository/streams-db.repository.spec.ts @@ -0,0 +1,108 @@ +// Stub the database.module so tests do not pull in env validation +// from the production module graph (#330). +jest.mock("../../database/database.module", () => ({ + PG_POOL: Symbol("PG_POOL"), +})) + +import { StreamsDbRepository } from "./streams-db.repository" + +/** + * Builds a stub that satisfies the parts of `pg.Pool` the repository + * actually calls. `rows` is returned for the SELECT query; the COUNT + * query is short-circuited so the assertions can focus on the WHERE + * fragment shared by both. + */ +function makeRepo(rows: unknown[], countRows: unknown[] = [{ count: "0" }]) { + const pool = { + query: jest.fn().mockImplementation((sql: string) => { + if (String(sql).trimStart().startsWith("SELECT COUNT")) { + return { rows: countRows } + } + return { rows } + }), + } + return { repo: new StreamsDbRepository(pool as never), pool } +} + +/** The WHERE fragment of a query, i.e. everything from `WHERE` onward. */ +function whereFragment(sql: string): string { + return sql.slice(sql.indexOf("WHERE")) +} + +describe("StreamsDbRepository — listPaginated search & tag predicates (issue #532)", () => { + it("q emits an ILIKE predicate over name and description with a shared placeholder", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { q: "football" }) + + const countSql = pool.query.mock.calls[0][0] as string + const selectSql = pool.query.mock.calls[1][0] as string + + // The repository source spells the Postgres escape char as `\\` + // inside a template literal, which produces `ESCAPE '\'` at runtime. + const ilikePredicate = `(name ILIKE $2 ESCAPE '\\' OR description ILIKE $2 ESCAPE '\\')` + expect(countSql).toContain(ilikePredicate) + expect(selectSql).toContain(ilikePredicate) + }) + + it("COUNT and SELECT share the exact same WHERE fragment so totals cannot disagree with the page", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { q: "football", tagId: 3 }) + + const countSql = pool.query.mock.calls[0][0] as string + const selectSql = pool.query.mock.calls[1][0] as string + // The SELECT appends a newline + ORDER BY / LIMIT / OFFSET after the + // WHERE, so trim the SELECT's WHERE fragment before comparing. + const selectWhere = whereFragment(selectSql).split(" ORDER BY")[0].trim() + expect(whereFragment(countSql).trim()).toBe(selectWhere) + }) + + it("q escapes % and _ so user input cannot become a wildcard", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { q: "100%_oops" }) + + // params: [$1 = viewer, $2 = pattern]. The pattern wraps the + // escaped input in %…% (each `\%` / `\_` is one backslash + char). + const params = pool.query.mock.calls[1][1] as unknown[] + expect(params[1]).toBe("%100\\%\\_oops%") + }) + + it("tagId emits an EXISTS predicate over stream_tags", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { tagId: 7 }) + + const selectSql = pool.query.mock.calls[1][0] as string + expect(selectSql).toContain( + "EXISTS (SELECT 1 FROM stream_tags st WHERE st.stream_id = streams.id AND st.tag_id = $2)", + ) + const params = pool.query.mock.calls[1][1] as unknown[] + expect(params[1]).toBe(7) + }) + + it("q and tagId combine into a single AND-ed WHERE fragment", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { q: "football", tagId: 3 }) + + const countSql = pool.query.mock.calls[0][0] as string + const where = whereFragment(countSql) + expect(where).toContain("ILIKE") + expect(where).toContain("stream_tags") + expect(where).toContain(" AND ") + + // $2 = pattern, $3 = tag id — both AFTER the visibility ACL at $1. + // (The COUNT query carries only the WHERE params; the SELECT appends + // LIMIT/OFFSET, so assert against the COUNT call.) + const countParams = pool.query.mock.calls[0][1] as unknown[] + expect(countParams).toEqual([1, "%football%", 3]) + }) + + it("leaves the WHERE fragment untouched when only visibility filters apply", async () => { + const { repo, pool } = makeRepo([]) + await repo.listPaginated(1, 10, 1, { visibility: "public" }) + + const countSql = pool.query.mock.calls[0][0] as string + const where = whereFragment(countSql) + expect(where).toContain("(visibility = 'public' OR user_id = $1)") + expect(where).not.toContain("ILIKE") + expect(where).not.toContain("stream_tags") + }) +}) diff --git a/api/src/streams/repository/streams-db.repository.ts b/api/src/streams/repository/streams-db.repository.ts index 1228832..e0cb754 100644 --- a/api/src/streams/repository/streams-db.repository.ts +++ b/api/src/streams/repository/streams-db.repository.ts @@ -15,7 +15,7 @@ import { StreamsRepository, PendingStreamEvent, type StreamCreateParams, - type StreamListFilter, + type StreamListPredicate, type StreamUpdateChanges, } from "./streams.repository" @@ -96,7 +96,7 @@ export class StreamsDbRepository { page: number, limit: number, viewerUserId: number, - filter?: StreamListFilter, + filter?: StreamListPredicate, ): Promise<{ items: Stream[]; total: number }> { const offset = (page - 1) * limit const params: unknown[] = [viewerUserId] @@ -119,6 +119,25 @@ export class StreamsDbRepository { params.push(filter.visibility) conditions.push(`visibility = $${params.length}`) } + // Case-insensitive search over name + description. `%`/`_` in user + // input are escaped so the pattern matches them literally (issue #532); + // the same $N placeholder is reused for both columns. + if (filter?.q) { + params.push(`%${escapeLikePattern(filter.q)}%`) + const ph = `$${params.length}` + conditions.push( + `(name ILIKE ${ph} ESCAPE '\\' OR description ILIKE ${ph} ESCAPE '\\')`, + ) + } + // Tag filter: restrict to streams carrying the resolved tag via the + // stream_tags join table. The tag id is resolved upstream (service) + // so this predicate never re-implements slug lookups. + if (filter?.tagId !== undefined) { + params.push(filter.tagId) + conditions.push( + `EXISTS (SELECT 1 FROM stream_tags st WHERE st.stream_id = streams.id AND st.tag_id = $${params.length})`, + ) + } const where = `WHERE ${conditions.join(" AND ")}` try { @@ -242,16 +261,18 @@ export class StreamsDbRepository { ): Promise { try { const { rows } = await this.pool.query<{ + id: number stream_id: number data: Record timestamp: Date }>( `INSERT INTO stream_data (stream_id, data, timestamp) VALUES ($1, $2, $3) - RETURNING stream_id, data, timestamp`, + RETURNING id, stream_id, data, timestamp`, [streamId, data, timestamp], ) return { + id: String(rows[0].id), streamId: String(rows[0].stream_id), data: rows[0].data, timestamp: rows[0].timestamp.toISOString(), @@ -420,6 +441,16 @@ function nullableNumber( return Number(value) } +/** + * Escapes LIKE wildcards so user-supplied search text is matched + * literally. `\` is escaped first because Postgres treats it as the + * default escape character; `%` and `_` follow so they lose their + * wildcard meaning. + */ +function escapeLikePattern(input: string): string { + return input.replace(/[\\%_]/g, (ch) => `\\${ch}`) +} + function roundPercent(value: number): number { return Math.round(value * 100) / 100 } diff --git a/api/src/streams/repository/streams.repository.spec.ts b/api/src/streams/repository/streams.repository.spec.ts new file mode 100644 index 0000000..9b81c03 --- /dev/null +++ b/api/src/streams/repository/streams.repository.spec.ts @@ -0,0 +1,207 @@ +import { StreamsRepository } from "./streams.repository" +import { TagsRepository } from "../../tags/repository/tags.repository" + +describe("StreamsRepository (in-memory) — search & tag filtering (issue #532)", () => { + let repo: StreamsRepository + let tags: TagsRepository + + beforeEach(async () => { + tags = new TagsRepository() + repo = new StreamsRepository(tags) + // viewer 1 owns stream 1 (public) and stream 2 (public); viewer 2 + // owns stream 3 (private). The ACL means viewer 1 never sees + // stream 3, and viewer 2 sees all three. + await repo.create({ + userId: 1, + name: "Sunday Night Football", + description: "NFL highlights and analysis", + visibility: "public", + }) + await repo.create({ + userId: 1, + name: "Chess Club", + description: "Weekly blitz tournament", + visibility: "public", + }) + await repo.create({ + userId: 2, + name: "Private Football Review", + description: "Internal tape review", + visibility: "private", + }) + // Tag "live" is attached to stream 1 (public, user 1) and stream 3 + // (private, user 2) — so the tag filter must respect visibility. + const live = await tags.upsertBySlug("Live", "live") + await tags.attachToStream(1, live.id) + await tags.attachToStream(3, live.id) + }) + + it("q matches stream names case-insensitively", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { + q: "FOOTBALL", + }) + // createdAt carries ms precision and the seeds are created in the + // same tick, so assert on the set (order is covered elsewhere). + expect(items.map((s) => s.name).sort()).toEqual([ + "Private Football Review", + "Sunday Night Football", + ]) + expect(total).toBe(2) + }) + + it("q matches stream descriptions", async () => { + const { items } = await repo.listPaginated(1, 10, 2, { q: "blitz" }) + expect(items.map((s) => s.name)).toEqual(["Chess Club"]) + }) + + it("q returns an empty page when nothing matches", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { + q: "cricket", + }) + expect(items).toEqual([]) + expect(total).toBe(0) + }) + + it("q stays inside the visibility ACL (a hidden private stream never matches)", async () => { + // Viewer 1 cannot see stream 3 (private, user 2) even though its + // name contains "football". + const { items, total } = await repo.listPaginated(1, 10, 1, { + q: "football", + }) + expect(items.map((s) => s.name)).toEqual(["Sunday Night Football"]) + expect(total).toBe(1) + }) + + it("q combines with status", async () => { + await repo.update(2, { status: "active" }) + const active = await repo.listPaginated(1, 10, 2, { + q: "club", + status: "active", + }) + expect(active.items.map((s) => s.name)).toEqual(["Chess Club"]) + expect(active.total).toBe(1) + + const inactive = await repo.listPaginated(1, 10, 2, { + q: "club", + status: "inactive", + }) + expect(inactive.items).toEqual([]) + expect(inactive.total).toBe(0) + }) + + it("q combines with visibility", async () => { + const publicOnly = await repo.listPaginated(1, 10, 2, { + q: "football", + visibility: "public", + }) + expect(publicOnly.items.map((s) => s.name)).toEqual([ + "Sunday Night Football", + ]) + expect(publicOnly.total).toBe(1) + + const privateOnly = await repo.listPaginated(1, 10, 2, { + q: "football", + visibility: "private", + }) + expect(privateOnly.items.map((s) => s.name)).toEqual([ + "Private Football Review", + ]) + expect(privateOnly.total).toBe(1) + }) + + it("q treats % and _ literally, never as wildcards", async () => { + await repo.create({ + userId: 1, + name: "100% organic", + description: "no additives", + visibility: "public", + }) + await repo.create({ + userId: 1, + name: "100 organic", + description: "no additives either", + visibility: "public", + }) + + const percent = await repo.listPaginated(1, 10, 1, { q: "100%" }) + expect(percent.items.map((s) => s.name)).toEqual(["100% organic"]) + expect(percent.total).toBe(1) + + // A bare "%" matches only names that literally contain "%". + const bare = await repo.listPaginated(1, 10, 1, { q: "%" }) + expect(bare.items.map((s) => s.name)).toEqual(["100% organic"]) + expect(bare.total).toBe(1) + }) + + it("tag filter returns only streams carrying the tag", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { tagId: 1 }) + // Streams 1 and 3 both carry tag id 1. + expect(items.map((s) => s.name).sort()).toEqual([ + "Private Football Review", + "Sunday Night Football", + ]) + expect(total).toBe(2) + }) + + it("tag filter respects the visibility ACL", async () => { + // Viewer 1 can only see stream 1 of the two tagged streams. + const { items, total } = await repo.listPaginated(1, 10, 1, { tagId: 1 }) + expect(items.map((s) => s.name)).toEqual(["Sunday Night Football"]) + expect(total).toBe(1) + }) + + it("tag filter combines with q", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { + tagId: 1, + q: "review", + }) + expect(items.map((s) => s.name)).toEqual(["Private Football Review"]) + expect(total).toBe(1) + }) + + it("results are sorted newest-first", async () => { + // Give the seeds distinct createdAt values so ordering is + // deterministic, then confirm the list comes back newest-first. + const now = new Date() + const repo2 = new StreamsRepository(tags) + const first = await repo2.create({ + userId: 1, + name: "Oldest", + visibility: "public", + }) + first.createdAt = new Date(now.getTime() - 10_000) + const second = await repo2.create({ + userId: 1, + name: "Newest", + visibility: "public", + }) + second.createdAt = now + + const { items } = await repo2.listPaginated(1, 10, 1) + expect(items.map((s) => s.name)).toEqual(["Newest", "Oldest"]) + }) + + it("unknown tag id matches nothing (empty page, not an error)", async () => { + const { items, total } = await repo.listPaginated(1, 10, 2, { tagId: 999 }) + expect(items).toEqual([]) + expect(total).toBe(0) + }) + + it("without an injected tag repository a tag filter honestly matches nothing", async () => { + const standalone = new StreamsRepository() + const { items, total } = await standalone.listPaginated(1, 10, 2, { + tagId: 1, + }) + expect(items).toEqual([]) + expect(total).toBe(0) + }) + + it("total reflects the filtered set, not the whole table", async () => { + const { total } = await repo.listPaginated(1, 2, 2, { q: "football" }) + expect(total).toBe(2) + // Page 2 of the same filtered set is empty, proving the slice is + // over the filtered set. + const page2 = await repo.listPaginated(2, 2, 2, { q: "football" }) + expect(page2.items).toEqual([]) + }) +}) diff --git a/api/src/streams/repository/streams.repository.ts b/api/src/streams/repository/streams.repository.ts index 7f0950a..9a63d9d 100644 --- a/api/src/streams/repository/streams.repository.ts +++ b/api/src/streams/repository/streams.repository.ts @@ -1,5 +1,6 @@ -import { Injectable, NotFoundException } from "@nestjs/common" +import { Inject, Injectable, NotFoundException, Optional } from "@nestjs/common" +import { TagsRepository } from "../../tags/repository/tags.repository" import { StreamAnalyticsDto } from "../dto/stream-analytics.dto" import { Stream } from "../stream.entity" @@ -21,9 +22,10 @@ export interface StreamUpdateChanges { } /** - * Filter passed to listing endpoints. The repository applies visibility - * semantics so the service layer is a pass-through; it never has to - * reason about who can see what. + * Filter passed from the controller/service into the listing endpoints. + * `tag` is the raw, unvalidated query value (a slug or numeric id) — + * the service resolves it to a `StreamListPredicate.tagId` before the + * repository ever sees it. */ export interface StreamListFilter { status?: string @@ -33,6 +35,27 @@ export interface StreamListFilter { * regardless of the stream's own visibility. Defaults to false. */ ownerOnly?: boolean + /** + * Case-insensitive substring matched against stream name and + * description. `%`/`_` are treated literally, never as wildcards. + */ + q?: string + /** Raw tag filter value: a tag slug or numeric id (issue #532). */ + tag?: string +} + +/** + * Repository-level listing predicate. `tag` has already been resolved + * to a concrete tag id by the service (via `TagsService.findBySlug`/ + * `findById`), so the SQL/in-memory implementations never duplicate + * slugification or tag-lookup logic. + */ +export interface StreamListPredicate { + status?: string + visibility?: StreamVisibility + ownerOnly?: boolean + q?: string + tagId?: number } /** @@ -72,11 +95,25 @@ export interface PendingStreamEvent { export class StreamsRepository { private readonly streamsById = new Map() private nextId = 1 + + constructor( + /** + * Injected so `tagId` filtering can resolve against the same tag + * associations the service attaches via `TagsService`. Optional so + * the in-memory repo stays constructible without a DI container + * (e.g. `new StreamsRepository()`); when absent a `tagId` filter + * simply matches nothing. + */ + @Optional() + @Inject(TagsRepository) + private readonly tagsRepository?: TagsRepository, + ) {} /** Per-stream append-only event log, mirroring the `stream_events` table. */ private readonly eventsByStream = new Map() private nextEventId = 1 /** Pending (unprocessed) events, mirroring the `stream_data` table (issue #514). */ private readonly pendingEvents: PendingStreamEvent[] = [] + private nextPendingEventId = 1 async findById(id: number): Promise { return this.streamsById.get(id) @@ -84,13 +121,14 @@ export class StreamsRepository { /** * Returns all streams visible to `viewerUserId`, optionally further - * narrowed by status, visibility, and an owner-only flag. Sorted - * newest-first (createdAt DESC). + * narrowed by status, visibility, an owner-only flag, a case-insensitive + * `q` search (name + description), and a resolved `tagId` (issue #532). + * Sorted newest-first (createdAt DESC). */ - private listFiltered( + private async listFiltered( viewerUserId: number, - filter?: StreamListFilter, - ): Stream[] { + filter?: StreamListPredicate, + ): Promise { let results = Array.from(this.streamsById.values()) if (filter?.status) { results = results.filter((s) => s.status === filter.status) @@ -109,6 +147,26 @@ export class StreamsRepository { if (filter?.visibility) { results = results.filter((s) => s.visibility === filter.visibility) } + // Case-insensitive substring search. `includes` is literal by + // construction, so `%`/`_` are never treated as wildcards — + // behaviourally identical to the SQL `ILIKE` path's escaping. + if (filter?.q) { + const needle = filter.q.toLowerCase() + results = results.filter( + (s) => + s.name.toLowerCase().includes(needle) || + (s.description ?? "").toLowerCase().includes(needle), + ) + } + // Tag filter: resolve the tag id to the set of stream ids carrying it. + // Without an injected TagsRepository there is no tag data to consult, + // so a tagId filter honestly matches nothing (mirrors an empty join). + if (filter?.tagId !== undefined) { + const streamIds = this.tagsRepository + ? await this.tagsRepository.listStreamIdsForTag(filter.tagId) + : new Set() + results = results.filter((s) => streamIds.has(s.id)) + } return results.sort( (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), ) @@ -121,9 +179,9 @@ export class StreamsRepository { page: number, limit: number, viewerUserId: number, - filter?: StreamListFilter, + filter?: StreamListPredicate, ): Promise<{ items: Stream[]; total: number }> { - const filtered = this.listFiltered(viewerUserId, filter) + const filtered = await this.listFiltered(viewerUserId, filter) const offset = (page - 1) * limit return { items: filtered.slice(offset, offset + limit), @@ -181,6 +239,7 @@ export class StreamsRepository { throw new NotFoundException(`stream ${streamId} not found`) } const event: PendingStreamEvent = { + id: String(this.nextPendingEventId++), streamId: String(streamId), data, timestamp: timestamp.toISOString(), diff --git a/api/src/streams/streams.controller.spec.ts b/api/src/streams/streams.controller.spec.ts index 801ff93..4d1ca0c 100644 --- a/api/src/streams/streams.controller.spec.ts +++ b/api/src/streams/streams.controller.spec.ts @@ -126,6 +126,8 @@ describe("StreamsController", () => { status: undefined, visibility: undefined, ownerOnly: undefined, + q: undefined, + tag: undefined, }) expect(res.data).toBeDefined() }) @@ -141,6 +143,21 @@ describe("StreamsController", () => { status: "active", visibility: "private", ownerOnly: true, + q: undefined, + tag: undefined, + }) + }) + + it("list forwards the q search and tag filter (issue #532)", async () => { + mockService.list.mockResolvedValue({ data: [], page: 1, limit: 20, total: 0, hasMore: false }) + const req = { auth: { userId: 9 } } as Request & { auth: { userId: number } } + await controller.list({ q: "football", tag: "live" }, req) + expect(mockService.list).toHaveBeenCalledWith(1, 20, 9, { + status: undefined, + visibility: undefined, + ownerOnly: undefined, + q: "football", + tag: "live", }) }) diff --git a/api/src/streams/streams.controller.ts b/api/src/streams/streams.controller.ts index e675d97..04c0980 100644 --- a/api/src/streams/streams.controller.ts +++ b/api/src/streams/streams.controller.ts @@ -182,7 +182,7 @@ export class StreamsController { @ApiOperation({ summary: "List streams", description: - "Returns a paginated list of streams visible to the caller. Public streams are returned to every authenticated user; private streams are returned only to their owner. Use `visibility` and `ownerOnly` to narrow further.", + "Returns a paginated list of streams visible to the caller. Public streams are returned to every authenticated user; private streams are returned only to their owner. Use `visibility`, `ownerOnly`, `q` (name/description search), and `tag` to narrow further.", }) @ApiOkResponse({ description: "Paginated list of streams." }) @ApiUnauthorizedResponse({ description: "Authentication required." }) @@ -196,6 +196,8 @@ export class StreamsController { status: query.status, visibility: query.visibility, ownerOnly: query.ownerOnly, + q: query.q, + tag: query.tag, }) // Serialize ids to strings at the API boundary, exactly like the // single-stream endpoints — `GET /streams` must not leak the diff --git a/api/src/streams/streams.module.ts b/api/src/streams/streams.module.ts index ba87776..14fa474 100644 --- a/api/src/streams/streams.module.ts +++ b/api/src/streams/streams.module.ts @@ -4,6 +4,8 @@ import { Module } from "@nestjs/common" import { AuthModule } from "../auth/auth.module" import { StreamsDbRepository } from "./repository/streams-db.repository" import { StreamsRepository } from "./repository/streams.repository" +import { StreamApiKeyGuard } from "./stream-api-key.guard" +import { StreamsController } from "./streams.controller" import { StreamsService } from "./streams.service" import { AuthGuard } from "../common/guards/auth.guard" import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" @@ -12,12 +14,6 @@ import { streamsCacheConfig } from "../config/cache.config" import { GatewaysModule } from "../gateways/gateways.module" import { TagsModule } from "../tags/tags.module" import { WebhooksModule } from "../webhooks/webhooks.module" -import { StreamsDbRepository } from "./repository/streams-db.repository" -import { StreamsRepository } from "./repository/streams.repository" -import { StreamApiKeyGuard } from "./stream-api-key.guard" -import { StreamsController } from "./streams.controller" -import { StreamsService } from "./streams.service" -import { streamsCacheConfig } from "../config/cache.config" /** * Injection token used to swap the streams repository implementation. diff --git a/api/src/streams/streams.service.spec.ts b/api/src/streams/streams.service.spec.ts index d55749f..0b096a9 100644 --- a/api/src/streams/streams.service.spec.ts +++ b/api/src/streams/streams.service.spec.ts @@ -7,8 +7,10 @@ import * as fc from "fast-check" import { Stream } from "./stream.entity" +import { StreamsGateway } from "../gateways/streams.gateway" import { Tag } from "../tags/tag.entity" import { TagsService } from "../tags/tags.service" +import { WebhooksService } from "../webhooks/webhooks.service" import { StreamsRepository } from "./repository/streams.repository" import { StreamsService } from "./streams.service" @@ -26,7 +28,7 @@ describe("StreamsService", () => { getPendingEvents: jest.Mock } let mockWebhooksService: { dispatchStreamEvent: jest.Mock } - let mockTagsService: { listForStreamIds: jest.Mock } + let mockTagsService: { listForStreamIds: jest.Mock; resolveTag: jest.Mock } let mockGateway: { emitStarted: jest.Mock emitStopped: jest.Mock @@ -65,6 +67,7 @@ describe("StreamsService", () => { } mockTagsService = { listForStreamIds: jest.fn().mockResolvedValue(new Map()), + resolveTag: jest.fn(), } mockGateway = { emitStarted: jest.fn(), @@ -166,6 +169,72 @@ describe("StreamsService", () => { }) }) + // ── Search & tag filtering (issue #532) ──────────────────────────────── + + it("list resolves a tag slug to its id and forwards the resolved id to the repository", async () => { + mockRepo.listPaginated.mockResolvedValue({ items: [], total: 0 }) + mockTagsService.resolveTag.mockResolvedValue({ + id: 9, + name: "Live", + slug: "live", + createdAt: new Date(), + }) + + await service.list(1, 20, 7, { tag: "live" }) + + expect(mockTagsService.resolveTag).toHaveBeenCalledWith("live") + expect(mockRepo.listPaginated).toHaveBeenCalledWith(1, 20, 7, { + tagId: 9, + }) + }) + + it("list returns an empty page (not an error) when the tag is unknown", async () => { + mockTagsService.resolveTag.mockResolvedValue(undefined) + + const res = await service.list(1, 20, 7, { tag: "nonexistent-tag" }) + + expect(res).toEqual({ data: [], page: 1, limit: 20, total: 0, hasMore: false }) + // The repository is never consulted for an unknown tag — the empty + // result is decided at the tag-resolution layer. + expect(mockRepo.listPaginated).not.toHaveBeenCalled() + }) + + it("list trims q before forwarding it to the repository", async () => { + mockRepo.listPaginated.mockResolvedValue({ items: [], total: 0 }) + + await service.list(1, 20, 7, { q: " football " }) + + expect(mockRepo.listPaginated).toHaveBeenCalledWith(1, 20, 7, { + q: "football", + }) + }) + + it("list combines q and a resolved tag with the existing filters", async () => { + mockRepo.listPaginated.mockResolvedValue({ items: [], total: 0 }) + mockTagsService.resolveTag.mockResolvedValue({ + id: 4, + name: "Football", + slug: "football", + createdAt: new Date(), + }) + + await service.list(2, 50, 7, { + q: "derby", + tag: "football", + status: "active", + visibility: "public", + ownerOnly: true, + }) + + expect(mockRepo.listPaginated).toHaveBeenCalledWith(2, 50, 7, { + q: "derby", + tagId: 4, + status: "active", + visibility: "public", + ownerOnly: true, + }) + }) + it("list with invalid viewerUserId rejects", async () => { await expect(service.list(1, 10, 0)).rejects.toThrow(NotFoundException) await expect(service.list(1, 10, -1)).rejects.toThrow(NotFoundException) @@ -415,9 +484,9 @@ describe("StreamsService", () => { nextCursor: null, }) - await service.getPendingEvents(100, 0) + await service.getPendingEvents(100, null) - expect(mockRepo.getPendingEvents).toHaveBeenCalledWith(100, 0) + expect(mockRepo.getPendingEvents).toHaveBeenCalledWith(100, null) }) it("delete existing stream resolves", async () => { diff --git a/api/src/streams/streams.service.ts b/api/src/streams/streams.service.ts index 4e73b4f..7968ca2 100644 --- a/api/src/streams/streams.service.ts +++ b/api/src/streams/streams.service.ts @@ -3,6 +3,7 @@ import { ConflictException, Injectable, NotFoundException, + Optional, PayloadTooLargeException, } from "@nestjs/common" @@ -20,6 +21,7 @@ import { PendingStreamEvent } from "./repository/streams.repository" import type { StreamVisibility } from "./dto/visibility" import type { StreamListFilter, + StreamListPredicate, StreamUpdateChanges, StreamCreateParams, } from "./repository/streams.repository" @@ -68,7 +70,9 @@ export class StreamsService { * Pass {@link StreamListFilter.ownerOnly} to restrict the result to * the caller's own streams regardless of visibility (useful for a * "my streams" tab). Pass {@link StreamListFilter.visibility} to - * narrow the visible-to-caller set further. + * narrow the visible-to-caller set further. Pass + * {@link StreamListFilter.q} for a case-insensitive name/description + * search and {@link StreamListFilter.tag} for tag filtering (issue #532). * * Also batches tags inline (issue #330) so the dashboard can render * tag chips without a second HTTP call per row. @@ -82,11 +86,33 @@ export class StreamsService { if (!Number.isInteger(viewerUserId) || viewerUserId <= 0) { throw new NotFoundException("invalid viewer") } + + // The repository only understands a resolved tag id; the raw `tag` + // query value (slug or id) is resolved here via the shared tag + // lookup so the storage layer never re-implements slugification. + // Only defined keys are emitted so callers that pass a partial + // filter (e.g. `{ visibility: "public" }`) get a matching predicate. + const predicate: StreamListPredicate = {} + if (filter?.status) predicate.status = filter.status + if (filter?.visibility) predicate.visibility = filter.visibility + if (filter?.ownerOnly) predicate.ownerOnly = filter.ownerOnly + const q = filter?.q?.trim() + if (q) predicate.q = q + if (filter?.tag) { + const tag = await this.tagsService.resolveTag(filter.tag) + if (!tag) { + // Unknown tag → an empty page, not an error (documented choice, + // issue #532). The totals and hasMore reflect that empty set. + return { data: [], page, limit, total: 0, hasMore: false } + } + predicate.tagId = tag.id + } + const { items, total } = await this.repo.listPaginated( page, limit, viewerUserId, - filter, + predicate, ) const tagsByStream = await this.tagsService.listForStreamIds( items.map((s) => s.id), diff --git a/api/src/tags/repository/tags-db.repository.ts b/api/src/tags/repository/tags-db.repository.ts index b6f787a..992fee0 100644 --- a/api/src/tags/repository/tags-db.repository.ts +++ b/api/src/tags/repository/tags-db.repository.ts @@ -6,6 +6,7 @@ import { ServiceUnavailableException, } from "@nestjs/common" import { Pool } from "pg" + import { PG_POOL } from "../../database/database.module" import { StreamTag, Tag } from "../tag.entity" @@ -204,6 +205,25 @@ export class TagsDbRepository { * (issue #330). The query plan uses the composite primary key * `(stream_id, tag_id)` on `stream_tags`. */ + + /** + * Returns the set of stream ids carrying `tagId` (issue #532). Backs the + * SQL `EXISTS (SELECT 1 FROM stream_tags …)` predicate used by + * {@link StreamsDbRepository.listPaginated} so the tag filter is a + * single bounded query rather than loading every association. + */ + async listStreamIdsForTag(tagId: number): Promise> { + try { + const { rows } = await this.pool.query<{ stream_id: number }>( + `SELECT stream_id FROM stream_tags WHERE tag_id = $1`, + [tagId], + ) + return new Set(rows.map((r) => r.stream_id)) + } catch (err) { + this.handleDbError(err, "listStreamIdsForTag") + } + } + async listForStreamIds( streamIds: number[], ): Promise> { diff --git a/api/src/tags/repository/tags.repository.ts b/api/src/tags/repository/tags.repository.ts index ab61a68..d96cbb6 100644 --- a/api/src/tags/repository/tags.repository.ts +++ b/api/src/tags/repository/tags.repository.ts @@ -1,4 +1,5 @@ import { Injectable } from "@nestjs/common" + import { StreamTag, Tag } from "../tag.entity" /** @@ -119,4 +120,17 @@ export class TagsRepository { } return result } + + /** + * Returns the set of stream ids carrying `tagId` (issue #532). Used by + * the in-memory {@link StreamsRepository} so its `tag` filter behaves + * the same as the SQL `EXISTS (SELECT 1 FROM stream_tags …)` path. + */ + async listStreamIdsForTag(tagId: number): Promise> { + const result = new Set() + for (const { streamId, tagId: attached } of this.streamTags.values()) { + if (attached === tagId) result.add(streamId) + } + return result + } } diff --git a/api/src/tags/tags.module.ts b/api/src/tags/tags.module.ts index 4f6117c..aecb350 100644 --- a/api/src/tags/tags.module.ts +++ b/api/src/tags/tags.module.ts @@ -1,12 +1,13 @@ import { Module } from "@nestjs/common" + import { AuthModule } from "../auth/auth.module" -import { AuthGuard } from "../common/guards/auth.guard" -import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" -import { StreamOwnershipService } from "../common/guards/stream-ownership.service" import { TagsDbRepository } from "./repository/tags-db.repository" import { TagsRepository } from "./repository/tags.repository" import { StreamTagsController, TagsListController } from "./tags.controller" import { TagsService } from "./tags.service" +import { AuthGuard } from "../common/guards/auth.guard" +import { StreamOwnershipGuard } from "../common/guards/stream-ownership.guard" +import { StreamOwnershipService } from "../common/guards/stream-ownership.service" /** * Injection token used to swap the tags repository implementation. @@ -37,6 +38,9 @@ const isTest = process.env.NODE_ENV === "test" StreamOwnershipService, AuthGuard, ], - exports: [TagsService], + // TagsRepository is exported so StreamsModule's in-memory + // StreamsRepository can resolve the same tag-association store and + // apply the `tag` list filter (issue #532). + exports: [TagsService, TagsRepository], }) export class TagsModule {} diff --git a/api/src/tags/tags.service.spec.ts b/api/src/tags/tags.service.spec.ts index f2a10c4..ffa00a6 100644 --- a/api/src/tags/tags.service.spec.ts +++ b/api/src/tags/tags.service.spec.ts @@ -66,6 +66,55 @@ describe("TagsService", () => { }) }) + // ── resolveTag (issue #532) ───────────────────────────────────────────── + + describe("resolveTag", () => { + it("looks up a non-numeric value by slug", async () => { + const tag = makeTag({ id: 7, slug: "live" }) + repo.findBySlug.mockResolvedValue(tag) + + const result = await service.resolveTag("live") + + expect(repo.findBySlug).toHaveBeenCalledWith("live") + expect(repo.findById).not.toHaveBeenCalled() + expect(result).toBe(tag) + }) + + it("treats a numeric value as an id", async () => { + const tag = makeTag({ id: 42 }) + repo.findById.mockResolvedValue(tag) + + const result = await service.resolveTag("42") + + expect(repo.findById).toHaveBeenCalledWith(42) + expect(repo.findBySlug).not.toHaveBeenCalled() + expect(result).toBe(tag) + }) + + it("returns undefined for an unknown slug", async () => { + repo.findBySlug.mockResolvedValue(undefined) + + const result = await service.resolveTag("nope") + + expect(result).toBeUndefined() + }) + + it("returns undefined for empty or whitespace-only input", async () => { + expect(await service.resolveTag("")).toBeUndefined() + expect(await service.resolveTag(" ")).toBeUndefined() + expect(await service.resolveTag(undefined)).toBeUndefined() + expect(repo.findBySlug).not.toHaveBeenCalled() + expect(repo.findById).not.toHaveBeenCalled() + }) + + it("does not slugify raw names — resolution is a lookup only", async () => { + await service.resolveTag("Live Streaming") + // A raw name is looked up as-is (it won't match a slug) rather + // than being transformed — slug logic stays in one place. + expect(repo.findBySlug).toHaveBeenCalledWith("Live Streaming") + }) + }) + // ── attachToStream ─────────────────────────────────────────────────────── describe("attachToStream", () => { diff --git a/api/src/tags/tags.service.ts b/api/src/tags/tags.service.ts index 7fa5d50..1840e78 100644 --- a/api/src/tags/tags.service.ts +++ b/api/src/tags/tags.service.ts @@ -28,6 +28,26 @@ export class TagsService { } } + /** + * Resolves a tag filter value (issue #532) to a concrete {@link Tag}. + * A value made entirely of digits is treated as a numeric id; anything + * else is looked up by slug. This is a *lookup*, not a slugification — + * raw names (e.g. "Live Streaming") are intentionally not slugified + * here, so the streams layer never re-implements slug logic. + * + * Returns `undefined` for an unknown tag, which callers translate to an + * empty result set (not an error). + */ + async resolveTag(raw: string | undefined): Promise { + if (!raw) return undefined + const value = raw.trim() + if (!value) return undefined + if (/^\d+$/.test(value)) { + return this.tags.findById(Number(value)) + } + return this.tags.findBySlug(value) + } + /** * Loads every tag attached to any stream in `streamIds`, grouped by * stream id. Powers the inline `tags` field on `GET /streams` diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts index bd2feb6..f125bd2 100644 --- a/api/src/users/users.service.spec.ts +++ b/api/src/users/users.service.spec.ts @@ -17,6 +17,7 @@ function dummyUser(overrides: Partial = {}): User { email: "test@example.com", password_hash: "hashed", created_at: new Date("2026-01-01T00:00:00Z"), + is_admin: false, ...overrides, } } diff --git a/app/hooks/useStreams.test.tsx b/app/hooks/useStreams.test.tsx index 87fc303..1d682a4 100644 --- a/app/hooks/useStreams.test.tsx +++ b/app/hooks/useStreams.test.tsx @@ -30,6 +30,32 @@ describe("useStreamList (issue #345 phase B)", () => { expect(streamKeys.tags(42)).toEqual(["streams", "detail", "42", "tags"]) }) + it("includes the q/tag search params in the query key so filtered lists never collide with unfiltered ones (issue #532)", () => { + expect(streamKeys.list(1, 20, "football", "live")).toEqual([ + "streams", + "list", + 1, + 20, + "football", + "live", + ]) + // A q-only filter and a tag-only filter are distinct cache entries. + expect(streamKeys.list(1, 20, "football", undefined)).toEqual([ + "streams", + "list", + 1, + 20, + "football", + ]) + expect(streamKeys.list(1, 20, undefined, "live")).toEqual([ + "streams", + "list", + 1, + 20, + "live", + ]) + }) + it("calls GET /streams with mapped page/limit and returns the parsed payload", async () => { // `Response` is a browser API not available in Node.js; use a // minimal mock that satisfies fetch's contract. @@ -70,6 +96,32 @@ describe("useStreamList (issue #345 phase B)", () => { expect(result.current.data?.data[0]?.name).toBe("First") expect(result.current.data?.data[0]?.tags).toEqual([]) }) + + it("passes q and tag through to GET /streams when provided (issue #532)", async () => { + const mockResponse = { + ok: true, + status: 200, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ + data: [], + page: 1, + limit: 20, + total: 0, + hasMore: false, + }), + } + const mock = jest.fn().mockResolvedValue(mockResponse) + global.fetch = mock as unknown as typeof fetch + + const { result } = renderHook( + () => useStreamList({ page: 1, limit: 20, q: "football", tag: "live" }), + { wrapper: createWrapper() }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + const calledUrl = (mock.mock.calls[0]?.[0] as URL | string).toString() + expect(calledUrl).toMatch(/\/streams\?page=1&limit=20&q=football&tag=live$/) + }) }) describe("useStreamTags (issue #517)", () => { diff --git a/app/hooks/useStreams.ts b/app/hooks/useStreams.ts index daa13f6..46a747f 100644 --- a/app/hooks/useStreams.ts +++ b/app/hooks/useStreams.ts @@ -7,7 +7,15 @@ import { type UseMutationResult, type UseQueryResult, } from "@tanstack/react-query" + import type { Stream } from "@xstreamroll/types" + +import { fetchJson } from "@/lib/api/fetch-json" +import { + getStream, + listStreams, + type PaginatedStreams, +} from "@/lib/api/streams" import { attachTagToStream, detachTagFromStream, @@ -15,12 +23,6 @@ import { type Tag, TagsApiError, } from "@/lib/api/tags" -import { fetchJson } from "@/lib/api/fetch-json" -import { - getStream, - listStreams, - type PaginatedStreams, -} from "@/lib/api/streams" /** * Centralised query-key factory — pins every cache key the hooks @@ -33,8 +35,8 @@ import { export const streamKeys = { all: ["streams"] as const, lists: () => [...streamKeys.all, "list"] as const, - list: (page: number, limit: number) => - [...streamKeys.lists(), page, limit] as const, + list: (page: number, limit: number, q?: string, tag?: string) => + [...streamKeys.lists(), page, limit, ...(q ? [q] : []), ...(tag ? [tag] : [])] as const, details: () => [...streamKeys.all, "detail"] as const, detail: (id: string | number) => [...streamKeys.details(), String(id)] as const, @@ -49,13 +51,14 @@ const DEFAULT_STALE_MS = 30_000 * stale-while-revalidate window (matches `lib/cache/cache-config.ts`). */ export function useStreamList( - params: { page?: number; limit?: number } = {}, + params: { page?: number; limit?: number; q?: string; tag?: string } = {}, ): UseQueryResult { const page = params.page ?? 1 const limit = params.limit ?? 20 return useQuery({ - queryKey: streamKeys.list(page, limit), - queryFn: ({ signal }) => listStreams({ page, limit, signal }), + queryKey: streamKeys.list(page, limit, params.q, params.tag), + queryFn: ({ signal }) => + listStreams({ page, limit, q: params.q, tag: params.tag, signal }), staleTime: DEFAULT_STALE_MS, }) } diff --git a/app/lib/api/streams.ts b/app/lib/api/streams.ts index 4cdd022..e46c8a4 100644 --- a/app/lib/api/streams.ts +++ b/app/lib/api/streams.ts @@ -15,9 +15,10 @@ * refreshing the token once and retrying (issue #518). */ -import type { PaginatedResponse, Stream } from "@xstreamroll/types" import { ApiRequestError, fetchJson } from "./fetch-json" +import type { PaginatedResponse, Stream } from "@xstreamroll/types" + export interface PaginatedStreams { data: Stream[] page: number @@ -38,11 +39,21 @@ function apiBase(): string { } export async function listStreams( - params: { page?: number; limit?: number; signal?: AbortSignal } = {}, + params: { + page?: number + limit?: number + /** Case-insensitive name/description search (issue #532). */ + q?: string + /** Tag slug or id to filter by (issue #532). */ + tag?: string + signal?: AbortSignal + } = {}, ): Promise { const url = new URL(`${apiBase()}/streams`) if (params.page) url.searchParams.set("page", String(params.page)) if (params.limit) url.searchParams.set("limit", String(params.limit)) + if (params.q) url.searchParams.set("q", params.q) + if (params.tag) url.searchParams.set("tag", params.tag) const json = await fetchJson< | PaginatedResponse diff --git a/package-lock.json b/package-lock.json index 22bcd01..e01afe7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ }, "api": { "name": "stellar-streaming-api", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@nestjs/cache-manager": "^2.3.0", "@nestjs/common": "^10.3.0", @@ -9333,6 +9333,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -14854,25 +14864,6 @@ } } }, - "node_modules/git-semver-tags/node_modules/conventional-commits-parser": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", - "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@simple-libs/stream-utils": "^1.2.0", - "meow": "^13.0.0" - }, - "bin": { - "conventional-commits-parser": "dist/cli/index.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -23727,7 +23718,7 @@ }, "xstreamroll-sdk": { "name": "@stellar/streaming-sdk", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@xstreamroll/types": "file:../packages/types" }, diff --git a/tests/contracts/src/streams.contract.ts b/tests/contracts/src/streams.contract.ts index 9678c1e..8f979d9 100644 --- a/tests/contracts/src/streams.contract.ts +++ b/tests/contracts/src/streams.contract.ts @@ -108,6 +108,45 @@ export const streamsContracts: Contract[] = [ schema: pendingStreamEventSchema, }, }, + { + // Runs after the seed stream exists (created in the provider suite's + // beforeAll). `q=seed` matches the seed stream's name + // case-insensitively; the response is the standard paginated + // envelope over the filtered set (issue #532). + name: "list-streams-search", + description: "GET /streams?q=… returns only streams matching the search, in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/streams", + query: { page: 1, limit: 20, q: "seed" }, + authenticated: true, + }, + response: { + status: 200, + schema: paginatedStreamsSchema, + }, + }, + { + // `tag=live-streaming` matches the seed stream, which the provider + // suite tags in beforeAll. Unknown tags return an empty page, so the + // shape contract holds either way (issue #532). + name: "list-streams-by-tag", + description: "GET /streams?tag=… returns only streams carrying the tag, in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/streams", + query: { page: 1, limit: 20, tag: "live-streaming" }, + authenticated: true, + }, + response: { + status: 200, + schema: paginatedStreamsSchema, + }, + }, { name: "update-stream", description: "PATCH /streams/:id updates a stream owned by the caller", diff --git a/xstreamroll-sdk/README.md b/xstreamroll-sdk/README.md index e002d4a..f3c410b 100644 --- a/xstreamroll-sdk/README.md +++ b/xstreamroll-sdk/README.md @@ -185,6 +185,31 @@ and drop the local tokens. const stream = await client.getStreamStatus("stream_abc") ``` +### Listing streams (search + tag filtering) + +`client.listStreams()` returns the page of streams visible to the +caller. Beyond `page`/`limit`, it accepts the server-side search and +tag filters (issue #532) so callers never have to fetch and filter +whole tables client-side: + +```ts +const page = await client.listStreams({ page: 1, limit: 20 }) + +// Case-insensitive name/description search, applied server-side so +// `total` and `hasMore` stay correct. +const search = await client.listStreams({ q: "football" }) + +// Restrict to streams carrying a tag (slug or id). Unknown tags +// return an empty page, not an error. +const tagged = await client.listStreams({ tag: "live" }) +const byId = await client.listStreams({ tag: "42" }) +``` + +`q` matches stream names and descriptions case-insensitively; `%` and +`_` are matched literally. The same filters can be driven through +`client.paginateAll("/streams", { query: { q: "football", tag: "live" } })` +to walk every matching stream without hand-driving the cursor. + ### Stream CRUD Stream CRUD (`POST /streams`, `GET /streams`, `PATCH /streams/:id`, diff --git a/xstreamroll-sdk/__tests__/client.test.ts b/xstreamroll-sdk/__tests__/client.test.ts deleted file mode 100644 index aadded1..0000000 --- a/xstreamroll-sdk/__tests__/client.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import axios from "axios" -import { StreamingClient } from "../src/client" -import type { AuthResponse } from "../src/types" - -jest.mock("axios") -const mockedAxios = axios as jest.Mocked - -// Helper to read the private apiUrl field for test assertions. -function getApiUrl(client: StreamingClient): string { - return (client as unknown as { apiUrl: string }).apiUrl -} - -// Helper to access the private tokens field. -function getTokens(client: StreamingClient): AuthResponse | null { - return (client as unknown as { tokens: AuthResponse | null }).tokens -} - -function setTokens(client: StreamingClient, tokens: AuthResponse): void { - ;(client as unknown as { tokens: AuthResponse }).tokens = tokens -} - -// Create a mock axios instance with interceptors and post/get methods. -// Axios instances are callable functions with properties. -function mockAxiosInstance() { - const fn = jest.fn() - const instance = Object.assign(fn, { - get: jest.fn(), - post: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { use: jest.fn() }, - }, - }) - return instance -} - -function mockAuthResponse(overrides: Partial = {}): AuthResponse { - return { - user: { - id: "1", - email: "test@example.com", - displayName: "Test User", - role: "viewer", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }, - accessToken: "access.token.here", - refreshToken: "refresh.token.here", - ...overrides, - } -} - -// ── Env Preset Tests ──────────────────────────────────────────────────────── - -describe("StreamingClient env presets", () => { - beforeEach(() => { - jest.clearAllMocks() - mockedAxios.create.mockReturnValue(mockAxiosInstance() as never) - }) - - it("defaults to development URL when no config given", () => { - const client = new StreamingClient({}) - expect(getApiUrl(client)).toBe("http://localhost:3001") - }) - - it("resolves production preset", () => { - const client = new StreamingClient({ env: "production" }) - expect(getApiUrl(client)).toBe("https://api.xstreamroll.io") - }) - - it("resolves staging preset", () => { - const client = new StreamingClient({ env: "staging" }) - expect(getApiUrl(client)).toBe("https://staging-api.xstreamroll.io") - }) - - it("resolves development preset explicitly", () => { - const client = new StreamingClient({ env: "development" }) - expect(getApiUrl(client)).toBe("http://localhost:3001") - }) - - it("custom baseUrl overrides env preset", () => { - const client = new StreamingClient({ - env: "production", - baseUrl: "https://custom.example.com", - }) - expect(getApiUrl(client)).toBe("https://custom.example.com") - }) - - it("legacy apiUrl still works", () => { - const client = new StreamingClient({ apiUrl: "http://legacy:9000" }) - expect(getApiUrl(client)).toBe("http://legacy:9000") - }) - - it("uses HttpClient internally (not axios)", () => { - const client = new StreamingClient({ baseUrl: "http://api.test" }) - const http = ( - client as unknown as { http: { constructor: { name: string } } } - ).http - expect(http.constructor.name).toBe("HttpClient") - }) -}) - -// ── Auth Tests ────────────────────────────────────────────────────────────── - -describe("StreamingClient auth", () => { - let httpInstance: ReturnType - - beforeEach(() => { - jest.clearAllMocks() - httpInstance = mockAxiosInstance() - mockedAxios.create.mockReturnValue(httpInstance as never) - }) - - // -- login ------------------------------------------------------------- - - describe("login", () => { - it("returns AuthResponse with user, accessToken, and refreshToken", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - const result = await client.login("alice@example.com", "password") - - expect(httpInstance.post).toHaveBeenCalledWith("/auth/login", { - email: "alice@example.com", - password: "password", - }) - expect(result).toEqual(authResp) - expect(result.user).toBeDefined() - expect(result.user.email).toBe("test@example.com") - expect(result.accessToken).toBe("access.token.here") - expect(result.refreshToken).toBe("refresh.token.here") - }) - - it("stores tokens on the instance after login", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - await client.login("alice@example.com", "password") - - expect(getTokens(client)).toEqual(authResp) - }) - - it("does not include expiresIn in the response shape", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - const result = await client.login("alice@example.com", "password") - - expect((result as unknown as Record).expiresIn).toBeUndefined() - }) - }) - - // -- register ---------------------------------------------------------- - - describe("register", () => { - it("returns AuthResponse with user, accessToken, and refreshToken", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - const dto = { - email: "new@example.com", - password: "password", - displayName: "New User", - } - const result = await client.register(dto) - - expect(httpInstance.post).toHaveBeenCalledWith("/auth/register", dto) - expect(result).toEqual(authResp) - expect(result.user).toBeDefined() - expect(result.accessToken).toBe("access.token.here") - expect(result.refreshToken).toBe("refresh.token.here") - }) - - it("stores tokens on the instance after register", async () => { - const authResp = mockAuthResponse() - httpInstance.post.mockResolvedValue({ data: authResp }) - - const client = new StreamingClient({}) - await client.register({ - email: "new@example.com", - password: "password", - displayName: "New User", - }) - - expect(getTokens(client)).toEqual(authResp) - }) - }) - - // -- refreshToken ------------------------------------------------------ - - describe("refreshToken", () => { - it("sends the stored refresh token in the request body", async () => { - const freshResp = mockAuthResponse({ - accessToken: "new.access.token", - refreshToken: "new.refresh.token", - }) - httpInstance.post.mockResolvedValue({ data: freshResp }) - - const client = new StreamingClient({}) - setTokens(client, mockAuthResponse()) - - const result = await client.refreshToken() - - expect(httpInstance.post).toHaveBeenCalledWith("/auth/refresh", { - refreshToken: "refresh.token.here", - }) - expect(result).toEqual(freshResp) - expect(getTokens(client)).toEqual(freshResp) - }) - - it("throws when no refresh token is available", async () => { - const client = new StreamingClient({}) - await expect(client.refreshToken()).rejects.toThrow( - "No refresh token available" - ) - }) - - it("updates stored tokens on successful refresh", async () => { - const freshResp = mockAuthResponse({ - accessToken: "new.access.token", - refreshToken: "new.refresh.token", - }) - httpInstance.post.mockResolvedValue({ data: freshResp }) - - const client = new StreamingClient({}) - setTokens(client, mockAuthResponse()) - - const result = await client.refreshToken() - - expect(httpInstance.post).toHaveBeenCalledWith("/auth/refresh", { - refreshToken: "refresh.token.here", - }) - expect(result).toEqual(freshResp) - expect(getTokens(client)).toEqual(freshResp) - }) - }) - - // -- logout ------------------------------------------------------------ - - describe("logout", () => { - it("clears stored tokens", async () => { - httpInstance.post.mockResolvedValue({}) - - const client = new StreamingClient({}) - setTokens(client, mockAuthResponse()) - - await client.logout() - - expect(getTokens(client)).toBeNull() - }) - - it("does not throw when no tokens are stored", async () => { - httpInstance.post.mockResolvedValue({}) - - const client = new StreamingClient({}) - await expect(client.logout()).resolves.toBeUndefined() - }) - }) - - // -- auto-refresh on 401 ---------------------------------------------- - - describe("401 auto-refresh interceptor", () => { - it("refreshes and retries the original request on 401", async () => { - // Capture the response error handler - let responseErrorHandler: ((error: unknown) => unknown) | null = null - const instance = Object.assign(jest.fn(), { - get: jest.fn(), - post: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { - use: jest.fn((_onFulfilled: unknown, onRejected: unknown) => { - responseErrorHandler = onRejected as (error: unknown) => unknown - }), - }, - }, - }) - mockedAxios.create.mockReturnValue(instance as never) - - const client = new StreamingClient({}) - const tokens = mockAuthResponse() - setTokens(client, tokens) - - // Setup refresh success - const freshTokens = mockAuthResponse({ - accessToken: "fresh.access", - refreshToken: "fresh.refresh", - }) - instance.post.mockResolvedValue({ data: freshTokens }) - - // Simulate a 401 error - const originalConfig = { - url: "/streams/123", - headers: {} as Record, - _retry: undefined as boolean | undefined, - } - const error = { - response: { status: 401 }, - config: originalConfig, - } - - // The retry calls the axios instance (callable) with the original config - instance.mockResolvedValue({ data: { id: "123", name: "test" } }) - - // Trigger the error handler - const resultPromise = responseErrorHandler!(error) as Promise - await resultPromise - - // Should have called refresh - expect(instance.post).toHaveBeenCalledWith("/auth/refresh", { - refreshToken: tokens.refreshToken, - }) - // Original config should be marked as retry - expect(originalConfig._retry).toBe(true) - // Authorization header should be updated with new token - expect(originalConfig.headers.Authorization).toBe("Bearer fresh.access") - }) - - it("does not retry when no refresh token is stored", async () => { - let responseErrorHandler: ((error: unknown) => unknown) | null = null - const instance = Object.assign(jest.fn(), { - get: jest.fn(), - post: jest.fn(), - interceptors: { - request: { use: jest.fn() }, - response: { - use: jest.fn((_onFulfilled: unknown, onRejected: unknown) => { - responseErrorHandler = onRejected as (error: unknown) => unknown - }), - }, - }, - }) - mockedAxios.create.mockReturnValue(instance as never) - - new StreamingClient({}) // provision instance, no tokens - // No tokens set - - const error = { - response: { status: 401 }, - config: { url: "/streams/123", headers: {} }, - } - - const result = responseErrorHandler!(error) - await expect(result).rejects.toEqual(error) - }) - }) -}) \ No newline at end of file diff --git a/xstreamroll-sdk/__tests__/contract.consumer.test.ts b/xstreamroll-sdk/__tests__/contract.consumer.test.ts index eaf18dc..973eaab 100644 --- a/xstreamroll-sdk/__tests__/contract.consumer.test.ts +++ b/xstreamroll-sdk/__tests__/contract.consumer.test.ts @@ -20,6 +20,7 @@ import { allContracts, authResponseSchema, + paginatedStreamsSchema, paginatedWebhookSubscriptionsSchema, pendingStreamEventSchema, streamSchema, @@ -78,6 +79,77 @@ describe("Consumer contract verification (xstreamroll-sdk)", () => { expect(result).toEqual(example) }) + it("listStreams() sends the filtered query the list-streams-search contract expects and returns a contract-valid page (issue #532)", async () => { + const c = contract("list-streams-search") + const example = { + data: [ + { + id: "1", + userId: "7", + name: "Seed stream", + description: "Seeded for contract verification", + status: "inactive", + visibility: "private", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tags: [], + }, + ], + total: 1, + page: 1, + limit: 20, + } + expect(() => paginatedStreamsSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/streams?page=1&limit=20&q=seed") + .reply(c.response.status, example) + + const result = await client.listStreams({ page: 1, limit: 20, q: "seed" }) + + expect(scope.isDone()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.data[0]?.name).toBe("Seed stream") + expect(result.total).toBe(1) + }) + + it("listStreams() sends the tag filter the list-streams-by-tag contract expects (issue #532)", async () => { + const c = contract("list-streams-by-tag") + const example = { + data: [ + { + id: "1", + userId: "7", + name: "Seed stream", + description: null, + status: "inactive", + visibility: "private", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + tags: [], + }, + ], + total: 1, + page: 1, + limit: 20, + } + expect(() => paginatedStreamsSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/streams?page=1&limit=20&tag=live-streaming") + .reply(c.response.status, example) + + const result = await client.listStreams({ + page: 1, + limit: 20, + tag: "live-streaming", + }) + + expect(scope.isDone()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.total).toBe(1) + }) + it("publishEvent() hits POST /streams/events with the api key and a contract-valid response (issue #514)", async () => { const c = contract("ingest-stream-event") const apiKey = "sk-test-123" diff --git a/xstreamroll-sdk/src/client.ts b/xstreamroll-sdk/src/client.ts index 3bcb885..030c4c4 100644 --- a/xstreamroll-sdk/src/client.ts +++ b/xstreamroll-sdk/src/client.ts @@ -11,6 +11,7 @@ import { type Stream, type StreamConfig, type StreamEvent, + type StreamListParams, type UpdateWebhookDto, type WebhookDelivery, type WebhookSubscription, @@ -146,6 +147,25 @@ export class StreamingClient { }) } + /** + * Lists streams visible to the caller (issue #532). Beyond paging, + * supports `q` (case-insensitive name/description search) and `tag` + * (slug or id) — both applied server-side so `total`/`hasMore` stay + * correct and the client never has to post-filter fetched pages. + */ + async listStreams(params: StreamListParams = {}): Promise> { + const qs = new URLSearchParams() + if (params.page !== undefined) qs.set("page", String(params.page)) + if (params.limit !== undefined) qs.set("limit", String(params.limit)) + if (params.q !== undefined) qs.set("q", params.q) + if (params.tag !== undefined) qs.set("tag", params.tag) + const query = qs.toString() + return this.requestJson>( + `/streams${query ? `?${query}` : ""}`, + { method: "GET" }, + ) + } + // ── Webhooks ────────────────────────────────────────────────────────────── /** @@ -238,14 +258,23 @@ export class StreamingClient { */ paginateAll( path: string, - params: { limit?: number; startPage?: number; maxPages?: number } = {}, + params: { + limit?: number + startPage?: number + maxPages?: number + query?: Record + } = {}, signal?: AbortSignal, ): AsyncIterable { const fetcher: PaginatedFetcher = async ( { page, limit }: { page: number; limit: number }, sig?: AbortSignal, ): Promise> => { - const url = `${path}?page=${page}&limit=${limit}` + const qs = new URLSearchParams({ page: String(page), limit: String(limit) }) + for (const [key, value] of Object.entries(params.query ?? {})) { + qs.set(key, String(value)) + } + const url = `${path}?${qs.toString()}` const response = sig ? await this.http.get(url, { signal: sig }) : await this.http.get(url) diff --git a/xstreamroll-sdk/src/index.ts b/xstreamroll-sdk/src/index.ts index c6013b3..0bc3efe 100644 --- a/xstreamroll-sdk/src/index.ts +++ b/xstreamroll-sdk/src/index.ts @@ -19,6 +19,7 @@ export type { StreamStatus, StreamVisibility, Stream, + StreamListParams, CreateStreamDto, UpdateStreamDto, // Stream Events diff --git a/xstreamroll-sdk/src/pagination.ts b/xstreamroll-sdk/src/pagination.ts index 7c02a98..4743683 100644 --- a/xstreamroll-sdk/src/pagination.ts +++ b/xstreamroll-sdk/src/pagination.ts @@ -47,6 +47,14 @@ export interface PaginateAllOptions { maxPages?: number /** Optional AbortSignal that cancels the iteration immediately. */ signal?: AbortSignal + /** + * Extra query params appended to every page request (e.g. the + * `q`/`tag` search filters on `GET /streams`, issue #532). How the + * params reach the wire is the fetcher's job — `StreamingClient`'s + * built-in fetcher serializes them after `page`/`limit`. Injected + * fetchers (tests) may ignore them. + */ + query?: Record } /** diff --git a/xstreamroll-sdk/src/types.ts b/xstreamroll-sdk/src/types.ts index dc75692..19914a9 100644 --- a/xstreamroll-sdk/src/types.ts +++ b/xstreamroll-sdk/src/types.ts @@ -1,7 +1,14 @@ // ─── Generated types from OpenAPI spec ───────────────────────────────────── // Regenerate with `npm run generate:types` (requires API server running). +import type { + ApiErrorResponse, + PaginatedResponse, + PaginationParams, + StreamEventType, + Tag, + User, +} from "@xstreamroll/types" import type { components } from "./generated/schema" -import type { ApiErrorResponse, StreamEventType } from "@xstreamroll/types" export type { components } @@ -47,6 +54,39 @@ export type { ApiErrorResponse, } from "@xstreamroll/types" +// ─── Streams ───────────────────────────────────────────────────────────────── + +/** + * Query parameters for `GET /streams` (issue #532). Extends the shared + * pagination params with the server-side search and tag filters so the + * dashboard search box can pass them through without client-side + * post-filtering. + */ +export interface StreamListParams extends PaginationParams { + /** + * Case-insensitive substring matched against stream name and + * description. `%`/`_` are treated literally by the server, never as + * wildcards. + */ + q?: string + /** + * Tag slug or numeric id. Only streams carrying that tag are + * returned; an unknown tag yields an empty page. + */ + tag?: string +} + +// ─── Tags ──────────────────────────────────────────────────────────────────── + +/** + * Paginated tag envelope returned by `GET /streams/:id/tags` (issue #517). + * Same shape as the API's `PagedTags` wire contract: the standard + * pagination envelope plus the legacy `hasMore` boolean. + */ +export interface PagedTags extends PaginatedResponse { + hasMore: boolean +} + // ─── Config ────────────────────────────────────────────────────────────────── /** Configuration for the StreamingClient. */ @@ -83,6 +123,14 @@ export interface AuthResponse { refreshToken: string } +/** + * The token pair returned by login/register/refresh. Kept as a separate + * alias (rather than using {@link AuthResponse} directly) because the + * client stores only the tokens — the `user` object rides along on the + * auth responses but isn't persisted by {@link StreamingClient}. + */ +export type AuthTokens = AuthResponse + // ─── Webhooks ───────────────────────────────────────────────────────────────── /** Payload for `subscribeWebhook()` / `POST /webhooks`. */