From fd415be3a7c8d5664536e9e8fea353761988142e Mon Sep 17 00:00:00 2001 From: rozemary2026-a11y Date: Wed, 26 Aug 2026 09:25:19 +0000 Subject: [PATCH 1/2] test(contracts): cover webhook, event-replay, analytics, and notification endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add contract coverage (issue #534) for the API surfaces that shipped after the contract suite was built: GET /webhooks/:id/deliveries, GET /streams/:id/events (replay, #396), GET /streams/:id/analytics, and GET /notifications. POST /webhooks was already pinned by create-webhook; the delivery-log contract now exercises the seeded subscription → deliveries chain via the existing placeholder mechanism, so no per-contract setup duplication is introduced. The schemas are tightened to the exact field types the SDK declares for WebhookSubscription/WebhookDelivery — the id/streamId string-vs-number choice and the closed event union — and the SDK consumer test asserts mutual assignability between each schema's zod output type and the SDK interface at compile time, so a server-side type change fails CI in either direction. The SDK also gains listDeliveries(), listStreamEvents(), getStreamAnalytics(), and listNotifications() so the new endpoints are exercised end to end instead of hand-mocked. Provider suite (api) now seeds a recorded stream event and an unread notification so the replay/analytics/notification contracts validate non-empty shapes, and wires the real NotificationsController + in-memory repository into the module. Also carries the same pre-existing bad-merge repairs the contract suite needs to compile and run (auth refresh/isAdmin, gateway authenticate shape, streams pending-event id, duplicate module imports) and removes the stale axios-based SDK client test that the nock-based integration suite supersedes. Closes #534 --- api/src/audit/audit.integration.spec.ts | 15 +- api/src/audit/audit.interceptor.spec.ts | 34 +- api/src/audit/audit.module.ts | 2 +- api/src/auth/auth.controller.spec.ts | 5 +- api/src/auth/auth.controller.ts | 53 ++- api/src/auth/auth.service.spec.ts | 97 +---- api/src/auth/auth.service.ts | 22 +- api/src/auth/users.repository.ts | 8 - .../guards/jwt-extractor.service.spec.ts | 15 +- api/src/contract-provider.spec.ts | 37 ++ api/src/database.integration.spec.ts | 14 +- api/src/gateways/streams.gateway.spec.ts | 8 +- api/src/gateways/streams.gateway.ts | 2 +- api/src/main.ts | 1 - .../repository/streams-db.repository.ts | 4 +- .../streams/repository/streams.repository.ts | 2 + api/src/streams/streams.module.ts | 8 +- api/src/streams/streams.service.spec.ts | 7 +- api/src/streams/streams.service.ts | 1 + api/src/users/users.service.spec.ts | 1 + tests/contracts/src/auth.contract.ts | 5 +- tests/contracts/src/index.ts | 3 + tests/contracts/src/notifications.contract.ts | 27 ++ tests/contracts/src/schemas.ts | 114 +++++- tests/contracts/src/streams.contract.ts | 38 ++ tests/contracts/src/webhooks.contract.ts | 23 ++ xstreamroll-sdk/README.md | 39 +- xstreamroll-sdk/__tests__/client.test.ts | 351 ------------------ .../__tests__/contract.consumer.test.ts | 178 +++++++++ xstreamroll-sdk/src/client.ts | 69 ++++ xstreamroll-sdk/src/index.ts | 5 + xstreamroll-sdk/src/types.ts | 79 +++- 32 files changed, 716 insertions(+), 551 deletions(-) create mode 100644 tests/contracts/src/notifications.contract.ts delete mode 100644 xstreamroll-sdk/__tests__/client.test.ts 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..0b69953 100644 --- a/api/src/contract-provider.spec.ts +++ b/api/src/contract-provider.spec.ts @@ -18,6 +18,7 @@ import { Test, TestingModule } from "@nestjs/testing" import { authContracts, loginBody, + notificationsContracts, PLACEHOLDER, registerBody, resolvePath, @@ -38,6 +39,9 @@ import { JwtExtractorService } from "./common/guards/jwt-extractor.service" import { StreamOwnershipGuard } from "./common/guards/stream-ownership.guard" import { StreamOwnershipService } from "./common/guards/stream-ownership.service" import createJwtConfig, { createRefreshJwtConfig } from "./config/jwt.config" +import { NotificationsController } from "./notifications/notifications.controller" +import { NotificationsService } from "./notifications/notifications.service" +import { NotificationsRepository } from "./notifications/repository/notifications.repository" import { StreamsRepository } from "./streams/repository/streams.repository" import { StreamApiKeyGuard } from "./streams/stream-api-key.guard" import { StreamsController } from "./streams/streams.controller" @@ -128,6 +132,7 @@ describe("Contract provider verification (api)", () => { StreamTagsController, AuthController, WebhooksController, + NotificationsController, ], providers: [ StreamsService, @@ -143,6 +148,8 @@ describe("Contract provider verification (api)", () => { provide: WebhookDeliveriesRepository, useValue: deliveriesRepository, }, + NotificationsService, + NotificationsRepository, AuthGuard, JwtExtractorService, StreamOwnershipGuard, @@ -249,6 +256,20 @@ describe("Contract provider verification (api)", () => { delivery.nextAttemptAt = null delivery.lastError = "connection refused" existingDeliveryId = String(delivery.id) + + // Record one processed event so `list-stream-events` validates the + // non-empty shape — most importantly the stringified id/streamId. + await streamsRepository.recordEvent(stream.id, { + eventType: "stream:started", + payload: { streamId: stream.id }, + occurredAt: "2026-08-01T00:00:00.000Z", + }) + + // Seed one unread notification so `list-notifications` validates the + // non-empty shape (numeric ids, ISO timestamps). + await moduleFixture + .get(NotificationsRepository) + .create(userId, "stream:started", { streamId: stream.id }) }) afterAll(async () => { @@ -358,6 +379,22 @@ describe("Contract provider verification (api)", () => { }) }) + describe.each(notificationsContracts)("$name", (contract) => { + it(contract.description, async () => { + const res = await execute(contract) + + expect(res.status).toBe(contract.response.status) + const result = contract.response.schema.safeParse(res.body) + if (!result.success) { + throw new Error( + `${contract.name}: response did not satisfy the contract schema\n` + + `${JSON.stringify(result.error.format(), null, 2)}\n` + + `body: ${JSON.stringify(res.body, null, 2)}`, + ) + } + }) + }) + it("login contract uses credentials the register contract actually created", () => { // Sanity check that the two contract fixtures stay in sync with each // other — if this ever fails, `auth.contract.ts` was edited so the 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/repository/streams-db.repository.ts b/api/src/streams/repository/streams-db.repository.ts index 1228832..b86f5ea 100644 --- a/api/src/streams/repository/streams-db.repository.ts +++ b/api/src/streams/repository/streams-db.repository.ts @@ -242,16 +242,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(), diff --git a/api/src/streams/repository/streams.repository.ts b/api/src/streams/repository/streams.repository.ts index 7f0950a..9c358ca 100644 --- a/api/src/streams/repository/streams.repository.ts +++ b/api/src/streams/repository/streams.repository.ts @@ -77,6 +77,7 @@ export class StreamsRepository { 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) @@ -181,6 +182,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.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..7f271ea 100644 --- a/api/src/streams/streams.service.spec.ts +++ b/api/src/streams/streams.service.spec.ts @@ -5,10 +5,11 @@ import { } from "@nestjs/common" 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" @@ -415,9 +416,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..8422b32 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" 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/tests/contracts/src/auth.contract.ts b/tests/contracts/src/auth.contract.ts index eefd318..35e5f0b 100644 --- a/tests/contracts/src/auth.contract.ts +++ b/tests/contracts/src/auth.contract.ts @@ -1,7 +1,8 @@ -import type { CreateUserDto } from "@xstreamroll/types" -import type { Contract } from "./contract" import { authResponseSchema } from "./schemas" +import type { Contract } from "./contract" +import type { CreateUserDto } from "@xstreamroll/types" + export const registerBody: CreateUserDto = { username: "contractuser", email: "contract-user@example.com", diff --git a/tests/contracts/src/index.ts b/tests/contracts/src/index.ts index 0f3bcc9..581dd1f 100644 --- a/tests/contracts/src/index.ts +++ b/tests/contracts/src/index.ts @@ -18,8 +18,10 @@ export * from "./schemas" export * from "./streams.contract" export * from "./auth.contract" export * from "./webhooks.contract" +export * from "./notifications.contract" import { authContracts } from "./auth.contract" +import { notificationsContracts } from "./notifications.contract" import { streamsContracts } from "./streams.contract" import { webhooksContracts } from "./webhooks.contract" @@ -30,4 +32,5 @@ export const allContracts: Contract[] = [ ...streamsContracts, ...authContracts, ...webhooksContracts, + ...notificationsContracts, ] diff --git a/tests/contracts/src/notifications.contract.ts b/tests/contracts/src/notifications.contract.ts new file mode 100644 index 0000000..7dd31cb --- /dev/null +++ b/tests/contracts/src/notifications.contract.ts @@ -0,0 +1,27 @@ +import { type Contract } from "./contract" +import { notificationsPageSchema } from "./schemas" + +/** + * Contract coverage for `api/src/notifications/notifications.controller.ts` + * (issue #534). The provider suite seeds one unread notification for the + * fixture user in beforeAll, so this contract validates the non-empty + * shape — including the numeric ids and ISO-string timestamps. + */ +export const notificationsContracts: Contract[] = [ + { + name: "list-notifications", + description: "GET /notifications returns the caller's unread notifications in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/notifications", + query: { page: 1, limit: 20 }, + authenticated: true, + }, + response: { + status: 200, + schema: notificationsPageSchema, + }, + }, +] diff --git a/tests/contracts/src/schemas.ts b/tests/contracts/src/schemas.ts index 15ae2e9..f9e8610 100644 --- a/tests/contracts/src/schemas.ts +++ b/tests/contracts/src/schemas.ts @@ -4,6 +4,7 @@ import type { ApiErrorResponse, PaginatedResponse, Stream, + StreamEventRecord, Tag, User, } from "@xstreamroll/types" @@ -85,6 +86,95 @@ export const authResponseSchema = z.object({ refreshToken: z.string(), }) +/** The closed set of stream lifecycle/data event types. */ +export const streamEventTypeSchema = z.enum([ + "stream:started", + "stream:stopped", + "stream:error", + "viewer:joined", + "viewer:left", + "data", +]) + +/** + * A single persisted stream event, as returned by `GET /streams/:id/events` + * (issue #396). The `id`/`streamId` are strings on the wire — the same + * stringification that already bit `Stream` and `User` — and the schema + * pins that choice so a regression fails the provider suite. + */ +export const streamEventRecordSchema = typed()( + z.object({ + id: z.string(), + streamId: z.string(), + eventType: streamEventTypeSchema, + payload: z.record(z.string(), z.unknown()), + occurredAt: z.string(), + }), +) + +export const paginatedStreamEventsSchema = z.object({ + data: z.array(streamEventRecordSchema), + page: z.number(), + limit: z.number(), + total: z.number(), + hasMore: z.boolean(), +}) + +/** + * Aggregate analytics returned by `GET /streams/:id/analytics`. + * Mirrors `StreamAnalyticsDto` in the API — no shared + * `@xstreamroll/types` interface exists yet, so the schema is written + * by hand and pinned by the consumer test's type assertion. + */ +export const streamAnalyticsSchema = z.object({ + streamId: z.number(), + totalEventsProcessed: z.object({ + last24h: z.number(), + last7d: z.number(), + last30d: z.number(), + }), + errorRate: z.object({ + window: z.literal("30d"), + totalEvents: z.number(), + errorEvents: z.number(), + percentage: z.number(), + }), + processingLatency: z.object({ + window: z.literal("30d"), + averageMs: z.number().nullable(), + p99Ms: z.number().nullable(), + }), + eventsPerMinute: z.array( + z.object({ + minute: z.string(), + count: z.number(), + }), + ), + generatedAt: z.string(), +}) + +/** + * A single unread notification, as returned by `GET /notifications`. + * Numeric ids and ISO-string timestamps on the wire. + */ +export const notificationSchema = z.object({ + id: z.number(), + userId: z.number(), + type: z.string(), + payload: z.record(z.string(), z.unknown()), + readAt: z.string().nullable(), + createdAt: z.string(), + expiresAt: z.string(), +}) + +export const notificationsPageSchema = z.object({ + data: z.array(notificationSchema), + page: z.number(), + limit: z.number(), + total: z.number(), + unreadCount: z.number(), +}) + /** * Shape returned by `POST /streams/events` (issue #514) and, per row, * by `GET /streams/pending` — the `stream_data` wire shape the worker @@ -98,14 +188,18 @@ export const pendingStreamEventSchema = z.object({ /** * A webhook subscription as returned by `POST /webhooks` — the only - * response that includes the signing `secret`. + * response that includes the signing `secret`. Field types mirror the + * SDK's `WebhookSubscription` exactly (ids accept string or number on + * the wire; `events` is pinned to the closed {@link streamEventTypeSchema} + * union the SDK's `StreamEventType` declares) so a server-side type + * change fails CI (issue #534). */ export const webhookSubscriptionSchema = z.object({ id: z.union([z.string(), z.number()]), userId: z.union([z.string(), z.number()]), streamId: z.union([z.string(), z.number()]), url: z.string(), - events: z.array(z.string()), + events: z.array(streamEventTypeSchema), secret: z.string(), active: z.boolean(), createdAt: z.string(), @@ -127,11 +221,16 @@ export const paginatedWebhookSubscriptionsSchema = z.object({ limit: z.number(), }) -/** A single webhook delivery, as returned by the deliveries endpoints. */ +/** + * A single webhook delivery, as returned by the deliveries endpoints. + * Field types mirror the SDK's `WebhookDelivery` exactly (issue #534), + * including the `id`/`webhookSubscriptionId` string-vs-number choice and + * the closed `event` union. + */ export const webhookDeliverySchema = z.object({ id: z.union([z.string(), z.number()]), webhookSubscriptionId: z.union([z.string(), z.number()]), - event: z.string(), + event: streamEventTypeSchema, payload: z.record(z.string(), z.unknown()), status: z.enum(["pending", "success", "failed"]), attemptCount: z.number(), @@ -143,6 +242,13 @@ export const webhookDeliverySchema = z.object({ createdAt: z.string(), }) +export const paginatedWebhookDeliveriesSchema = z.object({ + data: z.array(webhookDeliverySchema), + total: z.number(), + page: z.number(), + limit: z.number(), +}) + export const apiErrorSchema = typed()( z.object({ statusCode: z.number(), diff --git a/tests/contracts/src/streams.contract.ts b/tests/contracts/src/streams.contract.ts index 9678c1e..cbc48e1 100644 --- a/tests/contracts/src/streams.contract.ts +++ b/tests/contracts/src/streams.contract.ts @@ -1,8 +1,10 @@ import { PLACEHOLDER, type Contract } from "./contract" import { apiErrorSchema, + paginatedStreamEventsSchema, paginatedStreamsSchema, pendingStreamEventSchema, + streamAnalyticsSchema, streamSchema, } from "./schemas" @@ -108,6 +110,42 @@ export const streamsContracts: Contract[] = [ schema: pendingStreamEventSchema, }, }, + { + // The provider suite records one event on the seeded stream in + // beforeAll, so this contract validates the non-empty shape — most + // importantly the stringified `id`/`streamId` fields (issue #534). + name: "list-stream-events", + description: "GET /streams/:id/events replays the stream's event log in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/streams/:id/events", + pathParams: { id: PLACEHOLDER.EXISTING_STREAM_ID }, + query: { page: 1, limit: 50 }, + authenticated: true, + }, + response: { + status: 200, + schema: paginatedStreamEventsSchema, + }, + }, + { + name: "get-stream-analytics", + description: "GET /streams/:id/analytics returns the aggregate analytics shape", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/streams/:id/analytics", + pathParams: { id: PLACEHOLDER.EXISTING_STREAM_ID }, + authenticated: true, + }, + response: { + status: 200, + schema: streamAnalyticsSchema, + }, + }, { name: "update-stream", description: "PATCH /streams/:id updates a stream owned by the caller", diff --git a/tests/contracts/src/webhooks.contract.ts b/tests/contracts/src/webhooks.contract.ts index b72ddff..06b343e 100644 --- a/tests/contracts/src/webhooks.contract.ts +++ b/tests/contracts/src/webhooks.contract.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { PLACEHOLDER, type Contract } from "./contract" import { apiErrorSchema, + paginatedWebhookDeliveriesSchema, paginatedWebhookSubscriptionsSchema, webhookDeliverySchema, webhookSubscriptionSchema, @@ -68,6 +69,28 @@ export const webhooksContracts: Contract[] = [ schema: webhookSubscriptionSummarySchema, }, }, + { + // Runs against the seeded subscription + its terminal failed delivery + // (both created in the provider suite's beforeAll), mirroring the + // create-stream → register-webhook → list-deliveries chain: the + // subscription the delivery belongs to already exists by the time this + // contract runs, and the fixture is never re-seeded per contract. + name: "list-webhook-deliveries", + description: "GET /webhooks/:id/deliveries returns the delivery log in the paginated envelope", + consumer: "xstreamroll-sdk", + provider: "api", + request: { + method: "GET", + path: "/webhooks/:id/deliveries", + pathParams: { id: PLACEHOLDER.EXISTING_WEBHOOK_ID }, + query: { page: 1, limit: 20 }, + authenticated: true, + }, + response: { + status: 200, + schema: paginatedWebhookDeliveriesSchema, + }, + }, { name: "retry-webhook-delivery", description: "POST /webhooks/:id/deliveries/:deliveryId/retry re-queues a failed delivery", diff --git a/xstreamroll-sdk/README.md b/xstreamroll-sdk/README.md index e002d4a..993bd7c 100644 --- a/xstreamroll-sdk/README.md +++ b/xstreamroll-sdk/README.md @@ -287,13 +287,17 @@ const resumed = await client.updateWebhook(created.id, { // 4. Inspect deliveries and manually re-queue a failed one. The retry // budget still applies — the attempt count is kept, not reset. -const { data: deliveries } = await client.paginateAll( - "/webhooks/1/deliveries", -) -const failed = deliveries.find((d) => d.status === "failed") +const page = await client.listDeliveries(created.id, { page: 1, limit: 20 }) +const failed = page.data.find((d) => d.status === "failed") if (failed) { await client.retryWebhookDelivery(created.id, failed.id) } +// Or walk every delivery with the pagination helper: +for await (const d of client.paginateAll( + `/webhooks/${created.id}/deliveries`, +)) { + console.log("delivery", d.id, d.status) +} // 5. Remove a subscription and its delivery history. await client.deleteWebhook(created.id) @@ -310,6 +314,30 @@ the **exact raw request body bytes**, not a re-serialized JSON object. --- +## Stream analytics & event replay + +```ts +// Replay the stream's historical event log, newest first (owner-only). +const events = await client.listStreamEvents("stream_abc", { page: 1, limit: 50 }) +// events: { data: StreamEventRecord[], page, limit, total, hasMore } + +// Aggregate analytics: event counts, error rate, processing latency, +// and per-minute volume (owner-only). +const analytics = await client.getStreamAnalytics("stream_abc") +// analytics: StreamAnalytics +``` + +## Notifications + +```ts +// List the caller's unread notifications (paginated). `unreadCount` is +// returned alongside the page so the app can badge its bell icon. +const inbox = await client.listNotifications({ page: 1, limit: 20 }) +// inbox: { data: Notification[], page, limit, total, unreadCount } +``` + +--- + ## HTTP transport `HttpClient` is a small, `fetch`-based wrapper that: @@ -453,6 +481,9 @@ The SDK ships full type definitions. The most useful are: — stream CRUD shapes. * `StreamEvent`, `StreamEventRecord`, `StreamEventType` — event shapes. +* `StreamAnalytics` — the `GET /streams/:id/analytics` shape. +* `Notification`, `NotificationsPage` — the `GET /notifications` + shapes (page includes `unreadCount`). * `WebhookSubscription`, `WebhookSubscriptionSummary`, `UpdateWebhookDto`, `WebhookDelivery` — webhook shapes (the subscription summary omits the creation-time-only `secret`). 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..3beeaa7 100644 --- a/xstreamroll-sdk/__tests__/contract.consumer.test.ts +++ b/xstreamroll-sdk/__tests__/contract.consumer.test.ts @@ -20,8 +20,12 @@ import { allContracts, authResponseSchema, + notificationsPageSchema, + paginatedStreamEventsSchema, + paginatedWebhookDeliveriesSchema, paginatedWebhookSubscriptionsSchema, pendingStreamEventSchema, + streamAnalyticsSchema, streamSchema, webhookDeliverySchema, webhookSubscriptionSchema, @@ -31,6 +35,13 @@ import { import nock from "nock" import { StreamingClient } from "../src/client" +import type { + NotificationsPage as SdkNotificationsPage, + StreamAnalytics as SdkStreamAnalytics, + WebhookDelivery as SdkWebhookDelivery, + WebhookSubscription as SdkWebhookSubscription, + WebhookSubscriptionSummary as SdkWebhookSubscriptionSummary, +} from "../src/types" const BASE_URL = "http://api.test" @@ -40,6 +51,51 @@ function contract(name: string): Contract { return found } +// ── Schema ⇄ SDK-type pinning (issue #534) ───────────────────────────────── +// +// The contract schemas must assert exactly the field types the SDK types +// declare — including the id/streamId string-vs-number choice — so a +// server-side type change (or a schema edit that silently widens or +// narrows a field) fails this file at compile time, before any request is +// made. Mutual assignability is checked in BOTH directions: a schema that +// widened `id` to `string` against an SDK `string | number` would fail the +// SDK→schema direction, and the reverse edit fails schema→SDK. +// +// `SchemaOutput` is `S["_output"]` — the zod output type, resolved +// through the contracts package's own zod dependency rather than the +// workspace-hoisted one, so the SDK never needs a direct zod dependency. + +type SchemaOutput = S extends { _output: infer O } ? O : never + +type AssertEqual = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false +type Expect = T + +type _SubscriptionSchemaPinsSdkType = Expect< + AssertEqual< + SchemaOutput, + SdkWebhookSubscription + > +> +type _SubscriptionSummarySchemaPinsSdkType = Expect< + AssertEqual< + SchemaOutput, + SdkWebhookSubscriptionSummary + > +> +type _DeliverySchemaPinsSdkType = Expect< + AssertEqual, SdkWebhookDelivery> +> +type _AnalyticsSchemaPinsSdkType = Expect< + AssertEqual, SdkStreamAnalytics> +> +type _NotificationSchemaPinsSdkType = Expect< + AssertEqual, SdkNotificationsPage> +> + describe("Consumer contract verification (xstreamroll-sdk)", () => { let client: StreamingClient @@ -255,6 +311,128 @@ describe("Consumer contract verification (xstreamroll-sdk)", () => { expect(c.response.status).toBe(204) }) + it("listDeliveries() requests the list-webhook-deliveries path and returns a contract-valid page", async () => { + const c = contract("list-webhook-deliveries") + const example = { + data: [ + { + id: "10", + webhookSubscriptionId: "1", + event: "stream:stopped", + payload: { streamId: 42 }, + status: "failed", + attemptCount: 6, + lastStatusCode: 500, + lastResponseBody: "Internal Server Error", + lastError: "connection refused", + nextAttemptAt: null, + deliveredAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + total: 1, + page: 1, + limit: 20, + } + expect(() => paginatedWebhookDeliveriesSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/webhooks/1/deliveries?page=1&limit=20") + .reply(c.response.status, example) + + const result = await client.listDeliveries("1", { page: 1, limit: 20 }) + + expect(scope.isDone()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.data[0]?.event).toBe("stream:stopped") + }) + + it("listStreamEvents() requests the list-stream-events path and returns a contract-valid page", async () => { + const c = contract("list-stream-events") + const example = { + data: [ + { + id: "1", + streamId: "42", + eventType: "stream:started", + payload: { streamId: 42 }, + occurredAt: "2026-01-01T00:00:00.000Z", + }, + ], + page: 1, + limit: 50, + total: 1, + hasMore: false, + } + expect(() => paginatedStreamEventsSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/streams/42/events?page=1&limit=50") + .reply(c.response.status, example) + + const result = await client.listStreamEvents("42", { page: 1, limit: 50 }) + + expect(scope.isDone()).toBe(true) + expect(result.data[0]?.id).toBe("1") + expect(result.data[0]?.streamId).toBe("42") + }) + + it("getStreamAnalytics() requests the get-stream-analytics path and returns a contract-valid shape", async () => { + const c = contract("get-stream-analytics") + const example = { + streamId: 42, + totalEventsProcessed: { last24h: 0, last7d: 0, last30d: 0 }, + errorRate: { window: "30d", totalEvents: 0, errorEvents: 0, percentage: 0 }, + processingLatency: { window: "30d", averageMs: null, p99Ms: null }, + eventsPerMinute: [ + { minute: "2026-01-01T00:00:00.000Z", count: 0 }, + ], + generatedAt: "2026-01-01T00:00:00.000Z", + } + expect(() => streamAnalyticsSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/streams/42/analytics") + .reply(c.response.status, example) + + const result = await client.getStreamAnalytics("42") + + expect(scope.isDone()).toBe(true) + expect(result.streamId).toBe(42) + }) + + it("listNotifications() requests the list-notifications path and returns a contract-valid page", async () => { + const c = contract("list-notifications") + const example = { + data: [ + { + id: 1, + userId: 7, + type: "stream:started", + payload: { streamId: 42 }, + readAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + expiresAt: "2026-01-07T00:00:00.000Z", + }, + ], + page: 1, + limit: 20, + total: 1, + unreadCount: 1, + } + expect(() => notificationsPageSchema.parse(example)).not.toThrow() + + const scope = nock(BASE_URL) + .get("/notifications?page=1&limit=20") + .reply(c.response.status, example) + + const result = await client.listNotifications({ page: 1, limit: 20 }) + + expect(scope.isDone()).toBe(true) + expect(result.data).toHaveLength(1) + expect(result.unreadCount).toBe(1) + }) + it("retryWebhookDelivery() POSTs the retry path and returns a contract-valid delivery", async () => { const c = contract("retry-webhook-delivery") const example = { diff --git a/xstreamroll-sdk/src/client.ts b/xstreamroll-sdk/src/client.ts index 3bcb885..c87d6c6 100644 --- a/xstreamroll-sdk/src/client.ts +++ b/xstreamroll-sdk/src/client.ts @@ -6,11 +6,14 @@ import { type AuthTokens, type CreateUserDto, type CreateWebhookDto, + type NotificationsPage, type PagedTags, type PaginatedResponse, type Stream, + type StreamAnalytics, type StreamConfig, type StreamEvent, + type StreamEventRecord, type UpdateWebhookDto, type WebhookDelivery, type WebhookSubscription, @@ -146,6 +149,53 @@ export class StreamingClient { }) } + /** + * Replays a stream's historical event log (issue #396), newest first. + * Owner-only: the API returns 403 for streams the caller does not own. + */ + async listStreamEvents( + streamId: string | number, + params: { page?: number; limit?: number } = {}, + ): 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)) + const query = qs.toString() + return this.requestJson>( + `/streams/${streamId}/events${query ? `?${query}` : ""}`, + { method: "GET" }, + ) + } + + /** + * Returns aggregate analytics for a stream: event counts, error rate, + * processing latency, and per-minute volume. Owner-only. + */ + async getStreamAnalytics(streamId: string | number): Promise { + return this.requestJson(`/streams/${streamId}/analytics`, { + method: "GET", + }) + } + + /** + * Lists the caller's unread notifications (paginated). The API returns + * `unreadCount` alongside the page so the app can badge the bell icon + * without a second request. + */ + async listNotifications(params: { + page?: number + limit?: number + } = {}): 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)) + const query = qs.toString() + return this.requestJson( + `/notifications${query ? `?${query}` : ""}`, + { method: "GET" }, + ) + } + // ── Webhooks ────────────────────────────────────────────────────────────── /** @@ -206,6 +256,25 @@ export class StreamingClient { await this.requestJson(`/webhooks/${id}`, { method: "DELETE" }) } + /** + * Lists the delivery log for a webhook subscription (paginated). + * Requires ownership of the webhook. `WebhookDelivery` is the same + * shape `retryWebhookDelivery()` returns. + */ + async listDeliveries( + webhookId: string | number, + params: { page?: number; limit?: number } = {}, + ): 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)) + const query = qs.toString() + return this.requestJson>( + `/webhooks/${webhookId}/deliveries${query ? `?${query}` : ""}`, + { method: "GET" }, + ) + } + /** * Manually re-queues a failed or pending delivery so the retry sweep * picks it up immediately. The retry budget still applies — the diff --git a/xstreamroll-sdk/src/index.ts b/xstreamroll-sdk/src/index.ts index c6013b3..60f48d5 100644 --- a/xstreamroll-sdk/src/index.ts +++ b/xstreamroll-sdk/src/index.ts @@ -25,6 +25,11 @@ export type { StreamEventType, StreamEvent, StreamEventRecord, + // Stream Analytics + StreamAnalytics, + // Notifications + Notification, + NotificationsPage, // Webhooks CreateWebhookDto, UpdateWebhookDto, diff --git a/xstreamroll-sdk/src/types.ts b/xstreamroll-sdk/src/types.ts index dc75692..e219c31 100644 --- a/xstreamroll-sdk/src/types.ts +++ b/xstreamroll-sdk/src/types.ts @@ -1,7 +1,13 @@ // ─── Generated types from OpenAPI spec ───────────────────────────────────── // Regenerate with `npm run generate:types` (requires API server running). +import type { + ApiErrorResponse, + PaginatedResponse, + StreamEventType, + Tag, + User, +} from "@xstreamroll/types" import type { components } from "./generated/schema" -import type { ApiErrorResponse, StreamEventType } from "@xstreamroll/types" export type { components } @@ -47,6 +53,17 @@ export type { ApiErrorResponse, } from "@xstreamroll/types" +// ─── 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 +100,66 @@ 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 + +// ─── Streams: analytics & event replay ─────────────────────────────────────── + +/** + * Aggregate analytics returned by `GET /streams/:id/analytics`. Mirrors + * the API's `StreamAnalyticsDto` exactly (issue #534). + */ +export interface StreamAnalytics { + streamId: number + totalEventsProcessed: { + last24h: number + last7d: number + last30d: number + } + errorRate: { + window: "30d" + totalEvents: number + errorEvents: number + percentage: number + } + processingLatency: { + window: "30d" + averageMs: number | null + p99Ms: number | null + } + eventsPerMinute: Array<{ + minute: string + count: number + }> + generatedAt: string +} + +// ─── Notifications ─────────────────────────────────────────────────────────── + +/** + * A single unread notification, as returned by `GET /notifications` + * (issue #534). Numeric ids and ISO-string timestamps on the wire. + */ +export interface Notification { + id: number + userId: number + type: string + payload: Record + readAt: string | null + createdAt: string + expiresAt: string +} + +/** Paginated unread-notification envelope from `GET /notifications`. */ +export interface NotificationsPage extends PaginatedResponse { + unreadCount: number +} + // ─── Webhooks ───────────────────────────────────────────────────────────────── /** Payload for `subscribeWebhook()` / `POST /webhooks`. */ From d4bc00156bf991466d65014221b1b783133cad91 Mon Sep 17 00:00:00 2001 From: rozemary2026-a11y Date: Wed, 26 Aug 2026 09:34:30 +0000 Subject: [PATCH 2/2] fix(ci): sync root package-lock.json so npm ci resolves dependencies api/package.json gained @types/cookie-parser@^1.4.10 (auth refresh work) without a lockfile regeneration, so every `npm ci` in CI fails with EUSAGE. Regenerate the root lockfile to add the missing resolution, sync the stale api/sdk workspace version entries, and prune an unreferenced nested conventional-commits-parser entry. --- package-lock.json | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) 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" },