From 5baf12190de12e3268b4d80c2fb1615396df1d08 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 12:23:52 -0500 Subject: [PATCH 1/2] feat: restore public group listing and details --- docs/group-pages.md | 9 + openapi/README.md | 12 +- openapi/sgf-public-v1.openapi.json | 224 +++++++++++++++++- scripts/lib/schema-bytes.ts | 4 +- src/lib/server/api/factory.ts | 34 ++- .../api/generated/sgfPublicApiSchema.d.ts | 127 ++++++++++ src/lib/server/groups/load.ts | 41 ++++ src/lib/server/groups/mapper.ts | 85 +++++++ src/lib/server/home/mapper.ts | 2 +- src/routes/groups/+layout.ts | 1 + src/routes/groups/+page.server.ts | 18 ++ src/routes/groups/+page.svelte | 51 ++++ src/routes/groups/[slug]/+page.server.ts | 20 ++ src/routes/groups/[slug]/+page.svelte | 120 ++++++++++ tests/api/schema.test.ts | 4 +- tests/groups/fixture.ts | 14 ++ tests/groups/groups.test.ts | 70 ++++++ 17 files changed, 814 insertions(+), 22 deletions(-) create mode 100644 docs/group-pages.md create mode 100644 src/lib/server/groups/load.ts create mode 100644 src/lib/server/groups/mapper.ts create mode 100644 src/routes/groups/+layout.ts create mode 100644 src/routes/groups/+page.server.ts create mode 100644 src/routes/groups/+page.svelte create mode 100644 src/routes/groups/[slug]/+page.server.ts create mode 100644 src/routes/groups/[slug]/+page.svelte create mode 100644 tests/groups/fixture.ts create mode 100644 tests/groups/groups.test.ts diff --git a/docs/group-pages.md b/docs/group-pages.md new file mode 100644 index 00000000..8da3cf59 --- /dev/null +++ b/docs/group-pages.md @@ -0,0 +1,9 @@ +# Public group pages + +`/groups/` and `/groups/{slug}/` load the published CMS group projection through the existing server-only public client. They require `CMS_INTERNAL_ORIGIN` and the existing media configuration. Requests use the page request's fetch, a four-second timeout, omitted credentials and disabled redirects. Missing or protected content returns 404. Configuration failure returns 503; upstream or malformed payloads return 502. There is no content fallback. + +Raw native Delivery `group` values include a member picker, so the backend keeps `group` excluded. The two explicit public operations reuse Umbraco published route lookup and generated model converters, the anonymous content guard, public member lookup and Home presenter projection. Leaders contain only public display fields and profile paths. Group skills contain display names and URL segments, not content keys. The actual runtime contract and source commit are recorded in `openapi/README.md`. The native Delivery and private member contracts remain separate and unchanged. + +The list preserves published child order and excludes only the exact legacy name `Springfield Devs`. That group's detail is still addressable. Detail retains the group fields, ordered upcoming presentations and leaders. About HTML uses the existing page sanitizer; links and image URLs use existing safe mapping. The legacy YouTube video is a single static trusted URL, never CMS-provided iframe HTML. Members and Archive were commented out in the legacy Group view and remain absent. Event Details remains an unavailable placeholder. + +Skill links keep the legacy `/directory/?skill={urlSegment}` behavior. The current directory supports a different plural `skills` filter, so the legacy link opens the directory without applying that filter. Changing directory filtering is outside this layer. diff --git a/openapi/README.md b/openapi/README.md index 3afa2ee5..d934b695 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -5,16 +5,16 @@ Snapshot provenance: - Backend repository: `cms.sgf.dev` -- Backend commit: `01a276aed199405cbe7b511310cd9301b28ffa9e, draft https://github.com/sgfdevs/cms.sgf.dev/pull/10` +- Backend commit: `ba6d15b69d0e2a0c1151a5b4689b9790da19570c`, draft https://github.com/sgfdevs/cms.sgf.dev/pull/18 - Runtime export URL: `/umbraco/openapi/sgf-public-v1.json` -- Exported file: `/home/levi/.cache/sgf-migration-validation-3KOCBS/member-contract-export-1-nntnHn/evidence/sgf-public-v1.json` +- Exported file: `/home/levi/.cache/sgf-migration-validation-3KOCBS/group-pages-1-C44GXVUY/public-export-final/evidence/sgf-public-v1.openapi.json` - Runtime provenance: `export-provenance.json` alongside that artifact -- Raw runtime export SHA-256: `f9eee0ce6ec1ab9295ddd6eda12f0675c5bbd046efae7f1c23f768f33fde6d89` -- Committed canonical SHA-256: `f9eee0ce6ec1ab9295ddd6eda12f0675c5bbd046efae7f1c23f768f33fde6d89` +- Raw runtime export SHA-256: `c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba` +- Committed canonical SHA-256: `c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba` -Raw and canonical hashes happen to match for this export. Only the existing LF/terminal-newline policy was applied, with no JSON edits or reordering. The runtime export adds `PublicMember_Get` with its explicit public profile DTO and 404 ProblemDetails. Existing operations and components are unchanged. Generated error media without a schema remain `unknown`. +Raw and canonical hashes happen to match for this export. Only the existing LF/terminal-newline policy was applied, with no JSON edits or reordering. The runtime export adds `PublicGroups_List` and `PublicGroups_Get` with explicit public group DTOs and 404 ProblemDetails. Existing operations and components are unchanged. Generated error media without a schema remain `unknown`. -The server-only GET fence accepts the member template only with a snapshotted ASCII alphanumeric username of 1 to 1000 characters. The final Request allows only the anchored concrete member path, never the unresolved template. The four earlier literal paths and network/auth guards remain. +The server-only GET fence accepts the member template only with a snapshotted ASCII alphanumeric username of 1 to 1000 characters. The final Request allows only the anchored concrete member path, never the unresolved template. The group template requires a snapshotted ASCII alphanumeric slug with hyphens or underscores, 1 to 200 characters, and the final request checks the anchored group path. The earlier paths and network/auth guards remain. This export used the existing owned install-only SQLite procedure, fake Docker shim and AF S3 assembly exclusion. No schema import or content seed ran. Byte policy: diff --git a/openapi/sgf-public-v1.openapi.json b/openapi/sgf-public-v1.openapi.json index d96e4e69..5de6f117 100644 --- a/openapi/sgf-public-v1.openapi.json +++ b/openapi/sgf-public-v1.openapi.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://127.0.0.1:36395/" + "url": "http://127.0.0.1:40397/" } ], "paths": { @@ -123,6 +123,79 @@ } } }, + "/api/v1/public/groups": { + "get": { + "tags": [ + "PublicGroup" + ], + "operationId": "PublicGroups_List", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicGroupDto" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/public/groups/{slug}": { + "get": { + "tags": [ + "PublicGroup" + ], + "operationId": "PublicGroups_Get", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicGroupDto" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v1/public/home": { "get": { "tags": [ @@ -276,6 +349,152 @@ } } }, + "PublicGroupDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "aboutHtml": { + "type": [ + "null", + "string" + ] + }, + "imageUrl": { + "type": [ + "null", + "string" + ] + }, + "location": { + "type": [ + "null", + "string" + ] + }, + "establishedText": { + "type": [ + "null", + "string" + ] + }, + "websiteUrl": { + "type": [ + "null", + "string" + ] + }, + "twitterUrl": { + "type": [ + "null", + "string" + ] + }, + "linkedInUrl": { + "type": [ + "null", + "string" + ] + }, + "facebookUrl": { + "type": [ + "null", + "string" + ] + }, + "instagramUrl": { + "type": [ + "null", + "string" + ] + }, + "youTubeUrl": { + "type": [ + "null", + "string" + ] + }, + "skills": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicGroupSkillDto" + } + }, + "leaders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicGroupLeaderDto" + } + }, + "upcomingPresentations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicGroupPresentationDto" + } + } + } + }, + "PublicGroupLeaderDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "listLabel": { + "type": "string" + }, + "location": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "profilePath": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PublicGroupPresentationDto": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "eventName": { + "type": "string" + }, + "startsAtLocal": { + "type": "string" + }, + "presenters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicHomePresenterDto" + } + } + } + }, + "PublicGroupSkillDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + }, "PublicHomeDevNightDto": { "required": [ "name", @@ -647,6 +866,9 @@ { "name": "DevsApi" }, + { + "name": "PublicGroup" + }, { "name": "PublicHome" }, diff --git a/scripts/lib/schema-bytes.ts b/scripts/lib/schema-bytes.ts index d642d341..8563e543 100644 --- a/scripts/lib/schema-bytes.ts +++ b/scripts/lib/schema-bytes.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; -export const SGF_PUBLIC_SCHEMA_RAW_EXPORT_SHA256 = 'f9eee0ce6ec1ab9295ddd6eda12f0675c5bbd046efae7f1c23f768f33fde6d89'; -export const SGF_PUBLIC_SCHEMA_CANONICAL_SHA256 = 'f9eee0ce6ec1ab9295ddd6eda12f0675c5bbd046efae7f1c23f768f33fde6d89'; +export const SGF_PUBLIC_SCHEMA_RAW_EXPORT_SHA256 = 'c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba'; +export const SGF_PUBLIC_SCHEMA_CANONICAL_SHA256 = 'c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba'; export function normalizeOpenApiSnapshotText(text: string): string { return text.replace(/\r\n?/g, '\n').replace(/\n+$/g, ''); diff --git a/src/lib/server/api/factory.ts b/src/lib/server/api/factory.ts index 07eeeff2..faf8ab35 100644 --- a/src/lib/server/api/factory.ts +++ b/src/lib/server/api/factory.ts @@ -11,12 +11,20 @@ export const SGF_PUBLIC_GET_PATHS = [ '/api/directory/filters/skills', '/api/directory/search', '/api/v1/public/home', - '/api/v1/public/members/{username}' + '/api/v1/public/members/{username}', + '/api/v1/public/groups', + '/api/v1/public/groups/{slug}' ] as const satisfies readonly Extract[]; export const MEMBER_GET_TEMPLATE = '/api/v1/public/members/{username}'; -const publicGetPathSet = new Set(SGF_PUBLIC_GET_PATHS.filter((path) => path !== MEMBER_GET_TEMPLATE)); +export const GROUP_GET_TEMPLATE = '/api/v1/public/groups/{slug}'; +const publicGetPathSet = new Set(SGF_PUBLIC_GET_PATHS.filter((path) => path !== MEMBER_GET_TEMPLATE && path !== GROUP_GET_TEMPLATE)); const concreteMemberPath = /^\/api\/v1\/public\/members\/[A-Za-z0-9]{1,1000}$/; +const concreteGroupPath = /^\/api\/v1\/public\/groups\/[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/; + +export function isPublicGroupSlug(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/.test(value); +} export function isPublicMemberUsername(value: unknown): value is string { return typeof value === 'string' && value.length >= 1 && value.length <= 1000 && !/[^A-Za-z0-9]/.test(value); @@ -40,9 +48,9 @@ export function createSgfApiClientForOrigin(fetchImpl: SgfApiFetch, internalOrig return { GET(url, ...init) { - if (url !== MEMBER_GET_TEMPLATE) assertPublicGetPath(url); - const options = url === MEMBER_GET_TEMPLATE - ? snapshotMemberOptions(init[0] as RequestInitGuard) + if (url !== MEMBER_GET_TEMPLATE && url !== GROUP_GET_TEMPLATE) assertPublicGetPath(url); + const options = url === MEMBER_GET_TEMPLATE || url === GROUP_GET_TEMPLATE + ? snapshotPathOptions(init[0] as RequestInitGuard, url === MEMBER_GET_TEMPLATE ? 'username' : 'slug') : init[0] as RequestInitGuard; const guardedInit = guardPublicRequestInit(options); return client.GET(url, guardedInit as never); @@ -50,17 +58,21 @@ export function createSgfApiClientForOrigin(fetchImpl: SgfApiFetch, internalOrig }; } -function snapshotMemberOptions(init: RequestInitGuard): RequestInitGuard { - const params = init?.params as { path?: { username?: unknown } } | undefined; - const username = params?.path?.username; - if (!isPublicMemberUsername(username)) throw new Error('SGF API member username must be ASCII alphanumeric, 1 to 1000 characters.'); +function snapshotPathOptions(init: RequestInitGuard, field: 'username' | 'slug'): RequestInitGuard { + const params = init?.params as { path?: Record } | undefined; + const value = params?.path?.[field]; + if (!(field === 'username' ? isPublicMemberUsername(value) : isPublicGroupSlug(value))) { + throw new Error(field === 'username' + ? 'SGF API member username must be ASCII alphanumeric, 1 to 1000 characters.' + : 'SGF API group slug must be ASCII alphanumeric with hyphens or underscores, 1 to 200 characters.'); + } // Read getter/inherited path input once. Only the validated string reaches substitution. const snapshot: Record = {}; for (const key of Object.keys(params ?? {})) { if (key !== 'path') snapshot[key] = (params as Record)[key]; } - snapshot.path = { username }; + snapshot.path = { [field]: value }; const options: Record = {}; for (const key of Object.keys(init ?? {})) { if (key !== 'params') options[key] = init?.[key]; @@ -114,7 +126,7 @@ function assertCmsPublicRequest(request: Request, baseUrl: string): void { if (request.method !== 'GET') { throw new Error('SGF API request escaped the approved public GET methods.'); } - if (!concreteMemberPath.test(url.pathname)) assertPublicGetPath(url.pathname); + if (!concreteMemberPath.test(url.pathname) && !concreteGroupPath.test(url.pathname)) assertPublicGetPath(url.pathname); if (request.redirect !== 'error') { throw new Error('SGF API requests must keep redirect disabled.'); } diff --git a/src/lib/server/api/generated/sgfPublicApiSchema.d.ts b/src/lib/server/api/generated/sgfPublicApiSchema.d.ts index 7dfa6b24..a4ec7981 100644 --- a/src/lib/server/api/generated/sgfPublicApiSchema.d.ts +++ b/src/lib/server/api/generated/sgfPublicApiSchema.d.ts @@ -52,6 +52,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/public/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["PublicGroups_List"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/public/groups/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["PublicGroups_Get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/public/home": { parameters: { query?: never; @@ -103,6 +135,41 @@ export interface components { url: string; tags: string[]; }; + PublicGroupDto: { + name?: string; + path?: string; + aboutHtml?: null | string; + imageUrl?: null | string; + location?: null | string; + establishedText?: null | string; + websiteUrl?: null | string; + twitterUrl?: null | string; + linkedInUrl?: null | string; + facebookUrl?: null | string; + instagramUrl?: null | string; + youTubeUrl?: null | string; + skills?: components["schemas"]["PublicGroupSkillDto"][]; + leaders?: components["schemas"]["PublicGroupLeaderDto"][]; + upcomingPresentations?: components["schemas"]["PublicGroupPresentationDto"][]; + }; + PublicGroupLeaderDto: { + name?: string; + listLabel?: string; + location?: string; + imageUrl?: string; + profilePath?: string; + tags?: string[]; + }; + PublicGroupPresentationDto: { + title?: string; + eventName?: string; + startsAtLocal?: string; + presenters?: components["schemas"]["PublicHomePresenterDto"][]; + }; + PublicGroupSkillDto: { + name?: string; + slug?: string; + }; PublicHomeDevNightDto: { name: string; startsAtLocal: string; @@ -263,6 +330,66 @@ export interface operations { }; }; }; + PublicGroups_List: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicGroupDto"][]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; + PublicGroups_Get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicGroupDto"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; PublicHome_Get: { parameters: { query?: never; diff --git a/src/lib/server/groups/load.ts b/src/lib/server/groups/load.ts new file mode 100644 index 00000000..1a026316 --- /dev/null +++ b/src/lib/server/groups/load.ts @@ -0,0 +1,41 @@ +import { createSgfApiClientForOrigin, isPublicGroupSlug } from '../api/factory'; +import type { MediaSourceConfig } from '../media/config'; +import { mapGroup } from './mapper'; +import type { PublicGroupDto } from './mapper'; + +export type GroupLoadOptions = { + fetch: (request: Request) => Promise; + cmsInternalOrigin: string | undefined; + mediaConfig: MediaSourceConfig; + requestSignal?: AbortSignal; + timeoutMs?: number; +}; +export class GroupLoadError extends Error { + constructor(readonly status: 404 | 502 | 503, readonly publicMessage: string) { super(publicMessage); } +} + +// Omitting slug requests only the list. A bad detail slug never reaches the CMS. +export async function loadGroups(options: GroupLoadOptions, slug?: string) { + if (slug !== undefined && !isPublicGroupSlug(slug)) throw new GroupLoadError(404, 'Group not found.'); + let api; + try { api = createSgfApiClientForOrigin(options.fetch, options.cmsInternalOrigin); } + catch { throw new GroupLoadError(503, 'Group data is unavailable because the CMS API is not configured.'); } + const timeout = AbortSignal.timeout(options.timeoutMs ?? 4_000); + const signal = options.requestSignal ? AbortSignal.any([options.requestSignal, timeout]) : timeout; + try { + const result = slug === undefined + ? await api.GET('/api/v1/public/groups', { signal }) + : await api.GET('/api/v1/public/groups/{slug}', { params: { path: { slug } }, signal }); + if (result.response.status === 404) throw new GroupLoadError(404, 'Group content was not found.'); + if (!result.response.ok || result.error || result.data === undefined) throw new GroupLoadError(502, 'Group data returned an upstream error.'); + if (slug === undefined) { + if (!Array.isArray(result.data)) throw new GroupLoadError(502, 'Group data did not match the public contract.'); + return { groups: result.data.map(group => mapGroup(group, options.mediaConfig)), group: null }; + } + return { groups: [], group: mapGroup(result.data as PublicGroupDto, options.mediaConfig) }; + } catch (error) { + if (error instanceof GroupLoadError) throw error; + if (signal.aborted) throw new GroupLoadError(503, 'Group data request timed out or was cancelled.'); + throw new GroupLoadError(502, 'Group data could not be reached or did not match the public contract.'); + } +} diff --git a/src/lib/server/groups/mapper.ts b/src/lib/server/groups/mapper.ts new file mode 100644 index 00000000..03c1d79e --- /dev/null +++ b/src/lib/server/groups/mapper.ts @@ -0,0 +1,85 @@ +import type { components } from '../api/generated/sgfPublicApiSchema'; +import { isPublicGroupSlug, isPublicMemberUsername } from '../api/factory'; +import type { MediaSourceConfig } from '../media/config'; +import { mapMediaUrlToSameOrigin } from '../media/mapper'; +import { safeMemberHttpUrl } from '../member/links'; +import { sanitizePageHtml } from '../pages/html'; +import { mapPresenter } from '../home/mapper'; + +export type PublicGroupDto = components['schemas']['PublicGroupDto']; +export type GroupView = ReturnType; + +export class GroupDataError extends Error {} +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new GroupDataError(); + return value as Record; +} +function text(value: unknown): string { + if (typeof value !== 'string') throw new GroupDataError(); + return value; +} +function optional(value: unknown): string | null { + return value == null ? null : text(value); +} +function array(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new GroupDataError(); + return value; +} +function profilePath(value: unknown): string { + const path = text(value); + if (!path.startsWith('/member/') || !isPublicMemberUsername(path.slice(8))) throw new GroupDataError(); + return path; +} +export function groupPath(value: unknown): string { + const path = text(value); + const match = /^\/groups\/([^/]+)\/?$/.exec(path); + if (!match || !isPublicGroupSlug(match[1])) throw new GroupDataError(); + return path; +} + +export function mapGroup(value: PublicGroupDto, media: MediaSourceConfig) { + const group = record(value); + const websiteUrl = safeMemberHttpUrl(optional(group.websiteUrl)); + const socialSources: [string, string | null][] = [ + ['Website', websiteUrl], ['Twitter', optional(group.twitterUrl)], ['LinkedIn', optional(group.linkedInUrl)], + ['Facebook', optional(group.facebookUrl)], ['Instagram', optional(group.instagramUrl)], ['Youtube', optional(group.youTubeUrl)] + ]; + const socials = socialSources.flatMap(([label, raw]) => { + const url = safeMemberHttpUrl(raw); + return url ? [{ label, url }] : []; + }); + return { + name: text(group.name), path: groupPath(group.path), + aboutHtml: sanitizePageHtml(optional(group.aboutHtml) ?? '', media), + image: mapMediaUrlToSameOrigin(optional(group.imageUrl), media), + location: optional(group.location), establishedText: optional(group.establishedText), websiteUrl, socials, + skills: array(group.skills).map(value => { + const skill = record(value); + const slug = text(skill.slug); + if (!isPublicGroupSlug(slug)) throw new GroupDataError(); + // Keep the legacy singular skill query and URL segment, not content keys. + return { name: text(skill.name), url: `/directory/?skill=${encodeURIComponent(slug)}` }; + }), + leaders: array(group.leaders).map(value => { + const leader = record(value); + return { + name: text(leader.name), listLabel: text(leader.listLabel), location: text(leader.location), + image: mapMediaUrlToSameOrigin(text(leader.imageUrl), media), path: profilePath(leader.profilePath), + tags: array(leader.tags).map(text) + }; + }), + upcomingPresentations: array(group.upcomingPresentations).map(value => { + const presentation = record(value); + const startsAtLocal = text(presentation.startsAtLocal); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(startsAtLocal)) throw new GroupDataError(); + return { + title: text(presentation.title), eventName: text(presentation.eventName), startsAtLocal, + presenters: array(presentation.presenters).map(value => { + const presenter = record(value); + if (presenter.profilePath != null) profilePath(presenter.profilePath); + return mapPresenter(presenter, media); + }) + }; + }) + }; +} diff --git a/src/lib/server/home/mapper.ts b/src/lib/server/home/mapper.ts index c2980e12..17c2d2ad 100644 --- a/src/lib/server/home/mapper.ts +++ b/src/lib/server/home/mapper.ts @@ -119,7 +119,7 @@ function mapPresentation(value: unknown, mediaConfig: MediaSourceConfig, path: s }; } -function mapPresenter(value: unknown, mediaConfig: MediaSourceConfig, path: string): PublicHomePresenterView { +export function mapPresenter(value: unknown, mediaConfig: MediaSourceConfig, path = 'presenter'): PublicHomePresenterView { const source = requireRecord(value, path); return { name: requireString(source.name, `${path}.name`), diff --git a/src/routes/groups/+layout.ts b/src/routes/groups/+layout.ts new file mode 100644 index 00000000..d3c32508 --- /dev/null +++ b/src/routes/groups/+layout.ts @@ -0,0 +1 @@ +export const trailingSlash = 'always'; diff --git a/src/routes/groups/+page.server.ts b/src/routes/groups/+page.server.ts new file mode 100644 index 00000000..c7050085 --- /dev/null +++ b/src/routes/groups/+page.server.ts @@ -0,0 +1,18 @@ +import { CMS_INTERNAL_ORIGIN } from '$app/env/private'; +import { error, isHttpError } from '@sveltejs/kit'; +import { loadGroups, GroupLoadError } from '../../lib/server/groups/load'; +import { readMediaSourceConfig } from '../../lib/server/media/runtime'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + try { + const options = { fetch: event.fetch, cmsInternalOrigin: CMS_INTERNAL_ORIGIN, + mediaConfig: readMediaSourceConfig(), requestSignal: event.request.signal }; + return { groups: (await loadGroups(options)).groups }; + } catch (cause) { + if (isHttpError(cause)) throw cause; + if (cause instanceof GroupLoadError) error(cause.status, cause.publicMessage); + if (cause instanceof Error && cause.name === 'PrivateMediaConfigError') error(503, 'Group media configuration is unavailable.'); + throw cause; + } +}; diff --git a/src/routes/groups/+page.svelte b/src/routes/groups/+page.svelte new file mode 100644 index 00000000..262cbb75 --- /dev/null +++ b/src/routes/groups/+page.svelte @@ -0,0 +1,51 @@ + + +Springfield Devs - Groups + +
+
Groups
+
+ {#each data.groups as group} +
+ {#if group.image}
{group.name}
{/if} +
+

{group.name}

What we're about

+ + {@html group.aboutHtml} + Group Details + {#if group.leaders.length} +
Group Leaders
+ {#each group.leaders as leader} +
{#if leader.image}{leader.name}{/if}{leader.listLabel}
+ {/each} +
+ {/if} +
+
+ {/each} +
+
+ + diff --git a/src/routes/groups/[slug]/+page.server.ts b/src/routes/groups/[slug]/+page.server.ts new file mode 100644 index 00000000..8ae1331a --- /dev/null +++ b/src/routes/groups/[slug]/+page.server.ts @@ -0,0 +1,20 @@ +import { CMS_INTERNAL_ORIGIN } from '$app/env/private'; +import { error, isHttpError } from '@sveltejs/kit'; +import { loadGroups, GroupLoadError } from '../../../lib/server/groups/load'; +import { readMediaSourceConfig } from '../../../lib/server/media/runtime'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async (event) => { + try { + const options = { fetch: event.fetch, cmsInternalOrigin: CMS_INTERNAL_ORIGIN, + mediaConfig: readMediaSourceConfig(), requestSignal: event.request.signal }; + const { group } = await loadGroups(options, event.params.slug); + if (!group) error(404, 'Group not found.'); + return { group }; + } catch (cause) { + if (isHttpError(cause)) throw cause; + if (cause instanceof GroupLoadError) error(cause.status, cause.publicMessage); + if (cause instanceof Error && cause.name === 'PrivateMediaConfigError') error(503, 'Group media configuration is unavailable.'); + throw cause; + } +}; diff --git a/src/routes/groups/[slug]/+page.svelte b/src/routes/groups/[slug]/+page.svelte new file mode 100644 index 00000000..6a31ca3f --- /dev/null +++ b/src/routes/groups/[slug]/+page.svelte @@ -0,0 +1,120 @@ + + +Springfield Devs - {group.name} + +
+
+
+

Group

{group.name}

+ {#if group.websiteUrl}Visit Site{/if} +
+
+ + +
+
+
+ + +
+
+ + {#if group.image}
{group.name}
{/if} +

Social

+
+
+

Biography

{@html group.aboutHtml}
+ {#if group.skills.length}

Skills

{#each group.skills as skill}{skill.name}{' '}{/each}
{/if} +
+
+ {#if group.upcomingPresentations.length} + + +

Events

+ {#each group.upcomingPresentations as presentation} + {#if presentation.presenters[0]} +
+
{presentation.presenters[0].name}
{#each presentation.presenters[0].tags as tag}
{tag}
{/each}
+
{presentation.title}
{#each presentation.presenters as presenter}
{presenter.name}
{/each}
{presentation.eventName}
+
+ {/if} + {/each} +
+ {/if} + {#if group.leaders.length} + + +

Leaders

+ {#each group.leaders as leader} +
+
{#if leader.image}{leader.name}{/if}
{#each leader.tags as tag}
{tag}
{/each}
+
{leader.name}
{leader.location}
+
+ {/each} +
+ {/if} +
+
+ + diff --git a/tests/api/schema.test.ts b/tests/api/schema.test.ts index 231f3b5c..da363620 100644 --- a/tests/api/schema.test.ts +++ b/tests/api/schema.test.ts @@ -29,7 +29,7 @@ async function withTempDir(fn: (dir: string) => Promise): Promise { } describe('committed SGF OpenAPI snapshot', () => { - it('matches the reviewed backend export and contains only the public directory, Home and member routes', async () => { + it('matches the reviewed backend export and contains only the public directory, Home, member and group routes', async () => { const text = await readFile(schemaPath, 'utf8'); const document = JSON.parse(text); @@ -39,6 +39,8 @@ describe('committed SGF OpenAPI snapshot', () => { '/api/directory/filters/skills', '/api/directory/search', '/api/tags/skills', + '/api/v1/public/groups', + '/api/v1/public/groups/{slug}', '/api/v1/public/home', '/api/v1/public/members/{username}' ]); diff --git a/tests/groups/fixture.ts b/tests/groups/fixture.ts new file mode 100644 index 00000000..e0307cc9 --- /dev/null +++ b/tests/groups/fixture.ts @@ -0,0 +1,14 @@ +import type { PublicGroupDto } from '../../src/lib/server/groups/mapper'; + +export const media = { publicSourceOrigin: 'https://media.example.test', cmsInternalOrigin: 'http://cms.example.test' }; +export function groupFixture(name = 'Z synthetic group', path = '/groups/Rust-SGF/'): PublicGroupDto { + return { + name, path, aboutHtml: '

Existing group about text unchanged.

', + imageUrl: '/media/synthetic/group.jpg', location: 'Springfield, MO', establishedText: 'Established October 2020', + websiteUrl: 'https://example.test/group', twitterUrl: 'javascript:bad()', linkedInUrl: null, facebookUrl: null, instagramUrl: null, youTubeUrl: null, + skills: [{ name: 'Rust', slug: 'Rust' }], + leaders: [{ name: 'Jane Example', listLabel: 'Jane E.', location: 'Springfield, MO', imageUrl: '/images/pipey.jpg', profilePath: '/member/Jane', tags: ['Supporting Member'] }], + upcomingPresentations: [{ title: 'Synthetic presentation', eventName: 'Synthetic event', startsAtLocal: '2026-10-07T18:30:00', + presenters: [{ name: 'Jane Example', imageUrl: '/images/pipey.jpg', profilePath: '/member/Jane', tags: ['Supporting Member'] }] }] + }; +} diff --git a/tests/groups/groups.test.ts b/tests/groups/groups.test.ts new file mode 100644 index 00000000..a1a465c7 --- /dev/null +++ b/tests/groups/groups.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { createSgfApiClientForOrigin, isPublicGroupSlug } from '../../src/lib/server/api/factory'; +import { loadGroups, GroupLoadError } from '../../src/lib/server/groups/load'; +import { mapGroup, GroupDataError } from '../../src/lib/server/groups/mapper'; +import { groupFixture, media } from './fixture'; +const json = (value: unknown) => new Response(JSON.stringify(value), { headers: { 'Content-Type': 'application/json' } }); +const options = { cmsInternalOrigin: media.cmsInternalOrigin, mediaConfig: media }; + +test('real exported contract adds only groups and generated DTOs, not Delivery member pickers', async () => { + const raw = await readFile('openapi/sgf-public-v1.openapi.json'); + assert.equal(createHash('sha256').update(raw).digest('hex'), 'c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba'); + const schema = JSON.parse(raw.toString()); + assert.equal(schema.paths['/api/v1/public/groups'].get.operationId, 'PublicGroups_List'); + assert.equal(schema.paths['/api/v1/public/groups/{slug}'].get.operationId, 'PublicGroups_Get'); + for (const dto of ['PublicGroupDto', 'PublicGroupLeaderDto', 'PublicGroupSkillDto', 'PublicGroupPresentationDto']) + assert.ok(!Object.keys(schema.components.schemas[dto].properties).some(k => /id$|key|properties|email|password/i.test(k))); +}); + +test('group paths snapshot slug once, preserve case, omit credentials and reject escapes', async () => { + let calls = 0; + const api = createSgfApiClientForOrigin(async req => { + calls++; + assert.equal(req.url, media.cmsInternalOrigin + '/api/v1/public/groups/Rust-SGF'); + assert.equal(req.credentials, 'omit'); assert.equal(req.redirect, 'error'); assert.equal(req.method, 'GET'); + assert.ok(!req.headers.has('cookie') && !req.headers.has('authorization')); + return json(groupFixture()); + }, media.cmsInternalOrigin); + let reads = 0; + await api.GET('/api/v1/public/groups/{slug}', { params: { path: { get slug() { reads++; return reads === 1 ? 'Rust-SGF' : '../escape'; } } } }); + assert.equal(reads, 1); + for (const slug of ['', '..', '../x', 'a/b', 'a\\b', '%41', 'a?x', 'a#x', '-a', 'é', 'a'.repeat(201)]) { + assert.equal(isPublicGroupSlug(slug), false); + assert.throws(() => api.GET('/api/v1/public/groups/{slug}', { params: { path: { slug } } })); + } + for (const extra of [{ baseUrl: 'https://evil.test' }, { fetch: async () => json({}) }, { headers: { Cookie: 'private' } }, { headers: { Authorization: 'private' } }]) + assert.throws(() => api.GET('/api/v1/public/groups/{slug}', { params: { path: { slug: 'Rust-SGF' } }, ...extra })); + assert.equal(calls, 1); +}); + +test('explicit mapping keeps dates/order/case, safe leaders, skills and sanitized HTML without private fields', async () => { + const source = { ...groupFixture(), id: 'private-id', key: 'private-key', properties: { leaders: ['private-picker'] }, + aboutHtml: '

Text good

' }; + const mapped = mapGroup(source, media); + assert.equal(mapped.name, source.name); assert.equal(mapped.path, '/groups/Rust-SGF/'); + assert.equal(mapped.image, '/media/synthetic/group.jpg'); + assert.equal(mapped.establishedText, source.establishedText); + assert.equal(mapped.skills[0].url, '/directory/?skill=Rust'); + assert.equal(mapped.leaders[0].path, '/member/Jane'); + assert.equal(mapped.leaders[0].listLabel, 'Jane E.'); + assert.equal(mapped.upcomingPresentations[0].startsAtLocal, '2026-10-07T18:30:00'); + assert.equal(mapped.socials.length, 1); + for (const marker of ['private-', 'iframe', 'script', 'bad()', 'onerror']) assert.ok(!JSON.stringify(mapped).includes(marker)); + assert.throws(() => mapGroup({ ...source, leaders: [{ ...source.leaders![0], profilePath: '//evil.test' }] }, media), GroupDataError); + const list = await loadGroups({ ...options, fetch: async () => json([groupFixture(), groupFixture('A synthetic group', '/groups/a-group/')]) }); + assert.deepEqual(list.groups.map(g => g.name), ['Z synthetic group', 'A synthetic group']); + assert.equal((await loadGroups({ ...options, fetch: async () => json(groupFixture('Springfield Devs', '/groups/springfield-devs/')) }, 'springfield-devs')).group?.name, 'Springfield Devs'); +}); + +test('missing/protected group is404, malformed or upstream failures502 and configuration/timeout503, no successful fallback', async () => { + await assert.rejects(loadGroups({ ...options, fetch: async () => { assert.fail('invalid slug fetched'); } }, '../private'), e => e instanceof GroupLoadError && e.status === 404); + for (const status of [404, 500, 302]) await assert.rejects(loadGroups({ ...options, fetch: async () => new Response('private text', { status }) }, 'missing'), + e => e instanceof GroupLoadError && e.status === (status === 404 ? 404 : 502) && !e.message.includes('private')); + for (const fetch of [async () => json({}), async () => new Response('not JSON', { headers: { 'Content-Type': 'application/json' } }), async () => { throw new Error('private exception'); }]) + await assert.rejects(loadGroups({ ...options, fetch }, 'missing'), e => e instanceof GroupLoadError && e.status === 502 && !e.message.includes('private')); + await assert.rejects(loadGroups({ ...options, cmsInternalOrigin: undefined, fetch: async () => { assert.fail('not configured'); } }), e => e instanceof GroupLoadError && e.status === 503); + await assert.rejects(loadGroups({ ...options, timeoutMs: 1, fetch: async req => { await new Promise(r => setTimeout(r, 10)); req.signal.throwIfAborted(); return json([]); } }), e => e instanceof GroupLoadError && e.status === 503); +}); From 5af6f2a6e42d0121440116de10ad0cacc6558206 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 12:33:41 -0500 Subject: [PATCH 2/2] fix: apply directory filters from group skill links --- openapi/README.md | 10 +++++----- openapi/sgf-public-v1.openapi.json | 5 ++++- scripts/lib/schema-bytes.ts | 4 ++-- src/lib/server/api/generated/sgfPublicApiSchema.d.ts | 1 + src/lib/server/groups/mapper.ts | 7 +++---- tests/groups/fixture.ts | 2 +- tests/groups/groups.test.ts | 4 ++-- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/openapi/README.md b/openapi/README.md index d934b695..85ee6be4 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -5,14 +5,14 @@ Snapshot provenance: - Backend repository: `cms.sgf.dev` -- Backend commit: `ba6d15b69d0e2a0c1151a5b4689b9790da19570c`, draft https://github.com/sgfdevs/cms.sgf.dev/pull/18 +- Backend commit: `6423bcc1f5b529c73a033b9aea4922cabe4550cb`, draft https://github.com/sgfdevs/cms.sgf.dev/pull/18 - Runtime export URL: `/umbraco/openapi/sgf-public-v1.json` -- Exported file: `/home/levi/.cache/sgf-migration-validation-3KOCBS/group-pages-1-C44GXVUY/public-export-final/evidence/sgf-public-v1.openapi.json` +- Exported file: `/home/levi/.cache/sgf-migration-validation-3KOCBS/company-pages-1-0DOnnRsl/group-export/evidence/sgf-public-v1.openapi.json` - Runtime provenance: `export-provenance.json` alongside that artifact -- Raw runtime export SHA-256: `c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba` -- Committed canonical SHA-256: `c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba` +- Raw runtime export SHA-256: `3e074e950bf1243ee47ce724a214753e60f26c7e23363d4e662f55c26c17f416` +- Committed canonical SHA-256: `3e074e950bf1243ee47ce724a214753e60f26c7e23363d4e662f55c26c17f416` -Raw and canonical hashes happen to match for this export. Only the existing LF/terminal-newline policy was applied, with no JSON edits or reordering. The runtime export adds `PublicGroups_List` and `PublicGroups_Get` with explicit public group DTOs and 404 ProblemDetails. Existing operations and components are unchanged. Generated error media without a schema remain `unknown`. +Raw and canonical hashes happen to match for this export. Only the existing LF/terminal-newline policy was applied, with no JSON edits or reordering. The runtime export adds `PublicGroups_List` and `PublicGroups_Get` with explicit public group DTOs and 404 ProblemDetails. The group skill DTO now also exposes the published tag GUID as directoryFilterValue. Other operations and components are unchanged. Generated error media without a schema remain `unknown`. The server-only GET fence accepts the member template only with a snapshotted ASCII alphanumeric username of 1 to 1000 characters. The final Request allows only the anchored concrete member path, never the unresolved template. The group template requires a snapshotted ASCII alphanumeric slug with hyphens or underscores, 1 to 200 characters, and the final request checks the anchored group path. The earlier paths and network/auth guards remain. This export used the existing owned install-only SQLite procedure, fake Docker shim and AF S3 assembly exclusion. No schema import or content seed ran. diff --git a/openapi/sgf-public-v1.openapi.json b/openapi/sgf-public-v1.openapi.json index 5de6f117..31b04aec 100644 --- a/openapi/sgf-public-v1.openapi.json +++ b/openapi/sgf-public-v1.openapi.json @@ -7,7 +7,7 @@ }, "servers": [ { - "url": "http://127.0.0.1:40397/" + "url": "http://127.0.0.1:36021/" } ], "paths": { @@ -492,6 +492,9 @@ }, "slug": { "type": "string" + }, + "directoryFilterValue": { + "type": "string" } } }, diff --git a/scripts/lib/schema-bytes.ts b/scripts/lib/schema-bytes.ts index 8563e543..aa9c79b5 100644 --- a/scripts/lib/schema-bytes.ts +++ b/scripts/lib/schema-bytes.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; -export const SGF_PUBLIC_SCHEMA_RAW_EXPORT_SHA256 = 'c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba'; -export const SGF_PUBLIC_SCHEMA_CANONICAL_SHA256 = 'c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba'; +export const SGF_PUBLIC_SCHEMA_RAW_EXPORT_SHA256 = '3e074e950bf1243ee47ce724a214753e60f26c7e23363d4e662f55c26c17f416'; +export const SGF_PUBLIC_SCHEMA_CANONICAL_SHA256 = '3e074e950bf1243ee47ce724a214753e60f26c7e23363d4e662f55c26c17f416'; export function normalizeOpenApiSnapshotText(text: string): string { return text.replace(/\r\n?/g, '\n').replace(/\n+$/g, ''); diff --git a/src/lib/server/api/generated/sgfPublicApiSchema.d.ts b/src/lib/server/api/generated/sgfPublicApiSchema.d.ts index a4ec7981..05689c91 100644 --- a/src/lib/server/api/generated/sgfPublicApiSchema.d.ts +++ b/src/lib/server/api/generated/sgfPublicApiSchema.d.ts @@ -169,6 +169,7 @@ export interface components { PublicGroupSkillDto: { name?: string; slug?: string; + directoryFilterValue?: string; }; PublicHomeDevNightDto: { name: string; diff --git a/src/lib/server/groups/mapper.ts b/src/lib/server/groups/mapper.ts index 03c1d79e..df9a3541 100644 --- a/src/lib/server/groups/mapper.ts +++ b/src/lib/server/groups/mapper.ts @@ -55,10 +55,9 @@ export function mapGroup(value: PublicGroupDto, media: MediaSourceConfig) { location: optional(group.location), establishedText: optional(group.establishedText), websiteUrl, socials, skills: array(group.skills).map(value => { const skill = record(value); - const slug = text(skill.slug); - if (!isPublicGroupSlug(slug)) throw new GroupDataError(); - // Keep the legacy singular skill query and URL segment, not content keys. - return { name: text(skill.name), url: `/directory/?skill=${encodeURIComponent(slug)}` }; + const term = text(skill.directoryFilterValue); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(term)) throw new GroupDataError(); + return { name: text(skill.name), url: `/directory/?skills=${encodeURIComponent(term)}` }; }), leaders: array(group.leaders).map(value => { const leader = record(value); diff --git a/tests/groups/fixture.ts b/tests/groups/fixture.ts index e0307cc9..cbb5f5d9 100644 --- a/tests/groups/fixture.ts +++ b/tests/groups/fixture.ts @@ -6,7 +6,7 @@ export function groupFixture(name = 'Z synthetic group', path = '/groups/Rust-SG name, path, aboutHtml: '

Existing group about text unchanged.

', imageUrl: '/media/synthetic/group.jpg', location: 'Springfield, MO', establishedText: 'Established October 2020', websiteUrl: 'https://example.test/group', twitterUrl: 'javascript:bad()', linkedInUrl: null, facebookUrl: null, instagramUrl: null, youTubeUrl: null, - skills: [{ name: 'Rust', slug: 'Rust' }], + skills: [{ name: 'Rust', slug: 'Rust', directoryFilterValue: '11111111-2222-3333-4444-555555555555' }], leaders: [{ name: 'Jane Example', listLabel: 'Jane E.', location: 'Springfield, MO', imageUrl: '/images/pipey.jpg', profilePath: '/member/Jane', tags: ['Supporting Member'] }], upcomingPresentations: [{ title: 'Synthetic presentation', eventName: 'Synthetic event', startsAtLocal: '2026-10-07T18:30:00', presenters: [{ name: 'Jane Example', imageUrl: '/images/pipey.jpg', profilePath: '/member/Jane', tags: ['Supporting Member'] }] }] diff --git a/tests/groups/groups.test.ts b/tests/groups/groups.test.ts index a1a465c7..f8f27929 100644 --- a/tests/groups/groups.test.ts +++ b/tests/groups/groups.test.ts @@ -11,7 +11,7 @@ const options = { cmsInternalOrigin: media.cmsInternalOrigin, mediaConfig: media test('real exported contract adds only groups and generated DTOs, not Delivery member pickers', async () => { const raw = await readFile('openapi/sgf-public-v1.openapi.json'); - assert.equal(createHash('sha256').update(raw).digest('hex'), 'c99de23daaf51441e5b4cc33aba073736482fa3833b7088c42c9738a4d528cba'); + assert.equal(createHash('sha256').update(raw).digest('hex'), '3e074e950bf1243ee47ce724a214753e60f26c7e23363d4e662f55c26c17f416'); const schema = JSON.parse(raw.toString()); assert.equal(schema.paths['/api/v1/public/groups'].get.operationId, 'PublicGroups_List'); assert.equal(schema.paths['/api/v1/public/groups/{slug}'].get.operationId, 'PublicGroups_Get'); @@ -47,7 +47,7 @@ test('explicit mapping keeps dates/order/case, safe leaders, skills and sanitize assert.equal(mapped.name, source.name); assert.equal(mapped.path, '/groups/Rust-SGF/'); assert.equal(mapped.image, '/media/synthetic/group.jpg'); assert.equal(mapped.establishedText, source.establishedText); - assert.equal(mapped.skills[0].url, '/directory/?skill=Rust'); + assert.equal(mapped.skills[0].url, '/directory/?skills=11111111-2222-3333-4444-555555555555'); assert.equal(mapped.leaders[0].path, '/member/Jane'); assert.equal(mapped.leaders[0].listLabel, 'Jane E.'); assert.equal(mapped.upcomingPresentations[0].startsAtLocal, '2026-10-07T18:30:00');