Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/group-pages.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions openapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `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/member-contract-export-1-nntnHn/evidence/sgf-public-v1.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: `f9eee0ce6ec1ab9295ddd6eda12f0675c5bbd046efae7f1c23f768f33fde6d89`
- Committed canonical SHA-256: `f9eee0ce6ec1ab9295ddd6eda12f0675c5bbd046efae7f1c23f768f33fde6d89`
- 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 `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. 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 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:

Expand Down
227 changes: 226 additions & 1 deletion openapi/sgf-public-v1.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"servers": [
{
"url": "http://127.0.0.1:36395/"
"url": "http://127.0.0.1:36021/"
}
],
"paths": {
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -276,6 +349,155 @@
}
}
},
"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"
},
"directoryFilterValue": {
"type": "string"
}
}
},
"PublicHomeDevNightDto": {
"required": [
"name",
Expand Down Expand Up @@ -647,6 +869,9 @@
{
"name": "DevsApi"
},
{
"name": "PublicGroup"
},
{
"name": "PublicHome"
},
Expand Down
4 changes: 2 additions & 2 deletions scripts/lib/schema-bytes.ts
Original file line number Diff line number Diff line change
@@ -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 = '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, '');
Expand Down
34 changes: 23 additions & 11 deletions src/lib/server/api/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof paths, string>[];

export const MEMBER_GET_TEMPLATE = '/api/v1/public/members/{username}';
const publicGetPathSet = new Set<string>(SGF_PUBLIC_GET_PATHS.filter((path) => path !== MEMBER_GET_TEMPLATE));
export const GROUP_GET_TEMPLATE = '/api/v1/public/groups/{slug}';
const publicGetPathSet = new Set<string>(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);
Expand All @@ -40,27 +48,31 @@ 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);
}
};
}

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<string, unknown> } | 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<string, unknown> = {};
for (const key of Object.keys(params ?? {})) {
if (key !== 'path') snapshot[key] = (params as Record<string, unknown>)[key];
}
snapshot.path = { username };
snapshot.path = { [field]: value };
const options: Record<string, unknown> = {};
for (const key of Object.keys(init ?? {})) {
if (key !== 'params') options[key] = init?.[key];
Expand Down Expand Up @@ -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.');
}
Expand Down
Loading