Skip to main content

Catalog

GET /catalog returns the navigation tree the Chrome extension uses to render Layer 1–3 nav, the facet-grouped tag explorer, and the platform filter list.


Purpose

  • Operators edit surfaces, groups, channels, tags, platforms in the database.
  • Clients rebuild navigation from catalog data without a new build/release.
  • For a Chrome-extension client this is critical: Web Store review can take days, and a hardcoded taxonomy would make every edit a release.

Contract reference: API.md §6. Data model: TAXONOMY.md.


Implementation

Assembly

CatalogService.build(bucket) assembles the response. bucket is 'public' | 'moderator' | 'admin'; hidden surfaces (those with minPermission) are filtered per bucket.

async build(bucket: PermissionBucket): Promise<Catalog> {
const [surfaces, groups, channels, platforms, tags] = await Promise.all([
this.surfaces.findActive().sort({ displayOrder: 1 }).lean(),
this.groups.findActive().sort({ displayOrder: 1 }).lean(),
this.channels.findActive().sort({ displayOrder: 1 }).lean(),
this.platforms.findActive().sort({ displayOrder: 1 }).lean(),
this.tags.findDistinctFacets(), // only facets present; or read from config
]);

const visible = surfaces.filter(s => bucketCovers(bucket, s.minPermission));
const tree = visible.map(s => ({
...pluck(s, 'slug', 'label', 'routePrefix', 'browseMode', 'accentColor', 'displayOrder'),
groups: groupsBySurface.get(s.slug)?.map(g => ({
...pluck(g, 'slug', 'label', 'color', 'displayOrder'),
channels: channelsByGroup.get(g.slug)?.map(c =>
pluck(c, 'slug', 'label', 'displayOrder'),
) ?? [],
})) ?? [],
}));

return {
surfaces: tree,
facets: tags.map(t => ({ key: t, label: FACET_LABEL[t], color: FACET_COLOR[t] })),
platforms: platforms.map(p => pluck(p, 'slug', 'label', 'color')),
updatedAt: newest([surfaces, groups, channels, platforms]).updatedAt,
};
}

Indexing in memory (groupsBySurface, channelsByGroup) avoids N×M scans. For current scale (dozens to hundreds of entries), this is negligible.

Caching

Two layers:

  1. Server cache — Nest CacheModule keyed 'catalog:v1:<bucket>', TTL 300s. First request assembles; subsequent requests return cached.
  2. HTTP cache — response sets ETag: W/"<hash>" and Cache-Control: public, max-age=300, must-revalidate. Clients send If-None-Match; if the ETag matches, return 304.
@Get()
@CacheKey('catalog:v1') // NestJS cache interceptor
@CacheTTL(300)
async get(@CurrentUser() user: AuthenticatedUser | null, @Req() req: Request, @Res() res: Response) {
const bucket = bucketFor(user);
const catalog = await this.catalog.build(bucket);
const etag = `W/"${sha256(JSON.stringify(catalog)).slice(0, 16)}"`;
if (req.headers['if-none-match'] === etag) return res.status(304).end();
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate');
return res.json({ success: true, data: catalog });
}

Note: the controller uses raw @Res() for ETag handling — this bypasses the global ResponseEnvelopeInterceptor. That's deliberate for the catalog endpoint. /meta does the same.

Invalidation

CatalogCacheService.invalidate() clears all buckets. Called from every taxonomy write path:

@Injectable()
export class CatalogCacheService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async invalidate(): Promise<void> {
await Promise.all([
this.cache.del('catalog:v1:public'),
this.cache.del('catalog:v1:moderator'),
this.cache.del('catalog:v1:admin'),
]);
}
}

Wire into every write controller:

// e.g. GroupsController
@Post()
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequiredPermissions('catalog.manage')
async create(@Body() dto: CreateGroupDto): Promise<Group> {
const g = await this.groups.create(dto);
await this.catalogCache.invalidate();
return g;
}

Also invalidate on: Tag create/update/rename, Surface/Group/Channel create/update/delete (soft), Platform create/update.

Permission bucketing

Three buckets keep the cache small while honoring visibility:

  • public — anonymous users + logged-in users with no special permissions.
  • moderator — has content.moderate.
  • admin — has catalog.manage.

Mapping user → bucket:

function bucketFor(user: AuthenticatedUser | null): PermissionBucket {
if (user?.permissions.includes('catalog.manage')) return 'admin';
if (user?.permissions.includes('content.moderate')) return 'moderator';
return 'public';
}

This is coarse by design. If finer visibility rules are needed later, split into more buckets — but avoid per-user caches (defeats the purpose).


Required variables and services

  • SurfacesService, GroupsService, ChannelsService, PlatformsService, TagsService — injected.
  • CacheModule from @nestjs/cache-manager with an in-memory store (or Redis when scaling horizontally — the cache must be shared across instances).
  • No env variables specific to the catalog.

Gotchas

  • In-memory cache on multiple instances diverges. A write to instance A invalidates its cache but B still serves stale. Options: (a) accept up to 300s staleness, (b) use a shared Redis cache, (c) broadcast invalidations via a pub/sub. Choose (a) for v1; move to (b) when we deploy more than one replica.
  • ETag over the serialized payload catches content changes but also noise (field ordering). Use a stable JSON stringifier if field order is not guaranteed.
  • Empty groups. Decision in API.md §6: include all active groups regardless of whether they have content. Counts are not in the catalog.
  • Catalog is hot. Every extension cold start hits it. Don't add expensive computations (aggregations, counts) without confirming they cache cleanly.

Testing

  • Unit: bucketFor — null → public, user with only submit → public, user with moderate → moderator, user with catalog.manage → admin.
  • Integration: seed taxonomy, GET /catalog → 200 with expected shape.
  • Integration: GET /catalog again with If-None-Match: <etag from previous response> → 304.
  • Integration (invalidation): GET /catalog, POST /groups (with a test admin token), GET /catalog again → new group appears without waiting 300s.
  • Integration (permission): seed a hidden surface with minPermission: 'catalog.manage'; public GET /catalog excludes it; admin GET /catalog includes it.