From 16bf72792f0d81c9d6d7802d8eee4ae1404c78a2 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 09:37:37 -0500 Subject: [PATCH] feat: add same-origin member registration journey --- openapi/member-bridge.md | 10 +- openapi/sgf-member-v1.openapi.json | 137 +++++++++++++++++- src/hooks.server.ts | 2 +- src/lib/server/auth/client.ts | 3 +- .../auth/generated/memberApiSchema.d.ts | 69 +++++++++ src/routes/register/+page.server.ts | 46 ++++++ src/routes/register/+page.svelte | 78 ++++++++++ tests/auth/registration.test.ts | 44 ++++++ 8 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 src/routes/register/+page.server.ts create mode 100644 src/routes/register/+page.svelte create mode 100644 tests/auth/registration.test.ts diff --git a/openapi/member-bridge.md b/openapi/member-bridge.md index 0c78a33c..d534e036 100644 --- a/openapi/member-bridge.md +++ b/openapi/member-bridge.md @@ -1,15 +1,17 @@ # Private member bridge -`sgf-member-v1.openapi.json` is the unedited response from the backend's named `sgf-member-v1` document, exported at `/umbraco/openapi/sgf-member-v1.json` from commit `9ad88406bb8bb604343922d44268d6979556e2bf`. +`sgf-member-v1.openapi.json` is the unedited response from the backend's named `sgf-member-v1` document, exported at `/umbraco/openapi/sgf-member-v1.json` from commit `e7d72781629edc8bc1070f69b4e33ba476590f11`. -Raw SHA-256: `7cb034e19fee49f377c9ac4a3a03a986498070fa1aeba52d3f02b3b022ebf227`. +Raw SHA-256: `5b22b136d9682da2bd26ff36b1c83ee4004330ab7f168ec300daeca0c2cd537d`. -The export used a fresh source archive, private empty SQLite install, Development mode and the process-only `AF.Umbraco.S3.Media.Storage,` assembly exclusion. It did not import content or members. Empty installation does not prove existing-member login. +The export used a fresh source archive, private empty SQLite install, Development mode and the process-only `AF.Umbraco.S3.Media.Storage,` assembly exclusion. It did not import content or members. Empty installation does not prove existing-member login or registration with the real member schema/group. Run `npm run api:member:generate` to generate the separate server-only types. The public document and client are unchanged. -Set private `CMS_INTERNAL_ORIGIN` to the fixed CMS origin. HTTPS is required outside loopback. Set `CMS_MEMBER_BRIDGE_SECRET` privately to match backend `SGFDevs__MemberBridge__Secret`. Missing configuration disables login, not public pages. No browser receives this header. +Set private `CMS_INTERNAL_ORIGIN` to the fixed CMS origin. HTTPS is required outside loopback. Set `CMS_MEMBER_BRIDGE_SECRET` privately to match backend `SGFDevs__MemberBridge__Secret`. Missing configuration disables member actions, not public pages. No browser receives this header. `CMS_MEMBER_COOKIE_NAME` defaults to Umbraco 18.2's ASP.NET Identity application cookie, `.AspNetCore.Identity.Application`. Set it only if the CMS's existing member cookie has been customized. The bridge forwards that cookie and its `C1`, `C2`, etc. chunks only, preserving remembered expiry and sliding renewal. Frontend cookies are host-only, HTTPOnly, SameSite=Lax and Path=/, with Secure enabled in production. Configure the trusted reverse proxy's protocol handling correctly for Kit Origin/CSRF checks. Do not trust browser-supplied forwarded headers. +Registration posts through the same private bridge and signs in persistently to `/account`. It reuses legacy validation and requires the CMS's existing `Member` type, `firstName`, `lastName`, `username` properties and `SGF Devs` member group. Missing prerequisites return unavailable without creating schema or groups. Forgot/reset remain separate work. + Login defaults to remember-me, matching the legacy persistent login. Account editing is not available yet. Logout is a same-origin POST; GET only displays confirmation. Logout removes frontend member cookies even during a CMS outage; it cannot revoke a copied ticket during that outage. diff --git a/openapi/sgf-member-v1.openapi.json b/openapi/sgf-member-v1.openapi.json index 9316e3c2..d2296355 100644 --- a/openapi/sgf-member-v1.openapi.json +++ b/openapi/sgf-member-v1.openapi.json @@ -6,10 +6,80 @@ }, "servers": [ { - "url": "http://127.0.0.1:35899/" + "url": "http://127.0.0.1:53625/" } ], "paths": { + "/api/v1/member/register": { + "post": { + "tags": [ + "MemberRegistration" + ], + "operationId": "Member_Register", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationResult" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationResult" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationResult" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationResult" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationResult" + } + }, + "text/plain": { + "schema": { + "$ref": "#/components/schemas/MemberRegistrationResult" + } + } + } + } + } + } + }, "/api/v1/member/login": { "post": { "tags": [ @@ -145,6 +215,68 @@ } } }, + "MemberRegistrationRequest": { + "type": "object", + "properties": { + "firstName": { + "type": [ + "null", + "string" + ] + }, + "lastName": { + "type": [ + "null", + "string" + ] + }, + "email": { + "type": [ + "null", + "string" + ] + }, + "username": { + "type": [ + "null", + "string" + ] + }, + "password": { + "type": [ + "null", + "string" + ] + }, + "challengeQuestion": { + "type": [ + "null", + "string" + ] + } + } + }, + "MemberRegistrationResult": { + "required": [ + "succeeded", + "errors" + ], + "type": "object", + "properties": { + "succeeded": { + "type": "boolean" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, "MemberSessionDto": { "required": [ "username", @@ -163,6 +295,9 @@ } }, "tags": [ + { + "name": "MemberRegistration" + }, { "name": "MemberSession" } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 9b153846..30f793ab 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -16,7 +16,7 @@ export const handle: Handle = async ({ event, resolve }) => { } } const response = await resolve(event); - if (hasCookie || ['/login', '/logout', '/account'].includes(event.url.pathname)) { + if (hasCookie || ['/login', '/logout', '/account', '/register'].includes(event.url.pathname)) { response.headers.set('Cache-Control', 'no-store'); } return response; diff --git a/src/lib/server/auth/client.ts b/src/lib/server/auth/client.ts index 3efd531a..552bb109 100644 --- a/src/lib/server/auth/client.ts +++ b/src/lib/server/auth/client.ts @@ -28,7 +28,7 @@ export function createMemberClient(options: { fetch: async request => { const target = new URL(request.url); if (target.origin !== origin || ![ - '/api/v1/member/login', '/api/v1/member/session', '/api/v1/member/logout' + '/api/v1/member/login', '/api/v1/member/session', '/api/v1/member/logout', '/api/v1/member/register' ].includes(target.pathname) || target.search || target.hash) throw new Error('Invalid member bridge request.'); const headers = new Headers({ 'X-SGF-Member-Bridge': options.secret! }); const cookie = memberCookieHeader(options.cookies, base); @@ -45,6 +45,7 @@ export function createMemberClient(options: { } }); return { + register: (body: components['schemas']['MemberRegistrationRequest']) => client.POST('/api/v1/member/register', { body }), login: (body: components['schemas']['MemberLoginRequest']) => client.POST('/api/v1/member/login', { body }), session: () => client.GET('/api/v1/member/session'), logout: () => client.POST('/api/v1/member/logout') diff --git a/src/lib/server/auth/generated/memberApiSchema.d.ts b/src/lib/server/auth/generated/memberApiSchema.d.ts index 8af59732..e5e4657e 100644 --- a/src/lib/server/auth/generated/memberApiSchema.d.ts +++ b/src/lib/server/auth/generated/memberApiSchema.d.ts @@ -1,5 +1,21 @@ /** Generated from openapi/sgf-member-v1.openapi.json. Run npm run api:member:generate. */ export interface paths { + "/api/v1/member/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["Member_Register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/member/login": { parameters: { query?: never; @@ -61,6 +77,20 @@ export interface components { MemberLoginResult: { succeeded: boolean; }; + MemberRegistrationRequest: { + firstName?: null | string; + lastName?: null | string; + email?: null | string; + username?: null | string; + password?: null | string; + challengeQuestion?: null | string; + }; + MemberRegistrationResult: { + succeeded: boolean; + errors: { + [key: string]: string[]; + }; + }; MemberSessionDto: { username: string; name: string; @@ -74,6 +104,45 @@ export interface components { } export type $defs = Record; export interface operations { + Member_Register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberRegistrationRequest"]; + "text/json": components["schemas"]["MemberRegistrationRequest"]; + "application/*+json": components["schemas"]["MemberRegistrationRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MemberRegistrationResult"]; + "text/json": components["schemas"]["MemberRegistrationResult"]; + "text/plain": components["schemas"]["MemberRegistrationResult"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MemberRegistrationResult"]; + "text/json": components["schemas"]["MemberRegistrationResult"]; + "text/plain": components["schemas"]["MemberRegistrationResult"]; + }; + }; + }; + }; Member_Login: { parameters: { query?: never; diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts new file mode 100644 index 00000000..71f6fbed --- /dev/null +++ b/src/routes/register/+page.server.ts @@ -0,0 +1,46 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { memberClient } from '../../lib/server/auth/runtime'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ locals }) => { + if (locals.member) redirect(303, '/account'); +}; + +export const actions: Actions = { + default: async event => { + const form = await event.request.formData(); + const fields = ['firstName', 'lastName', 'email', 'username', 'challengeQuestion'] as const; + const values = Object.fromEntries(fields.map(field => { + const value = form.get(field); + return [field, typeof value === 'string' ? value.slice(0, 256) : '']; + })) as Record; + const errors: Record = {}; + for (const field of fields) { + const value = form.get(field); + if (typeof value !== 'string') errors[field] = ['This field is required.']; + else if (value.length > 256) errors[field] = ['This value is too long.']; + } + const password = form.get('password'); + if (typeof password !== 'string' || !password) errors.password = ['Password is required.']; + else if (password.length > 4096) errors.password = ['This value is too long.']; + if (Object.keys(errors).length || typeof password !== 'string') return fail(400, { values, errors }); + + try { + const { data, error, response } = await memberClient(event).register({ ...values, password }); + if (!data?.succeeded) { + const upstream = data?.errors ?? error?.errors; + for (const field of [...fields, 'password', '']) { + const messages = upstream?.[field]; + if (Array.isArray(messages) && messages.length) errors[field] = messages.slice(0, 3); + } + if (!Object.keys(errors).length) errors[''] = ['Registration is unavailable right now.']; + return fail(response.status === 503 || response.status === 429 ? 503 : 400, { values, errors }); + } + } catch { + errors[''] = ['Registration is unavailable right now.']; + return fail(503, { values, errors }); + } + // The bridge has already relayed the persistent member cookie. The next request loads its session. + redirect(303, '/account'); + } +}; diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 00000000..0529e4ae --- /dev/null +++ b/src/routes/register/+page.svelte @@ -0,0 +1,78 @@ + + +Springfield Devs - Register + +
+
{ + const password = formElement.elements.namedItem('password'); + if (password instanceof HTMLInputElement) password.value = ''; + }}> +
+

