From dbd46cc157a8ea54d6bb3ddf83da0d509fc3d622 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 20:57:06 +0100 Subject: [PATCH 01/10] docs(backlog): strike EventSeriesBadge dead-code follow-up as shipped --- docs/LAUNCH_READINESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index bfdd9de..e32e3b0 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -140,7 +140,7 @@ See `docs/TODO.md` for: - ~~`tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members~~ — fixed 2026-04-29 (commit `f66455e`); `memberSavings` now gated on `hasMemberAccess(member)`. - Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`. - ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`. -- Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages in `app/` or `server/`. Candidate for deletion in a future cleanup pass. +- ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed. ### Known gotchas worth addressing post-launch From 90acc357923f9010e68ffab7a2472163b6133d86 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 21:00:22 +0100 Subject: [PATCH 02/10] fix(helcim): always issue payment-bridge cookie on signup Free ($0) signups need the same short-lived bridge cookie as paid signups so /api/helcim/subscription can identify the member during activation without a verified auth session. Drops the contributionAmount > 0 guard that broke free-tier activation in the same flow. --- server/api/helcim/customer.post.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 3382b7f..d0fc95d 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -88,12 +88,11 @@ export default defineEventHandler(async (event) => { member }) - // Paid-tier signups need to complete Helcim checkout in the same tab - // before the magic link can be clicked. Issue a short-lived, payment-only - // bridge cookie so /api/helcim/initialize-payment accepts the request. - if (body.contributionAmount > 0) { - setPaymentBridgeCookie(event, member) - } + // Signup completes (paid checkout or free activation) before the magic + // link is clicked, so issue a short-lived, payment-only bridge cookie + // that lets /api/helcim/initialize-payment and /api/helcim/subscription + // identify the member without a verified auth session. + setPaymentBridgeCookie(event, member) return { success: true, From 6527bbbe4efbd3b57e7875406f422b5f85b01402 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 21:00:27 +0100 Subject: [PATCH 03/10] =?UTF-8?q?test(api):=20cover=20free-signup=20?= =?UTF-8?q?=E2=86=92=20subscription=20bridge-cookie=20hand-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests guarding the regression where /api/helcim/customer skipped setPaymentBridgeCookie for $0 signups and left the user unable to complete activation. Second test confirms the auth gate on /api/helcim/subscription still rejects fresh unauthenticated calls. --- tests/server/api/free-signup-flow.test.js | 156 ++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/server/api/free-signup-flow.test.js diff --git a/tests/server/api/free-signup-flow.test.js b/tests/server/api/free-signup-flow.test.js new file mode 100644 index 0000000..c7789ad --- /dev/null +++ b/tests/server/api/free-signup-flow.test.js @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import Member from '../../../server/models/member.js' +import { createHelcimCustomer } from '../../../server/utils/helcim.js' +import customerHandler from '../../../server/api/helcim/customer.post.js' +import subscriptionHandler from '../../../server/api/helcim/subscription.post.js' +import { resetRateLimit } from '../../../server/utils/rateLimit.js' +import { sendWelcomeEmail } from '../../../server/utils/resend.js' +import { createMockEvent } from '../helpers/createMockEvent.js' + +// IMPORTANT: do NOT mock server/utils/auth.js. This test exists to verify the +// real bridge-cookie hand-off between the two handlers. Mocking auth would +// hide the exact regression we're guarding against (setPaymentBridgeCookie +// being skipped for $0 signups while subscription.post.js still requires +// either a bridge cookie or a verified session). + +vi.mock('../../../server/models/member.js', () => ({ + default: { + findOne: vi.fn(), + create: vi.fn(), + findByIdAndUpdate: vi.fn(), + findById: vi.fn(), + findOneAndUpdate: vi.fn() + } +})) +vi.mock('../../../server/utils/mongoose.js', () => ({ connectDB: vi.fn() })) +vi.mock('../../../server/utils/helcim.js', () => ({ + createHelcimCustomer: vi.fn(), + createHelcimSubscription: vi.fn(), + generateIdempotencyKey: vi.fn().mockReturnValue('idem-1'), + listHelcimCustomerTransactions: vi.fn().mockResolvedValue([]) +})) +vi.mock('../../../server/utils/magicLink.js', () => ({ + sendMagicLink: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('../../../server/utils/resend.js', () => ({ + sendWelcomeEmail: vi.fn().mockResolvedValue({ success: true }) +})) +vi.mock('../../../server/utils/slack.ts', () => ({ + getSlackService: vi.fn().mockReturnValue(null) +})) +vi.mock('../../../server/utils/payments.js', () => ({ + upsertPaymentFromHelcim: vi.fn().mockResolvedValue({ created: true }) +})) + +vi.stubGlobal('helcimCustomerSchema', {}) +vi.stubGlobal('helcimSubscriptionSchema', {}) + +const ALLOWED_ORIGIN = 'https://ghostguild.test' +const MEMBER_ID = '69f231152939bf109ac79d83' + +function extractBridgeCookie(event) { + const setCookie = event.node.res.getHeader('set-cookie') + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean) + const match = cookies.find(c => typeof c === 'string' && c.startsWith('payment-bridge=')) + if (!match) return null + return match.match(/payment-bridge=([^;]+)/)[1] +} + +describe('signup → subscription bridge-cookie hand-off', () => { + beforeEach(() => { + vi.clearAllMocks() + resetRateLimit() + process.env.BASE_URL = ALLOWED_ORIGIN + + createHelcimCustomer.mockResolvedValue({ id: 999, customerCode: 'CST999' }) + Member.findOne.mockResolvedValue(null) + Member.create.mockResolvedValue({ + _id: MEMBER_ID, + email: 'free@example.com', + name: 'Free User', + circle: 'community', + contributionAmount: 0, + status: 'pending_payment' + }) + }) + + it('$0 signup: customer endpoint issues a bridge cookie that subscription endpoint accepts', async () => { + // --- Step 1: POST /api/helcim/customer (free tier) --- + const customerEvent = createMockEvent({ + method: 'POST', + path: '/api/helcim/customer', + headers: { origin: ALLOWED_ORIGIN }, + body: { + name: 'Free User', + email: 'free@example.com', + circle: 'community', + contributionAmount: 0, + agreedToGuidelines: true, + billingAddress: { country: 'CA' } + } + }) + + const result1 = await customerHandler(customerEvent) + expect(result1.success).toBe(true) + expect(result1.member.status).toBe('pending_payment') + + // The regression: prior code skipped setPaymentBridgeCookie when + // contributionAmount === 0, leaving the user unable to complete + // subscription activation in the same flow. + const bridgeToken = extractBridgeCookie(customerEvent) + expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy() + + // --- Step 2: POST /api/helcim/subscription (free tier) --- + Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' }) + Member.findById.mockResolvedValue({ + _id: MEMBER_ID, + email: 'free@example.com', + name: 'Free User', + circle: 'community', + contributionAmount: 0, + status: 'active' + }) + + const subscriptionEvent = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + headers: { origin: ALLOWED_ORIGIN }, + cookies: { 'payment-bridge': bridgeToken }, + body: { + customerId: 999, + customerCode: 'CST999', + contributionAmount: 0, + cadence: 'monthly', + cardToken: null + } + }) + + const result2 = await subscriptionHandler(subscriptionEvent) + expect(result2.success).toBe(true) + expect(result2.member.status).toBe('active') + expect(sendWelcomeEmail).toHaveBeenCalledTimes(1) + }) + + it('$0 signup with no bridge cookie carried forward → subscription returns 401', async () => { + // Sanity check: confirms the auth gate still rejects fresh, unauthenticated + // calls to /api/helcim/subscription. If this ever stops failing, the + // bridge cookie has stopped being load-bearing and the gate is open. + const subscriptionEvent = createMockEvent({ + method: 'POST', + path: '/api/helcim/subscription', + headers: { origin: ALLOWED_ORIGIN }, + body: { + customerId: 999, + customerCode: 'CST999', + contributionAmount: 0, + cadence: 'monthly', + cardToken: null + } + }) + + await expect(subscriptionHandler(subscriptionEvent)).rejects.toMatchObject({ + statusCode: 401 + }) + }) +}) From 26791cc0e39d0187d66e0a8f51a8d0d0792fa212 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 21:50:00 +0100 Subject: [PATCH 04/10] chore(simplify): trim narrating comments and dedup test body Test file: drop step markers, regression explainers, and the lead comment block that restated the contract; hoist the shared subscription request body to a const; move Member mock defaults into the test that uses them. Two it() cases unchanged. Events page: drop WCAG comment that narrated what the .past-toggle:focus-visible selector already says. --- app/pages/events/index.vue | 1 - tests/server/api/free-signup-flow.test.js | 44 ++++++++--------------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index 4894a07..3a64189 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -430,7 +430,6 @@ const isAlmostFull = (event) => { border-color: var(--candle-faint); color: var(--text-dim); } -/* WCAG 2.4.7 — keyboard focus must be visibly indicated. */ .past-toggle:focus-visible { outline: 2px dashed var(--candle); outline-offset: 3px; diff --git a/tests/server/api/free-signup-flow.test.js b/tests/server/api/free-signup-flow.test.js index c7789ad..521c0b2 100644 --- a/tests/server/api/free-signup-flow.test.js +++ b/tests/server/api/free-signup-flow.test.js @@ -8,11 +8,8 @@ import { resetRateLimit } from '../../../server/utils/rateLimit.js' import { sendWelcomeEmail } from '../../../server/utils/resend.js' import { createMockEvent } from '../helpers/createMockEvent.js' -// IMPORTANT: do NOT mock server/utils/auth.js. This test exists to verify the -// real bridge-cookie hand-off between the two handlers. Mocking auth would -// hide the exact regression we're guarding against (setPaymentBridgeCookie -// being skipped for $0 signups while subscription.post.js still requires -// either a bridge cookie or a verified session). +// Deliberately does NOT mock server/utils/auth.js — the bridge-cookie +// hand-off between the two handlers is the contract under test. vi.mock('../../../server/models/member.js', () => ({ default: { @@ -49,6 +46,14 @@ vi.stubGlobal('helcimSubscriptionSchema', {}) const ALLOWED_ORIGIN = 'https://ghostguild.test' const MEMBER_ID = '69f231152939bf109ac79d83' +const SUBSCRIPTION_BODY = { + customerId: 999, + customerCode: 'CST999', + contributionAmount: 0, + cadence: 'monthly', + cardToken: null +} + function extractBridgeCookie(event) { const setCookie = event.node.res.getHeader('set-cookie') const cookies = Array.isArray(setCookie) ? setCookie : [setCookie].filter(Boolean) @@ -64,6 +69,9 @@ describe('signup → subscription bridge-cookie hand-off', () => { process.env.BASE_URL = ALLOWED_ORIGIN createHelcimCustomer.mockResolvedValue({ id: 999, customerCode: 'CST999' }) + }) + + it('$0 signup: customer endpoint issues a bridge cookie that subscription endpoint accepts', async () => { Member.findOne.mockResolvedValue(null) Member.create.mockResolvedValue({ _id: MEMBER_ID, @@ -73,10 +81,7 @@ describe('signup → subscription bridge-cookie hand-off', () => { contributionAmount: 0, status: 'pending_payment' }) - }) - it('$0 signup: customer endpoint issues a bridge cookie that subscription endpoint accepts', async () => { - // --- Step 1: POST /api/helcim/customer (free tier) --- const customerEvent = createMockEvent({ method: 'POST', path: '/api/helcim/customer', @@ -95,13 +100,9 @@ describe('signup → subscription bridge-cookie hand-off', () => { expect(result1.success).toBe(true) expect(result1.member.status).toBe('pending_payment') - // The regression: prior code skipped setPaymentBridgeCookie when - // contributionAmount === 0, leaving the user unable to complete - // subscription activation in the same flow. const bridgeToken = extractBridgeCookie(customerEvent) expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy() - // --- Step 2: POST /api/helcim/subscription (free tier) --- Member.findOneAndUpdate.mockResolvedValue({ _id: MEMBER_ID, status: 'pending_payment' }) Member.findById.mockResolvedValue({ _id: MEMBER_ID, @@ -117,13 +118,7 @@ describe('signup → subscription bridge-cookie hand-off', () => { path: '/api/helcim/subscription', headers: { origin: ALLOWED_ORIGIN }, cookies: { 'payment-bridge': bridgeToken }, - body: { - customerId: 999, - customerCode: 'CST999', - contributionAmount: 0, - cadence: 'monthly', - cardToken: null - } + body: SUBSCRIPTION_BODY }) const result2 = await subscriptionHandler(subscriptionEvent) @@ -133,20 +128,11 @@ describe('signup → subscription bridge-cookie hand-off', () => { }) it('$0 signup with no bridge cookie carried forward → subscription returns 401', async () => { - // Sanity check: confirms the auth gate still rejects fresh, unauthenticated - // calls to /api/helcim/subscription. If this ever stops failing, the - // bridge cookie has stopped being load-bearing and the gate is open. const subscriptionEvent = createMockEvent({ method: 'POST', path: '/api/helcim/subscription', headers: { origin: ALLOWED_ORIGIN }, - body: { - customerId: 999, - customerCode: 'CST999', - contributionAmount: 0, - cadence: 'monthly', - cardToken: null - } + body: SUBSCRIPTION_BODY }) await expect(subscriptionHandler(subscriptionEvent)).rejects.toMatchObject({ From 1c2d1537a8278654a876656bb8ee6311e3e422ac Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 29 Apr 2026 21:50:43 +0100 Subject: [PATCH 05/10] docs(backlog): log 2026-04-29 simplify-pass and deferred follow-ups --- docs/LAUNCH_READINESS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md index e32e3b0..2fdacdf 100644 --- a/docs/LAUNCH_READINESS.md +++ b/docs/LAUNCH_READINESS.md @@ -141,6 +141,7 @@ See `docs/TODO.md` for: - Simplify-pass follow-ups (2026-04-25): SHIPPED 2026-04-27 on branch `chore/simplify-pass-follow-ups` (pending merge). See `~/.claude/projects/-Users-jennie-Sites-ghostguild-org/memory/project_simplify_pass_2026_04_25.md`. - ~~Reconcile `customerCode` bug~~ — fixed on `main` in commit `3c38333` ("pass customerCode (not helcimCustomerId) to Helcim transactions API"). Verified in `server/api/internal/reconcile-payments.post.js:97`. - ~~Drive-by from 2026-04-29 phantom-Tailwind sweep: `app/components/EventSeriesBadge.vue` has zero usages~~ — deleted 2026-04-29 (commit `f85f284`); 81 lines removed. +- Simplify-pass follow-ups (2026-04-29): smallest wins shipped in commit `26791cc`; deferred items (rename `setPaymentBridgeCookie`, dedup admin `STATUS_LABELS`, extract `.tint-candle`/`.tint-ember` utilities, audit `member &&` truthy checks in sibling routes, restore `ImageUpload` alt-text input focus styling) tracked in `docs/TODO.md` § _Simplify-pass follow-ups — 2026-04-29_. ### Known gotchas worth addressing post-launch From cad57b008337223b4599ddf4fd5a32a5fa23017a Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 30 Apr 2026 00:13:02 +0100 Subject: [PATCH 06/10] =?UTF-8?q?style(visual-fidelity):=20pages-public=20?= =?UTF-8?q?=E2=80=94=20batches=20A,D,F,G,H?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - about.vue: promote h3 → h2 on circle headings (h1→h2→h2→h2) - coming-soon.vue: font-weight 700 → 600 - members/[id].vue: inline circle badge → ; hero size 42→36 - community-guidelines.vue: padding + font-size off-scale snaps - board.vue: loading/empty padding 60→64 - series/index.vue, join.vue: padding off-scale snaps --- app/pages/about.vue | 6 +++--- app/pages/board.vue | 4 ++-- app/pages/coming-soon.vue | 2 +- app/pages/community-guidelines.vue | 4 ++-- app/pages/join.vue | 2 +- app/pages/members/[id].vue | 6 ++---- app/pages/series/index.vue | 2 +- 7 files changed, 12 insertions(+), 14 deletions(-) diff --git a/app/pages/about.vue b/app/pages/about.vue index a423811..5920e9b 100644 --- a/app/pages/about.vue +++ b/app/pages/about.vue @@ -38,16 +38,16 @@
-

Community

+

Community

For anyone exploring cooperative models.

-

Founder

+

Founder

For people actively building cooperatives.

-

Practitioner

+

Practitioner

For experienced practitioners sharing what they know.

diff --git a/app/pages/board.vue b/app/pages/board.vue index 93cdd5d..2257533 100644 --- a/app/pages/board.vue +++ b/app/pages/board.vue @@ -357,13 +357,13 @@ onMounted(async () => { /* ---- LOADING / EMPTY ---- */ .loading-state { - padding: 60px 24px; + padding: 64px 24px; text-align: center; color: var(--text-faint); font-size: 12px; } .empty-state { - padding: 60px 24px; + padding: 64px 24px; text-align: center; } .empty-title { diff --git a/app/pages/coming-soon.vue b/app/pages/coming-soon.vue index f7de6b2..b43c391 100644 --- a/app/pages/coming-soon.vue +++ b/app/pages/coming-soon.vue @@ -124,7 +124,7 @@ const handleLogout = async () => { .coming-soon-title { font-family: var(--font-display); font-size: 3rem; - font-weight: 700; + font-weight: 600; color: var(--text-bright); margin-bottom: 8px; } diff --git a/app/pages/community-guidelines.vue b/app/pages/community-guidelines.vue index 2708198..13f7feb 100644 --- a/app/pages/community-guidelines.vue +++ b/app/pages/community-guidelines.vue @@ -309,7 +309,7 @@ useHead({ } .guidelines-section ul li { position: relative; - padding: 2px 0 2px 18px; + padding: 2px 0 2px 16px; font-size: 13px; color: var(--text-dim); line-height: 1.7; @@ -365,7 +365,7 @@ useHead({ font-family: "Brygada 1918", serif; font-style: italic; color: var(--text-bright); - font-size: 15px; + font-size: 16px; margin-top: 12px; } diff --git a/app/pages/join.vue b/app/pages/join.vue index 1a88b43..a76c316 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -747,7 +747,7 @@ onUnmounted(() => { padding: 0; } .tier-list li { - padding: 5px 0; + padding: 4px 0; font-size: 12px; color: var(--text-dim); border-bottom: 1px dashed var(--border); diff --git a/app/pages/members/[id].vue b/app/pages/members/[id].vue index 962aaca..384a8ed 100644 --- a/app/pages/members/[id].vue +++ b/app/pages/members/[id].vue @@ -37,9 +37,7 @@ {{ member.pronouns }}
- - {{ circleLabels[member.circle] }} - +