From 0838e2ec7f067e56036f0336c66ff5d9f7966c32 Mon Sep 17 00:00:00 2001 From: Orlando Goncalves Date: Fri, 21 Aug 2026 12:01:48 -0500 Subject: [PATCH 1/3] feat(streams): add starter_pack user stream source with tag-aware IDs Adds a 4-part user stream ID format (source:all:all:tag1,tag2) so starter pack results can be cached per ordered tag list in Dexie. While Nexus staging lacks the starter_pack source, a config flag keeps IDs in a starter_pack_mock namespace served by most_followed, so stale mock rows can never satisfy live requests after the swap (#2390). Changes: - buildStarterPackStreamId helper: canonicalizes (trim/lowercase) and validates tags (1-5, 1-20 chars, no banned chars) via Err.validation - createUserStreamParams: parse/validate 4-part starter pack IDs, enforce all:all segments, reject noncanonical hand-built IDs - NexusUserStreamService.fetch: dispatch starter_pack (live URL with tags param) and starter_pack_mock (most_followed, no tags param) - STARTER_PACK_SOURCE_LIVE flag + STARTER_PACK_MAX_TAGS in config - Tests: helper/parser/dispatch coverage + Dexie row isolation and mock-to-live swap regression at the application layer Refs: #2386 --- src/config/nexus.ts | 17 +++ .../application/stream/users/users.test.ts | 46 +++++- .../stream/user/userStream.helper.test.ts | 121 +++++++++++++++ .../models/stream/user/userStream.helper.ts | 62 +++++++- .../models/stream/user/userStream.types.ts | 18 ++- .../nexus/stream/users/userStream.api.ts | 5 + .../nexus/stream/users/userStream.test.ts | 76 +++++++++- .../services/nexus/stream/users/userStream.ts | 25 +++- .../nexus/stream/users/userStream.types.ts | 11 +- .../stream/users/userStream.utils.test.ts | 139 +++++++++++++++++- .../nexus/stream/users/userStream.utils.ts | 81 +++++++++- 11 files changed, 582 insertions(+), 19 deletions(-) create mode 100644 src/core/models/stream/user/userStream.helper.test.ts diff --git a/src/config/nexus.ts b/src/config/nexus.ts index 47a128ab39..f3a2fefdc0 100644 --- a/src/config/nexus.ts +++ b/src/config/nexus.ts @@ -8,3 +8,20 @@ export const NEXUS_NOTIFICATIONS_LIMIT = 30; export const NEXUS_POSTS_PER_PAGE = 10; // Number of posts to fetch per page in streams export const NEXUS_STREAM_MAX_LIMIT = 50; // Hard cap Nexus enforces on a single stream `limit`; requests above this are rejected export const NEXUS_USERS_PER_PAGE = 10; // Number of users to fetch per page in streams + +/** + * Nexus contract limit: `source=starter_pack` accepts 1-5 comma-separated interest tags + * (pubky/pubky-nexus#1024). Fixed by the backend — independent of the runtime-configurable + * `getMaxStreamTags()`, which may be set higher and must never widen this bound. + */ +export const STARTER_PACK_MAX_TAGS = 5; + +/** + * TEMPORARY — remove in pubky/pubky-app#2390. + * pubky/pubky-nexus#1024 is merged but not yet deployed to staging. While false, + * `buildStarterPackStreamId` emits a `starter_pack_mock:*` cache namespace that dispatches to + * `most_followed` (`recommended` returns nothing for brand-new accounts, pubky/pubky-nexus#1022). + * Flipping to true changes every starter pack cache key, so the first live load is a guaranteed + * cache miss and stale mock rows can never satisfy live requests. + */ +export const STARTER_PACK_SOURCE_LIVE = false; diff --git a/src/core/application/stream/users/users.test.ts b/src/core/application/stream/users/users.test.ts index 6792da371b..192d376538 100644 --- a/src/core/application/stream/users/users.test.ts +++ b/src/core/application/stream/users/users.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UserStreamApplication } from '@/application/stream/users/users'; import type { Pubky } from '@/models/models.types'; import { buildUserCompositeId } from '@/models/stream/user/userStream.helper'; -import { UserStreamTypes } from '@/models/stream/user/userStream.types'; +import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; import { UserDetailsModel } from '@/models/user/details/userDetails'; import { LocalStreamUsersService } from '@/services/local/stream/users/users'; import type { NexusUser } from '@/services/nexus/nexus.types'; @@ -289,6 +289,50 @@ describe('UserStreamApplication', () => { expect(result.nextPageIds).toEqual(['influencer-1', 'influencer-2', 'influencer-3']); }); + describe('starter pack cache isolation', () => { + const LIVE_STREAM_ID = 'starter_pack:all:all:bitcoin,music' as UserStreamId; + const MOCK_STREAM_ID = 'starter_pack_mock:all:all:bitcoin,music' as UserStreamId; + + it('should persist reversed tag orders as distinct Dexie rows', async () => { + const forwardId = 'starter_pack_mock:all:all:travel,music' as UserStreamId; + const reversedId = 'starter_pack_mock:all:all:music,travel' as UserStreamId; + + await LocalStreamUsersService.upsert({ streamId: forwardId, stream: ['user-a'] }); + await LocalStreamUsersService.upsert({ streamId: reversedId, stream: ['user-b'] }); + + expect((await LocalStreamUsersService.findById(forwardId))?.stream).toEqual(['user-a']); + expect((await LocalStreamUsersService.findById(reversedId))?.stream).toEqual(['user-b']); + }); + + it('should never serve cached mock rows for live starter pack requests (mock-to-live swap regression)', async () => { + // Seed a mock-namespaced row exactly as a pre-swap session would have left it + const mockCachedIds: Pubky[] = ['mock-user-1', 'mock-user-2']; + await LocalStreamUsersService.upsert({ streamId: MOCK_STREAM_ID, stream: mockCachedIds }); + await createUserDetails(mockCachedIds); + + const liveUserIds: Pubky[] = ['live-user-1', 'live-user-2']; + const fetchSpy = vi.spyOn(NexusUserStreamService, 'fetch').mockResolvedValue(liveUserIds); + + const result = await UserStreamApplication.getOrFetchStreamSlice({ + streamId: LIVE_STREAM_ID, + skip: 0, + limit: 2, + viewerId: DEFAULT_VIEWER_ID, + }); + + // The live key must miss the cache and hit the network + expect(fetchSpy).toHaveBeenCalledWith({ + streamId: LIVE_STREAM_ID, + params: { skip: 0, limit: 2, viewer_id: DEFAULT_VIEWER_ID }, + }); + expect(result.nextPageIds).toEqual(liveUserIds); + + // Both rows stay isolated: live written fresh, mock untouched + expect((await LocalStreamUsersService.findById(LIVE_STREAM_ID))?.stream).toEqual(liveUserIds); + expect((await LocalStreamUsersService.findById(MOCK_STREAM_ID))?.stream).toEqual(mockCachedIds); + }); + }); + it('should pass viewerId to Nexus API for relationship data', async () => { const streamId = buildUserCompositeId({ userId: DEFAULT_USER_ID, reach: 'followers' }); const mockUserIds: Pubky[] = ['follower-1', 'follower-2']; diff --git a/src/core/models/stream/user/userStream.helper.test.ts b/src/core/models/stream/user/userStream.helper.test.ts new file mode 100644 index 0000000000..4dd90b4504 --- /dev/null +++ b/src/core/models/stream/user/userStream.helper.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AppError } from '@/libs/error/error'; +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { ErrorCategory } from '@/libs/error/error.types'; +import { buildStarterPackStreamId, USER_STREAM_TAG_DELIMITER } from './userStream.helper'; + +const expectValidationError = (fn: () => unknown) => { + try { + fn(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AppError); + const appError = error as AppError; + expect(appError.category).toBe(ErrorCategory.Validation); + expect(appError.code).toBe(ValidationErrorCode.INVALID_INPUT); + } +}; + +describe('buildStarterPackStreamId', () => { + // The checked-in STARTER_PACK_SOURCE_LIVE flag is false until Nexus deploys the + // starter_pack source to staging (#2390 flips it and removes the mock namespace). + describe('with the shipped flag value (mock namespace)', () => { + it('should join ordered tags under the mock namespace', () => { + expect(buildStarterPackStreamId(['bitcoin', 'music'])).toBe('starter_pack_mock:all:all:bitcoin,music'); + }); + + it('should preserve tag order (reversed lists yield distinct IDs)', () => { + const forward = buildStarterPackStreamId(['travel', 'music']); + const reversed = buildStarterPackStreamId(['music', 'travel']); + + expect(forward).toBe('starter_pack_mock:all:all:travel,music'); + expect(reversed).toBe('starter_pack_mock:all:all:music,travel'); + expect(forward).not.toBe(reversed); + }); + + it('should canonicalize labels so casing/whitespace variants map to one ID', () => { + const fromMixedCase = buildStarterPackStreamId(['Bitcoin ', 'MUSIC']); + const fromCanonical = buildStarterPackStreamId(['bitcoin', 'music']); + + expect(fromMixedCase).toBe(fromCanonical); + expect(fromMixedCase).toBe('starter_pack_mock:all:all:bitcoin,music'); + }); + + it('should accept the maximum of 5 tags', () => { + expect(buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e'])).toBe('starter_pack_mock:all:all:a,b,c,d,e'); + }); + + it('should accept a single tag', () => { + expect(buildStarterPackStreamId(['bitcoin'])).toBe('starter_pack_mock:all:all:bitcoin'); + }); + }); + + describe('validation', () => { + it('should reject an empty tag list', () => { + expectValidationError(() => buildStarterPackStreamId([])); + }); + + it('should reject more than 5 tags', () => { + expectValidationError(() => buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e', 'f'])); + }); + + it('should reject empty and whitespace-only labels', () => { + expectValidationError(() => buildStarterPackStreamId([''])); + expectValidationError(() => buildStarterPackStreamId([' '])); + }); + + it('should reject labels with inner whitespace (banned characters)', () => { + expectValidationError(() => buildStarterPackStreamId(['rock music'])); + expectValidationError(() => buildStarterPackStreamId(['tab\there'])); + expectValidationError(() => buildStarterPackStreamId(['new\nline'])); + }); + + it('should reject labels containing the tag delimiter', () => { + expectValidationError(() => buildStarterPackStreamId([`bit${USER_STREAM_TAG_DELIMITER}coin`])); + }); + + it('should reject labels containing the stream ID delimiter', () => { + expectValidationError(() => buildStarterPackStreamId(['bit:coin'])); + }); + + it('should reject overlength labels (>20 chars)', () => { + expectValidationError(() => buildStarterPackStreamId(['a'.repeat(21)])); + }); + + it('should accept a label at exactly 20 chars', () => { + expect(buildStarterPackStreamId(['a'.repeat(20)])).toContain(`:${'a'.repeat(20)}`); + }); + }); + + describe('STARTER_PACK_SOURCE_LIVE flag', () => { + it('should emit the live namespace when the flag is true', async () => { + vi.resetModules(); + vi.doMock('@/config/nexus', async (importOriginal) => ({ + ...(await importOriginal()), + STARTER_PACK_SOURCE_LIVE: true, + })); + + const { buildStarterPackStreamId: buildWithLiveFlag } = await import('./userStream.helper'); + + expect(buildWithLiveFlag(['bitcoin', 'music'])).toBe('starter_pack:all:all:bitcoin,music'); + + vi.doUnmock('@/config/nexus'); + vi.resetModules(); + }); + + it('should emit the mock namespace when the flag is false', async () => { + vi.resetModules(); + vi.doMock('@/config/nexus', async (importOriginal) => ({ + ...(await importOriginal()), + STARTER_PACK_SOURCE_LIVE: false, + })); + + const { buildStarterPackStreamId: buildWithMockFlag } = await import('./userStream.helper'); + + expect(buildWithMockFlag(['bitcoin', 'music'])).toBe('starter_pack_mock:all:all:bitcoin,music'); + + vi.doUnmock('@/config/nexus'); + vi.resetModules(); + }); + }); +}); diff --git a/src/core/models/stream/user/userStream.helper.ts b/src/core/models/stream/user/userStream.helper.ts index 7119733c72..1d54baad0a 100644 --- a/src/core/models/stream/user/userStream.helper.ts +++ b/src/core/models/stream/user/userStream.helper.ts @@ -1,8 +1,20 @@ +import { STARTER_PACK_MAX_TAGS, STARTER_PACK_SOURCE_LIVE } from '@/config/nexus'; +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { Err } from '@/libs/error/error.factories'; +import { ErrorService } from '@/libs/error/error.types'; +import { isValidTagLabel } from '@/libs/utils/utils'; import type { Pubky } from '@/models/models.types'; import { UserStreamModelSchema } from './userStream.schema'; -import { UserStreamCompositeId, UserStreamId } from './userStream.types'; +import { + STARTER_PACK_MOCK_STREAM_SOURCE, + STARTER_PACK_STREAM_SOURCE, + StarterPackStreamId, + UserStreamCompositeId, + UserStreamId, +} from './userStream.types'; export const USER_STREAM_ID_DELIMITER = ':' as const; +export const USER_STREAM_TAG_DELIMITER = ',' as const; /** * Parts of a user stream composite ID @@ -46,6 +58,54 @@ export function parseUserCompositeId(compositeId: string): UserStreamIdParts { }; } +/** + * Build a starter pack stream ID from ordered interest tags. + * + * Labels are canonicalized (trimmed + lowercased) so 'Bitcoin' and 'bitcoin' resolve to the same + * Dexie row, then validated against the canonical tag contract (1-20 chars, no banned characters) + * and the Nexus starter pack limit (1-5 tags). Order is preserved: Nexus interleaves per-tag + * rankings in the order given, so ['travel','music'] and ['music','travel'] are different streams. + * + * While STARTER_PACK_SOURCE_LIVE is false the ID is namespaced under `starter_pack_mock`, which + * dispatches to `most_followed`. Flipping the flag changes every cache key, guaranteeing the first + * live load bypasses stale mock rows. + * + * @example + * buildStarterPackStreamId(['Bitcoin ', 'music']) + * // Returns: 'starter_pack:all:all:bitcoin,music' (or 'starter_pack_mock:...' while mocked) + */ +export function buildStarterPackStreamId(tags: string[]): StarterPackStreamId { + const canonical = tags.map((tag) => tag.trim().toLowerCase()); + + if (canonical.length === 0 || canonical.length > STARTER_PACK_MAX_TAGS) { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + `Starter pack streams require 1-${STARTER_PACK_MAX_TAGS} tags`, + { + service: ErrorService.Nexus, + operation: 'buildStarterPackStreamId', + context: { tagCount: canonical.length }, + }, + ); + } + + const invalidLabels = canonical.filter((tag) => !isValidTagLabel(tag)); + if (invalidLabels.length > 0) { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + 'Starter pack tags must be 1-20 characters without banned characters', + { + service: ErrorService.Nexus, + operation: 'buildStarterPackStreamId', + context: { invalidLabels }, + }, + ); + } + + const source = STARTER_PACK_SOURCE_LIVE ? STARTER_PACK_STREAM_SOURCE : STARTER_PACK_MOCK_STREAM_SOURCE; + return `${source}:all:all:${canonical.join(USER_STREAM_TAG_DELIMITER)}` as StarterPackStreamId; +} + export const createDefaultUserStream = (id: UserStreamId, stream: Pubky[] = []): UserStreamModelSchema => { return { id, diff --git a/src/core/models/stream/user/userStream.types.ts b/src/core/models/stream/user/userStream.types.ts index 8dd43ee557..8c93960ff8 100644 --- a/src/core/models/stream/user/userStream.types.ts +++ b/src/core/models/stream/user/userStream.types.ts @@ -6,6 +6,12 @@ import type { UserStreamCompositeReach } from '@/services/nexus/nexus.types'; // - TIMEFRAME: today, this_week, this_month, all_time, all // - REACH (Supported in 'influencers' source): followers, following, friends, wot (u8), all // +// Starter pack IDs extend the pattern with a 4th ordered-tag segment: source:timeframe:reach:tag1,tag2 +// - SOURCE: starter_pack (live Nexus source) or starter_pack_mock (FE-only cache namespace used +// while the Nexus source is not deployed; dispatches to most_followed — see STARTER_PACK_SOURCE_LIVE +// in @/config/nexus). Distinct namespaces guarantee mock rows never satisfy live requests. +// - Tag order matters: Nexus interleaves per-tag rankings in the order given. +// // Note: Different from PostStreamTypes pattern (sorting:source:kind) to optimize for user-centric queries export enum UserStreamTypes { // Bootstrap default lists: @@ -17,7 +23,17 @@ export enum UserStreamTypes { // TODO: Add all possible cases } +// Live Nexus stream source for starter packs (pubky/pubky-nexus#1024) +export const STARTER_PACK_STREAM_SOURCE = 'starter_pack' as const; +// FE-only cache-key namespace while the Nexus source is not deployed; never sent to Nexus +export const STARTER_PACK_MOCK_STREAM_SOURCE = 'starter_pack_mock' as const; + +// Starter pack ID format: source:all:all:tag1,tag2 (ordered, canonicalized tags) +export type StarterPackStreamId = + | `${typeof STARTER_PACK_STREAM_SOURCE}:all:all:${string}` + | `${typeof STARTER_PACK_MOCK_STREAM_SOURCE}:all:all:${string}`; + // Composite ID format: userId:reach (e.g., 'user123:followers') export type UserStreamCompositeId = `${Pubky}:${UserStreamCompositeReach}`; -export type UserStreamId = UserStreamTypes | UserStreamCompositeId; +export type UserStreamId = UserStreamTypes | UserStreamCompositeId | StarterPackStreamId; diff --git a/src/core/services/nexus/stream/users/userStream.api.ts b/src/core/services/nexus/stream/users/userStream.api.ts index b66adf65e3..38316844f3 100644 --- a/src/core/services/nexus/stream/users/userStream.api.ts +++ b/src/core/services/nexus/stream/users/userStream.api.ts @@ -4,6 +4,7 @@ import { type TUserStreamInfluencersParams, type TUserStreamPostRepliesParams, type TUserStreamQueryParams, + type TUserStreamStarterPackParams, type TUserStreamUsernameParams, type TUserStreamUsersByIdsParams, type TUserStreamWithDepthParams, @@ -76,6 +77,10 @@ export const userStreamApi = { mostFollowed: (params: TUserStreamBase) => buildUserStreamUrl(params, UserStreamSource.MOST_FOLLOWED, USER_STREAM_PREFIX.USER_IDS), + // Starter pack: ranked users for 1-5 ordered interest tags (pubky/pubky-nexus#1024) + starterPack: (params: TUserStreamStarterPackParams) => + buildUserStreamUrl(params, UserStreamSource.STARTER_PACK, USER_STREAM_PREFIX.USER_IDS), + // Username search username: (params: TUserStreamUsernameParams) => buildUserStreamUrl(params, null, USER_STREAM_PREFIX.USERNAME), diff --git a/src/core/services/nexus/stream/users/userStream.test.ts b/src/core/services/nexus/stream/users/userStream.test.ts index d4a2712122..986b21f56e 100644 --- a/src/core/services/nexus/stream/users/userStream.test.ts +++ b/src/core/services/nexus/stream/users/userStream.test.ts @@ -1,10 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Pubky } from '@/models/models.types'; import { buildUserCompositeId } from '@/models/stream/user/userStream.helper'; -import { UserStreamTypes } from '@/models/stream/user/userStream.types'; +import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; import { UserStreamReach, UserStreamTimeframe } from '@/services/nexus/nexus.types'; +import { queryNexus } from '@/services/nexus/nexus.utils'; import { asInvalid } from '@/test-utils/type-assertions'; +import { NexusUserStreamService } from './userStream'; import { buildUserStreamBodyUrl, userStreamApi } from './userStream.api'; +vi.mock('@/services/nexus/nexus.utils', async (importOriginal) => ({ + ...(await importOriginal()), + queryNexus: vi.fn(), +})); + describe('Users Stream API - Error Control', () => { const mockUserId = 'erztyis9oiaho93ckucetcf5xnxacecqwhbst5hnd7mmkf69dhby'; const mockViewerId = 'viewer-pubky-id'; @@ -290,9 +298,9 @@ describe('Users Stream API - Error Control', () => { }); describe('UserStreamApiEndpoint type', () => { - it('should have exactly 10 endpoints', () => { + it('should have exactly 11 endpoints', () => { const endpointKeys = Object.keys(userStreamApi); - expect(endpointKeys).toHaveLength(10); + expect(endpointKeys).toHaveLength(11); expect(endpointKeys).toContain('followers'); expect(endpointKeys).toContain('following'); expect(endpointKeys).toContain('friends'); @@ -301,6 +309,7 @@ describe('Users Stream API - Error Control', () => { expect(endpointKeys).toContain('postReplies'); expect(endpointKeys).toContain('friendsWithDepth'); expect(endpointKeys).toContain('mostFollowed'); + expect(endpointKeys).toContain('starterPack'); expect(endpointKeys).toContain('username'); expect(endpointKeys).toContain('usersByIds'); }); @@ -395,6 +404,32 @@ describe('NexusUserStreamService.fetch', () => { expect(url).toContain('source=recommended'); }); + + it('should generate correct starter pack URL with ordered comma-joined tags', () => { + const url = userStreamApi.starterPack({ + tags: 'bitcoin,music', + viewer_id: mockUserId, + skip: 0, + limit: 10, + }); + + expect(url).toContain('v0/stream/users/ids?'); + expect(url).toContain('source=starter_pack'); + // URLSearchParams encodes the comma; order must be preserved + expect(url).toContain('tags=bitcoin%2Cmusic'); + expect(url).toContain(`viewer_id=${mockUserId}`); + expect(url).toContain('skip=0'); + expect(url).toContain('limit=10'); + }); + + it('should generate distinct starter pack URLs for reversed tag orders', () => { + const forward = userStreamApi.starterPack({ tags: 'travel,music' }); + const reversed = userStreamApi.starterPack({ tags: 'music,travel' }); + + expect(forward).toContain('tags=travel%2Cmusic'); + expect(reversed).toContain('tags=music%2Ctravel'); + expect(forward).not.toBe(reversed); + }); }); describe('Parameter handling', () => { @@ -444,6 +479,39 @@ describe('NexusUserStreamService.fetch', () => { }); }); + describe('starter pack dispatch', () => { + beforeEach(() => { + vi.mocked(queryNexus).mockReset(); + vi.mocked(queryNexus).mockResolvedValue([]); + }); + + it('should dispatch mock-namespaced IDs to most_followed without a tags param', async () => { + await NexusUserStreamService.fetch({ + streamId: 'starter_pack_mock:all:all:bitcoin,music' as UserStreamId, + params: { skip: 0, limit: 10 }, + }); + + expect(queryNexus).toHaveBeenCalledTimes(1); + const { url } = vi.mocked(queryNexus).mock.calls[0][0]; + expect(url).toContain('source=most_followed'); + expect(url).not.toContain('starter_pack'); + expect(url).not.toContain('tags='); + }); + + it('should dispatch live IDs to source=starter_pack with ordered tags', async () => { + await NexusUserStreamService.fetch({ + streamId: 'starter_pack:all:all:bitcoin,music' as UserStreamId, + params: { skip: 0, limit: 10, viewer_id: 'viewer-abc' as Pubky }, + }); + + expect(queryNexus).toHaveBeenCalledTimes(1); + const { url } = vi.mocked(queryNexus).mock.calls[0][0]; + expect(url).toContain('source=starter_pack'); + expect(url).toContain('tags=bitcoin%2Cmusic'); + expect(url).toContain('viewer_id=viewer-abc'); + }); + }); + describe('URL structure validation', () => { it('should always start with v0/stream/users/ids?', () => { const url = userStreamApi.followers({ diff --git a/src/core/services/nexus/stream/users/userStream.ts b/src/core/services/nexus/stream/users/userStream.ts index 6c58c1d401..815cdc41a7 100644 --- a/src/core/services/nexus/stream/users/userStream.ts +++ b/src/core/services/nexus/stream/users/userStream.ts @@ -1,3 +1,6 @@ +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { Err } from '@/libs/error/error.factories'; +import { ErrorService } from '@/libs/error/error.types'; import { HttpMethod } from '@/libs/http/http.types'; import type { Pubky } from '@/models/models.types'; import type { NexusUser, NexusUserIdsStream } from '@/services/nexus/nexus.types'; @@ -7,6 +10,7 @@ import type { TFetchUserStreamParams, TUserStreamBase, TUserStreamInfluencersParams, + TUserStreamStarterPackParams, TUserStreamUsersByIdsParams, TUserStreamWithUserIdParams, } from '@/services/nexus/stream/users/userStream.types'; @@ -44,8 +48,25 @@ export class NexusUserStreamService { case 'most_followed': url = userStreamApi.mostFollowed(apiParams as TUserStreamBase); break; - default: - throw new Error(`Invalid reach type: ${reach}`); + case 'starter_pack': + url = userStreamApi.starterPack(apiParams as TUserStreamStarterPackParams); + break; + // TEMPORARY (#2390): mock namespace served by most_followed until Nexus deploys starter_pack + case 'starter_pack_mock': + url = userStreamApi.mostFollowed(apiParams as TUserStreamBase); + break; + default: { + const exhaustiveCheck: never = reach; + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + `Unsupported user stream reach: ${String(exhaustiveCheck)}`, + { + service: ErrorService.Nexus, + operation: 'fetch', + context: { streamId }, + }, + ); + } } return await queryNexus({ url }); diff --git a/src/core/services/nexus/stream/users/userStream.types.ts b/src/core/services/nexus/stream/users/userStream.types.ts index 6e3e6ca07b..8f3050e01a 100644 --- a/src/core/services/nexus/stream/users/userStream.types.ts +++ b/src/core/services/nexus/stream/users/userStream.types.ts @@ -17,6 +17,7 @@ export enum UserStreamSource { RECOMMENDED = 'recommended', POST_REPLIES = 'post_replies', MOST_FOLLOWED = 'most_followed', + STARTER_PACK = 'starter_pack', } export type TUserStreamBase = TPaginationParams & { @@ -47,6 +48,13 @@ export type TUserStreamUsernameParams = Omit & { username: string; }; +// tags: comma-joined ordered interest labels (1-5); Nexus rejects `tags` on any other source. +// viewer_id (from TUserStreamBase) doubles as the excluded subject: Nexus drops that user and +// everyone they already follow when user_id is absent. +export type TUserStreamStarterPackParams = TUserStreamBase & { + tags: string; +}; + export type TUserStreamUsersByIdsParams = { user_ids: Pubky[]; viewer_id?: Pubky; @@ -73,4 +81,5 @@ export type TUserStreamQueryParams = | TUserStreamPostRepliesParams | TUserStreamWithDepthParams | TUserStreamBase - | TUserStreamUsernameParams; + | TUserStreamUsernameParams + | TUserStreamStarterPackParams; diff --git a/src/core/services/nexus/stream/users/userStream.utils.test.ts b/src/core/services/nexus/stream/users/userStream.utils.test.ts index cf0758e19e..b957d3bba7 100644 --- a/src/core/services/nexus/stream/users/userStream.utils.test.ts +++ b/src/core/services/nexus/stream/users/userStream.utils.test.ts @@ -1,14 +1,30 @@ import { describe, expect, it } from 'vitest'; +import { AppError } from '@/libs/error/error'; +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { ErrorCategory } from '@/libs/error/error.types'; import type { Pubky } from '@/models/models.types'; import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; import { userStreamApi } from '@/services/nexus/stream/users/userStream.api'; import type { TUserStreamBase, TUserStreamInfluencersParams, + TUserStreamStarterPackParams, TUserStreamWithUserIdParams, } from '@/services/nexus/stream/users/userStream.types'; import { createUserStreamParams, streamRequiresUserId } from './userStream.utils'; +const expectValidationError = (fn: () => unknown) => { + try { + fn(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(AppError); + const appError = error as AppError; + expect(appError.category).toBe(ErrorCategory.Validation); + expect(appError.code).toBe(ValidationErrorCode.INVALID_INPUT); + } +}; + describe('createUserStreamParams', () => { const baseParams: TUserStreamBase = { skip: 0, @@ -267,6 +283,116 @@ describe('createUserStreamParams', () => { }); }); + // ============================================================================ + // 4-Part Starter Pack IDs (source:timeframe:reach:tags) + // ============================================================================ + + describe('4-part starter pack IDs (source:timeframe:reach:tags)', () => { + it('should parse a live starter pack ID into starter_pack reach with a tags param', () => { + const streamId = 'starter_pack:all:all:bitcoin,music' as UserStreamId; + + const result = createUserStreamParams(streamId, baseParams); + + expect(result.reach).toBe('starter_pack'); + expect(result.apiParams).toEqual({ + skip: 0, + limit: 20, + tags: 'bitcoin,music', + }); + }); + + it('should preserve tag order in the tags param', () => { + const forward = createUserStreamParams('starter_pack:all:all:travel,music' as UserStreamId, baseParams); + const reversed = createUserStreamParams('starter_pack:all:all:music,travel' as UserStreamId, baseParams); + + expect((forward.apiParams as TUserStreamStarterPackParams).tags).toBe('travel,music'); + expect((reversed.apiParams as TUserStreamStarterPackParams).tags).toBe('music,travel'); + }); + + it('should spread baseParams including viewer_id for live starter pack IDs', () => { + const streamId = 'starter_pack:all:all:bitcoin' as UserStreamId; + const paramsWithViewer: TUserStreamBase = { + skip: 0, + limit: 20, + viewer_id: 'viewer-abc' as Pubky, + }; + + const result = createUserStreamParams(streamId, paramsWithViewer); + + expect(result.apiParams).toEqual({ + skip: 0, + limit: 20, + viewer_id: 'viewer-abc', + tags: 'bitcoin', + }); + }); + + it('should parse a mock starter pack ID into starter_pack_mock reach without a tags param', () => { + const streamId = 'starter_pack_mock:all:all:bitcoin,music' as UserStreamId; + + const result = createUserStreamParams(streamId, baseParams); + + expect(result.reach).toBe('starter_pack_mock'); + // most_followed serves the mock and rejects a `tags` query param + expect(result.apiParams).toEqual(baseParams); + expect(result.apiParams).not.toHaveProperty('tags'); + }); + + it('should reject an empty tag segment', () => { + expectValidationError(() => createUserStreamParams('starter_pack:all:all:' as UserStreamId, baseParams)); + }); + + it('should reject more than 5 tags', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:all:all:a,b,c,d,e,f' as UserStreamId, baseParams), + ); + }); + + it('should reject noncanonical labels instead of silently normalizing', () => { + expectValidationError(() => createUserStreamParams('starter_pack:all:all:Bitcoin' as UserStreamId, baseParams)); + }); + + it('should reject labels with whitespace or banned characters', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:all:all:rock music' as UserStreamId, baseParams), + ); + expectValidationError(() => createUserStreamParams('starter_pack:all:all:a,,b' as UserStreamId, baseParams)); + }); + + it('should reject overlength labels (>20 chars)', () => { + expectValidationError(() => + createUserStreamParams(`starter_pack:all:all:${'a'.repeat(21)}` as UserStreamId, baseParams), + ); + }); + + it('should reject 4-part IDs whose source is not a starter pack variant', () => { + expectValidationError(() => createUserStreamParams('part1:part2:part3:part4' as UserStreamId, baseParams)); + expectValidationError(() => createUserStreamParams('most_followed:all:all:bitcoin' as UserStreamId, baseParams)); + }); + + it('should reject non-"all" timeframe segments', () => { + expectValidationError(() => createUserStreamParams('starter_pack:today:all:bitcoin' as UserStreamId, baseParams)); + expectValidationError(() => + createUserStreamParams('starter_pack_mock:this_month:all:bitcoin' as UserStreamId, baseParams), + ); + }); + + it('should reject non-"all" reach segments', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:all:friends:bitcoin' as UserStreamId, baseParams), + ); + expectValidationError(() => + createUserStreamParams('starter_pack_mock:all:followers:bitcoin' as UserStreamId, baseParams), + ); + }); + + it('should reject IDs where both timeframe and reach are non-"all"', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:today:friends:bitcoin' as UserStreamId, baseParams), + ); + }); + }); + // ============================================================================ // Edge Cases and Error Handling // ============================================================================ @@ -278,20 +404,19 @@ describe('createUserStreamParams', () => { ); }); - it('should throw error for invalid format (1 part)', () => { + it('should throw a validation error for invalid format (1 part)', () => { const streamId = 'invalid-stream-id' as UserStreamId; + expectValidationError(() => createUserStreamParams(streamId, baseParams)); expect(() => createUserStreamParams(streamId, baseParams)).toThrow( - 'Invalid stream ID: "invalid-stream-id". Expected 2 or 3 parts separated by ":"', + 'Invalid stream ID: expected 2, 3, or 4 parts separated by ":"', ); }); - it('should throw error for invalid format (4+ parts)', () => { - const streamId = 'part1:part2:part3:part4' as UserStreamId; + it('should throw a validation error for invalid format (5+ parts)', () => { + const streamId = 'part1:part2:part3:part4:part5' as UserStreamId; - expect(() => createUserStreamParams(streamId, baseParams)).toThrow( - 'Invalid stream ID: "part1:part2:part3:part4". Expected 2 or 3 parts separated by ":"', - ); + expectValidationError(() => createUserStreamParams(streamId, baseParams)); }); it('should throw error for empty streamId', () => { diff --git a/src/core/services/nexus/stream/users/userStream.utils.ts b/src/core/services/nexus/stream/users/userStream.utils.ts index 377ed52604..c7fe912217 100644 --- a/src/core/services/nexus/stream/users/userStream.utils.ts +++ b/src/core/services/nexus/stream/users/userStream.utils.ts @@ -1,12 +1,20 @@ +import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; import { ValidationErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { ErrorService } from '@/libs/error/error.types'; +import { isValidTagLabel } from '@/libs/utils/utils'; import type { Pubky } from '@/models/models.types'; -import type { UserStreamId } from '@/models/stream/user/userStream.types'; +import { USER_STREAM_TAG_DELIMITER } from '@/models/stream/user/userStream.helper'; +import { + STARTER_PACK_MOCK_STREAM_SOURCE, + STARTER_PACK_STREAM_SOURCE, + type UserStreamId, +} from '@/models/stream/user/userStream.types'; import type { UserStreamReach, UserStreamTimeframe } from '@/services/nexus/nexus.types'; import { type TUserStreamBase, type TUserStreamInfluencersParams, + type TUserStreamStarterPackParams, type TUserStreamWithUserIdParams, UserStreamSource, } from '@/services/nexus/stream/users/userStream.types'; @@ -102,7 +110,73 @@ export function createUserStreamParams( }; } - throw new Error(`Invalid stream ID: "${streamId}". Expected 2 or 3 parts separated by "${DELIMITER}"`); + // If we are dealing with source:timeframe:reach:tags format (starter pack only; tags cannot + // contain ':' so 4 parts is exact) + if (parts.length === 4) { + const [source, timeframe, reach, tagSegment] = parts; + + if (source !== STARTER_PACK_STREAM_SOURCE && source !== STARTER_PACK_MOCK_STREAM_SOURCE) { + throw Err.validation(ValidationErrorCode.INVALID_INPUT, 'Only starter pack stream IDs carry a tag segment', { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }); + } + + // Starter pack requests are always all-time/all-reach; accepting other values here would + // create misleading duplicate cache rows that all map to the same Nexus request + if (timeframe !== 'all' || reach !== 'all') { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + 'Starter pack stream IDs require "all" timeframe and reach segments', + { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }, + ); + } + + const tags = tagSegment.split(USER_STREAM_TAG_DELIMITER); + const hasInvalidLabel = tags.some((label) => label !== label.trim().toLowerCase() || !isValidTagLabel(label)); + if (tags.length > STARTER_PACK_MAX_TAGS || hasInvalidLabel) { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + `Starter pack stream IDs require 1-${STARTER_PACK_MAX_TAGS} canonical (trimmed, lowercase) tags`, + { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }, + ); + } + + // Mock namespace dispatches to most_followed, which rejects a `tags` query param — omit it + if (source === STARTER_PACK_MOCK_STREAM_SOURCE) { + return { + reach: 'starter_pack_mock', + apiParams: baseParams, + } as NexusParamsResult<'starter_pack_mock'>; + } + + return { + reach: 'starter_pack', + apiParams: { + ...baseParams, + tags: tags.join(USER_STREAM_TAG_DELIMITER), + }, + } as NexusParamsResult<'starter_pack'>; + } + + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + `Invalid stream ID: expected 2, 3, or 4 parts separated by "${DELIMITER}"`, + { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }, + ); } /** @@ -128,6 +202,9 @@ type UserStreamApiParamsMap = { recommended: TUserStreamWithUserIdParams; influencers: TUserStreamInfluencersParams; most_followed: TUserStreamBase; + starter_pack: TUserStreamStarterPackParams; + // FE-only mock namespace; served by most_followed until the Nexus source is deployed (#2390) + starter_pack_mock: TUserStreamBase; }; type ReachType = keyof UserStreamApiParamsMap; From 757dba64832104d676c22ccf4792180e1e7dd591 Mon Sep 17 00:00:00 2001 From: Orlando Goncalves Date: Fri, 21 Aug 2026 14:41:20 -0500 Subject: [PATCH 2/3] refactor(streams): remove starter_pack mock now that Nexus deployed it pubky/pubky-nexus#1024 is live on staging (verified: 200 with ordered comma-separated tags, 400 when tags are missing), so the temporary STARTER_PACK_SOURCE_LIVE flag, the starter_pack_mock cache namespace, and the most_followed aliasing are no longer needed. Supersedes #2390. --- src/config/nexus.ts | 10 ---- .../application/stream/users/users.test.ts | 37 ++----------- .../stream/user/userStream.helper.test.ts | 52 ++++--------------- .../models/stream/user/userStream.helper.ts | 12 ++--- .../models/stream/user/userStream.types.ts | 12 ++--- .../nexus/stream/users/userStream.test.ts | 15 +----- .../services/nexus/stream/users/userStream.ts | 4 -- .../stream/users/userStream.utils.test.ts | 15 +----- .../nexus/stream/users/userStream.utils.ts | 18 +------ 9 files changed, 23 insertions(+), 152 deletions(-) diff --git a/src/config/nexus.ts b/src/config/nexus.ts index f3a2fefdc0..b71dc5656a 100644 --- a/src/config/nexus.ts +++ b/src/config/nexus.ts @@ -15,13 +15,3 @@ export const NEXUS_USERS_PER_PAGE = 10; // Number of users to fetch per page in * `getMaxStreamTags()`, which may be set higher and must never widen this bound. */ export const STARTER_PACK_MAX_TAGS = 5; - -/** - * TEMPORARY — remove in pubky/pubky-app#2390. - * pubky/pubky-nexus#1024 is merged but not yet deployed to staging. While false, - * `buildStarterPackStreamId` emits a `starter_pack_mock:*` cache namespace that dispatches to - * `most_followed` (`recommended` returns nothing for brand-new accounts, pubky/pubky-nexus#1022). - * Flipping to true changes every starter pack cache key, so the first live load is a guaranteed - * cache miss and stale mock rows can never satisfy live requests. - */ -export const STARTER_PACK_SOURCE_LIVE = false; diff --git a/src/core/application/stream/users/users.test.ts b/src/core/application/stream/users/users.test.ts index 192d376538..5a16f7699f 100644 --- a/src/core/application/stream/users/users.test.ts +++ b/src/core/application/stream/users/users.test.ts @@ -289,13 +289,10 @@ describe('UserStreamApplication', () => { expect(result.nextPageIds).toEqual(['influencer-1', 'influencer-2', 'influencer-3']); }); - describe('starter pack cache isolation', () => { - const LIVE_STREAM_ID = 'starter_pack:all:all:bitcoin,music' as UserStreamId; - const MOCK_STREAM_ID = 'starter_pack_mock:all:all:bitcoin,music' as UserStreamId; - + describe('starter pack caching', () => { it('should persist reversed tag orders as distinct Dexie rows', async () => { - const forwardId = 'starter_pack_mock:all:all:travel,music' as UserStreamId; - const reversedId = 'starter_pack_mock:all:all:music,travel' as UserStreamId; + const forwardId = 'starter_pack:all:all:travel,music' as UserStreamId; + const reversedId = 'starter_pack:all:all:music,travel' as UserStreamId; await LocalStreamUsersService.upsert({ streamId: forwardId, stream: ['user-a'] }); await LocalStreamUsersService.upsert({ streamId: reversedId, stream: ['user-b'] }); @@ -303,34 +300,6 @@ describe('UserStreamApplication', () => { expect((await LocalStreamUsersService.findById(forwardId))?.stream).toEqual(['user-a']); expect((await LocalStreamUsersService.findById(reversedId))?.stream).toEqual(['user-b']); }); - - it('should never serve cached mock rows for live starter pack requests (mock-to-live swap regression)', async () => { - // Seed a mock-namespaced row exactly as a pre-swap session would have left it - const mockCachedIds: Pubky[] = ['mock-user-1', 'mock-user-2']; - await LocalStreamUsersService.upsert({ streamId: MOCK_STREAM_ID, stream: mockCachedIds }); - await createUserDetails(mockCachedIds); - - const liveUserIds: Pubky[] = ['live-user-1', 'live-user-2']; - const fetchSpy = vi.spyOn(NexusUserStreamService, 'fetch').mockResolvedValue(liveUserIds); - - const result = await UserStreamApplication.getOrFetchStreamSlice({ - streamId: LIVE_STREAM_ID, - skip: 0, - limit: 2, - viewerId: DEFAULT_VIEWER_ID, - }); - - // The live key must miss the cache and hit the network - expect(fetchSpy).toHaveBeenCalledWith({ - streamId: LIVE_STREAM_ID, - params: { skip: 0, limit: 2, viewer_id: DEFAULT_VIEWER_ID }, - }); - expect(result.nextPageIds).toEqual(liveUserIds); - - // Both rows stay isolated: live written fresh, mock untouched - expect((await LocalStreamUsersService.findById(LIVE_STREAM_ID))?.stream).toEqual(liveUserIds); - expect((await LocalStreamUsersService.findById(MOCK_STREAM_ID))?.stream).toEqual(mockCachedIds); - }); }); it('should pass viewerId to Nexus API for relationship data', async () => { diff --git a/src/core/models/stream/user/userStream.helper.test.ts b/src/core/models/stream/user/userStream.helper.test.ts index 4dd90b4504..fa884664d7 100644 --- a/src/core/models/stream/user/userStream.helper.test.ts +++ b/src/core/models/stream/user/userStream.helper.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { AppError } from '@/libs/error/error'; import { ValidationErrorCode } from '@/libs/error/error.codes'; import { ErrorCategory } from '@/libs/error/error.types'; @@ -17,19 +17,17 @@ const expectValidationError = (fn: () => unknown) => { }; describe('buildStarterPackStreamId', () => { - // The checked-in STARTER_PACK_SOURCE_LIVE flag is false until Nexus deploys the - // starter_pack source to staging (#2390 flips it and removes the mock namespace). - describe('with the shipped flag value (mock namespace)', () => { - it('should join ordered tags under the mock namespace', () => { - expect(buildStarterPackStreamId(['bitcoin', 'music'])).toBe('starter_pack_mock:all:all:bitcoin,music'); + describe('ID construction', () => { + it('should join ordered tags under the starter_pack source', () => { + expect(buildStarterPackStreamId(['bitcoin', 'music'])).toBe('starter_pack:all:all:bitcoin,music'); }); it('should preserve tag order (reversed lists yield distinct IDs)', () => { const forward = buildStarterPackStreamId(['travel', 'music']); const reversed = buildStarterPackStreamId(['music', 'travel']); - expect(forward).toBe('starter_pack_mock:all:all:travel,music'); - expect(reversed).toBe('starter_pack_mock:all:all:music,travel'); + expect(forward).toBe('starter_pack:all:all:travel,music'); + expect(reversed).toBe('starter_pack:all:all:music,travel'); expect(forward).not.toBe(reversed); }); @@ -38,15 +36,15 @@ describe('buildStarterPackStreamId', () => { const fromCanonical = buildStarterPackStreamId(['bitcoin', 'music']); expect(fromMixedCase).toBe(fromCanonical); - expect(fromMixedCase).toBe('starter_pack_mock:all:all:bitcoin,music'); + expect(fromMixedCase).toBe('starter_pack:all:all:bitcoin,music'); }); it('should accept the maximum of 5 tags', () => { - expect(buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e'])).toBe('starter_pack_mock:all:all:a,b,c,d,e'); + expect(buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e'])).toBe('starter_pack:all:all:a,b,c,d,e'); }); it('should accept a single tag', () => { - expect(buildStarterPackStreamId(['bitcoin'])).toBe('starter_pack_mock:all:all:bitcoin'); + expect(buildStarterPackStreamId(['bitcoin'])).toBe('starter_pack:all:all:bitcoin'); }); }); @@ -86,36 +84,4 @@ describe('buildStarterPackStreamId', () => { expect(buildStarterPackStreamId(['a'.repeat(20)])).toContain(`:${'a'.repeat(20)}`); }); }); - - describe('STARTER_PACK_SOURCE_LIVE flag', () => { - it('should emit the live namespace when the flag is true', async () => { - vi.resetModules(); - vi.doMock('@/config/nexus', async (importOriginal) => ({ - ...(await importOriginal()), - STARTER_PACK_SOURCE_LIVE: true, - })); - - const { buildStarterPackStreamId: buildWithLiveFlag } = await import('./userStream.helper'); - - expect(buildWithLiveFlag(['bitcoin', 'music'])).toBe('starter_pack:all:all:bitcoin,music'); - - vi.doUnmock('@/config/nexus'); - vi.resetModules(); - }); - - it('should emit the mock namespace when the flag is false', async () => { - vi.resetModules(); - vi.doMock('@/config/nexus', async (importOriginal) => ({ - ...(await importOriginal()), - STARTER_PACK_SOURCE_LIVE: false, - })); - - const { buildStarterPackStreamId: buildWithMockFlag } = await import('./userStream.helper'); - - expect(buildWithMockFlag(['bitcoin', 'music'])).toBe('starter_pack_mock:all:all:bitcoin,music'); - - vi.doUnmock('@/config/nexus'); - vi.resetModules(); - }); - }); }); diff --git a/src/core/models/stream/user/userStream.helper.ts b/src/core/models/stream/user/userStream.helper.ts index 1d54baad0a..9c25e31f66 100644 --- a/src/core/models/stream/user/userStream.helper.ts +++ b/src/core/models/stream/user/userStream.helper.ts @@ -1,4 +1,4 @@ -import { STARTER_PACK_MAX_TAGS, STARTER_PACK_SOURCE_LIVE } from '@/config/nexus'; +import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; import { ValidationErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { ErrorService } from '@/libs/error/error.types'; @@ -6,7 +6,6 @@ import { isValidTagLabel } from '@/libs/utils/utils'; import type { Pubky } from '@/models/models.types'; import { UserStreamModelSchema } from './userStream.schema'; import { - STARTER_PACK_MOCK_STREAM_SOURCE, STARTER_PACK_STREAM_SOURCE, StarterPackStreamId, UserStreamCompositeId, @@ -66,13 +65,9 @@ export function parseUserCompositeId(compositeId: string): UserStreamIdParts { * and the Nexus starter pack limit (1-5 tags). Order is preserved: Nexus interleaves per-tag * rankings in the order given, so ['travel','music'] and ['music','travel'] are different streams. * - * While STARTER_PACK_SOURCE_LIVE is false the ID is namespaced under `starter_pack_mock`, which - * dispatches to `most_followed`. Flipping the flag changes every cache key, guaranteeing the first - * live load bypasses stale mock rows. - * * @example * buildStarterPackStreamId(['Bitcoin ', 'music']) - * // Returns: 'starter_pack:all:all:bitcoin,music' (or 'starter_pack_mock:...' while mocked) + * // Returns: 'starter_pack:all:all:bitcoin,music' */ export function buildStarterPackStreamId(tags: string[]): StarterPackStreamId { const canonical = tags.map((tag) => tag.trim().toLowerCase()); @@ -102,8 +97,7 @@ export function buildStarterPackStreamId(tags: string[]): StarterPackStreamId { ); } - const source = STARTER_PACK_SOURCE_LIVE ? STARTER_PACK_STREAM_SOURCE : STARTER_PACK_MOCK_STREAM_SOURCE; - return `${source}:all:all:${canonical.join(USER_STREAM_TAG_DELIMITER)}` as StarterPackStreamId; + return `${STARTER_PACK_STREAM_SOURCE}:all:all:${canonical.join(USER_STREAM_TAG_DELIMITER)}` as StarterPackStreamId; } export const createDefaultUserStream = (id: UserStreamId, stream: Pubky[] = []): UserStreamModelSchema => { diff --git a/src/core/models/stream/user/userStream.types.ts b/src/core/models/stream/user/userStream.types.ts index 8c93960ff8..aaed38bcc9 100644 --- a/src/core/models/stream/user/userStream.types.ts +++ b/src/core/models/stream/user/userStream.types.ts @@ -7,9 +7,7 @@ import type { UserStreamCompositeReach } from '@/services/nexus/nexus.types'; // - REACH (Supported in 'influencers' source): followers, following, friends, wot (u8), all // // Starter pack IDs extend the pattern with a 4th ordered-tag segment: source:timeframe:reach:tag1,tag2 -// - SOURCE: starter_pack (live Nexus source) or starter_pack_mock (FE-only cache namespace used -// while the Nexus source is not deployed; dispatches to most_followed — see STARTER_PACK_SOURCE_LIVE -// in @/config/nexus). Distinct namespaces guarantee mock rows never satisfy live requests. +// - SOURCE: starter_pack (pubky/pubky-nexus#1024) // - Tag order matters: Nexus interleaves per-tag rankings in the order given. // // Note: Different from PostStreamTypes pattern (sorting:source:kind) to optimize for user-centric queries @@ -23,15 +21,11 @@ export enum UserStreamTypes { // TODO: Add all possible cases } -// Live Nexus stream source for starter packs (pubky/pubky-nexus#1024) +// Nexus stream source for starter packs (pubky/pubky-nexus#1024) export const STARTER_PACK_STREAM_SOURCE = 'starter_pack' as const; -// FE-only cache-key namespace while the Nexus source is not deployed; never sent to Nexus -export const STARTER_PACK_MOCK_STREAM_SOURCE = 'starter_pack_mock' as const; // Starter pack ID format: source:all:all:tag1,tag2 (ordered, canonicalized tags) -export type StarterPackStreamId = - | `${typeof STARTER_PACK_STREAM_SOURCE}:all:all:${string}` - | `${typeof STARTER_PACK_MOCK_STREAM_SOURCE}:all:all:${string}`; +export type StarterPackStreamId = `${typeof STARTER_PACK_STREAM_SOURCE}:all:all:${string}`; // Composite ID format: userId:reach (e.g., 'user123:followers') export type UserStreamCompositeId = `${Pubky}:${UserStreamCompositeReach}`; diff --git a/src/core/services/nexus/stream/users/userStream.test.ts b/src/core/services/nexus/stream/users/userStream.test.ts index 986b21f56e..7821fcf0e2 100644 --- a/src/core/services/nexus/stream/users/userStream.test.ts +++ b/src/core/services/nexus/stream/users/userStream.test.ts @@ -485,20 +485,7 @@ describe('NexusUserStreamService.fetch', () => { vi.mocked(queryNexus).mockResolvedValue([]); }); - it('should dispatch mock-namespaced IDs to most_followed without a tags param', async () => { - await NexusUserStreamService.fetch({ - streamId: 'starter_pack_mock:all:all:bitcoin,music' as UserStreamId, - params: { skip: 0, limit: 10 }, - }); - - expect(queryNexus).toHaveBeenCalledTimes(1); - const { url } = vi.mocked(queryNexus).mock.calls[0][0]; - expect(url).toContain('source=most_followed'); - expect(url).not.toContain('starter_pack'); - expect(url).not.toContain('tags='); - }); - - it('should dispatch live IDs to source=starter_pack with ordered tags', async () => { + it('should dispatch starter pack IDs to source=starter_pack with ordered tags', async () => { await NexusUserStreamService.fetch({ streamId: 'starter_pack:all:all:bitcoin,music' as UserStreamId, params: { skip: 0, limit: 10, viewer_id: 'viewer-abc' as Pubky }, diff --git a/src/core/services/nexus/stream/users/userStream.ts b/src/core/services/nexus/stream/users/userStream.ts index 815cdc41a7..bc36929493 100644 --- a/src/core/services/nexus/stream/users/userStream.ts +++ b/src/core/services/nexus/stream/users/userStream.ts @@ -51,10 +51,6 @@ export class NexusUserStreamService { case 'starter_pack': url = userStreamApi.starterPack(apiParams as TUserStreamStarterPackParams); break; - // TEMPORARY (#2390): mock namespace served by most_followed until Nexus deploys starter_pack - case 'starter_pack_mock': - url = userStreamApi.mostFollowed(apiParams as TUserStreamBase); - break; default: { const exhaustiveCheck: never = reach; throw Err.validation( diff --git a/src/core/services/nexus/stream/users/userStream.utils.test.ts b/src/core/services/nexus/stream/users/userStream.utils.test.ts index b957d3bba7..9b601b18d9 100644 --- a/src/core/services/nexus/stream/users/userStream.utils.test.ts +++ b/src/core/services/nexus/stream/users/userStream.utils.test.ts @@ -327,17 +327,6 @@ describe('createUserStreamParams', () => { }); }); - it('should parse a mock starter pack ID into starter_pack_mock reach without a tags param', () => { - const streamId = 'starter_pack_mock:all:all:bitcoin,music' as UserStreamId; - - const result = createUserStreamParams(streamId, baseParams); - - expect(result.reach).toBe('starter_pack_mock'); - // most_followed serves the mock and rejects a `tags` query param - expect(result.apiParams).toEqual(baseParams); - expect(result.apiParams).not.toHaveProperty('tags'); - }); - it('should reject an empty tag segment', () => { expectValidationError(() => createUserStreamParams('starter_pack:all:all:' as UserStreamId, baseParams)); }); @@ -373,7 +362,7 @@ describe('createUserStreamParams', () => { it('should reject non-"all" timeframe segments', () => { expectValidationError(() => createUserStreamParams('starter_pack:today:all:bitcoin' as UserStreamId, baseParams)); expectValidationError(() => - createUserStreamParams('starter_pack_mock:this_month:all:bitcoin' as UserStreamId, baseParams), + createUserStreamParams('starter_pack:this_month:all:bitcoin' as UserStreamId, baseParams), ); }); @@ -382,7 +371,7 @@ describe('createUserStreamParams', () => { createUserStreamParams('starter_pack:all:friends:bitcoin' as UserStreamId, baseParams), ); expectValidationError(() => - createUserStreamParams('starter_pack_mock:all:followers:bitcoin' as UserStreamId, baseParams), + createUserStreamParams('starter_pack:all:followers:bitcoin' as UserStreamId, baseParams), ); }); diff --git a/src/core/services/nexus/stream/users/userStream.utils.ts b/src/core/services/nexus/stream/users/userStream.utils.ts index c7fe912217..6db28a5093 100644 --- a/src/core/services/nexus/stream/users/userStream.utils.ts +++ b/src/core/services/nexus/stream/users/userStream.utils.ts @@ -5,11 +5,7 @@ import { ErrorService } from '@/libs/error/error.types'; import { isValidTagLabel } from '@/libs/utils/utils'; import type { Pubky } from '@/models/models.types'; import { USER_STREAM_TAG_DELIMITER } from '@/models/stream/user/userStream.helper'; -import { - STARTER_PACK_MOCK_STREAM_SOURCE, - STARTER_PACK_STREAM_SOURCE, - type UserStreamId, -} from '@/models/stream/user/userStream.types'; +import { STARTER_PACK_STREAM_SOURCE, type UserStreamId } from '@/models/stream/user/userStream.types'; import type { UserStreamReach, UserStreamTimeframe } from '@/services/nexus/nexus.types'; import { type TUserStreamBase, @@ -115,7 +111,7 @@ export function createUserStreamParams( if (parts.length === 4) { const [source, timeframe, reach, tagSegment] = parts; - if (source !== STARTER_PACK_STREAM_SOURCE && source !== STARTER_PACK_MOCK_STREAM_SOURCE) { + if (source !== STARTER_PACK_STREAM_SOURCE) { throw Err.validation(ValidationErrorCode.INVALID_INPUT, 'Only starter pack stream IDs carry a tag segment', { service: ErrorService.Nexus, operation: 'createUserStreamParams', @@ -151,14 +147,6 @@ export function createUserStreamParams( ); } - // Mock namespace dispatches to most_followed, which rejects a `tags` query param — omit it - if (source === STARTER_PACK_MOCK_STREAM_SOURCE) { - return { - reach: 'starter_pack_mock', - apiParams: baseParams, - } as NexusParamsResult<'starter_pack_mock'>; - } - return { reach: 'starter_pack', apiParams: { @@ -203,8 +191,6 @@ type UserStreamApiParamsMap = { influencers: TUserStreamInfluencersParams; most_followed: TUserStreamBase; starter_pack: TUserStreamStarterPackParams; - // FE-only mock namespace; served by most_followed until the Nexus source is deployed (#2390) - starter_pack_mock: TUserStreamBase; }; type ReachType = keyof UserStreamApiParamsMap; From e41fbb8d26759794542f84be222732ab0ba30c45 Mon Sep 17 00:00:00 2001 From: Orlando Goncalves Date: Tue, 1 Sep 2026 11:37:23 -0500 Subject: [PATCH 3/3] fix(streams): harden starter pack stream contracts Reject malformed starter pack IDs before dispatch and canonicalize ordered tags through a shared utility. Deduplicate builder inputs while rejecting duplicate hand-built cache keys, and exercise cache isolation through the application layer.\n\nRefs: #2386 --- .../application/stream/users/users.test.ts | 37 +++++++++++++++---- .../stream/user/userStream.helper.test.ts | 15 +++++++- .../models/stream/user/userStream.helper.ts | 13 ++++--- .../nexus/stream/users/userStream.test.ts | 16 ++++++++ .../services/nexus/stream/users/userStream.ts | 2 +- .../stream/users/userStream.utils.test.ts | 28 ++++++++++++-- .../nexus/stream/users/userStream.utils.ts | 36 +++++++++++++++--- src/libs/utils/utils.test.ts | 11 ++++++ src/libs/utils/utils.ts | 10 +++++ 9 files changed, 142 insertions(+), 26 deletions(-) diff --git a/src/core/application/stream/users/users.test.ts b/src/core/application/stream/users/users.test.ts index 5a16f7699f..41d422529a 100644 --- a/src/core/application/stream/users/users.test.ts +++ b/src/core/application/stream/users/users.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UserStreamApplication } from '@/application/stream/users/users'; import type { Pubky } from '@/models/models.types'; -import { buildUserCompositeId } from '@/models/stream/user/userStream.helper'; -import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; +import { buildStarterPackStreamId, buildUserCompositeId } from '@/models/stream/user/userStream.helper'; +import { UserStreamTypes } from '@/models/stream/user/userStream.types'; import { UserDetailsModel } from '@/models/user/details/userDetails'; import { LocalStreamUsersService } from '@/services/local/stream/users/users'; import type { NexusUser } from '@/services/nexus/nexus.types'; @@ -291,12 +291,33 @@ describe('UserStreamApplication', () => { describe('starter pack caching', () => { it('should persist reversed tag orders as distinct Dexie rows', async () => { - const forwardId = 'starter_pack:all:all:travel,music' as UserStreamId; - const reversedId = 'starter_pack:all:all:music,travel' as UserStreamId; - - await LocalStreamUsersService.upsert({ streamId: forwardId, stream: ['user-a'] }); - await LocalStreamUsersService.upsert({ streamId: reversedId, stream: ['user-b'] }); - + const forwardId = buildStarterPackStreamId(['travel', 'music']); + const reversedId = buildStarterPackStreamId(['music', 'travel']); + const fetchSpy = vi.spyOn(NexusUserStreamService, 'fetch').mockImplementation(async ({ streamId }) => { + return streamId === forwardId ? ['user-a'] : ['user-b']; + }); + + await UserStreamApplication.getOrFetchStreamSlice({ + streamId: forwardId, + skip: 0, + limit: 1, + viewerId: DEFAULT_VIEWER_ID, + }); + await UserStreamApplication.getOrFetchStreamSlice({ + streamId: reversedId, + skip: 0, + limit: 1, + viewerId: DEFAULT_VIEWER_ID, + }); + + expect(fetchSpy).toHaveBeenNthCalledWith(1, { + streamId: forwardId, + params: { skip: 0, limit: 1, viewer_id: DEFAULT_VIEWER_ID }, + }); + expect(fetchSpy).toHaveBeenNthCalledWith(2, { + streamId: reversedId, + params: { skip: 0, limit: 1, viewer_id: DEFAULT_VIEWER_ID }, + }); expect((await LocalStreamUsersService.findById(forwardId))?.stream).toEqual(['user-a']); expect((await LocalStreamUsersService.findById(reversedId))?.stream).toEqual(['user-b']); }); diff --git a/src/core/models/stream/user/userStream.helper.test.ts b/src/core/models/stream/user/userStream.helper.test.ts index fa884664d7..f7612024db 100644 --- a/src/core/models/stream/user/userStream.helper.test.ts +++ b/src/core/models/stream/user/userStream.helper.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { AppError } from '@/libs/error/error'; import { ValidationErrorCode } from '@/libs/error/error.codes'; -import { ErrorCategory } from '@/libs/error/error.types'; +import { ErrorCategory, ErrorService } from '@/libs/error/error.types'; import { buildStarterPackStreamId, USER_STREAM_TAG_DELIMITER } from './userStream.helper'; const expectValidationError = (fn: () => unknown) => { @@ -13,6 +13,7 @@ const expectValidationError = (fn: () => unknown) => { const appError = error as AppError; expect(appError.category).toBe(ErrorCategory.Validation); expect(appError.code).toBe(ValidationErrorCode.INVALID_INPUT); + expect(appError.service).toBe(ErrorService.Local); } }; @@ -39,6 +40,16 @@ describe('buildStarterPackStreamId', () => { expect(fromMixedCase).toBe('starter_pack:all:all:bitcoin,music'); }); + it('should deduplicate canonical labels while preserving first-seen order', () => { + expect(buildStarterPackStreamId(['Bitcoin ', 'music', 'bitcoin', 'MUSIC', 'art'])).toBe( + 'starter_pack:all:all:bitcoin,music,art', + ); + }); + + it('should enforce the tag cap after canonical duplicates are removed', () => { + expect(buildStarterPackStreamId(['a', 'A', 'b', 'B', 'c', 'C'])).toBe('starter_pack:all:all:a,b,c'); + }); + it('should accept the maximum of 5 tags', () => { expect(buildStarterPackStreamId(['a', 'b', 'c', 'd', 'e'])).toBe('starter_pack:all:all:a,b,c,d,e'); }); @@ -81,7 +92,7 @@ describe('buildStarterPackStreamId', () => { }); it('should accept a label at exactly 20 chars', () => { - expect(buildStarterPackStreamId(['a'.repeat(20)])).toContain(`:${'a'.repeat(20)}`); + expect(buildStarterPackStreamId(['a'.repeat(20)])).toBe(`starter_pack:all:all:${'a'.repeat(20)}`); }); }); }); diff --git a/src/core/models/stream/user/userStream.helper.ts b/src/core/models/stream/user/userStream.helper.ts index 9c25e31f66..ea9d5614ff 100644 --- a/src/core/models/stream/user/userStream.helper.ts +++ b/src/core/models/stream/user/userStream.helper.ts @@ -2,12 +2,12 @@ import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; import { ValidationErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { ErrorService } from '@/libs/error/error.types'; -import { isValidTagLabel } from '@/libs/utils/utils'; +import { canonicalizeTagLabel, isValidTagLabel } from '@/libs/utils/utils'; import type { Pubky } from '@/models/models.types'; import { UserStreamModelSchema } from './userStream.schema'; import { STARTER_PACK_STREAM_SOURCE, - StarterPackStreamId, + type StarterPackStreamId, UserStreamCompositeId, UserStreamId, } from './userStream.types'; @@ -69,15 +69,16 @@ export function parseUserCompositeId(compositeId: string): UserStreamIdParts { * buildStarterPackStreamId(['Bitcoin ', 'music']) * // Returns: 'starter_pack:all:all:bitcoin,music' */ +// Exported for the starter-pack onboarding consumer (#2388). export function buildStarterPackStreamId(tags: string[]): StarterPackStreamId { - const canonical = tags.map((tag) => tag.trim().toLowerCase()); + const canonical = [...new Set(tags.map(canonicalizeTagLabel))]; if (canonical.length === 0 || canonical.length > STARTER_PACK_MAX_TAGS) { throw Err.validation( ValidationErrorCode.INVALID_INPUT, `Starter pack streams require 1-${STARTER_PACK_MAX_TAGS} tags`, { - service: ErrorService.Nexus, + service: ErrorService.Local, operation: 'buildStarterPackStreamId', context: { tagCount: canonical.length }, }, @@ -90,14 +91,14 @@ export function buildStarterPackStreamId(tags: string[]): StarterPackStreamId { ValidationErrorCode.INVALID_INPUT, 'Starter pack tags must be 1-20 characters without banned characters', { - service: ErrorService.Nexus, + service: ErrorService.Local, operation: 'buildStarterPackStreamId', context: { invalidLabels }, }, ); } - return `${STARTER_PACK_STREAM_SOURCE}:all:all:${canonical.join(USER_STREAM_TAG_DELIMITER)}` as StarterPackStreamId; + return `${STARTER_PACK_STREAM_SOURCE}:all:all:${canonical.join(USER_STREAM_TAG_DELIMITER)}`; } export const createDefaultUserStream = (id: UserStreamId, stream: Pubky[] = []): UserStreamModelSchema => { diff --git a/src/core/services/nexus/stream/users/userStream.test.ts b/src/core/services/nexus/stream/users/userStream.test.ts index 7821fcf0e2..7b5d68d613 100644 --- a/src/core/services/nexus/stream/users/userStream.test.ts +++ b/src/core/services/nexus/stream/users/userStream.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ValidationErrorCode } from '@/libs/error/error.codes'; +import { ErrorCategory } from '@/libs/error/error.types'; import type { Pubky } from '@/models/models.types'; import { buildUserCompositeId } from '@/models/stream/user/userStream.helper'; import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; @@ -497,6 +499,20 @@ describe('NexusUserStreamService.fetch', () => { expect(url).toContain('tags=bitcoin%2Cmusic'); expect(url).toContain('viewer_id=viewer-abc'); }); + + it('should reject unsupported runtime sources without querying Nexus', async () => { + await expect( + NexusUserStreamService.fetch({ + streamId: 'unsupported:all:all' as UserStreamId, + params: { skip: 0, limit: 10 }, + }), + ).rejects.toMatchObject({ + category: ErrorCategory.Validation, + code: ValidationErrorCode.INVALID_INPUT, + }); + + expect(queryNexus).not.toHaveBeenCalled(); + }); }); describe('URL structure validation', () => { diff --git a/src/core/services/nexus/stream/users/userStream.ts b/src/core/services/nexus/stream/users/userStream.ts index bc36929493..e8b79b4801 100644 --- a/src/core/services/nexus/stream/users/userStream.ts +++ b/src/core/services/nexus/stream/users/userStream.ts @@ -55,7 +55,7 @@ export class NexusUserStreamService { const exhaustiveCheck: never = reach; throw Err.validation( ValidationErrorCode.INVALID_INPUT, - `Unsupported user stream reach: ${String(exhaustiveCheck)}`, + `Unsupported user stream source: ${String(exhaustiveCheck)}`, { service: ErrorService.Nexus, operation: 'fetch', diff --git a/src/core/services/nexus/stream/users/userStream.utils.test.ts b/src/core/services/nexus/stream/users/userStream.utils.test.ts index 9b601b18d9..14e4f89fbb 100644 --- a/src/core/services/nexus/stream/users/userStream.utils.test.ts +++ b/src/core/services/nexus/stream/users/userStream.utils.test.ts @@ -3,6 +3,7 @@ import { AppError } from '@/libs/error/error'; import { ValidationErrorCode } from '@/libs/error/error.codes'; import { ErrorCategory } from '@/libs/error/error.types'; import type { Pubky } from '@/models/models.types'; +import { buildStarterPackStreamId } from '@/models/stream/user/userStream.helper'; import { type UserStreamId, UserStreamTypes } from '@/models/stream/user/userStream.types'; import { userStreamApi } from '@/services/nexus/stream/users/userStream.api'; import type { @@ -81,6 +82,10 @@ describe('createUserStreamParams', () => { expect(() => createUserStreamParams(streamId, baseParams)).toThrow('Muted user lists are homeserver-backed only'); }); + it('should reject starter_pack as a 2-part reach', () => { + expectValidationError(() => createUserStreamParams('user-abc:starter_pack' as UserStreamId, baseParams)); + }); + it('should handle user IDs with special characters', () => { const streamId = 'user_with-special.chars:followers' as UserStreamId; @@ -127,6 +132,11 @@ describe('createUserStreamParams', () => { // ============================================================================ describe('3-part enum types (source:timeframe:reach)', () => { + it('should reject starter_pack without a tag segment', () => { + expectValidationError(() => createUserStreamParams('starter_pack:all:all' as UserStreamId, baseParams)); + expectValidationError(() => createUserStreamParams('starter_pack:today:friends' as UserStreamId, baseParams)); + }); + it('should parse influencers stream ID correctly and omit reach when "all"', () => { const streamId = UserStreamTypes.TODAY_INFLUENCERS_ALL; @@ -309,6 +319,15 @@ describe('createUserStreamParams', () => { expect((reversed.apiParams as TUserStreamStarterPackParams).tags).toBe('music,travel'); }); + it('should accept IDs produced by the starter pack builder', () => { + const streamId = buildStarterPackStreamId([' Travel ', 'MUSIC']); + + const result = createUserStreamParams(streamId, baseParams); + + expect(result.reach).toBe('starter_pack'); + expect(result.apiParams).toMatchObject({ tags: 'travel,music' }); + }); + it('should spread baseParams including viewer_id for live starter pack IDs', () => { const streamId = 'starter_pack:all:all:bitcoin' as UserStreamId; const paramsWithViewer: TUserStreamBase = { @@ -341,6 +360,12 @@ describe('createUserStreamParams', () => { expectValidationError(() => createUserStreamParams('starter_pack:all:all:Bitcoin' as UserStreamId, baseParams)); }); + it('should reject duplicate labels in hand-built IDs', () => { + expectValidationError(() => + createUserStreamParams('starter_pack:all:all:bitcoin,bitcoin' as UserStreamId, baseParams), + ); + }); + it('should reject labels with whitespace or banned characters', () => { expectValidationError(() => createUserStreamParams('starter_pack:all:all:rock music' as UserStreamId, baseParams), @@ -397,9 +422,6 @@ describe('createUserStreamParams', () => { const streamId = 'invalid-stream-id' as UserStreamId; expectValidationError(() => createUserStreamParams(streamId, baseParams)); - expect(() => createUserStreamParams(streamId, baseParams)).toThrow( - 'Invalid stream ID: expected 2, 3, or 4 parts separated by ":"', - ); }); it('should throw a validation error for invalid format (5+ parts)', () => { diff --git a/src/core/services/nexus/stream/users/userStream.utils.ts b/src/core/services/nexus/stream/users/userStream.utils.ts index 6db28a5093..f4b849e939 100644 --- a/src/core/services/nexus/stream/users/userStream.utils.ts +++ b/src/core/services/nexus/stream/users/userStream.utils.ts @@ -2,7 +2,7 @@ import { STARTER_PACK_MAX_TAGS } from '@/config/nexus'; import { ValidationErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { ErrorService } from '@/libs/error/error.types'; -import { isValidTagLabel } from '@/libs/utils/utils'; +import { canonicalizeTagLabel, isValidTagLabel } from '@/libs/utils/utils'; import type { Pubky } from '@/models/models.types'; import { USER_STREAM_TAG_DELIMITER } from '@/models/stream/user/userStream.helper'; import { STARTER_PACK_STREAM_SOURCE, type UserStreamId } from '@/models/stream/user/userStream.types'; @@ -33,9 +33,10 @@ function throwMutedStreamUnsupported(streamId: UserStreamId): never { * Transforms stream identifiers into type-safe parameters for userStreamApi methods. * The apiParams type is automatically mapped to the correct type based on reach. * - * Handles two formats: + * Handles three formats: * - 2 parts: `userId:reach` (e.g., 'user123:followers') * - 3 parts: `source:timeframe:reach` (e.g., 'influencers:today:all') + * - 4 parts: `source:timeframe:reach:tags` (e.g., 'starter_pack:all:all:bitcoin,music') * * @param streamId - Stream identifier * @param baseParams - Base pagination/query parameters @@ -58,6 +59,17 @@ export function createUserStreamParams( if (reach === 'muted') { throwMutedStreamUnsupported(streamId); } + if (reach === STARTER_PACK_STREAM_SOURCE) { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + 'Starter pack stream IDs require the source:all:all:tags format', + { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }, + ); + } return { reach: reach as ReachType, apiParams: { user_id: userId as Pubky, ...baseParams } as UserStreamApiParamsMap[ReachType], @@ -70,6 +82,17 @@ export function createUserStreamParams( if (source === 'muted') { throwMutedStreamUnsupported(streamId); } + if (source === STARTER_PACK_STREAM_SOURCE) { + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + 'Starter pack stream IDs require the source:all:all:tags format', + { + service: ErrorService.Nexus, + operation: 'createUserStreamParams', + context: { streamId }, + }, + ); + } // Influencers need timeframe and optionally reach in params // Note: 'all' is not a valid API value for reach - omit it to get all users @@ -134,11 +157,12 @@ export function createUserStreamParams( } const tags = tagSegment.split(USER_STREAM_TAG_DELIMITER); - const hasInvalidLabel = tags.some((label) => label !== label.trim().toLowerCase() || !isValidTagLabel(label)); - if (tags.length > STARTER_PACK_MAX_TAGS || hasInvalidLabel) { + const hasInvalidLabel = tags.some((label) => label !== canonicalizeTagLabel(label) || !isValidTagLabel(label)); + const hasDuplicateLabel = new Set(tags).size !== tags.length; + if (tags.length > STARTER_PACK_MAX_TAGS || hasInvalidLabel || hasDuplicateLabel) { throw Err.validation( ValidationErrorCode.INVALID_INPUT, - `Starter pack stream IDs require 1-${STARTER_PACK_MAX_TAGS} canonical (trimmed, lowercase) tags`, + `Starter pack stream IDs require 1-${STARTER_PACK_MAX_TAGS} unique canonical (trimmed, lowercase) tags`, { service: ErrorService.Nexus, operation: 'createUserStreamParams', @@ -151,7 +175,7 @@ export function createUserStreamParams( reach: 'starter_pack', apiParams: { ...baseParams, - tags: tags.join(USER_STREAM_TAG_DELIMITER), + tags: tagSegment, }, } as NexusParamsResult<'starter_pack'>; } diff --git a/src/libs/utils/utils.test.ts b/src/libs/utils/utils.test.ts index bf83d2b038..7fe6cde21e 100644 --- a/src/libs/utils/utils.test.ts +++ b/src/libs/utils/utils.test.ts @@ -7,6 +7,7 @@ import { } from '@/test-utils/pubky'; import { asInvalid } from '@/test-utils/type-assertions'; import { + canonicalizeTagLabel, canSubmitPost, clearCookies, cn, @@ -1258,6 +1259,16 @@ describe('Utils', () => { }); }); + describe('canonicalizeTagLabel', () => { + it('should trim surrounding whitespace and lowercase the label', () => { + expect(canonicalizeTagLabel(' BitCoin ')).toBe('bitcoin'); + }); + + it('should preserve valid non-Latin labels', () => { + expect(canonicalizeTagLabel(' 日本語 ')).toBe('日本語'); + }); + }); + describe('canSubmitPost', () => { describe('when submitting is in progress', () => { it('should return false regardless of content', () => { diff --git a/src/libs/utils/utils.ts b/src/libs/utils/utils.ts index 0743bb0d37..960bd672be 100644 --- a/src/libs/utils/utils.ts +++ b/src/libs/utils/utils.ts @@ -596,6 +596,16 @@ export function sanitizeTagInput(value: string): string { return value.replace(TAG_BANNED_CHARS, ''); } +/** + * Convert a tag label to the canonical form used by local storage and Nexus. + * + * @param value - The raw tag label + * @returns The trimmed, lowercase tag label + */ +export function canonicalizeTagLabel(value: string): string { + return value.trim().toLowerCase(); +} + /** * Checks whether a string is a valid tag label (correct length, no banned characters). *