Get started with your account

+

Meet your peers. Engage with experts. Iron sharpening iron, you get it. Do it all with a membership to Springfield Devs. Already have an account? Log in

+ {#each form?.errors?.[''] ?? [] as message}{/each} +
+ {#each fields as field} +
+
+ + {#each form?.errors?.[field.name] ?? [] as message}{message}{/each} +
+ +
+ {/each} +
+
+ + {#each form?.errors?.password ?? [] as message}{message}{/each} +
+ +
+
+
+ + + {#each form?.errors?.challengeQuestion ?? [] as message}{message}{/each} +
+ {#if hint}

The answer is SGF

{/if} + +
+
+ +

By clicking the "get started" button, you are creating a Springfield Devs account, and you agree to Springfield Devs Code of Conduct.

+
+
+
+ + diff --git a/tests/auth/registration.test.ts b/tests/auth/registration.test.ts new file mode 100644 index 00000000..d1af76b3 --- /dev/null +++ b/tests/auth/registration.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Cookies } from '@sveltejs/kit'; +import { createMemberClient } from '../../src/lib/server/auth/client'; +import { DEFAULT_MEMBER_COOKIE as cookie } from '../../src/lib/server/auth/cookies'; + +test('generated registration uses only the private bridge and relays persistent member sign-in', async () => { + const stored: { name: string; options: Record }[] = []; + const cookies = { + getAll: () => [], + set: (name: string, _value: string, options: Record) => stored.push({ name, options }), + delete: () => {} + } as unknown as Cookies; + const client = createMemberClient({ + origin: 'http://127.0.0.1:9999', secret: 'synthetic-only', cookies, secure: true, + fetchImpl: async input => { + const request = input as Request; + assert.equal(request.url, 'http://127.0.0.1:9999/api/v1/member/register'); + assert.equal(request.method, 'POST'); + assert.equal(request.headers.get('origin'), null); + assert.equal(request.headers.get('x-sgf-member-bridge'), 'synthetic-only'); + assert.equal(request.cache, 'no-store'); + const body = await request.json(); + assert.equal(body.firstName, 'Synthetic'); + assert.equal(body.lastName, 'Member'); + assert.equal(body.username, 'Synthetic'); + assert.equal(body.challengeQuestion, 'sgf'); + return new Response(JSON.stringify({ succeeded: true, errors: {} }), { headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': cookie + '=synthetic-ticket; Domain=cms.invalid; Expires=Tue, 01 Jan 2030 00:00:00 GMT; Path=/; HttpOnly' + } }); + } + }); + assert.equal((await client.register({ + firstName: 'Synthetic', lastName: 'Member', email: 'synthetic@example.test', + username: 'Synthetic', password: 'Fictional9!', challengeQuestion: 'sgf' + })).data?.succeeded, true); + assert.equal(stored.length, 1); + assert.equal(stored[0].name, cookie); + assert.equal(stored[0].options.httpOnly, true); + assert.equal(stored[0].options.secure, true); + assert.equal(stored[0].options.domain, undefined); + assert.ok(stored[0].options.expires instanceof Date); +});