@@ -534,6 +537,24 @@ onMounted(loadActivity)
margin-top: 12px;
}
+.field-hint {
+ font-size: 11px;
+ color: var(--text-faint);
+ margin: 6px 0 0;
+ line-height: 1.4;
+}
+
+.field-hint--warn {
+ color: var(--ember);
+ border-left: 2px solid var(--ember);
+ padding: 4px 0 4px 8px;
+}
+
+.field-hint code {
+ font-family: "Commit Mono", monospace;
+ font-size: 10px;
+}
+
.form-actions {
display: flex;
gap: 8px;
diff --git a/app/pages/admin/members/index.vue b/app/pages/admin/members/index.vue
index 4e8f28d..bce22e1 100644
--- a/app/pages/admin/members/index.vue
+++ b/app/pages/admin/members/index.vue
@@ -42,7 +42,7 @@
@@ -373,7 +371,7 @@
-
- {{ circleLabels[member.circle] }}
-
+
·
{{ member.studio }}
@@ -372,7 +370,7 @@ useHead({
}
.profile-name {
font-family: "Brygada 1918", serif;
- font-size: 42px;
+ font-size: 36px;
font-weight: 600;
color: var(--text-bright);
margin: 0;
diff --git a/app/pages/series/index.vue b/app/pages/series/index.vue
index 6634190..aaade84 100644
--- a/app/pages/series/index.vue
+++ b/app/pages/series/index.vue
@@ -185,7 +185,7 @@ const getEventStatus = (event) => {
display: flex;
align-items: baseline;
gap: 12px;
- padding: 10px 28px;
+ padding: 12px 28px;
border-bottom: 1px dashed var(--border);
font-size: 12px;
}
diff --git a/docs/LAUNCH_READINESS.md b/docs/LAUNCH_READINESS.md
index 27c0e99..2fdacdf 100644
--- a/docs/LAUNCH_READINESS.md
+++ b/docs/LAUNCH_READINESS.md
@@ -132,13 +132,16 @@ Not blocking launch — the amendment hasn't passed yet, and the user-visible co
See `docs/TODO.md` for:
- Button minimum target size (WCAG AAA 2.5.5).
-- `/oidc/interaction/[uid]` routing quirk.
-- Admin layout migration from `guild-*` tokens to zine spec.
-- Admin dashboard quick-action button contrast.
-- Members table NAME column clipping.
+- ~~`/oidc/interaction/[uid]` routing quirk~~ — fixed 2026-04-29 (commit `23154ff`); root cause was `oidc-provider`'s `devInteractions` overriding our custom `interactions.url`.
+- ~~Admin layout migration from `guild-*` tokens to zine spec~~ — verified clean 2026-04-29; grep for `guild-[0-9]|candlelight-[0-9]|ember-[0-9]` across `app/layouts/`, `app/pages/admin/`, `app/components/admin/` returns zero matches. All tokens already converted.
+- ~~Admin dashboard quick-action button contrast~~ — verified stale 2026-04-29.
+- ~~Members table NAME column clipping~~ — verified stale 2026-04-29.
- OWASP ASVS L1 Phase 4 (file-upload validation pipeline, granular RBAC, credential encryption).
-- `tickets/available.get.js:115` `memberSavings` block reports `$0 saved` for inactive members — cosmetic; suppress comparison block when `!hasMemberAccess(member)` if it ever surfaces in UI.
-- Simplify-pass follow-ups (2026-04-25): source-grep test bloat, login/verify rate-limit gap, stringly-typed `metadata.type`, reconcile-payments sequential loop, stale `new Date()` in events list, `loadPublicSeries` helper extraction.
+- ~~`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~~ — 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
@@ -150,14 +153,13 @@ See `docs/TODO.md` for:
Context: Phase 4 audit against `docs/specs/events-visual-audit-findings.md` fixed all critical phantom-palette, rounded-corner, CTA-mismatch, and input-styling issues across `EventTicketCard`, `EventTicketPurchase`, `EventSeriesTicketCard`, `SeriesPassPurchase`. Items below were explicitly deferred or out of reach.
-- **Success-state color convention (4 instances).** "You're Registered!" blocks use `--candle` (gold) instead of `--green`. Touches `EventSeriesTicketCard.vue:186-196` (still uses phantom `candlelight-*` classes — preserved byte-for-byte pending decision) and registered-state wrappers in `SeriesPassPurchase.vue`. Needs a UX call on whether success should render gold (zine-consistent) or green (semantic). Once decided, finish the phantom-palette removal on those 4 lines.
-- **Sidebar breakpoint unverified.** `app/layouts/default.vue:89` hides the sidebar at ≤1024px per spec. Browser `resize_window` tool refused viewport changes during the audit, so the actual crossover and any layout shift at 1023–1025px was never visually confirmed. Do a manual responsive check before declaring the sidebar pattern shipped.
-- **`EventTicketPurchase.vue:469` magic padding.** `.consent-hint { padding-left: 24px; }` is a hardcoded offset to align the hint under the checkbox text. Cosmetic; swap for a gap/grid approach when touching the consent block next.
-- **`.section-label` extraction candidate.** Several audited files repeat the same uppercase/letter-spaced small label pattern inline. Low-priority refactor into a utility class in `main.css`.
-- **Past-events toggle component.** Existing, untouched this pass; noted in findings doc as a future consistency check.
+- ~~**Success-state color convention (4 instances).**~~ Resolved 2026-04-29: gold (`--candle`) chosen as zine-consistent. Phantom-Tailwind cleanup shipped in `dc2becf` (`EventSeriesTicketCard.vue` + `SeriesPassPurchase.vue` member-benefit notice).
+- ~~**Sidebar breakpoint unverified.**~~ Verified clean 2026-04-29 — `.events-mini` hides at ≤1024px cleanly across 1023/1024/1025/1100. Actual rule lives in `EventsMiniSidebar.vue:129` + `ColumnsLayout.vue:83` (audit doc cited the wrong line).
+- ~~**`EventTicketPurchase.vue:469` magic padding.**~~ Fixed 2026-04-29 (commit `7e44809`); consent block now uses a grid approach.
+- ~~**`.section-label` extraction candidate.**~~ Verified 2026-04-29 — utility already exists at `main.css:128` and is used in 30+ places. Two scoped overrides intentionally diverge.
+- ~~**Past-events toggle component.**~~ Audited 2026-04-29 — consistent with the design system (dashed-border button, gold active state, valid `aria-pressed` toggle). Added missing `:focus-visible` outline in commit `dadec1a`; no other changes warranted.
### Contribution-amount redesign — cosmetic cleanup (naming only, not behavior)
-- Rename admin members column header "Tier" → "Contribution" (`app/pages/admin/members/index.vue:265`).
-- Update error log message referencing "tier" in `server/api/members/update-contribution.post.js:221`.
-- Rename `handleUpdateTier` handler in `app/pages/member/account.vue`.
+
+SHIPPED 2026-04-29 in commit `955217a` (admin column header, dropdown labels, handler rename, log message).
diff --git a/server/api/events/[id]/tickets/available.get.js b/server/api/events/[id]/tickets/available.get.js
index 2fd64a3..12d64e2 100644
--- a/server/api/events/[id]/tickets/available.get.js
+++ b/server/api/events/[id]/tickets/available.get.js
@@ -6,6 +6,7 @@ import {
checkTicketAvailability,
checkUserSeriesPass,
formatPrice,
+ hasMemberAccess,
} from "../../../../utils/tickets.js";
/**
@@ -111,7 +112,7 @@ export default defineEventHandler(async (event) => {
);
}
- if (member && eventData.tickets?.public?.available) {
+ if (hasMemberAccess(member) && eventData.tickets?.public?.available) {
response.publicTicket = {
price: eventData.tickets.public.price,
formattedPrice: formatPrice(
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,
diff --git a/server/api/members/update-contribution.post.js b/server/api/members/update-contribution.post.js
index 0e4d1f9..3e2c071 100644
--- a/server/api/members/update-contribution.post.js
+++ b/server/api/members/update-contribution.post.js
@@ -203,6 +203,10 @@ export default defineEventHandler(async (event) => {
}
const memberCadence = member.billingCadence || 'monthly';
+ // TODO: Cadence-switch UI on /member/account. Plain Helcim subscription
+ // updates can't change billing period — would need a sub-replacement flow
+ // (cancel current, create new at desired cadence). See
+ // docs/LAUNCH_READINESS.md "Known gotchas" → "Cadence switch rejected".
if (body.cadence && body.cadence !== memberCadence) {
throw createError({
statusCode: 400,
@@ -250,7 +254,7 @@ export default defineEventHandler(async (event) => {
};
} catch (error) {
if (error.statusCode) throw error;
- console.error("Error updating contribution tier:", error);
+ console.error("Error updating contribution amount:", error);
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
diff --git a/server/utils/oidc-provider.ts b/server/utils/oidc-provider.ts
index dfc7042..187f8a8 100644
--- a/server/utils/oidc-provider.ts
+++ b/server/utils/oidc-provider.ts
@@ -86,9 +86,7 @@ export async function getOidcProvider() {
},
features: {
- devInteractions: {
- enabled: process.env.NODE_ENV !== "production",
- },
+ devInteractions: { enabled: false },
revocation: { enabled: true },
rpInitiatedLogout: {
enabled: true,
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..521c0b2
--- /dev/null
+++ b/tests/server/api/free-signup-flow.test.js
@@ -0,0 +1,142 @@
+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'
+
+// 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: {
+ 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'
+
+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)
+ 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' })
+ })
+
+ it('$0 signup: customer endpoint issues a bridge cookie that subscription endpoint accepts', async () => {
+ Member.findOne.mockResolvedValue(null)
+ Member.create.mockResolvedValue({
+ _id: MEMBER_ID,
+ email: 'free@example.com',
+ name: 'Free User',
+ circle: 'community',
+ contributionAmount: 0,
+ status: 'pending_payment'
+ })
+
+ 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')
+
+ const bridgeToken = extractBridgeCookie(customerEvent)
+ expect(bridgeToken, 'payment-bridge cookie missing on $0 signup').toBeTruthy()
+
+ 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: SUBSCRIPTION_BODY
+ })
+
+ 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 () => {
+ const subscriptionEvent = createMockEvent({
+ method: 'POST',
+ path: '/api/helcim/subscription',
+ headers: { origin: ALLOWED_ORIGIN },
+ body: SUBSCRIPTION_BODY
+ })
+
+ await expect(subscriptionHandler(subscriptionEvent)).rejects.toMatchObject({
+ statusCode: 401
+ })
+ })
+})