Compare commits

...

6 commits

Author SHA1 Message Date
8a529a8e7c Add OIDC provider for Outline wiki SSO
Add oidc-provider with MongoDB adapter so ghostguild.org can act as
the identity provider for the self-hosted Outline wiki. Members
authenticate via the existing magic-link flow, with automatic SSO
when an active session exists. Includes interaction routes, well-known
discovery endpoint, and login page.
2026-03-01 15:46:01 +00:00
a232a7bbf8 Handle error status codes in profile patch and update endpoints 2026-03-01 14:18:29 +00:00
a8ae173914 Fix session token expiry 2026-03-01 14:05:26 +00:00
b7279f57d1 Add Zod validation, fix mass assignment, remove test endpoints and dead code
- Add centralized Zod schemas (server/utils/schemas.js) and validateBody
  utility for all API endpoints
- Fix critical mass assignment in member creation: raw body no longer
  passed to new Member(), only validated fields (email, name, circle,
  contributionTier) are accepted
- Apply Zod validation to login, profile patch, event registration,
  updates, verify-payment, and admin event creation endpoints
- Fix logout cookie flags to match login (httpOnly: true, secure
  conditional on NODE_ENV)
- Delete unauthenticated test/debug endpoints (test-connection,
  test-subscription, test-bot)
- Remove sensitive console.log statements from Helcim and member
  endpoints
- Remove unused bcryptjs dependency
- Add 10MB file size limit on image uploads
- Use runtime config for JWT secret across all endpoints
- Add 38 validation tests (117 total, all passing)
2026-03-01 14:02:46 +00:00
26c300c357 Implement OWASP ASVS L1 security remediation (Phases 0-2)
Auth: Add requireAuth/requireAdmin guards with JWT cookie verification,
member status checks (suspended/cancelled = 403), and admin role
enforcement. Apply to all admin, upload, and payment endpoints. Add
role field to Member model.

CSRF: Double-submit cookie middleware with client plugin. Exempt
webhook and magic-link verify routes.

Headers: X-Content-Type-Options, X-Frame-Options, X-XSS-Protection,
Referrer-Policy, Permissions-Policy on all responses. HSTS and CSP
(Helcim/Cloudinary/Plausible sources) in production only.

Rate limiting: Auth 5/5min, payment 10/min, upload 10/min, general
100/min via rate-limiter-flexible, keyed by client IP.

XSS: DOMPurify sanitization on marked() output with tag/attr
allowlists. escapeHtml() utility for email template interpolation.

Anti-enumeration: Login returns identical response for existing and
non-existing emails. Remove 404 handling from login UI components.

Mass assignment: Remove helcimCustomerId from profile allowedFields.

Session: 7-day token expiry, refresh endpoint, httpOnly+secure cookies.

Environment: Validate required secrets on startup via server plugin.
Remove JWT_SECRET hardcoded fallback.
2026-03-01 12:53:18 +00:00
29c96a207e Add Vitest security test suite and update security evaluation doc
Set up Vitest with server (node) and client (jsdom) test projects.
79 tests across 8 files verify all Phase 0-1 security controls:
escapeHtml sanitization, DOMPurify markdown XSS prevention, CSRF
enforcement, security headers, rate limiting, auth guards, profile
field allowlist, and login anti-enumeration. Updated SECURITY_EVALUATION.md
with remediation status, implementation summary, and automated test
coverage details.
2026-03-01 12:30:06 +00:00
92 changed files with 4734 additions and 693 deletions

View file

@ -20,4 +20,9 @@ SLACK_OAUTH_TOKEN=your-slack-oauth-token
JWT_SECRET=your-jwt-secret-key-change-this-in-production JWT_SECRET=your-jwt-secret-key-change-this-in-production
# Application URLs # Application URLs
APP_URL=http://localhost:3000 APP_URL=http://localhost:3000
# OIDC Provider (for Outline Wiki SSO)
OIDC_CLIENT_ID=outline-wiki
OIDC_CLIENT_SECRET=<generate-with-openssl-rand-hex-32>
OIDC_COOKIE_SECRET=<generate-with-openssl-rand-hex-32>

2
.gitignore vendored
View file

@ -17,10 +17,10 @@ logs
.DS_Store .DS_Store
.fleet .fleet
.idea .idea
docs/*
# Local env files # Local env files
.env .env
.env.* .env.*
!.env.example !.env.example
/docs/
scripts/*.js scripts/*.js

View file

@ -211,9 +211,7 @@ const handleLogin = async () => {
} catch (err) { } catch (err) {
console.error("Login error:", err); console.error("Login error:", err);
if (err.statusCode === 404) { if (err.statusCode === 500) {
loginError.value = "No account found";
} else if (err.statusCode === 500) {
loginError.value = "Failed to send email"; loginError.value = "Failed to send email";
} else { } else {
loginError.value = "Something went wrong"; loginError.value = "Something went wrong";

View file

@ -167,9 +167,7 @@ const handleLogin = async () => {
} catch (err) { } catch (err) {
console.error('Login error:', err) console.error('Login error:', err)
if (err.statusCode === 404) { if (err.statusCode === 500) {
loginError.value = 'No account found with that email. Please check your email or join Ghost Guild.'
} else if (err.statusCode === 500) {
loginError.value = 'Failed to send login email. Please try again later.' loginError.value = 'Failed to send login email. Please try again later.'
} else { } else {
loginError.value = err.statusMessage || 'Something went wrong. Please try again.' loginError.value = err.statusMessage || 'Something went wrong. Please try again.'

View file

@ -1,12 +1,29 @@
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'isomorphic-dompurify'
const ALLOWED_TAGS = [
'a', 'strong', 'em', 'b', 'i', 'u',
'ul', 'ol', 'li', 'p', 'br',
'code', 'pre', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
'del', 'sup', 'sub'
]
const ALLOWED_ATTR = ['href', 'target', 'rel', 'class']
export const useMarkdown = () => { export const useMarkdown = () => {
const render = (markdown) => { const render = (markdown) => {
if (!markdown) return '' if (!markdown) return ''
return marked(markdown, { const raw = marked(markdown, {
breaks: true, breaks: true,
gfm: true gfm: true
}) })
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false
})
} }
return { return {

View file

@ -1,18 +1,17 @@
export default defineNuxtRouteMiddleware((to) => { export default defineNuxtRouteMiddleware(async (to) => {
// Skip middleware in server-side rendering to avoid errors
if (process.server) return if (process.server) return
// TODO: Temporarily disabled for testing - enable when authentication is set up const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
// Check if user is authenticated (you'll need to implement proper auth state)
// const isAuthenticated = useCookie('auth-token').value if (!isAuthenticated.value) {
await checkMemberStatus()
// if (!isAuthenticated) { }
// throw createError({
// statusCode: 401, if (!isAuthenticated.value) {
// statusMessage: 'Authentication required' return navigateTo('/')
// }) }
// }
if (memberData.value?.role !== 'admin') {
// TODO: Add proper role-based authorization return navigateTo('/members')
// For now, we assume anyone with a valid token is an admin }
}) })

96
app/pages/oidc/login.vue Normal file
View file

@ -0,0 +1,96 @@
<script setup lang="ts">
definePageMeta({
layout: false,
});
const route = useRoute();
const uid = route.query.uid as string;
const email = ref("");
const sent = ref(false);
const loading = ref(false);
const error = ref("");
async function sendMagicLink() {
if (!email.value || !uid) return;
loading.value = true;
error.value = "";
try {
await $fetch("/oidc/interaction/login", {
method: "POST",
body: { email: email.value, uid },
});
sent.value = true;
} catch (e: any) {
error.value = e?.data?.statusMessage || "Something went wrong. Please try again.";
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div class="w-full max-w-sm">
<div class="bg-white rounded-lg shadow-md p-8">
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-gray-900">Ghost Guild Wiki</h1>
<p class="mt-2 text-sm text-gray-600">
Sign in with your Ghost Guild account
</p>
</div>
<template v-if="!sent">
<form @submit.prevent="sendMagicLink" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email address
</label>
<input
id="email"
v-model="email"
type="email"
required
placeholder="you@example.com"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
:disabled="loading"
/>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="loading || !email"
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ loading ? "Sending..." : "Send magic link" }}
</button>
</form>
<p class="mt-4 text-xs text-center text-gray-500">
We'll send a sign-in link to your email.
</p>
</template>
<template v-else>
<div class="text-center space-y-3">
<div class="text-4xl"></div>
<h2 class="text-lg font-semibold text-gray-900">Check your email</h2>
<p class="text-sm text-gray-600">
We sent a sign-in link to <strong>{{ email }}</strong>.
Click the link in the email to continue.
</p>
<button
@click="sent = false; email = '';"
class="mt-4 text-sm text-blue-600 hover:text-blue-800"
>
Use a different email
</button>
</div>
</template>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,23 @@
export default defineNuxtPlugin(() => {
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS'])
globalThis.$fetch = globalThis.$fetch.create({
onRequest({ options }) {
const method = (options.method || 'GET').toUpperCase()
if (safeMethods.has(method)) return
// Read CSRF token from cookie
const csrfToken = useCookie('csrf-token').value
if (csrfToken) {
options.headers = options.headers || {}
if (options.headers instanceof Headers) {
options.headers.set('x-csrf-token', csrfToken)
} else if (Array.isArray(options.headers)) {
options.headers.push(['x-csrf-token', csrfToken])
} else {
options.headers['x-csrf-token'] = csrfToken
}
}
}
})
})

282
docs/SECURITY_EVALUATION.md Normal file
View file

@ -0,0 +1,282 @@
# Ghost Guild Security Evaluation
**Date:** 2026-02-28 (updated 2026-03-01)
**Framework:** OWASP ASVS v4.0, Level 1
**Scope:** Full application stack (Nuxt 4 + Nitro server + MongoDB)
---
## Framework Rationale
Ghost Guild handles payments (Helcim), PII, and user-generated content. We evaluated four frameworks before selecting ASVS:
| Framework | Why Not |
|---|---|
| PCI-DSS | Too narrow -- only covers payment surface, misses auth/XSS/access control |
| NIST CSF / ISO 27001 | Organizational frameworks, not application-level |
| OWASP Top 10 | Too coarse -- categories without testable requirements |
| **OWASP ASVS L1** | **Selected** -- purpose-built for web apps, testable pass/fail criteria across 14 chapters |
ASVS Level 1 targets "all software" and is achievable for a small team.
---
## Findings
### Severity Summary
| Severity | Count | Key Areas |
|----------|-------|-----------|
| CRITICAL | 5 | Admin auth disabled, payment verification stub, XSS (markdown + emails), cookie flags |
| HIGH | 9 | No rate limiting, no CSRF, JWT secret fallback, unauthenticated endpoints, mass assignment |
| MEDIUM | 5 | Long-lived tokens, Slack secrets unused, devtools, data logging, regex injection |
| LOW | 2 | User enumeration, email format validation |
---
### CRITICAL
#### C1. Admin endpoints completely unprotected
- **ASVS:** V4.1.1 (Trusted enforcement)
- **Files:** All routes under `server/api/admin/`
- **Evidence:** Auth code is commented out with `// TODO: Temporarily disabled auth for testing`. Anyone can list all members, create/delete events, and view revenue dashboard. The Member model has no `role` or `isAdmin` field.
#### C2. Payment verification is a stub
- **ASVS:** V10.2.1 (Business logic integrity)
- **File:** `server/api/helcim/verify-payment.post.js`
- **Evidence:** Returns `{ success: true, cardToken: body.cardToken }` without calling the Helcim API.
#### C3. XSS via unsanitized markdown
- **ASVS:** V5.3.3 (Output encoding for HTML)
- **Files:** `app/composables/useMarkdown.js`, `app/pages/members.vue:247`
- **Evidence:** `marked()` output rendered via `v-html` with no DOMPurify.
#### C4. XSS in email templates
- **ASVS:** V5.2.6 (Server-side injection)
- **File:** `server/utils/resend.js` (11+ interpolation points)
- **Evidence:** User-supplied values interpolated directly into HTML email bodies.
#### C5. Session cookie allows JavaScript access
- **ASVS:** V3.2.1 (Cookie attributes)
- **File:** `server/api/auth/verify.get.js:40-45`
- **Evidence:** `httpOnly: false, secure: false`. Session token accessible to any JavaScript.
---
### HIGH
#### H1. No rate limiting on any endpoint
- **ASVS:** V13.2.5
- **Evidence:** No rate limiting middleware anywhere. Login, registration, uploads, and payment endpoints are unlimited.
#### H2. No CSRF protection
- **ASVS:** V3.5.2
- **Evidence:** No CSRF tokens, double-submit cookies, or CSRF middleware.
#### H3. Hardcoded JWT fallback secret
- **ASVS:** V6.4.1 (Key management)
- **File:** `nuxt.config.ts:17`
- **Evidence:** `jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production'`
#### H4. File upload endpoint unauthenticated
- **ASVS:** V4.1.3
- **File:** `server/api/upload/image.post.js`
- **Evidence:** No auth check. Anyone can upload images.
#### H5. Helcim payment endpoints mostly unauthenticated
- **ASVS:** V4.1.1
- **Files:** `verify-payment.post.js`, `initialize-payment.post.js`, `update-billing.post.js`, `subscription.post.js`
#### H6. Mass assignment: helcimCustomerId in allowedFields
- **ASVS:** V5.1.3
- **File:** `server/api/members/profile.patch.js:40`
- **Evidence:** A member can change their own Helcim customer ID.
#### H7. No input validation
- **ASVS:** V5.1.3
- **Evidence:** Zod is installed but has zero imports. All validation is ad-hoc.
#### H8. User enumeration on login
- **ASVS:** V2.1.7
- **File:** `server/api/auth/login.post.js:24-28`
- **Evidence:** Returns 404 "No account found" vs success.
#### H9. No security headers
- **ASVS:** V9.1.1
- **Evidence:** No CSP, HSTS, X-Frame-Options, or X-Content-Type-Options.
---
### MEDIUM
| ID | ASVS | Finding | File |
|----|------|---------|------|
| M1 | V2.5.2 | 30-day static session token, no rotation | `verify.get.js:36` |
| M2 | V10.2.2 | Slack signing secret defined but never used | `nuxt.config.ts:21` |
| M3 | V7.4.1 | DevTools enabled unconditionally | `nuxt.config.ts:4` |
| M4 | V7.1.1 | Console logging of payment data and API token substrings | `helcim/customer.post.js:42` |
| M5 | V5.3.4 | Unescaped regex in directory search | `members/directory.get.js:49-51` |
### LOW
| ID | ASVS | Finding | File |
|----|------|---------|------|
| L1 | V2.1.7 | Timing-based enumeration via DB lookup | `auth/login.post.js` |
| L2 | V5.1.3 | No email format validation on login | `auth/login.post.js:15` |
---
## Future Feature Risk Assessment
| Planned Feature | Risk | Must Address First |
|---|---|---|
| Rich text member updates | Same XSS pattern as C3 | Fix markdown sanitization (C3) |
| Resource library with downloads | Unauthenticated upload (H4), malware distribution | Add upload auth (H4), file validation |
| Etherpad integration | External content rendered unsanitized | Build sanitization utility (C3) |
| Cal.com integration | API credential exposure | Fix secret management (H3) |
| Member-proposed events | No admin role model, no approval workflow | Build RBAC (C1) |
| Advanced search/analytics | Regex injection (M5), privacy leakage | Fix regex escaping (M5) |
---
## Remediation Summary (Phases 0-1 + partial Phase 2)
All work lives on branch `security/asvs-remediation`.
### Auth guards (`server/utils/auth.js`)
- `requireAuth(event)` -- Reads JWT from `auth-token` cookie, verifies against `jwtSecret`, loads member from DB, rejects suspended/cancelled accounts (403). Auto-imported by Nitro.
- `requireAdmin(event)` -- Calls `requireAuth`, then checks `member.role === 'admin'` (403 if not). Member model gained `role` field (enum: `member`/`admin`, default `member`).
- Applied to all `server/api/admin/`, `server/api/upload/`, and `server/api/helcim/` endpoints.
### CSRF (`server/middleware/01.csrf.js` + `app/plugins/csrf.client.js`)
- Double-submit cookie pattern. Middleware generates a random token cookie on first request, then enforces that POST/PATCH/DELETE to `/api/*` include a matching `x-csrf-token` header.
- Client plugin reads the cookie and attaches the header on every `$fetch` request.
- Exempt routes: `/api/helcim/webhook`, `/api/slack/webhook`, `/api/auth/verify`.
### Security headers (`server/middleware/02.security-headers.js`)
- Always: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 0`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
- Production only: HSTS (1 year, includeSubDomains) and CSP allowing Helcim, Cloudinary, and Plausible sources.
### Rate limiting (`server/middleware/03.rate-limit.js`)
- `rate-limiter-flexible` with in-memory stores, keyed by client IP.
- Auth endpoints: 5 requests per 5 minutes. Payment endpoints: 10 per minute. Uploads: 10 per minute. General API: 100 per minute.
- Returns 429 with `Retry-After` header on exhaustion.
### XSS prevention
- **Markdown** (`app/composables/useMarkdown.js`): `marked()` output sanitized through DOMPurify with explicit allowlists for tags and attributes. Strips script/iframe/object/embed/img and all event handler attributes.
- **Email templates** (`server/utils/escapeHtml.js`): Pure function escaping `& < > " '` to HTML entities. Applied to all user-supplied interpolations in `server/utils/resend.js`.
### Anti-enumeration (`server/api/auth/login.post.js`)
- Login returns identical `{ success: true, message: "If this email is registered, we've sent a login link." }` for both existing and non-existing emails.
### Mass assignment (`server/api/members/profile.patch.js`)
- Explicit allowlist of profile fields. `helcimCustomerId`, `role`, `status`, `email`, `_id` are excluded from the `$set` update.
### Session management
- 7-day token expiry with refresh endpoint at `/api/auth/refresh`.
---
## Remediation Roadmap
### Phase 0: Emergency (before any production traffic) -- COMPLETE
| # | Finding | Fix | ASVS | Status |
|---|---------|-----|------|--------|
| 1 | C5 | Set `httpOnly: true`, `secure` conditional on NODE_ENV | V3.2.1 | Done |
| 2 | C3 | Install `isomorphic-dompurify`, sanitize `marked()` output | V5.3.3 | Done |
| 3 | C1 | Add `role` field to Member model, re-enable admin auth with role check | V4.1.1 | Done |
| 4 | C2 | Call Helcim API in verify-payment to confirm transactions server-side | V10.2.1 | Done |
| 5 | H3 | Throw on missing JWT_SECRET instead of fallback | V6.4.1 | Done |
### Phase 1: Pre-launch (before public access) -- COMPLETE
| # | Finding | Fix | ASVS | Status |
|---|---------|-----|------|--------|
| 6 | C4 | Create `escapeHtml()` utility, apply to all email template interpolations | V5.2.6 | Done |
| 7 | H4 | Add JWT verification to upload endpoint | V4.1.3 | Done |
| 8 | H5 | Add JWT verification to payment endpoints | V4.1.1 | Done |
| 9 | H6 | Remove `helcimCustomerId` from `allowedFields` | V5.1.3 | Done |
| 10 | H2 | Add CSRF double-submit cookie middleware (exempt webhooks) | V3.5.2 | Done |
| 11 | H9 | Add CSP, HSTS, X-Frame-Options, X-Content-Type-Options headers | V9.1.1 | Done |
| 12 | H1 | Add rate limiting to login, registration, upload, payment | V13.2.5 | Done |
| 13 | H8 | Return identical response for existing/non-existing accounts | V2.1.7 | Done |
| 14 | -- | Add `status: 'active'` check to auth endpoints | V4.1.1 | Done |
### Phase 2: Hardening (within 30 days of launch) -- PARTIAL
| # | Finding | Fix | ASVS | Status |
|---|---------|-----|------|--------|
| 15 | H7 | Implement Zod validation across all API endpoints | V5.1.3 | Open |
| 16 | M5 | Escape regex in directory search | V5.3.4 | Open |
| 17 | M4 | Remove sensitive console.log statements | V7.1.1 | Open |
| 18 | M3 | Make devtools conditional on NODE_ENV | V7.4.1 | Open |
| 19 | M1 | Shorter session tokens (7d) with refresh endpoint | V2.5.2 | Done |
| 20 | -- | Create shared `requireAuth()`/`requireAdmin()` utilities | V4.1.1 | Done |
### Phase 3: Before building planned features
| # | Fix | Status |
|---|-----|--------|
| 21 | Build sanitization utility (DOMPurify wrapper) for all user-generated HTML | Open |
| 22 | Design admin role model with granular permissions | Open |
| 23 | Implement file validation pipeline (type, size, virus scanning) | Open |
| 24 | Design credential management patterns (encrypted at rest) | Open |
---
## Automated Testing
### Framework
Vitest with two test projects:
- **Server tests** (`tests/server/`): Node.js environment, h3 globals stubbed from real h3 functions
- **Client tests** (`tests/client/`): jsdom environment for browser-side composables
```bash
npm run test # Watch mode
npm run test:run # Single run (CI)
```
### Infrastructure
- `tests/server/setup.js` -- Stubs real h3 functions (`getCookie`, `setCookie`, `getMethod`, `getHeader`, `setHeader`, `getRequestURL`, `createError`, `defineEventHandler`, `readBody`, etc.) as globals to simulate Nitro auto-imports. Also stubs `useRuntimeConfig`.
- `tests/server/helpers/createMockEvent.js` -- Factory that builds real h3 events from Node.js `IncomingMessage`/`ServerResponse` pairs. Accepts `method`, `path`, `headers`, `cookies`, `body`, and `remoteAddress`. Captures response headers via `event._testSetHeaders` for assertions.
### Test Coverage (79 tests across 8 files)
| File | Tests | Security Controls Verified |
|------|-------|---------------------------|
| `tests/server/utils/escapeHtml.test.js` | 12 | All 5 HTML entity escapes, null/undefined handling, `<script>` and `<img onerror>` XSS payloads (C4, V5.2.6) |
| `tests/client/composables/useMarkdown.test.js` | 18 | Script/iframe/object/embed tags stripped, onerror/onclick attrs stripped, javascript: URIs sanitized, safe markdown preserved (C3, V5.3.3) |
| `tests/server/middleware/csrf.test.js` | 14 | GET/HEAD/OPTIONS bypass, non-API bypass, webhook exemptions, 403 on missing/mismatched token, PATCH/DELETE enforcement, cookie provisioning (H2, V3.5.2) |
| `tests/server/middleware/security-headers.test.js` | 12 | X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy, Permissions-Policy always set; HSTS + CSP production-only; CSP includes Helcim/Cloudinary/Plausible sources (H9, V9.1.1) |
| `tests/server/middleware/rate-limit.test.js` | 4 | Auth endpoint 5/5min limit, payment endpoint 10/min limit, IP isolation between clients (H1, V13.2.5) |
| `tests/server/utils/auth.test.js` | 8 | No cookie = 401, invalid JWT = 401, member not found = 401, suspended = 403, cancelled = 403, active = returns member, non-admin = 403, admin = returns member (C1, V4.1.1) |
| `tests/server/api/auth-login.test.js` | 4 | Existing and non-existing emails return identical response shape and message, missing email = 400 (H8, V2.1.7) |
| `tests/server/api/members-profile-patch.test.js` | 7 | `helcimCustomerId`, `role`, `status`, `email`, `_id` blocked from `$set`; allowed fields (`pronouns`, `bio`, `studio`, etc.) and nested objects (`offering`, `lookingFor`) pass through (H6, V5.1.3) |
### Manual Test Cases
These items require browser or network-level verification and are not covered by automated tests:
**Item 1 (Cookie flags):** Open DevTools > Application > Cookies. Verify `auth-token` has `HttpOnly: true`. Run `document.cookie` in console -- `auth-token` must NOT appear. Auth flow must still work.
**Item 4 (Payment verification):** Fake `cardToken` = failure. Real HelcimPay.js flow in test mode = success.
**Item 5 (JWT fallback):** Unset `JWT_SECRET`, start server = crash with clear error. Set it = normal startup.
**Item 7 (Upload auth):** POST `/api/upload/image` with no cookie = 401. With valid auth = success.
**Item 8 (Payment auth):** Each endpoint with no cookie = 401. Full join flow still completes.
### Integration verification (after each phase)
- `npm run test:run` -- all 79 tests pass
- `npm run build` succeeds
- Full join flow: free tier + paid tier
- Full login flow: magic link request, click, redirect to `/members`
- Profile editing: avatar upload, bio update, privacy settings
- Admin pages: access control verified
- `curl` against hardened endpoints: unauthenticated = rejected

View file

@ -1,7 +1,7 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2025-07-15", compatibilityDate: "2025-07-15",
devtools: { enabled: true }, devtools: { enabled: process.env.NODE_ENV !== 'production' },
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"], modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
build: { build: {
transpile: ["vue-cal"], transpile: ["vue-cal"],
@ -14,12 +14,15 @@ export default defineNuxtConfig({
// Private keys (server-side only) // Private keys (server-side only)
mongodbUri: mongodbUri:
process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild", process.env.MONGODB_URI || "mongodb://localhost:27017/ghostguild",
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-in-production", jwtSecret: process.env.JWT_SECRET || "",
resendApiKey: process.env.RESEND_API_KEY || "", resendApiKey: process.env.RESEND_API_KEY || "",
helcimApiToken: process.env.HELCIM_API_TOKEN || "", helcimApiToken: process.env.HELCIM_API_TOKEN || "",
slackBotToken: process.env.SLACK_BOT_TOKEN || "", slackBotToken: process.env.SLACK_BOT_TOKEN || "",
slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "", slackSigningSecret: process.env.SLACK_SIGNING_SECRET || "",
slackVettingChannelId: process.env.SLACK_VETTING_CHANNEL_ID || "", slackVettingChannelId: process.env.SLACK_VETTING_CHANNEL_ID || "",
oidcClientId: process.env.OIDC_CLIENT_ID || "outline-wiki",
oidcClientSecret: process.env.OIDC_CLIENT_SECRET || "",
oidcCookieSecret: process.env.OIDC_COOKIE_SECRET || "",
// Public keys (available on client-side) // Public keys (available on client-side)
public: { public: {

1742
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,9 @@
"dev": "nuxt dev", "dev": "nuxt dev",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"test": "vitest",
"test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"@cloudinary/vue": "^1.13.3", "@cloudinary/vue": "^1.13.3",
@ -17,15 +19,17 @@
"@nuxt/ui": "^4.0.0", "@nuxt/ui": "^4.0.0",
"@nuxtjs/plausible": "^3.0.1", "@nuxtjs/plausible": "^3.0.1",
"@slack/web-api": "^7.10.0", "@slack/web-api": "^7.10.0",
"bcryptjs": "^3.0.2",
"chrono-node": "^2.8.4", "chrono-node": "^2.8.4",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
"eslint": "^9.34.0", "eslint": "^9.34.0",
"isomorphic-dompurify": "^3.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"marked": "^17.0.3", "marked": "^17.0.3",
"mongoose": "^8.18.0", "mongoose": "^8.18.0",
"nitro-cors": "^0.7.1", "nitro-cors": "^0.7.1",
"nuxt": "^4.0.3", "nuxt": "^4.0.3",
"oidc-provider": "^9.6.1",
"rate-limiter-flexible": "^9.1.1",
"resend": "^6.0.1", "resend": "^6.0.1",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vue": "^3.5.20", "vue": "^3.5.20",
@ -33,6 +37,11 @@
"zod": "^4.1.3" "zod": "^4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.19" "@nuxt/test-utils": "^4.0.0",
"@tailwindcss/typography": "^0.5.19",
"@types/jsonwebtoken": "^9.0.10",
"@types/oidc-provider": "^9.5.0",
"jsdom": "^28.1.0",
"vitest": "^4.0.18"
} }
} }

View file

@ -1,26 +1,13 @@
import Member from '../../models/member.js' import Member from '../../models/member.js'
import Event from '../../models/event.js' import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import jwt from 'jsonwebtoken' import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up await requireAdmin(event)
// Basic auth check
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// jwt.verify(token, config.jwtSecret)
await connectDB() await connectDB()
// Get stats // Get stats
const totalMembers = await Member.countDocuments() const totalMembers = await Member.countDocuments()
const now = new Date() const now = new Date()
@ -28,21 +15,21 @@ export default defineEventHandler(async (event) => {
startDate: { $lte: now }, startDate: { $lte: now },
endDate: { $gte: now } endDate: { $gte: now }
}) })
// Calculate monthly revenue from member contributions // Calculate monthly revenue from member contributions
const members = await Member.find({}, 'contributionTier').lean() const members = await Member.find({}, 'contributionTier').lean()
const monthlyRevenue = members.reduce((total, member) => { const monthlyRevenue = members.reduce((total, member) => {
return total + parseInt(member.contributionTier || '0') return total + parseInt(member.contributionTier || '0')
}, 0) }, 0)
const pendingSlackInvites = await Member.countDocuments({ slackInvited: false }) const pendingSlackInvites = await Member.countDocuments({ slackInvited: false })
// Get recent members (last 5) // Get recent members (last 5)
const recentMembers = await Member.find() const recentMembers = await Member.find()
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.limit(5) .limit(5)
.lean() .lean()
// Get upcoming events (next 5) // Get upcoming events (next 5)
const upcomingEvents = await Event.find({ const upcomingEvents = await Event.find({
startDate: { $gte: now } startDate: { $gte: now }
@ -50,7 +37,7 @@ export default defineEventHandler(async (event) => {
.sort({ startDate: 1 }) .sort({ startDate: 1 })
.limit(5) .limit(5)
.lean() .lean()
return { return {
stats: { stats: {
totalMembers, totalMembers,
@ -62,9 +49,10 @@ export default defineEventHandler(async (event) => {
upcomingEvents upcomingEvents
} }
} catch (error) { } catch (error) {
if (error.statusCode) throw error
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to fetch dashboard data' statusMessage: 'Failed to fetch dashboard data'
}) })
} }
}) })

View file

@ -1,34 +1,22 @@
import Event from '../../models/event.js' import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import jwt from 'jsonwebtoken' import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up await requireAdmin(event)
// Basic auth check - you may want to implement proper admin role checking
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// jwt.verify(token, config.jwtSecret)
await connectDB() await connectDB()
const events = await Event.find() const events = await Event.find()
.sort({ startDate: 1 }) .sort({ startDate: 1 })
.lean() .lean()
return events return events
} catch (error) { } catch (error) {
if (error.statusCode) throw error
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to fetch events' statusMessage: 'Failed to fetch events'
}) })
} }
}) })

View file

@ -1,37 +1,20 @@
import Event from "../../models/event.js"; import Event from "../../models/event.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
import jwt from "jsonwebtoken"; import { requireAdmin } from "../../utils/auth.js";
import { validateBody } from "../../utils/validateBody.js";
import { adminEventCreateSchema } from "../../utils/schemas.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up const admin = await requireAdmin(event);
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) { const body = await validateBody(event, adminEventCreateSchema);
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// const decoded = jwt.verify(token, config.jwtSecret)
const body = await readBody(event);
// Validate required fields
if (!body.title || !body.description || !body.startDate || !body.endDate) {
throw createError({
statusCode: 400,
statusMessage: "Missing required fields",
});
}
await connectDB(); await connectDB();
const eventData = { const eventData = {
...body, ...body,
createdBy: "admin@ghostguild.org", // TODO: Use actual authenticated user createdBy: admin.email,
startDate: new Date(body.startDate), startDate: new Date(body.startDate),
endDate: new Date(body.endDate), endDate: new Date(body.endDate),
registrationDeadline: body.registrationDeadline registrationDeadline: body.registrationDeadline
@ -67,6 +50,7 @@ export default defineEventHandler(async (event) => {
return savedEvent; return savedEvent;
} catch (error) { } catch (error) {
if (error.statusCode) throw error;
console.error("Error creating event:", error); console.error("Error creating event:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,

View file

@ -1,26 +1,15 @@
import Event from '../../../models/event.js' import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
import jwt from 'jsonwebtoken' import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up await requireAdmin(event)
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// const decoded = jwt.verify(token, config.jwtSecret)
const eventId = getRouterParam(event, 'id') const eventId = getRouterParam(event, 'id')
await connectDB() await connectDB()
const deletedEvent = await Event.findByIdAndDelete(eventId) const deletedEvent = await Event.findByIdAndDelete(eventId)
if (!deletedEvent) { if (!deletedEvent) {
@ -29,13 +18,14 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Event not found' statusMessage: 'Event not found'
}) })
} }
return { success: true, message: 'Event deleted successfully' } return { success: true, message: 'Event deleted successfully' }
} catch (error) { } catch (error) {
if (error.statusCode) throw error
console.error('Error deleting event:', error) console.error('Error deleting event:', error)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: error.message || 'Failed to delete event' statusMessage: error.message || 'Failed to delete event'
}) })
} }
}) })

View file

@ -1,47 +1,31 @@
import Event from '../../../models/event.js' import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
import jwt from 'jsonwebtoken' import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up await requireAdmin(event)
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// const decoded = jwt.verify(token, config.jwtSecret)
const eventId = getRouterParam(event, 'id') const eventId = getRouterParam(event, 'id')
console.log('🔍 API: Get event by ID called')
console.log('🔍 API: Event ID param:', eventId)
await connectDB() await connectDB()
const eventData = await Event.findById(eventId) const eventData = await Event.findById(eventId)
console.log('🔍 API: Event data found:', eventData ? 'YES' : 'NO')
console.log('🔍 API: Event data preview:', eventData ? { id: eventData._id, title: eventData.title } : null)
if (!eventData) { if (!eventData) {
console.log('❌ API: Event not found in database')
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
statusMessage: 'Event not found' statusMessage: 'Event not found'
}) })
} }
console.log('✅ API: Returning event data')
return { data: eventData } return { data: eventData }
} catch (error) { } catch (error) {
console.error('❌ API: Error fetching event:', error) if (error.statusCode) throw error
console.error('Error fetching event:', error)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: error.message || 'Failed to fetch event' statusMessage: error.message || 'Failed to fetch event'
}) })
} }
}) })

View file

@ -1,25 +1,14 @@
import Event from '../../../models/event.js' import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
import jwt from 'jsonwebtoken' import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up await requireAdmin(event)
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// const decoded = jwt.verify(token, config.jwtSecret)
const eventId = getRouterParam(event, 'id') const eventId = getRouterParam(event, 'id')
const body = await readBody(event) const body = await readBody(event)
// Validate required fields // Validate required fields
if (!body.title || !body.description || !body.startDate || !body.endDate) { if (!body.title || !body.description || !body.startDate || !body.endDate) {
throw createError({ throw createError({
@ -29,7 +18,7 @@ export default defineEventHandler(async (event) => {
} }
await connectDB() await connectDB()
const updateData = { const updateData = {
...body, ...body,
startDate: new Date(body.startDate), startDate: new Date(body.startDate),
@ -37,7 +26,7 @@ export default defineEventHandler(async (event) => {
registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null, registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null,
updatedAt: new Date() updatedAt: new Date()
} }
// Handle ticket data // Handle ticket data
if (body.tickets) { if (body.tickets) {
updateData.tickets = { updateData.tickets = {
@ -67,13 +56,14 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Event not found' statusMessage: 'Event not found'
}) })
} }
return updatedEvent return updatedEvent
} catch (error) { } catch (error) {
if (error.statusCode) throw error
console.error('Error updating event:', error) console.error('Error updating event:', error)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: error.message || 'Failed to update event' statusMessage: error.message || 'Failed to update event'
}) })
} }
}) })

View file

@ -1,34 +1,22 @@
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import jwt from 'jsonwebtoken' import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up await requireAdmin(event)
// Basic auth check - you may want to implement proper admin role checking
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// jwt.verify(token, config.jwtSecret)
await connectDB() await connectDB()
const members = await Member.find() const members = await Member.find()
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.lean() .lean()
return members return members
} catch (error) { } catch (error) {
if (error.statusCode) throw error
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to fetch members' statusMessage: 'Failed to fetch members'
}) })
} }
}) })

View file

@ -1,24 +1,13 @@
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import jwt from 'jsonwebtoken' import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
// TODO: Temporarily disabled auth for testing - enable when authentication is set up await requireAdmin(event)
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
// if (!token) {
// throw createError({
// statusCode: 401,
// statusMessage: 'Authentication required'
// })
// }
// const config = useRuntimeConfig()
// jwt.verify(token, config.jwtSecret)
const body = await readBody(event) const body = await readBody(event)
// Validate required fields // Validate required fields
if (!body.name || !body.email || !body.circle || !body.contributionTier) { if (!body.name || !body.email || !body.circle || !body.contributionTier) {
throw createError({ throw createError({
@ -28,7 +17,7 @@ export default defineEventHandler(async (event) => {
} }
await connectDB() await connectDB()
// Check if member already exists // Check if member already exists
const existingMember = await Member.findOne({ email: body.email }) const existingMember = await Member.findOne({ email: body.email })
if (existingMember) { if (existingMember) {
@ -47,14 +36,14 @@ export default defineEventHandler(async (event) => {
}) })
const savedMember = await newMember.save() const savedMember = await newMember.save()
return savedMember return savedMember
} catch (error) { } catch (error) {
if (error.statusCode) throw error if (error.statusCode) throw error
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Failed to create member' statusMessage: 'Failed to create member'
}) })
} }
}) })

View file

@ -1,9 +1,11 @@
import Series from "../../models/series.js"; import Series from "../../models/series.js";
import Event from "../../models/event.js"; import Event from "../../models/event.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
import { requireAdmin } from "../../utils/auth.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAdmin(event);
await connectDB(); await connectDB();
// Fetch all series // Fetch all series

View file

@ -1,12 +1,14 @@
import Series from '../../models/series.js' import Series from '../../models/series.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const admin = await requireAdmin(event)
await connectDB() await connectDB()
const body = await readBody(event) const body = await readBody(event)
// Validate required fields // Validate required fields
if (!body.id || !body.title || !body.description) { if (!body.id || !body.title || !body.description) {
throw createError({ throw createError({
@ -14,7 +16,7 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Series ID, title, and description are required' statusMessage: 'Series ID, title, and description are required'
}) })
} }
// Create new series // Create new series
const newSeries = new Series({ const newSeries = new Series({
id: body.id, id: body.id,
@ -22,7 +24,7 @@ export default defineEventHandler(async (event) => {
description: body.description, description: body.description,
type: body.type || 'workshop_series', type: body.type || 'workshop_series',
totalEvents: body.totalEvents || null, totalEvents: body.totalEvents || null,
createdBy: 'admin' // TODO: Get from authentication createdBy: admin.email
}) })
await newSeries.save() await newSeries.save()

View file

@ -1,9 +1,11 @@
import Series from '../../models/series.js' import Series from '../../models/series.js'
import Event from '../../models/event.js' import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { requireAdmin } from '../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAdmin(event)
await connectDB() await connectDB()
const body = await readBody(event) const body = await readBody(event)

View file

@ -1,9 +1,11 @@
import Series from '../../../models/series.js' import Series from '../../../models/series.js'
import Event from '../../../models/event.js' import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAdmin(event)
await connectDB() await connectDB()
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')

View file

@ -1,9 +1,11 @@
import Series from '../../../models/series.js' import Series from '../../../models/series.js'
import Event from '../../../models/event.js' import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAdmin(event)
await connectDB() await connectDB()
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')

View file

@ -1,9 +1,11 @@
import Series from '../../../models/series.js' import Series from '../../../models/series.js'
import Event from '../../../models/event.js' import Event from '../../../models/event.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
import { requireAdmin } from '../../../utils/auth.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAdmin(event)
await connectDB() await connectDB()
const body = await readBody(event) const body = await readBody(event)

View file

@ -3,6 +3,8 @@ import jwt from "jsonwebtoken";
import { Resend } from "resend"; import { Resend } from "resend";
import Member from "../../models/member.js"; import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
import { validateBody } from "../../utils/validateBody.js";
import { emailSchema } from "../../utils/schemas.js";
const resend = new Resend(process.env.RESEND_API_KEY); const resend = new Resend(process.env.RESEND_API_KEY);
@ -10,28 +12,26 @@ export default defineEventHandler(async (event) => {
// Connect to database // Connect to database
await connectDB(); await connectDB();
const { email } = await readBody(event); const { email } = await validateBody(event, emailSchema);
if (!email) { const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
throw createError({
statusCode: 400,
statusMessage: "Email is required",
});
}
const member = await Member.findOne({ email }); const member = await Member.findOne({ email });
if (!member) { if (!member) {
throw createError({ // Return same response shape to prevent enumeration
statusCode: 404, return {
statusMessage: "No account found with that email address", success: true,
}); message: GENERIC_MESSAGE,
};
} }
// Generate magic link token // Generate magic link token (use runtime config for consistency with verify/requireAuth)
const config = useRuntimeConfig(event);
const token = jwt.sign( const token = jwt.sign(
{ memberId: member._id }, { memberId: member._id },
process.env.JWT_SECRET, config.jwtSecret,
{ expiresIn: "15m" }, // Shorter expiry for security { expiresIn: "15m" },
); );
// Get the base URL for the magic link // Get the base URL for the magic link
@ -65,7 +65,7 @@ export default defineEventHandler(async (event) => {
return { return {
success: true, success: true,
message: "Login link sent to your email", message: GENERIC_MESSAGE,
}; };
} catch (error) { } catch (error) {
console.error("Failed to send email:", error); console.error("Failed to send email:", error);

View file

@ -1,8 +1,8 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
// Clear the auth token cookie // Clear the auth token cookie (flags must match login for proper clearing)
setCookie(event, 'auth-token', '', { setCookie(event, 'auth-token', '', {
httpOnly: false, // Match the original cookie settings httpOnly: true,
secure: false, // Don't require HTTPS in development secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
maxAge: 0 // Expire immediately maxAge: 0 // Expire immediately
}) })

View file

@ -1,60 +1,30 @@
import jwt from "jsonwebtoken"; import { requireAuth } from "../../utils/auth.js";
import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await connectDB(); const member = await requireAuth(event);
const token = getCookie(event, "auth-token"); return {
console.log("Auth check - token found:", !!token); _id: member._id,
id: member._id,
if (!token) { email: member.email,
console.log("No auth token found in cookies"); name: member.name,
throw createError({ role: member.role || 'member',
statusCode: 401, circle: member.circle,
statusMessage: "Not authenticated", contributionTier: member.contributionTier,
}); membershipLevel: `${member.circle}-${member.contributionTier}`,
} // Profile fields
pronouns: member.pronouns,
try { timeZone: member.timeZone,
const decoded = jwt.verify(token, process.env.JWT_SECRET); avatar: member.avatar,
const member = await Member.findById(decoded.memberId).select("-__v"); studio: member.studio,
bio: member.bio,
if (!member) { location: member.location,
throw createError({ socialLinks: member.socialLinks,
statusCode: 404, offering: member.offering,
statusMessage: "Member not found", lookingFor: member.lookingFor,
}); showInDirectory: member.showInDirectory,
} privacy: member.privacy,
// Peer support
return { peerSupport: member.peerSupport,
_id: member._id, };
id: member._id,
email: member.email,
name: member.name,
circle: member.circle,
contributionTier: member.contributionTier,
membershipLevel: `${member.circle}-${member.contributionTier}`,
// Profile fields
pronouns: member.pronouns,
timeZone: member.timeZone,
avatar: member.avatar,
studio: member.studio,
bio: member.bio,
location: member.location,
socialLinks: member.socialLinks,
offering: member.offering,
lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory,
privacy: member.privacy,
// Peer support
peerSupport: member.peerSupport,
};
} catch (err) {
console.error("Token verification error:", err);
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
}); });

View file

@ -0,0 +1,58 @@
import jwt from 'jsonwebtoken'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await connectDB()
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Not authenticated'
})
}
let decoded
try {
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
}
const member = await Member.findById(decoded.memberId)
if (!member) {
throw createError({
statusCode: 401,
statusMessage: 'Member not found'
})
}
if (member.status === 'suspended' || member.status === 'cancelled') {
throw createError({
statusCode: 403,
statusMessage: 'Account is ' + member.status
})
}
// Issue a fresh token
const newToken = jwt.sign(
{ memberId: member._id, email: member.email },
useRuntimeConfig().jwtSecret,
{ expiresIn: '7d' }
)
setCookie(event, 'auth-token', newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7 // 7 days
})
return { success: true }
})

View file

@ -6,22 +6,22 @@ export default defineEventHandler(async (event) => {
await connectDB() await connectDB()
const token = getCookie(event, 'auth-token') const token = getCookie(event, 'auth-token')
console.log('🔍 Auth status check - token exists:', !!token)
if (!token) { if (!token) {
return { authenticated: false, member: null } return { authenticated: false, member: null }
} }
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET) const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
const member = await Member.findById(decoded.memberId).select('-__v') const member = await Member.findById(decoded.memberId).select('-__v')
if (!member) { if (!member) {
console.log('⚠️ Token valid but member not found')
return { authenticated: false, member: null } return { authenticated: false, member: null }
} }
console.log('✅ Auth status check - member found:', member.email) if (member.status === 'suspended' || member.status === 'cancelled') {
return { authenticated: false, member: null, reason: 'account_' + member.status }
}
return { return {
authenticated: true, authenticated: true,
member: { member: {
@ -34,7 +34,6 @@ export default defineEventHandler(async (event) => {
} }
} }
} catch (err) { } catch (err) {
console.error('❌ Auth status check - token verification failed:', err.message)
return { authenticated: false, member: null } return { authenticated: false, member: null }
} }
}) })

View file

@ -18,8 +18,9 @@ export default defineEventHandler(async (event) => {
} }
try { try {
// Verify the JWT token // Verify the JWT token (use runtime config for consistency with login/requireAuth)
const decoded = jwt.verify(token, process.env.JWT_SECRET) const config = useRuntimeConfig(event)
const decoded = jwt.verify(token, config.jwtSecret)
const member = await Member.findById(decoded.memberId) const member = await Member.findById(decoded.memberId)
if (!member) { if (!member) {
@ -32,23 +33,22 @@ export default defineEventHandler(async (event) => {
// Create a new session token for the authenticated user // Create a new session token for the authenticated user
const sessionToken = jwt.sign( const sessionToken = jwt.sign(
{ memberId: member._id, email: member.email }, { memberId: member._id, email: member.email },
process.env.JWT_SECRET, config.jwtSecret,
{ expiresIn: '30d' } { expiresIn: '7d' }
) )
// Set the session cookie // Set the session cookie
setCookie(event, 'auth-token', sessionToken, { setCookie(event, 'auth-token', sessionToken, {
httpOnly: false, // Allow JavaScript access for debugging in development httpOnly: true,
secure: false, // Don't require HTTPS in development secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30 // 30 days maxAge: 60 * 60 * 24 * 7 // 7 days
}) })
// Redirect to the members dashboard or home page // Redirect to the members dashboard or home page
await sendRedirect(event, '/members', 302) await sendRedirect(event, '/members', 302)
} catch (err) { } catch (err) {
console.error('Token verification error:', err)
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
statusMessage: 'Invalid or expired token' statusMessage: 'Invalid or expired token'

View file

@ -2,6 +2,8 @@ import Event from "../../../models/event.js";
import Member from "../../../models/member.js"; import Member from "../../../models/member.js";
import { connectDB } from "../../../utils/mongoose.js"; import { connectDB } from "../../../utils/mongoose.js";
import { sendEventRegistrationEmail } from "../../../utils/resend.js"; import { sendEventRegistrationEmail } from "../../../utils/resend.js";
import { validateBody } from "../../../utils/validateBody.js";
import { eventRegistrationSchema } from "../../../utils/schemas.js";
import mongoose from "mongoose"; import mongoose from "mongoose";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -9,7 +11,7 @@ export default defineEventHandler(async (event) => {
// Ensure database connection // Ensure database connection
await connectDB(); await connectDB();
const identifier = getRouterParam(event, "id"); const identifier = getRouterParam(event, "id");
const body = await readBody(event); const body = await validateBody(event, eventRegistrationSchema);
if (!identifier) { if (!identifier) {
throw createError({ throw createError({
@ -18,14 +20,6 @@ export default defineEventHandler(async (event) => {
}); });
} }
// Validate required fields
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: "Name and email are required",
});
}
// Fetch the event - try by slug first, then by ID // Fetch the event - try by slug first, then by ID
let eventData; let eventData;

View file

@ -92,7 +92,6 @@ export default defineEventHandler(async (event) => {
// Optional: Verify the transaction with Helcim API // Optional: Verify the transaction with Helcim API
// This adds extra security to ensure the transaction is legitimate // This adds extra security to ensure the transaction is legitimate
// For now, we trust the transaction ID from HelcimPay.js // For now, we trust the transaction ID from HelcimPay.js
console.log("Payment completed with transaction ID:", transactionId);
} }
// Create registration // Create registration

View file

@ -16,7 +16,6 @@ export default defineEventHandler(async (event) => {
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Creating payment plan:', body.name)
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, { const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'POST', method: 'POST',
@ -44,7 +43,6 @@ export default defineEventHandler(async (event) => {
} }
const planData = await response.json() const planData = await response.json()
console.log('Payment plan created:', planData)
return { return {
success: true, success: true,

View file

@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
// Decode JWT token // Decode JWT token
let decoded let decoded
try { try {
decoded = jwt.verify(token, process.env.JWT_SECRET) decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) { } catch (err) {
throw createError({ throw createError({
statusCode: 401, statusCode: 401,

View file

@ -38,8 +38,6 @@ export default defineEventHandler(async (event) => {
}) })
} }
// Debug: Log token (first few chars only)
console.log('Using Helcim token:', helcimToken.substring(0, 10) + '...')
// Test the connection first with native fetch // Test the connection first with native fetch
try { try {
@ -55,8 +53,7 @@ export default defineEventHandler(async (event) => {
throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`) throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`)
} }
const testData = await testResponse.json() await testResponse.json()
console.log('Connection test passed:', testData)
} catch (testError) { } catch (testError) {
console.error('Connection test failed:', testError) console.error('Connection test failed:', testError)
throw createError({ throw createError({
@ -108,23 +105,19 @@ export default defineEventHandler(async (event) => {
email: body.email, email: body.email,
helcimCustomerId: customerData.id helcimCustomerId: customerData.id
}, },
process.env.JWT_SECRET, config.jwtSecret,
{ expiresIn: '24h' } { expiresIn: '7d' }
) )
// Set the session cookie server-side // Set the session cookie server-side
console.log('Setting auth-token cookie for member:', member.email)
console.log('NODE_ENV:', process.env.NODE_ENV)
setCookie(event, 'auth-token', token, { setCookie(event, 'auth-token', token, {
httpOnly: true, // Server-only for security httpOnly: true,
secure: false, // Don't require HTTPS in development secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24 * 7, // 7 days (matches verify.get.js and refresh.post.js)
path: '/', path: '/',
domain: undefined // Let browser set domain automatically domain: undefined // Let browser set domain automatically
}) })
console.log('Cookie set successfully')
return { return {
success: true, success: true,
customerId: customerData.id, customerId: customerData.id,

View file

@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
// Decode JWT token // Decode JWT token
let decoded let decoded
try { try {
decoded = jwt.verify(token, process.env.JWT_SECRET) decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) { } catch (err) {
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
@ -59,7 +59,6 @@ export default defineEventHandler(async (event) => {
const existingCustomer = searchData.customers.find(c => c.email === member.email) const existingCustomer = searchData.customers.find(c => c.email === member.email)
if (existingCustomer) { if (existingCustomer) {
console.log('Found existing Helcim customer:', existingCustomer.id)
// Update member record with customer ID if not already set // Update member record with customer ID if not already set
if (!member.helcimCustomerId) { if (!member.helcimCustomerId) {
@ -77,12 +76,11 @@ export default defineEventHandler(async (event) => {
} }
} }
} catch (searchError) { } catch (searchError) {
console.log('Error searching for customer:', searchError) console.error('Error searching for customer:', searchError)
// Continue to create new customer // Continue to create new customer
} }
// No existing customer found, create new one // No existing customer found, create new one
console.log('Creating new Helcim customer for:', member.email)
const createResponse = await fetch(`${HELCIM_API_BASE}/customers`, { const createResponse = await fetch(`${HELCIM_API_BASE}/customers`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -107,7 +105,6 @@ export default defineEventHandler(async (event) => {
} }
const customerData = await createResponse.json() const customerData = await createResponse.json()
console.log('Created Helcim customer:', customerData.id)
// Update member record with customer ID // Update member record with customer ID
member.helcimCustomerId = customerData.id member.helcimCustomerId = customerData.id

View file

@ -1,13 +1,14 @@
// Initialize HelcimPay.js session // Initialize HelcimPay.js session
import { requireAuth } from "../../utils/auth.js";
const HELCIM_API_BASE = "https://api.helcim.com/v2"; const HELCIM_API_BASE = "https://api.helcim.com/v2";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAuth(event);
const config = useRuntimeConfig(event); const config = useRuntimeConfig(event);
const body = await readBody(event); const body = await readBody(event);
// Debug log the request body
console.log("Initialize payment request body:", body);
const helcimToken = const helcimToken =
config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN; config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN;
@ -43,8 +44,6 @@ export default defineEventHandler(async (event) => {
requestBody.orderNumber = `${body.metadata.eventId}`; requestBody.orderNumber = `${body.metadata.eventId}`;
} }
console.log("Helcim request body:", JSON.stringify(requestBody, null, 2));
// Initialize HelcimPay.js session // Initialize HelcimPay.js session
const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, { const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, {
method: "POST", method: "POST",

View file

@ -6,8 +6,6 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Fetching payment plans from Helcim...')
const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, { const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -18,17 +16,13 @@ export default defineEventHandler(async (event) => {
if (!response.ok) { if (!response.ok) {
console.error('Failed to fetch payment plans:', response.status, response.statusText) console.error('Failed to fetch payment plans:', response.status, response.statusText)
const errorText = await response.text()
console.error('Response body:', errorText)
throw createError({ throw createError({
statusCode: response.status, statusCode: response.status,
statusMessage: `Failed to fetch payment plans: ${errorText}` statusMessage: 'Failed to fetch payment plans'
}) })
} }
const plansData = await response.json() const plansData = await response.json()
console.log('Payment plans retrieved:', JSON.stringify(plansData, null, 2))
return { return {
success: true, success: true,

View file

@ -3,6 +3,7 @@ import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js'
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts' import { getSlackService } from '../../utils/slack.ts'
import { requireAuth } from '../../utils/auth.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2' const HELCIM_API_BASE = 'https://api.helcim.com/v2'
@ -72,6 +73,7 @@ async function inviteToSlack(member) {
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAuth(event)
await connectDB() await connectDB()
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const body = await readBody(event) const body = await readBody(event)
@ -91,11 +93,8 @@ export default defineEventHandler(async (event) => {
}) })
} }
console.log('Subscription request body:', body)
// Check if payment is required // Check if payment is required
if (!requiresPayment(body.contributionTier)) { if (!requiresPayment(body.contributionTier)) {
console.log('No payment required for tier:', body.contributionTier)
// For free tier, just update member status // For free tier, just update member status
const member = await Member.findOneAndUpdate( const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId }, { helcimCustomerId: body.customerId },
@ -107,8 +106,6 @@ export default defineEventHandler(async (event) => {
{ new: true } { new: true }
) )
console.log('Updated member for free tier:', member)
// Send Slack invitation for free tier members // Send Slack invitation for free tier members
await inviteToSlack(member) await inviteToSlack(member)
@ -119,11 +116,8 @@ export default defineEventHandler(async (event) => {
} }
} }
console.log('Payment required for tier:', body.contributionTier)
// Get the Helcim plan ID // Get the Helcim plan ID
const planId = getHelcimPlanId(body.contributionTier) const planId = getHelcimPlanId(body.contributionTier)
console.log('Plan ID for tier:', planId)
// Validate card token is provided // Validate card token is provided
if (!body.cardToken) { if (!body.cardToken) {
@ -135,8 +129,6 @@ export default defineEventHandler(async (event) => {
// Check if we have a configured plan for this tier // Check if we have a configured plan for this tier
if (!planId) { if (!planId) {
console.log('No Helcim plan configured for tier:', body.contributionTier)
const member = await Member.findOneAndUpdate( const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId }, { helcimCustomerId: body.customerId },
{ {
@ -168,8 +160,6 @@ export default defineEventHandler(async (event) => {
// Try to create subscription in Helcim // Try to create subscription in Helcim
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Attempting to create Helcim subscription with plan ID:', planId)
// Generate a proper alphanumeric idempotency key (exactly 25 characters) // Generate a proper alphanumeric idempotency key (exactly 25 characters)
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let idempotencyKey = '' let idempotencyKey = ''
@ -197,10 +187,6 @@ export default defineEventHandler(async (event) => {
'idempotency-key': idempotencyKey 'idempotency-key': idempotencyKey
} }
console.log('Subscription request body:', requestBody)
console.log('Request headers:', requestHeaders)
console.log('Request URL:', `${HELCIM_API_BASE}/subscriptions`)
try { try {
const subscriptionResponse = await fetch(`${HELCIM_API_BASE}/subscriptions`, { const subscriptionResponse = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST', method: 'POST',
@ -210,47 +196,11 @@ export default defineEventHandler(async (event) => {
if (!subscriptionResponse.ok) { if (!subscriptionResponse.ok) {
const errorText = await subscriptionResponse.text() const errorText = await subscriptionResponse.text()
console.error('Subscription creation failed:') console.error('Subscription creation failed:', subscriptionResponse.status)
console.error('Status:', subscriptionResponse.status)
console.error('Status Text:', subscriptionResponse.statusText)
console.error('Headers:', Object.fromEntries(subscriptionResponse.headers.entries()))
console.error('Response Body:', errorText)
console.error('Request was:', {
url: `${HELCIM_API_BASE}/subscriptions`,
method: 'POST',
body: requestBody,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken ? 'present' : 'missing'
}
})
// If it's a validation error, let's try to get more info about available plans // If it's a validation error, let's try to get more info about available plans
if (subscriptionResponse.status === 400 || subscriptionResponse.status === 404) { if (subscriptionResponse.status === 400 || subscriptionResponse.status === 404) {
console.log('Plan might not exist. Trying to get list of available payment plans...') // Plan might not exist -- update member status and proceed
// Try to fetch available payment plans
try {
const plansResponse = await fetch(`${HELCIM_API_BASE}/payment-plans`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
if (plansResponse.ok) {
const plansData = await plansResponse.json()
console.log('Available payment plans:', JSON.stringify(plansData, null, 2))
} else {
console.log('Could not fetch payment plans:', plansResponse.status, plansResponse.statusText)
}
} catch (planError) {
console.log('Error fetching payment plans:', planError.message)
}
// For now, just update member status and let user know we need to configure plans
const member = await Member.findOneAndUpdate( const member = await Member.findOneAndUpdate(
{ helcimCustomerId: body.customerId }, { helcimCustomerId: body.customerId },
{ {
@ -287,7 +237,6 @@ export default defineEventHandler(async (event) => {
} }
const subscriptionData = await subscriptionResponse.json() const subscriptionData = await subscriptionResponse.json()
console.log('Subscription created successfully:', subscriptionData)
// Extract the first subscription from the response array // Extract the first subscription from the response array
const subscription = subscriptionData.data?.[0] const subscription = subscriptionData.data?.[0]

View file

@ -6,8 +6,6 @@ export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
console.log('Fetching existing subscriptions from Helcim...')
const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, { const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -18,17 +16,13 @@ export default defineEventHandler(async (event) => {
if (!response.ok) { if (!response.ok) {
console.error('Failed to fetch subscriptions:', response.status, response.statusText) console.error('Failed to fetch subscriptions:', response.status, response.statusText)
const errorText = await response.text()
console.error('Response body:', errorText)
throw createError({ throw createError({
statusCode: response.status, statusCode: response.status,
statusMessage: `Failed to fetch subscriptions: ${errorText}` statusMessage: 'Failed to fetch subscriptions'
}) })
} }
const subscriptionsData = await response.json() const subscriptionsData = await response.json()
console.log('Existing subscriptions:', JSON.stringify(subscriptionsData, null, 2))
return { return {
success: true, success: true,

View file

@ -1,46 +0,0 @@
// Test Helcim API connection
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
// Log token info (safely)
const tokenInfo = {
hasToken: !!config.public.helcimToken,
tokenLength: config.public.helcimToken ? config.public.helcimToken.length : 0,
tokenPrefix: config.public.helcimToken ? config.public.helcimToken.substring(0, 10) : null
}
console.log('Helcim Token Info:', tokenInfo)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// Try connection test endpoint
const response = await $fetch(`${HELCIM_API_BASE}/connection-test`, {
method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
})
return {
success: true,
message: 'Helcim API connection successful',
tokenInfo,
connectionResponse: response
}
} catch (error) {
console.error('Helcim test error:', error)
return {
success: false,
message: error.message || 'Failed to connect to Helcim API',
statusCode: error.statusCode,
tokenInfo: {
hasToken: !!useRuntimeConfig().public.helcimToken,
tokenLength: useRuntimeConfig().public.helcimToken ? useRuntimeConfig().public.helcimToken.length : 0
}
}
}
})

View file

@ -1,77 +0,0 @@
// Test minimal subscription creation to understand required fields
const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
// Generate a 25-character idempotency key
const idempotencyKey = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`.substring(0, 25)
// Test with minimal fields first
const testRequest1 = {
customerCode: 'CST1020', // Use a recent customer code
planId: 20162
}
console.log('Testing subscription with minimal fields:', testRequest1)
try {
const response1 = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken,
'idempotency-key': idempotencyKey + 'a'
},
body: JSON.stringify(testRequest1)
})
const result1 = await response1.text()
console.log('Test 1 - Status:', response1.status)
console.log('Test 1 - Response:', result1)
if (!response1.ok) {
// Try with paymentPlanId instead
const testRequest2 = {
customerCode: 'CST1020',
paymentPlanId: 20162
}
console.log('Testing subscription with paymentPlanId:', testRequest2)
const response2 = await fetch(`${HELCIM_API_BASE}/subscriptions`, {
method: 'POST',
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'api-token': helcimToken,
'idempotency-key': idempotencyKey + 'b'
},
body: JSON.stringify(testRequest2)
})
const result2 = await response2.text()
console.log('Test 2 - Status:', response2.status)
console.log('Test 2 - Response:', result2)
}
} catch (error) {
console.error('Test error:', error)
}
return {
success: true,
message: 'Check server logs for test results'
}
} catch (error) {
console.error('Error in test endpoint:', error)
throw createError({
statusCode: 500,
statusMessage: error.message
})
}
})

View file

@ -1,8 +1,11 @@
// Update customer billing address // Update customer billing address
import { requireAuth } from '../../utils/auth.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2' const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAuth(event)
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const body = await readBody(event) const body = await readBody(event)

View file

@ -1,38 +1,67 @@
// Verify payment token from HelcimPay.js // Verify payment token from HelcimPay.js
import { requireAuth } from '../../utils/auth.js'
import { validateBody } from '../../utils/validateBody.js'
import { paymentVerifySchema } from '../../utils/schemas.js'
const HELCIM_API_BASE = 'https://api.helcim.com/v2' const HELCIM_API_BASE = 'https://api.helcim.com/v2'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAuth(event)
const config = useRuntimeConfig(event) const config = useRuntimeConfig(event)
const body = await readBody(event) const body = await validateBody(event, paymentVerifySchema)
// Validate required fields const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
if (!body.cardToken || !body.customerId) {
if (!helcimToken) {
throw createError({ throw createError({
statusCode: 400, statusCode: 500,
statusMessage: 'Card token and customer ID are required' statusMessage: 'Helcim API token not configured'
}) })
} }
console.log('Payment verification request:', { // Verify the card token by fetching the customer's cards from Helcim
customerId: body.customerId, const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}/cards`, {
cardToken: body.cardToken ? 'present' : 'missing' method: 'GET',
headers: {
'accept': 'application/json',
'api-token': helcimToken
}
}) })
// Since HelcimPay.js already verified the payment and we have the card token, if (!response.ok) {
// we can just return success. The card is already associated with the customer. const errorText = await response.text()
console.log('Payment already verified through HelcimPay.js, returning success') console.error('Payment verification failed:', response.status, errorText)
throw createError({
statusCode: 502,
statusMessage: 'Payment verification failed with Helcim'
})
}
const cards = await response.json()
// Verify the card token exists for this customer
const cardExists = Array.isArray(cards) && cards.some(card =>
card.cardToken === body.cardToken
)
if (!cardExists) {
throw createError({
statusCode: 400,
statusMessage: 'Payment method not found or does not belong to this customer'
})
}
return { return {
success: true, success: true,
cardToken: body.cardToken, cardToken: body.cardToken,
message: 'Payment verified successfully through HelcimPay.js' message: 'Payment verified with Helcim'
} }
} catch (error) { } catch (error) {
console.error('Error verifying payment:', error) console.error('Error verifying payment:', error)
throw createError({ throw createError({
statusCode: error.statusCode || 500, statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to verify payment' statusMessage: error.statusMessage || 'Failed to verify payment'
}) })
} }
}) })

View file

@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
// Decode JWT token // Decode JWT token
let decoded; let decoded;
try { try {
decoded = jwt.verify(token, process.env.JWT_SECRET); decoded = jwt.verify(token, config.jwtSecret);
} catch (err) { } catch (err) {
throw createError({ throw createError({
statusCode: 401, statusCode: 401,

View file

@ -2,6 +2,8 @@
import Member from '../../models/member.js' import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js' import { connectDB } from '../../utils/mongoose.js'
import { getSlackService } from '../../utils/slack.ts' import { getSlackService } from '../../utils/slack.ts'
import { validateBody } from '../../utils/validateBody.js'
import { memberCreateSchema } from '../../utils/schemas.js'
// Simple payment check function to avoid import issues // Simple payment check function to avoid import issues
const requiresPayment = (contributionValue) => contributionValue !== '0' const requiresPayment = (contributionValue) => contributionValue !== '0'
@ -14,7 +16,7 @@ async function inviteToSlack(member) {
return return
} }
console.log(`Processing Slack invitation for ${member.email}...`) console.warn(`Processing Slack invitation for member`)
const inviteResult = await slackService.inviteUserToSlack( const inviteResult = await slackService.inviteUserToSlack(
member.email, member.email,
@ -45,13 +47,13 @@ async function inviteToSlack(member) {
inviteResult.status inviteResult.status
) )
console.log(`Successfully processed Slack invitation for ${member.email}: ${inviteResult.status}`) console.warn(`Slack invitation processed: ${inviteResult.status}`)
} else { } else {
// Update member record to reflect failed invitation // Update member record to reflect failed invitation
member.slackInviteStatus = 'failed' member.slackInviteStatus = 'failed'
await member.save() await member.save()
console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`) console.error(`Failed to process Slack invitation: ${inviteResult.error}`)
// Don't throw error - member creation should still succeed // Don't throw error - member creation should still succeed
} }
} catch (error) { } catch (error) {
@ -73,32 +75,30 @@ export default defineEventHandler(async (event) => {
// Ensure database is connected // Ensure database is connected
await connectDB() await connectDB()
const body = await readBody(event) const validatedData = await validateBody(event, memberCreateSchema)
try { try {
// Check if member already exists // Check if member already exists
const existingMember = await Member.findOne({ email: body.email }) const existingMember = await Member.findOne({ email: validatedData.email })
if (existingMember) { if (existingMember) {
throw createError({ throw createError({
statusCode: 409, statusCode: 409,
statusMessage: 'A member with this email already exists' statusMessage: 'A member with this email already exists'
}) })
} }
const member = new Member(body) const member = new Member(validatedData)
await member.save() await member.save()
// Send Slack invitation for new members // Send Slack invitation for new members
await inviteToSlack(member) await inviteToSlack(member)
// TODO: Process payment with Helcim if not free tier // TODO: Process payment with Helcim if not free tier
if (requiresPayment(body.contributionTier)) { if (requiresPayment(validatedData.contributionTier)) {
// Payment processing will be added here // Payment processing will be added here
console.log('Payment processing needed for tier:', body.contributionTier)
} }
// TODO: Send welcome email // TODO: Send welcome email
console.log('Welcome email should be sent to:', body.email)
return { success: true, member } return { success: true, member }
} catch (error) { } catch (error) {

View file

@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
if (token) { if (token) {
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
currentMemberId = decoded.memberId; currentMemberId = decoded.memberId;
isAuthenticated = true; isAuthenticated = true;
} catch (err) { } catch (err) {
@ -46,9 +46,11 @@ export default defineEventHandler(async (event) => {
// Search by name or bio // Search by name or bio
if (search) { if (search) {
// Escape special regex characters to prevent ReDoS
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$or = [ dbQuery.$or = [
{ name: { $regex: search, $options: "i" } }, { name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: search, $options: "i" } }, { bio: { $regex: escaped, $options: "i" } },
]; ];
} }
@ -60,11 +62,12 @@ export default defineEventHandler(async (event) => {
]; ];
// If search is also present, combine with AND // If search is also present, combine with AND
if (search) { if (search) {
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
dbQuery.$and = [ dbQuery.$and = [
{ {
$or: [ $or: [
{ name: { $regex: search, $options: "i" } }, { name: { $regex: escaped, $options: "i" } },
{ bio: { $regex: search, $options: "i" } }, { bio: { $regex: escaped, $options: "i" } },
], ],
}, },
{ {

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
let memberId; let memberId;
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId; memberId = decoded.memberId;
} catch (err) { } catch (err) {
throw createError({ throw createError({

View file

@ -1,34 +1,16 @@
import jwt from "jsonwebtoken";
import Member from "../../models/member.js"; import Member from "../../models/member.js";
import { connectDB } from "../../utils/mongoose.js"; import { requireAuth } from "../../utils/auth.js";
import { validateBody } from "../../utils/validateBody.js";
import { memberProfileUpdateSchema } from "../../utils/schemas.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await connectDB(); const authedMember = await requireAuth(event);
const memberId = authedMember._id;
const token = getCookie(event, "auth-token"); const body = await validateBody(event, memberProfileUpdateSchema);
if (!token) { // Profile fields from validated body
throw createError({ const profileFields = [
statusCode: 401,
statusMessage: "Not authenticated",
});
}
let memberId;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
memberId = decoded.memberId;
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
const body = await readBody(event);
// Define allowed profile fields
const allowedFields = [
"pronouns", "pronouns",
"timeZone", "timeZone",
"avatar", "avatar",
@ -37,10 +19,9 @@ export default defineEventHandler(async (event) => {
"location", "location",
"socialLinks", "socialLinks",
"showInDirectory", "showInDirectory",
"helcimCustomerId",
]; ];
// Define privacy fields // Privacy fields from validated body
const privacyFields = [ const privacyFields = [
"pronounsPrivacy", "pronounsPrivacy",
"timeZonePrivacy", "timeZonePrivacy",
@ -53,10 +34,10 @@ export default defineEventHandler(async (event) => {
"lookingForPrivacy", "lookingForPrivacy",
]; ];
// Build update object // Build update object from validated data
const updateData = {}; const updateData = {};
allowedFields.forEach((field) => { profileFields.forEach((field) => {
if (body[field] !== undefined) { if (body[field] !== undefined) {
updateData[field] = body[field]; updateData[field] = body[field];
} }
@ -94,7 +75,7 @@ export default defineEventHandler(async (event) => {
if (!member) { if (!member) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
message: "Member not found", statusMessage: "Member not found",
}); });
} }
@ -117,10 +98,11 @@ export default defineEventHandler(async (event) => {
showInDirectory: member.showInDirectory, showInDirectory: member.showInDirectory,
}; };
} catch (error) { } catch (error) {
if (error.statusCode) throw error;
console.error("Profile update error:", error); console.error("Profile update error:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
message: "Failed to update profile", statusMessage: "Failed to update profile",
}); });
} }
}); });

View file

@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
// Decode JWT token // Decode JWT token
let decoded; let decoded;
try { try {
decoded = jwt.verify(token, process.env.JWT_SECRET); decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
} catch (err) { } catch (err) {
throw createError({ throw createError({
statusCode: 401, statusCode: 401,

View file

@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
if (token) { if (token) {
try { try {
jwt.verify(token, process.env.JWT_SECRET); jwt.verify(token, useRuntimeConfig().jwtSecret);
isAuthenticated = true; isAuthenticated = true;
} catch (err) { } catch (err) {
isAuthenticated = false; isAuthenticated = false;

View file

@ -1,68 +0,0 @@
import { WebClient } from '@slack/web-api'
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
if (!config.slackBotToken) {
return {
success: false,
error: 'Slack bot token not configured'
}
}
const client = new WebClient(config.slackBotToken)
try {
// Test basic API access
const authTest = await client.auth.test()
console.log('Auth test result:', authTest)
// Test if admin API is available
let adminApiAvailable = false
let adminError = null
try {
// Try to call admin.users.list to test admin API access
await client.admin.users.list({ limit: 1 })
adminApiAvailable = true
} catch (error: any) {
adminError = error.data?.error || error.message
console.log('Admin API test failed:', adminError)
}
// Test channel access if channel ID is configured
let channelAccess = false
let channelError = null
if (config.slackVettingChannelId) {
try {
const channelInfo = await client.conversations.info({
channel: config.slackVettingChannelId
})
channelAccess = !!channelInfo.channel
} catch (error: any) {
channelError = error.data?.error || error.message
}
}
return {
success: true,
botInfo: {
user: authTest.user,
team: authTest.team,
url: authTest.url
},
adminApiAvailable,
adminError: adminApiAvailable ? null : adminError,
channelAccess,
channelError: channelAccess ? null : channelError,
channelId: config.slackVettingChannelId || 'Not configured'
}
} catch (error: any) {
return {
success: false,
error: error.data?.error || error.message || 'Unknown error'
}
}
})

View file

@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
let memberId; let memberId;
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId; memberId = decoded.memberId;
} catch (err) { } catch (err) {
throw createError({ statusCode: 401, statusMessage: "Invalid token" }); throw createError({ statusCode: 401, statusMessage: "Invalid token" });

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
let memberId; let memberId;
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId; memberId = decoded.memberId;
} catch (err) { } catch (err) {
throw createError({ throw createError({

View file

@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
// Check if user is authenticated // Check if user is authenticated
if (token) { if (token) {
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId; memberId = decoded.memberId;
} catch (err) { } catch (err) {
// Token invalid, continue as non-member // Token invalid, continue as non-member

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
let memberId; let memberId;
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId; memberId = decoded.memberId;
} catch (err) { } catch (err) {
throw createError({ throw createError({

View file

@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
// Check if user is authenticated // Check if user is authenticated
if (token) { if (token) {
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId; memberId = decoded.memberId;
} catch (err) { } catch (err) {
// Token invalid, continue as non-member // Token invalid, continue as non-member

View file

@ -1,6 +1,8 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import Update from "../../models/update.js"; import Update from "../../models/update.js";
import { connectDB } from "../../utils/mongoose.js"; import { connectDB } from "../../utils/mongoose.js";
import { validateBody } from "../../utils/validateBody.js";
import { updateCreateSchema } from "../../utils/schemas.js";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await connectDB(); await connectDB();
@ -16,7 +18,7 @@ export default defineEventHandler(async (event) => {
let memberId; let memberId;
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId; memberId = decoded.memberId;
} catch (err) { } catch (err) {
throw createError({ throw createError({
@ -25,14 +27,7 @@ export default defineEventHandler(async (event) => {
}); });
} }
const body = await readBody(event); const body = await validateBody(event, updateCreateSchema);
if (!body.content || !body.content.trim()) {
throw createError({
statusCode: 400,
statusMessage: "Content is required",
});
}
try { try {
const update = await Update.create({ const update = await Update.create({
@ -48,6 +43,7 @@ export default defineEventHandler(async (event) => {
return update; return update;
} catch (error) { } catch (error) {
if (error.statusCode) throw error;
console.error("Create update error:", error); console.error("Create update error:", error);
throw createError({ throw createError({
statusCode: 500, statusCode: 500,

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
let memberId; let memberId;
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
memberId = decoded.memberId; memberId = decoded.memberId;
} catch (err) { } catch (err) {
throw createError({ throw createError({

View file

@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
// Check if user is authenticated // Check if user is authenticated
if (token) { if (token) {
try { try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
currentMemberId = decoded.memberId; currentMemberId = decoded.memberId;
} catch (err) { } catch (err) {
// Token invalid, continue as non-member // Token invalid, continue as non-member

View file

@ -1,4 +1,5 @@
import { v2 as cloudinary } from 'cloudinary' import { v2 as cloudinary } from 'cloudinary'
import { requireAuth } from '../../utils/auth.js'
// Configure Cloudinary // Configure Cloudinary
cloudinary.config({ cloudinary.config({
@ -9,6 +10,7 @@ cloudinary.config({
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
await requireAuth(event)
// Parse the multipart form data // Parse the multipart form data
const formData = await readMultipartFormData(event) const formData = await readMultipartFormData(event)
@ -37,6 +39,15 @@ export default defineEventHandler(async (event) => {
}) })
} }
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024
if (fileData.data.length > maxSize) {
throw createError({
statusCode: 400,
statusMessage: 'File too large. Maximum size is 10MB.'
})
}
// Convert buffer to base64 for Cloudinary upload // Convert buffer to base64 for Cloudinary upload
const base64File = `data:${fileData.type};base64,${fileData.data.toString('base64')}` const base64File = `data:${fileData.type};base64,${fileData.data.toString('base64')}`

View file

@ -0,0 +1,47 @@
import crypto from 'crypto'
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
// Routes exempt from CSRF (external webhooks, magic link verify)
const EXEMPT_PREFIXES = [
'/api/helcim/webhook',
'/api/slack/webhook',
'/api/auth/verify',
'/oidc/',
]
function isExempt(path) {
return EXEMPT_PREFIXES.some(prefix => path.startsWith(prefix))
}
export default defineEventHandler((event) => {
const method = getMethod(event)
const path = getRequestURL(event).pathname
// Always set a CSRF token cookie if one doesn't exist
let csrfToken = getCookie(event, 'csrf-token')
if (!csrfToken) {
csrfToken = crypto.randomBytes(32).toString('hex')
setCookie(event, 'csrf-token', csrfToken, {
httpOnly: false, // Must be readable by JS to include in requests
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
})
}
// Only check state-changing methods
if (SAFE_METHODS.has(method)) return
if (!path.startsWith('/api/')) return
if (isExempt(path)) return
// Double-submit cookie check: header must match cookie
const headerToken = getHeader(event, 'x-csrf-token')
if (!headerToken || headerToken !== csrfToken) {
throw createError({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
}
})

View file

@ -0,0 +1,30 @@
export default defineEventHandler((event) => {
const headers = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '0',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
}
if (process.env.NODE_ENV === 'production') {
headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
// CSP: allow self, Cloudinary images, HelcimPay.js, Plausible analytics
headers['Content-Security-Policy'] = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://myposjs.helcim.com https://plausible.io",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://res.cloudinary.com https://*.cloudinary.com",
"font-src 'self'",
"connect-src 'self' https://api.helcim.com https://myposjs.helcim.com https://plausible.io",
"frame-src 'self' https://myposjs.helcim.com https://secure.helcim.com",
"base-uri 'self'",
"form-action 'self'",
].join('; ')
}
for (const [key, value] of Object.entries(headers)) {
setHeader(event, key, value)
}
})

View file

@ -0,0 +1,65 @@
import { RateLimiterMemory } from 'rate-limiter-flexible'
// Strict rate limit for auth endpoints
const authLimiter = new RateLimiterMemory({
points: 5, // 5 requests
duration: 300, // per 5 minutes
keyPrefix: 'rl_auth'
})
// Moderate rate limit for payment endpoints
const paymentLimiter = new RateLimiterMemory({
points: 10,
duration: 60,
keyPrefix: 'rl_payment'
})
// Light rate limit for upload endpoints
const uploadLimiter = new RateLimiterMemory({
points: 10,
duration: 60,
keyPrefix: 'rl_upload'
})
// General API rate limit
const generalLimiter = new RateLimiterMemory({
points: 100,
duration: 60,
keyPrefix: 'rl_general'
})
function getClientIp(event) {
return getHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
|| getHeader(event, 'x-real-ip')
|| event.node.req.socket.remoteAddress
|| 'unknown'
}
const AUTH_PATHS = new Set(['/api/auth/login'])
const PAYMENT_PREFIXES = ['/api/helcim/']
const UPLOAD_PATHS = new Set(['/api/upload/image'])
export default defineEventHandler(async (event) => {
const path = getRequestURL(event).pathname
if (!path.startsWith('/api/')) return
const ip = getClientIp(event)
try {
if (AUTH_PATHS.has(path)) {
await authLimiter.consume(ip)
} else if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
await paymentLimiter.consume(ip)
} else if (UPLOAD_PATHS.has(path)) {
await uploadLimiter.consume(ip)
} else {
await generalLimiter.consume(ip)
}
} catch (rateLimiterRes) {
setHeader(event, 'Retry-After', Math.ceil(rateLimiterRes.msBeforeNext / 1000))
throw createError({
statusCode: 429,
statusMessage: 'Too many requests. Please try again later.'
})
}
})

View file

@ -22,6 +22,11 @@ const memberSchema = new mongoose.Schema({
enum: getValidContributionValues(), enum: getValidContributionValues(),
required: true, required: true,
}, },
role: {
type: String,
enum: ["member", "admin"],
default: "member",
},
status: { status: {
type: String, type: String,
enum: ["pending_payment", "active", "suspended", "cancelled"], enum: ["pending_payment", "active", "suspended", "cancelled"],

View file

@ -0,0 +1,9 @@
export default defineNitroPlugin(() => {
const config = useRuntimeConfig()
if (!config.jwtSecret) {
console.error('FATAL: JWT_SECRET environment variable is not set. Server cannot start without it.')
console.error('Set JWT_SECRET in your .env file or environment variables.')
process.exit(1)
}
})

View file

@ -0,0 +1,24 @@
/**
* Forward /.well-known/openid-configuration to the oidc-provider.
*
* The provider generates this discovery document automatically, but since the
* catch-all route is mounted under /oidc/, requests to /.well-known/ need
* explicit forwarding.
*/
import { getOidcProvider } from "../../utils/oidc-provider.js";
export default defineEventHandler(async (event) => {
const provider = await getOidcProvider();
const { req, res } = event.node;
// The provider expects the path relative to its root
req.url = "/.well-known/openid-configuration";
const callback = provider.callback() as Function;
await new Promise<void>((resolve, reject) => {
callback(req, res, (err: unknown) => {
if (err) reject(err);
else resolve();
});
});
});

View file

@ -0,0 +1,30 @@
/**
* Catch-all route that delegates all /oidc/* requests to the oidc-provider.
*
* This exposes the standard OIDC endpoints:
* /oidc/auth authorization
* /oidc/token token exchange
* /oidc/me userinfo
* /oidc/session/end logout
* /oidc/jwks JSON Web Key Set
*/
import { getOidcProvider } from "../../utils/oidc-provider.js";
export default defineEventHandler(async (event) => {
const provider = await getOidcProvider();
const { req, res } = event.node;
// oidc-provider expects paths relative to its own mount point.
// Nitro gives us the full path, so strip the /oidc prefix.
const originalUrl = req.url || "";
req.url = originalUrl.replace(/^\/oidc/, "") || "/";
// Hand off to oidc-provider's Connect-style callback
const callback = provider.callback() as Function;
await new Promise<void>((resolve, reject) => {
callback(req, res, (err: unknown) => {
if (err) reject(err);
else resolve();
});
});
});

View file

@ -0,0 +1,94 @@
/**
* OIDC interaction handler checks for an existing Ghost Guild session.
*
* Flow:
* 1. Outline redirects user to /oidc/auth
* 2. oidc-provider creates an interaction and redirects here
* 3. If the user has a valid auth-token cookie complete the interaction (SSO)
* 4. Otherwise redirect to the OIDC login page
*/
import jwt from "jsonwebtoken";
import Member from "../../../models/member.js";
import { connectDB } from "../../../utils/mongoose.js";
import { getOidcProvider } from "../../../utils/oidc-provider.js";
export default defineEventHandler(async (event) => {
const provider = await getOidcProvider();
const uid = getRouterParam(event, "uid")!;
// Load the interaction details from oidc-provider
const interactionDetails = await provider.interactionDetails(
event.node.req,
event.node.res
);
const { prompt } = interactionDetails;
// ----- Login prompt -----
if (prompt.name === "login") {
// Check for existing Ghost Guild session
const token = getCookie(event, "auth-token");
if (token) {
try {
const config = useRuntimeConfig();
const decoded = jwt.verify(token, config.jwtSecret) as {
memberId: string;
};
await connectDB();
const member = await (Member as any).findById(decoded.memberId);
if (
member &&
member.status !== "suspended" &&
member.status !== "cancelled"
) {
// Auto-complete the login interaction (SSO)
const result = {
login: { accountId: member._id.toString() },
};
await provider.interactionFinished(
event.node.req,
event.node.res,
result,
{ mergeWithLastSubmission: false }
);
return;
}
} catch {
// Token invalid — fall through to login page
}
}
// No valid session — redirect to login page
return sendRedirect(event, `/oidc/login?uid=${uid}`, 302);
}
// ----- Consent prompt -----
if (prompt.name === "consent") {
// Auto-approve consent for our first-party client
const grant = interactionDetails.grantId
? await provider.Grant.find(interactionDetails.grantId)
: new provider.Grant({
accountId: interactionDetails.session!.accountId,
clientId: interactionDetails.params.client_id as string,
});
if (grant) {
grant.addOIDCScope("openid profile email");
await grant.save();
const result = { consent: { grantId: grant.jti } };
await provider.interactionFinished(
event.node.req,
event.node.res,
result,
{ mergeWithLastSubmission: true }
);
return;
}
}
// Fallback — shouldn't reach here normally
throw createError({ statusCode: 400, statusMessage: "Unknown interaction" });
});

View file

@ -0,0 +1,81 @@
/**
* Handle magic link login request during OIDC interaction flow.
*
* POST /oidc/interaction/login
* Body: { email: string, uid: string }
*
* Sends a magic link email. The link includes the OIDC interaction uid so the
* verify step can complete the interaction after authenticating.
*/
import jwt from "jsonwebtoken";
import { Resend } from "resend";
import Member from "../../../models/member.js";
import { connectDB } from "../../../utils/mongoose.js";
const resend = new Resend(process.env.RESEND_API_KEY);
export default defineEventHandler(async (event) => {
await connectDB();
const body = await readBody(event);
const email = body?.email?.trim()?.toLowerCase();
const uid = body?.uid;
if (!email || !uid) {
throw createError({
statusCode: 400,
statusMessage: "Email and interaction uid are required",
});
}
const GENERIC_MESSAGE =
"If this email is registered, we've sent a login link.";
const member = await (Member as any).findOne({ email });
if (!member) {
return { success: true, message: GENERIC_MESSAGE };
}
const config = useRuntimeConfig(event);
const token = jwt.sign(
{ memberId: member._id, oidcUid: uid },
config.jwtSecret,
{ expiresIn: "15m" }
);
const headers = getHeaders(event);
const baseUrl =
process.env.BASE_URL ||
`${headers.host?.includes("localhost") ? "http" : "https"}://${headers.host}`;
try {
await resend.emails.send({
from: "Ghost Guild <ghostguild@babyghosts.org>",
to: email,
subject: "Sign in to Ghost Guild Wiki",
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #2563eb;">Sign in to the Ghost Guild Wiki</h2>
<p>Click the button below to sign in:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${baseUrl}/oidc/interaction/verify?token=${token}"
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Sign In
</a>
</div>
<p style="color: #666; font-size: 14px;">
This link expires in 15 minutes. If you didn't request this, you can safely ignore this email.
</p>
</div>
`,
});
return { success: true, message: GENERIC_MESSAGE };
} catch (error) {
console.error("Failed to send OIDC login email:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to send login email. Please try again.",
});
}
});

View file

@ -0,0 +1,75 @@
/**
* Verify magic link token and complete the OIDC login interaction.
*
* GET /oidc/interaction/verify?token=...
*
* This is the endpoint the magic link email points to. It:
* 1. Verifies the JWT token
* 2. Sets the Ghost Guild session cookie (so future logins are SSO)
* 3. Completes the OIDC interaction so the user is redirected back to Outline
*/
import jwt from "jsonwebtoken";
import Member from "../../../models/member.js";
import { connectDB } from "../../../utils/mongoose.js";
import { getOidcProvider } from "../../../utils/oidc-provider.js";
export default defineEventHandler(async (event) => {
const { token } = getQuery(event);
if (!token) {
throw createError({ statusCode: 400, statusMessage: "Token is required" });
}
const config = useRuntimeConfig(event);
let decoded: { memberId: string; oidcUid: string };
try {
decoded = jwt.verify(token as string, config.jwtSecret) as typeof decoded;
} catch {
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired token",
});
}
await connectDB();
const member = await (Member as any).findById(decoded.memberId);
if (!member) {
throw createError({ statusCode: 404, statusMessage: "Member not found" });
}
if (member.status === "suspended" || member.status === "cancelled") {
throw createError({
statusCode: 403,
statusMessage: `Account is ${member.status}`,
});
}
// Set Ghost Guild session cookie for future SSO
const sessionToken = jwt.sign(
{ memberId: member._id, email: member.email },
config.jwtSecret,
{ expiresIn: "7d" }
);
setCookie(event, "auth-token", sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
});
// Complete the OIDC interaction
const provider = await getOidcProvider();
const result = {
login: { accountId: member._id.toString() },
};
await provider.interactionFinished(
event.node.req,
event.node.res,
result,
{ mergeWithLastSubmission: false }
);
});

65
server/utils/auth.js Normal file
View file

@ -0,0 +1,65 @@
import jwt from 'jsonwebtoken'
import Member from '../models/member.js'
import { connectDB } from './mongoose.js'
/**
* Verify JWT from cookie and return the decoded member.
* Throws 401 if token is missing or invalid.
*/
export async function requireAuth(event) {
await connectDB()
const token = getCookie(event, 'auth-token')
if (!token) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
let decoded
try {
decoded = jwt.verify(token, useRuntimeConfig().jwtSecret)
} catch (err) {
throw createError({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
}
const member = await Member.findById(decoded.memberId)
if (!member) {
throw createError({
statusCode: 401,
statusMessage: 'Member not found'
})
}
if (member.status === 'suspended' || member.status === 'cancelled') {
throw createError({
statusCode: 403,
statusMessage: 'Account is ' + member.status
})
}
return member
}
/**
* Verify JWT and require admin role.
* Throws 401 if not authenticated, 403 if not admin.
*/
export async function requireAdmin(event) {
const member = await requireAuth(event)
if (member.role !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Admin access required'
})
}
return member
}

View file

@ -0,0 +1,18 @@
const ESCAPE_MAP = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}
const ESCAPE_RE = /[&<>"']/g
/**
* Escape HTML special characters to prevent XSS in email templates.
* Returns empty string for null/undefined input.
*/
export function escapeHtml(str) {
if (str == null) return ''
return String(str).replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch])
}

View file

@ -0,0 +1,114 @@
/**
* MongoDB adapter for oidc-provider.
*
* Stores OIDC tokens, sessions, and grants in an `oidc_payloads` collection
* with TTL indexes for automatic cleanup. Uses the existing Mongoose connection.
*/
import mongoose from "mongoose";
import { connectDB } from "./mongoose.js";
const collectionName = "oidc_payloads";
type MongoPayload = {
_id: string;
payload: Record<string, unknown>;
expiresAt?: Date;
userCode?: string;
uid?: string;
grantId?: string;
};
let collectionReady = false;
async function getCollection() {
await connectDB();
const db = mongoose.connection.db!;
const col = db.collection<MongoPayload>(collectionName);
if (!collectionReady) {
// TTL index — MongoDB automatically removes documents after expiresAt
await col
.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })
.catch(() => {});
// Lookup indexes
await col.createIndex({ "payload.grantId": 1 }).catch(() => {});
await col.createIndex({ "payload.userCode": 1 }).catch(() => {});
await col.createIndex({ "payload.uid": 1 }).catch(() => {});
collectionReady = true;
}
return col;
}
function prefixedId(model: string, id: string) {
return `${model}:${id}`;
}
export class MongoAdapter {
model: string;
constructor(model: string) {
this.model = model;
}
async upsert(
id: string,
payload: Record<string, unknown>,
expiresIn: number
) {
const col = await getCollection();
const expiresAt = expiresIn
? new Date(Date.now() + expiresIn * 1000)
: undefined;
await col.updateOne(
{ _id: prefixedId(this.model, id) as any },
{
$set: {
payload,
...(expiresAt ? { expiresAt } : {}),
},
},
{ upsert: true }
);
}
async find(id: string) {
const col = await getCollection();
const doc = await col.findOne({ _id: prefixedId(this.model, id) as any });
if (!doc) return undefined;
return doc.payload;
}
async findByUserCode(userCode: string) {
const col = await getCollection();
const doc = await col.findOne({ "payload.userCode": userCode });
if (!doc) return undefined;
return doc.payload;
}
async findByUid(uid: string) {
const col = await getCollection();
const doc = await col.findOne({ "payload.uid": uid });
if (!doc) return undefined;
return doc.payload;
}
async consume(id: string) {
const col = await getCollection();
await col.updateOne(
{ _id: prefixedId(this.model, id) as any },
{ $set: { "payload.consumed": Math.floor(Date.now() / 1000) } }
);
}
async destroy(id: string) {
const col = await getCollection();
await col.deleteOne({ _id: prefixedId(this.model, id) as any });
}
async revokeByGrantId(grantId: string) {
const col = await getCollection();
await col.deleteMany({ "payload.grantId": grantId });
}
}

View file

@ -0,0 +1,117 @@
/**
* OIDC Provider configuration for Ghost Guild.
*
* ghostguild.org acts as the identity provider. Outline wiki is the sole
* relying party (client). Members authenticate via the existing magic-link
* flow, and the provider issues standard OIDC tokens so Outline can identify
* them.
*/
import Provider from "oidc-provider";
import { MongoAdapter } from "./oidc-mongodb-adapter.js";
import Member from "../models/member.js";
import { connectDB } from "./mongoose.js";
let _provider: InstanceType<typeof Provider> | null = null;
export async function getOidcProvider() {
if (_provider) return _provider;
const config = useRuntimeConfig();
const issuer =
process.env.OIDC_ISSUER || config.public.appUrl || "https://ghostguild.org";
_provider = new Provider(issuer, {
adapter: MongoAdapter,
clients: [
{
client_id: process.env.OIDC_CLIENT_ID || "outline-wiki",
client_secret: process.env.OIDC_CLIENT_SECRET || "",
redirect_uris: [
"https://wiki.ghostguild.org/auth/oidc.callback",
// Local development callback
"http://localhost:3100/auth/oidc.callback",
],
post_logout_redirect_uris: [
"https://wiki.ghostguild.org",
"http://localhost:3100",
],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
},
],
claims: {
openid: ["sub"],
profile: ["name", "preferred_username"],
email: ["email", "email_verified"],
},
scopes: ["openid", "profile", "email", "offline_access"],
findAccount: async (_ctx: unknown, id: string) => {
await connectDB();
const member = await (Member as any).findById(id);
if (!member) return undefined;
return {
accountId: id,
async claims(_use: string, _scope: string) {
return {
sub: id,
name: member.name,
preferred_username: member.name,
email: member.email,
email_verified: true,
};
},
};
},
cookies: {
keys: (process.env.OIDC_COOKIE_SECRET || "dev-cookie-secret").split(","),
},
ttl: {
AccessToken: 3600, // 1 hour
AuthorizationCode: 600, // 10 minutes
RefreshToken: 14 * 24 * 60 * 60, // 14 days
Session: 14 * 24 * 60 * 60, // 14 days
Interaction: 600, // 10 minutes
Grant: 14 * 24 * 60 * 60, // 14 days
},
features: {
devInteractions: {
enabled: process.env.NODE_ENV !== "production",
},
revocation: { enabled: true },
rpInitiatedLogout: { enabled: true },
},
interactions: {
url(_ctx: unknown, interaction: { uid: string }) {
return `/oidc/interaction/${interaction.uid}`;
},
},
// Allow Outline to use PKCE but don't require it
pkce: {
required: () => false,
},
// Skip consent for our first-party Outline client
loadExistingGrant: async (ctx: any) => {
const grant = new (ctx.oidc.provider.Grant as any)({
accountId: ctx.oidc.session!.accountId,
clientId: ctx.oidc.client!.clientId,
});
grant.addOIDCScope("openid profile email");
await grant.save();
return grant;
},
});
return _provider;
}

View file

@ -1,4 +1,5 @@
import { Resend } from "resend"; import { Resend } from "resend";
import { escapeHtml } from "./escapeHtml.js";
const resend = new Resend(process.env.RESEND_API_KEY); const resend = new Resend(process.env.RESEND_API_KEY);
@ -33,7 +34,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
const { data, error } = await resend.emails.send({ const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>", from: "Ghost Guild <events@babyghosts.org>",
to: [registration.email], to: [registration.email],
subject: `You're registered for ${eventData.title}`, subject: `You're registered for ${escapeHtml(eventData.title)}`,
html: ` html: `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -105,9 +106,9 @@ export async function sendEventRegistrationEmail(registration, eventData) {
</div> </div>
<div class="content"> <div class="content">
<p>Hi ${registration.name},</p> <p>Hi ${escapeHtml(registration.name)},</p>
<p>Thank you for registering for <strong>${eventData.title}</strong>!</p> <p>Thank you for registering for <strong>${escapeHtml(eventData.title)}</strong>!</p>
<div class="event-details"> <div class="event-details">
<div class="detail-row"> <div class="detail-row">
@ -122,11 +123,11 @@ export async function sendEventRegistrationEmail(registration, eventData) {
<div class="detail-row"> <div class="detail-row">
<div class="label">Location</div> <div class="label">Location</div>
<div class="value">${eventData.location}</div> <div class="value">${escapeHtml(eventData.location)}</div>
</div> </div>
</div> </div>
${eventData.description ? `<p>${eventData.description}</p>` : ""} ${eventData.description ? `<p>${escapeHtml(eventData.description)}</p>` : ""}
${ ${
registration.ticketType && registration.ticketType &&
@ -148,7 +149,7 @@ export async function sendEventRegistrationEmail(registration, eventData) {
? ` ? `
<div class="detail-row"> <div class="detail-row">
<div class="label">Transaction ID</div> <div class="label">Transaction ID</div>
<div class="value" style="font-size: 12px; font-family: monospace;">${registration.paymentId}</div> <div class="value" style="font-size: 12px; font-family: monospace;">${escapeHtml(registration.paymentId)}</div>
</div> </div>
` `
: "" : ""
@ -211,7 +212,7 @@ export async function sendEventCancellationEmail(registration, eventData) {
const { data, error } = await resend.emails.send({ const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@ghostguild.org>", from: "Ghost Guild <events@ghostguild.org>",
to: [registration.email], to: [registration.email],
subject: `Registration cancelled: ${eventData.title}`, subject: `Registration cancelled: ${escapeHtml(eventData.title)}`,
html: ` html: `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -264,9 +265,9 @@ export async function sendEventCancellationEmail(registration, eventData) {
</div> </div>
<div class="content"> <div class="content">
<p>Hi ${registration.name},</p> <p>Hi ${escapeHtml(registration.name)},</p>
<p>Your registration for <strong>${eventData.title}</strong> has been cancelled.</p> <p>Your registration for <strong>${escapeHtml(eventData.title)}</strong> has been cancelled.</p>
<p>We're sorry you can't make it. You can always register again if your plans change.</p> <p>We're sorry you can't make it. You can always register again if your plans change.</p>
@ -332,7 +333,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
const { data, error } = await resend.emails.send({ const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@ghostguild.org>", from: "Ghost Guild <events@ghostguild.org>",
to: [waitlistEntry.email], to: [waitlistEntry.email],
subject: `A spot opened up for ${eventData.title}!`, subject: `A spot opened up for ${escapeHtml(eventData.title)}!`,
html: ` html: `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -413,9 +414,9 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
</div> </div>
<div class="content"> <div class="content">
<p>Hi ${waitlistEntry.name},</p> <p>Hi ${escapeHtml(waitlistEntry.name)},</p>
<p>Great news! A spot has become available for <strong>${eventData.title}</strong>, and you're on the waitlist.</p> <p>Great news! A spot has become available for <strong>${escapeHtml(eventData.title)}</strong>, and you're on the waitlist.</p>
<div class="urgent"> <div class="urgent">
<p style="margin: 0; font-weight: 600; color: #92400e;"> <p style="margin: 0; font-weight: 600; color: #92400e;">
@ -426,7 +427,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
<div class="event-details"> <div class="event-details">
<div class="detail-row"> <div class="detail-row">
<div class="label">Event</div> <div class="label">Event</div>
<div class="value">${eventData.title}</div> <div class="value">${escapeHtml(eventData.title)}</div>
</div> </div>
<div class="detail-row"> <div class="detail-row">
@ -441,7 +442,7 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
<div class="detail-row"> <div class="detail-row">
<div class="label">Location</div> <div class="label">Location</div>
<div class="value">${eventData.location}</div> <div class="value">${escapeHtml(eventData.location)}</div>
</div> </div>
</div> </div>
@ -529,7 +530,7 @@ export async function sendSeriesPassConfirmation(options) {
const { data, error } = await resend.emails.send({ const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>", from: "Ghost Guild <events@babyghosts.org>",
to: [to], to: [to],
subject: `Your Series Pass for ${series.title}`, subject: `Your Series Pass for ${escapeHtml(series.title)}`,
html: ` html: `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -620,10 +621,10 @@ export async function sendSeriesPassConfirmation(options) {
</div> </div>
<div class="content"> <div class="content">
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${name},</p> <p style="font-size: 18px; margin-bottom: 10px;">Hi ${escapeHtml(name)},</p>
<p> <p>
Great news! Your series pass for <strong>${series.title}</strong> is confirmed. Great news! Your series pass for <strong>${escapeHtml(series.title)}</strong> is confirmed.
You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}. You're now registered for all ${events.length} events in this ${seriesTypeLabels[series.type] || "series"}.
</p> </p>
@ -647,7 +648,7 @@ export async function sendSeriesPassConfirmation(options) {
<div class="detail-row"> <div class="detail-row">
<div class="label">Series</div> <div class="label">Series</div>
<div class="value">${series.title}</div> <div class="value">${escapeHtml(series.title)}</div>
</div> </div>
${ ${
@ -655,7 +656,7 @@ export async function sendSeriesPassConfirmation(options) {
? ` ? `
<div class="detail-row"> <div class="detail-row">
<div class="label">About</div> <div class="label">About</div>
<div class="value">${series.description}</div> <div class="value">${escapeHtml(series.description)}</div>
</div> </div>
` `
: "" : ""
@ -676,7 +677,7 @@ export async function sendSeriesPassConfirmation(options) {
? ` ? `
<div class="detail-row"> <div class="detail-row">
<div class="label">Transaction ID</div> <div class="label">Transaction ID</div>
<div class="value" style="font-family: monospace; font-size: 14px;">${paymentId}</div> <div class="value" style="font-family: monospace; font-size: 14px;">${escapeHtml(paymentId)}</div>
</div> </div>
` `
: "" : ""
@ -699,7 +700,7 @@ export async function sendSeriesPassConfirmation(options) {
(event, index) => ` (event, index) => `
<div class="event-item"> <div class="event-item">
<div style="font-weight: 600; color: #7c3aed; margin-bottom: 5px;"> <div style="font-weight: 600; color: #7c3aed; margin-bottom: 5px;">
Event ${index + 1}: ${event.title} Event ${index + 1}: ${escapeHtml(event.title)}
</div> </div>
<div style="font-size: 14px; color: #666; margin: 5px 0;"> <div style="font-size: 14px; color: #666; margin: 5px 0;">
📅 ${formatDate(event.startDate)} 📅 ${formatDate(event.startDate)}
@ -708,7 +709,7 @@ export async function sendSeriesPassConfirmation(options) {
🕐 ${formatTime(event.startDate, event.endDate)} 🕐 ${formatTime(event.startDate, event.endDate)}
</div> </div>
<div style="font-size: 14px; color: #666; margin: 5px 0;"> <div style="font-size: 14px; color: #666; margin: 5px 0;">
📍 ${event.location} 📍 ${escapeHtml(event.location)}
</div> </div>
</div> </div>
`, `,

96
server/utils/schemas.js Normal file
View file

@ -0,0 +1,96 @@
import * as z from 'zod'
const privacyEnum = z.enum(['public', 'members', 'private'])
export const emailSchema = z.object({
email: z.string().trim().toLowerCase().email()
})
export const memberCreateSchema = z.object({
email: z.string().trim().toLowerCase().email(),
name: z.string().min(1).max(200),
circle: z.enum(['community', 'founder', 'practitioner']),
contributionTier: z.enum(['0', '5', '15', '30', '50'])
})
export const memberProfileUpdateSchema = z.object({
pronouns: z.string().max(100).optional(),
timeZone: z.string().max(100).optional(),
avatar: z.union([z.string().url().max(500), z.literal('')]).optional(),
studio: z.string().max(200).optional(),
bio: z.string().max(5000).optional(),
location: z.string().max(200).optional(),
socialLinks: z.object({
mastodon: z.string().max(300).optional(),
linkedin: z.string().max(300).optional(),
website: z.string().max(300).optional(),
other: z.string().max(300).optional()
}).optional(),
offering: z.object({
text: z.string().max(2000).optional(),
tags: z.array(z.string().max(100)).max(20).optional()
}).optional(),
lookingFor: z.object({
text: z.string().max(2000).optional(),
tags: z.array(z.string().max(100)).max(20).optional()
}).optional(),
showInDirectory: z.boolean().optional(),
pronounsPrivacy: privacyEnum.optional(),
timeZonePrivacy: privacyEnum.optional(),
avatarPrivacy: privacyEnum.optional(),
studioPrivacy: privacyEnum.optional(),
bioPrivacy: privacyEnum.optional(),
locationPrivacy: privacyEnum.optional(),
socialLinksPrivacy: privacyEnum.optional(),
offeringPrivacy: privacyEnum.optional(),
lookingForPrivacy: privacyEnum.optional()
})
export const eventRegistrationSchema = z.object({
name: z.string().min(1).max(200),
email: z.string().trim().toLowerCase().email(),
dietary: z.boolean().optional()
})
export const updateCreateSchema = z.object({
content: z.string().min(1).max(50000),
images: z.array(z.string().url()).max(20).optional(),
privacy: z.enum(['public', 'members', 'private']).optional(),
commentsEnabled: z.boolean().optional()
})
export const paymentVerifySchema = z.object({
cardToken: z.string().min(1),
customerId: z.string().min(1)
})
export const adminEventCreateSchema = z.object({
title: z.string().min(1).max(500),
description: z.string().min(1).max(50000),
startDate: z.string().min(1),
endDate: z.string().min(1),
location: z.string().max(500).optional(),
maxAttendees: z.number().int().positive().optional(),
membersOnly: z.boolean().optional(),
registrationDeadline: z.string().optional(),
pricing: z.object({
paymentRequired: z.boolean().optional(),
isFree: z.boolean().optional()
}).optional(),
tickets: z.object({
enabled: z.boolean().optional(),
public: z.object({
available: z.boolean().optional(),
name: z.string().max(200).optional(),
description: z.string().max(2000).optional(),
price: z.number().min(0).optional(),
quantity: z.number().int().positive().optional(),
earlyBirdPrice: z.number().min(0).optional(),
earlyBirdDeadline: z.string().optional()
}).optional()
}).optional(),
image: z.string().url().optional(),
category: z.string().max(100).optional(),
tags: z.array(z.string().max(100)).max(20).optional(),
series: z.string().optional()
})

View file

@ -0,0 +1,12 @@
export async function validateBody(event, schema) {
const body = await readBody(event)
const result = schema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 400,
statusMessage: 'Validation failed',
data: result.error.flatten().fieldErrors
})
}
return result.data
}

View file

@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { useMarkdown } from '../../../app/composables/useMarkdown.js'
describe('useMarkdown', () => {
const { render } = useMarkdown()
describe('XSS prevention', () => {
it('strips script tags', () => {
const result = render('Hello <script>alert("xss")</script> world')
expect(result).not.toContain('<script>')
expect(result).not.toContain('</script>')
expect(result).toContain('Hello')
expect(result).toContain('world')
})
it('strips onerror attributes', () => {
const result = render('<img onerror="alert(1)" src="x">')
expect(result).not.toContain('onerror')
})
it('strips onclick attributes', () => {
const result = render('<a onclick="alert(1)" href="#">click</a>')
expect(result).not.toContain('onclick')
})
it('strips iframe tags', () => {
const result = render('<iframe src="https://evil.com"></iframe>')
expect(result).not.toContain('<iframe')
})
it('strips object tags', () => {
const result = render('<object data="exploit.swf"></object>')
expect(result).not.toContain('<object')
})
it('strips embed tags', () => {
const result = render('<embed src="exploit.swf">')
expect(result).not.toContain('<embed')
})
it('sanitizes javascript: URIs', () => {
const result = render('[click me](javascript:alert(1))')
expect(result).not.toContain('javascript:')
})
it('strips img tags (not in allowed list)', () => {
const result = render('![alt](https://example.com/img.png)')
expect(result).not.toContain('<img')
})
})
describe('preserves safe markdown', () => {
it('renders bold and italic', () => {
const result = render('**bold** and *italic*')
expect(result).toContain('<strong>bold</strong>')
expect(result).toContain('<em>italic</em>')
})
it('renders links with href', () => {
const result = render('[Ghost Guild](https://ghostguild.org)')
expect(result).toContain('<a')
expect(result).toContain('href="https://ghostguild.org"')
})
it('preserves headings h1-h6', () => {
for (let i = 1; i <= 6; i++) {
const hashes = '#'.repeat(i)
const result = render(`${hashes} Heading ${i}`)
expect(result).toContain(`<h${i}>`)
}
})
it('preserves code blocks', () => {
const result = render('`inline code` and\n\n```\nblock code\n```')
expect(result).toContain('<code>')
expect(result).toContain('<pre>')
})
it('preserves blockquotes', () => {
const result = render('> This is a quote')
expect(result).toContain('<blockquote>')
})
it('preserves lists', () => {
const result = render('- item 1\n- item 2')
expect(result).toContain('<ul>')
expect(result).toContain('<li>')
})
it('preserves allowed attributes: href, target, rel, class', () => {
// DOMPurify allows href on <a> tags
const result = render('[link](https://example.com)')
expect(result).toContain('href=')
})
})
describe('edge cases', () => {
it('returns empty string for null', () => {
expect(render(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(render(undefined)).toBe('')
})
it('returns empty string for empty string', () => {
expect(render('')).toBe('')
})
})
})

View file

@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: { findOne: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('jsonwebtoken', () => ({
default: { sign: vi.fn().mockReturnValue('mock-jwt-token') }
}))
vi.mock('resend', () => ({
Resend: class MockResend {
constructor() {
this.emails = { send: vi.fn().mockResolvedValue({ id: 'email-123' }) }
}
}
}))
import Member from '../../../server/models/member.js'
import loginHandler from '../../../server/api/auth/login.post.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('auth login endpoint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns generic success message for existing member', async () => {
Member.findOne.mockResolvedValue({
_id: 'member-123',
email: 'exists@example.com'
})
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'exists@example.com' },
headers: { host: 'localhost:3000' }
})
const result = await loginHandler(event)
expect(result).toEqual({
success: true,
message: "If this email is registered, we've sent a login link."
})
})
it('returns identical response for non-existing member (anti-enumeration)', async () => {
Member.findOne.mockResolvedValue(null)
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'nonexistent@example.com' },
headers: { host: 'localhost:3000' }
})
const result = await loginHandler(event)
expect(result).toEqual({
success: true,
message: "If this email is registered, we've sent a login link."
})
})
it('both existing and non-existing produce same shape and message', async () => {
// Existing member
Member.findOne.mockResolvedValue({ _id: 'member-123', email: 'a@b.com' })
const event1 = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'a@b.com' },
headers: { host: 'localhost:3000' }
})
const result1 = await loginHandler(event1)
vi.clearAllMocks()
// Non-existing member
Member.findOne.mockResolvedValue(null)
const event2 = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: { email: 'nobody@example.com' },
headers: { host: 'localhost:3000' }
})
const result2 = await loginHandler(event2)
// Response shape and message must be identical
expect(Object.keys(result1).sort()).toEqual(Object.keys(result2).sort())
expect(result1.success).toBe(result2.success)
expect(result1.message).toBe(result2.message)
})
it('throws 400 when email is missing from body', async () => {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
body: {},
headers: { host: 'localhost:3000' }
})
await expect(loginHandler(event)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
})
})

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/utils/auth.js', () => ({
requireAuth: vi.fn()
}))
vi.mock('../../../server/models/member.js', () => ({
default: { findByIdAndUpdate: vi.fn() }
}))
import { requireAuth } from '../../../server/utils/auth.js'
import Member from '../../../server/models/member.js'
import profilePatchHandler from '../../../server/api/members/profile.patch.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('members profile PATCH endpoint', () => {
const mockMember = {
_id: 'member-123',
email: 'test@example.com',
name: 'Test User',
circle: 'community',
contributionTier: 5,
pronouns: 'they/them',
timeZone: 'America/New_York',
avatar: 'https://example.com/avatar.jpg',
studio: 'Test Studio',
bio: 'Updated bio',
location: 'NYC',
socialLinks: { twitter: '@test' },
offering: { text: 'help', tags: ['code'] },
lookingFor: { text: 'feedback', tags: ['design'] },
showInDirectory: true
}
beforeEach(() => {
vi.clearAllMocks()
requireAuth.mockResolvedValue({ _id: 'member-123' })
Member.findByIdAndUpdate.mockResolvedValue(mockMember)
})
describe('field allowlist - forbidden fields are rejected', () => {
it('does not pass helcimCustomerId to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', helcimCustomerId: 'hacked-id' }
})
await profilePatchHandler(event)
const updateCall = Member.findByIdAndUpdate.mock.calls[0]
const setData = updateCall[1].$set
expect(setData).not.toHaveProperty('helcimCustomerId')
expect(setData).toHaveProperty('bio', 'new bio')
})
it('does not pass role to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', role: 'admin' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('role')
})
it('does not pass status to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', status: 'active' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('status')
})
it('does not pass email to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', email: 'hacked@evil.com' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('email')
})
it('does not pass _id to database update', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: { bio: 'new bio', _id: 'different-id' }
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).not.toHaveProperty('_id')
})
})
describe('field allowlist - allowed fields pass through', () => {
it('passes allowed profile fields through', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: {
pronouns: 'they/them',
bio: 'Updated bio',
studio: 'Test Studio',
location: 'NYC',
timeZone: 'America/New_York',
avatar: 'https://example.com/avatar.jpg',
showInDirectory: true,
socialLinks: { twitter: '@test' }
}
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData).toHaveProperty('pronouns', 'they/them')
expect(setData).toHaveProperty('bio', 'Updated bio')
expect(setData).toHaveProperty('studio', 'Test Studio')
expect(setData).toHaveProperty('location', 'NYC')
expect(setData).toHaveProperty('timeZone', 'America/New_York')
expect(setData).toHaveProperty('avatar', 'https://example.com/avatar.jpg')
expect(setData).toHaveProperty('showInDirectory', true)
expect(setData).toHaveProperty('socialLinks')
})
it('passes offering and lookingFor nested objects through', async () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
body: {
offering: { text: 'mentoring', tags: ['code', 'design'] },
lookingFor: { text: 'feedback', tags: ['art'] }
}
})
await profilePatchHandler(event)
const setData = Member.findByIdAndUpdate.mock.calls[0][1].$set
expect(setData.offering).toEqual({ text: 'mentoring', tags: ['code', 'design'] })
expect(setData.lookingFor).toEqual({ text: 'feedback', tags: ['art'] })
})
})
})

View file

@ -0,0 +1,273 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
import {
emailSchema,
memberCreateSchema,
memberProfileUpdateSchema,
eventRegistrationSchema,
updateCreateSchema,
paymentVerifySchema,
adminEventCreateSchema
} from '../../../server/utils/schemas.js'
import { validateBody } from '../../../server/utils/validateBody.js'
// --- Schema unit tests ---
describe('emailSchema', () => {
it('accepts a valid email', () => {
const result = emailSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(true)
expect(result.data.email).toBe('test@example.com')
})
it('rejects a malformed email', () => {
const result = emailSchema.safeParse({ email: 'not-an-email' })
expect(result.success).toBe(false)
})
it('rejects missing email', () => {
const result = emailSchema.safeParse({})
expect(result.success).toBe(false)
})
it('trims and lowercases email', () => {
const result = emailSchema.safeParse({ email: ' Test@EXAMPLE.COM ' })
expect(result.success).toBe(true)
expect(result.data.email).toBe('test@example.com')
})
})
describe('memberCreateSchema', () => {
const validMember = {
email: 'new@example.com',
name: 'Test User',
circle: 'community',
contributionTier: '0'
}
it('accepts valid member data', () => {
const result = memberCreateSchema.safeParse(validMember)
expect(result.success).toBe(true)
})
it('rejects role field (mass assignment)', () => {
const result = memberCreateSchema.safeParse({ ...validMember, role: 'admin' })
expect(result.success).toBe(true)
// role should NOT be in the output
expect(result.data).not.toHaveProperty('role')
})
it('rejects status field (mass assignment)', () => {
const result = memberCreateSchema.safeParse({ ...validMember, status: 'active' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('status')
})
it('rejects helcimCustomerId field (mass assignment)', () => {
const result = memberCreateSchema.safeParse({ ...validMember, helcimCustomerId: 'cust_123' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('helcimCustomerId')
})
it('rejects _id field (mass assignment)', () => {
const result = memberCreateSchema.safeParse({ ...validMember, _id: '507f1f77bcf86cd799439011' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('_id')
})
it('rejects invalid circle enum', () => {
const result = memberCreateSchema.safeParse({ ...validMember, circle: 'superadmin' })
expect(result.success).toBe(false)
})
it('rejects invalid contributionTier enum', () => {
const result = memberCreateSchema.safeParse({ ...validMember, contributionTier: '999' })
expect(result.success).toBe(false)
})
it('rejects missing required fields', () => {
const result = memberCreateSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(false)
})
it('lowercases email', () => {
const result = memberCreateSchema.safeParse({ ...validMember, email: 'NEW@Example.COM' })
expect(result.success).toBe(true)
expect(result.data.email).toBe('new@example.com')
})
})
describe('eventRegistrationSchema', () => {
it('accepts valid registration', () => {
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'jane@example.com' })
expect(result.success).toBe(true)
})
it('rejects missing name', () => {
const result = eventRegistrationSchema.safeParse({ email: 'jane@example.com' })
expect(result.success).toBe(false)
})
it('rejects malformed email', () => {
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'bad' })
expect(result.success).toBe(false)
})
it('lowercases email', () => {
const result = eventRegistrationSchema.safeParse({ name: 'Jane', email: 'JANE@Example.COM' })
expect(result.success).toBe(true)
expect(result.data.email).toBe('jane@example.com')
})
})
describe('updateCreateSchema', () => {
it('accepts valid content', () => {
const result = updateCreateSchema.safeParse({ content: 'Hello world' })
expect(result.success).toBe(true)
})
it('rejects empty content', () => {
const result = updateCreateSchema.safeParse({ content: '' })
expect(result.success).toBe(false)
})
it('rejects content exceeding 50000 chars', () => {
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50001) })
expect(result.success).toBe(false)
})
it('accepts content at exactly 50000 chars', () => {
const result = updateCreateSchema.safeParse({ content: 'a'.repeat(50000) })
expect(result.success).toBe(true)
})
it('validates images are URLs', () => {
const result = updateCreateSchema.safeParse({
content: 'test',
images: ['not-a-url']
})
expect(result.success).toBe(false)
})
it('accepts valid images array', () => {
const result = updateCreateSchema.safeParse({
content: 'test',
images: ['https://example.com/img.png']
})
expect(result.success).toBe(true)
})
it('rejects more than 20 images', () => {
const images = Array.from({ length: 21 }, (_, i) => `https://example.com/img${i}.png`)
const result = updateCreateSchema.safeParse({ content: 'test', images })
expect(result.success).toBe(false)
})
it('validates privacy enum', () => {
const result = updateCreateSchema.safeParse({ content: 'test', privacy: 'invalid' })
expect(result.success).toBe(false)
})
})
describe('paymentVerifySchema', () => {
it('accepts valid card token and customer ID', () => {
const result = paymentVerifySchema.safeParse({ cardToken: 'tok_123', customerId: 'cust_456' })
expect(result.success).toBe(true)
})
it('rejects missing cardToken', () => {
const result = paymentVerifySchema.safeParse({ customerId: 'cust_456' })
expect(result.success).toBe(false)
})
it('rejects empty cardToken', () => {
const result = paymentVerifySchema.safeParse({ cardToken: '', customerId: 'cust_456' })
expect(result.success).toBe(false)
})
})
describe('adminEventCreateSchema', () => {
const validEvent = {
title: 'Test Event',
description: 'A test event',
startDate: '2026-04-01T10:00:00Z',
endDate: '2026-04-01T12:00:00Z'
}
it('accepts valid event data', () => {
const result = adminEventCreateSchema.safeParse(validEvent)
expect(result.success).toBe(true)
})
it('rejects missing title', () => {
const { title, ...rest } = validEvent
const result = adminEventCreateSchema.safeParse(rest)
expect(result.success).toBe(false)
})
it('rejects missing dates', () => {
const { startDate, endDate, ...rest } = validEvent
const result = adminEventCreateSchema.safeParse({ ...rest, title: 'Test' })
expect(result.success).toBe(false)
})
})
describe('memberProfileUpdateSchema', () => {
it('rejects role in profile update', () => {
const result = memberProfileUpdateSchema.safeParse({ role: 'admin', bio: 'test' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('role')
})
it('rejects status in profile update', () => {
const result = memberProfileUpdateSchema.safeParse({ status: 'active', bio: 'test' })
expect(result.success).toBe(true)
expect(result.data).not.toHaveProperty('status')
})
it('validates privacy enum values', () => {
const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'invalid' })
expect(result.success).toBe(false)
})
it('accepts valid privacy values', () => {
const result = memberProfileUpdateSchema.safeParse({ bioPrivacy: 'public' })
expect(result.success).toBe(true)
})
})
// --- validateBody integration tests ---
describe('validateBody', () => {
it('returns validated data on success', async () => {
const event = createMockEvent({
method: 'POST',
body: { email: 'test@example.com' }
})
const data = await validateBody(event, emailSchema)
expect(data.email).toBe('test@example.com')
})
it('throws 400 on validation failure', async () => {
const event = createMockEvent({
method: 'POST',
body: { email: 'bad' }
})
await expect(validateBody(event, emailSchema)).rejects.toMatchObject({
statusCode: 400,
statusMessage: 'Validation failed'
})
})
it('strips unknown fields from output', async () => {
const event = createMockEvent({
method: 'POST',
body: { email: 'test@example.com', name: 'Test', circle: 'community', contributionTier: '0', role: 'admin', _id: 'fake' }
})
const data = await validateBody(event, memberCreateSchema)
expect(data).not.toHaveProperty('role')
expect(data).not.toHaveProperty('_id')
expect(data.email).toBe('test@example.com')
expect(data.name).toBe('Test')
})
})

View file

@ -0,0 +1,72 @@
import { IncomingMessage, ServerResponse } from 'node:http'
import { Socket } from 'node:net'
import { createEvent } from 'h3'
/**
* Create a real h3 event backed by real Node.js request/response objects.
*
* Options:
* method - HTTP method (default 'GET')
* path - Request path (default '/')
* headers - Object of request headers
* cookies - Object of cookie key/value pairs (serialized to cookie header)
* body - Request body (will be JSON-serialized)
* remoteAddress - Client IP (default '127.0.0.1')
*/
export function createMockEvent(options = {}) {
const {
method = 'GET',
path = '/',
headers = {},
cookies = {},
body = undefined,
remoteAddress = '127.0.0.1'
} = options
// Build cookie header from cookies object
const cookiePairs = Object.entries(cookies).map(([k, v]) => `${k}=${v}`)
if (cookiePairs.length > 0) {
headers.cookie = [headers.cookie, cookiePairs.join('; ')].filter(Boolean).join('; ')
}
// Build a real IncomingMessage
const socket = new Socket()
// remoteAddress is a getter-only property on Socket, so use defineProperty
Object.defineProperty(socket, 'remoteAddress', {
value: remoteAddress,
writable: true,
configurable: true
})
const req = new IncomingMessage(socket)
req.method = method
req.url = path
req.headers = Object.fromEntries(
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])
)
// If body is provided, push it into the request stream so readBody can consume it
if (body !== undefined) {
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body)
req.headers['content-type'] = req.headers['content-type'] || 'application/json'
req.headers['content-length'] = Buffer.byteLength(bodyStr).toString()
req.push(bodyStr)
req.push(null)
}
const res = new ServerResponse(req)
// Capture response headers for test assertions
const setHeaders = {}
const originalSetHeader = res.setHeader.bind(res)
res.setHeader = (name, value) => {
setHeaders[name.toLowerCase()] = value
return originalSetHeader(name, value)
}
const event = createEvent(req, res)
// Attach captured headers for test access
event._testSetHeaders = setHeaders
return event
}

View file

@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
// Import the handler — defineEventHandler is a global passthrough,
// so the default export is the raw handler function.
import csrfMiddleware from '../../../server/middleware/01.csrf.js'
describe('CSRF middleware', () => {
describe('safe methods bypass', () => {
it.each(['GET', 'HEAD', 'OPTIONS'])('%s requests pass through', (method) => {
const event = createMockEvent({ method, path: '/api/test' })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('non-API paths bypass', () => {
it('POST to non-/api/ path passes through', () => {
const event = createMockEvent({ method: 'POST', path: '/some/page' })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('exempt routes bypass', () => {
it.each([
'/api/helcim/webhook',
'/api/slack/webhook',
'/api/auth/verify'
])('POST to %s passes through', (path) => {
const event = createMockEvent({ method: 'POST', path })
expect(() => csrfMiddleware(event)).not.toThrow()
})
})
describe('CSRF enforcement on state-changing API requests', () => {
it('throws 403 when POST to /api/* has no x-csrf-token header', () => {
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': 'abc123' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
)
})
it('throws 403 when header and cookie tokens do not match', () => {
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': 'cookie-token' },
headers: { 'x-csrf-token': 'different-token' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({
statusCode: 403,
statusMessage: 'CSRF token missing or invalid'
})
)
})
it('passes when header and cookie tokens match', () => {
const token = 'matching-token-value'
const event = createMockEvent({
method: 'POST',
path: '/api/members/profile',
cookies: { 'csrf-token': token },
headers: { 'x-csrf-token': token }
})
expect(() => csrfMiddleware(event)).not.toThrow()
})
it('enforces CSRF on DELETE requests', () => {
const event = createMockEvent({
method: 'DELETE',
path: '/api/admin/events/123',
cookies: { 'csrf-token': 'abc' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({ statusCode: 403 })
)
})
it('enforces CSRF on PATCH requests', () => {
const event = createMockEvent({
method: 'PATCH',
path: '/api/members/profile',
cookies: { 'csrf-token': 'abc' }
})
expect(() => csrfMiddleware(event)).toThrowError(
expect.objectContaining({ statusCode: 403 })
)
})
})
describe('cookie provisioning', () => {
it('sets csrf-token cookie when none exists', () => {
const event = createMockEvent({ method: 'GET', path: '/api/test' })
csrfMiddleware(event)
// The middleware calls setCookie which sets a Set-Cookie header
const setCookieHeader = event._testSetHeaders['set-cookie']
expect(setCookieHeader).toBeDefined()
expect(setCookieHeader).toContain('csrf-token=')
})
it('does not set a new cookie when one already exists', () => {
const event = createMockEvent({
method: 'GET',
path: '/api/test',
cookies: { 'csrf-token': 'existing-token' }
})
csrfMiddleware(event)
const setCookieHeader = event._testSetHeaders['set-cookie']
// Should not have set a new cookie
expect(setCookieHeader).toBeUndefined()
})
})
})

View file

@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('rate-limit middleware', () => {
// Fresh import per describe block to get fresh limiter instances
let rateLimitMiddleware
beforeEach(async () => {
vi.resetModules()
const mod = await import('../../../server/middleware/03.rate-limit.js')
rateLimitMiddleware = mod.default
})
describe('non-API paths', () => {
it('skips rate limiting for non-/api/ paths', async () => {
const event = createMockEvent({ path: '/about', remoteAddress: '10.0.0.1' })
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
})
})
describe('auth endpoint limiting (5 per 5 min)', () => {
it('allows 5 requests then blocks the 6th', async () => {
const ip = '10.0.1.1'
// First 5 should succeed
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
}
// 6th should be rate limited
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429
})
// Check Retry-After header was set
expect(event._testSetHeaders['retry-after']).toBeDefined()
})
})
describe('payment endpoint limiting (10 per min)', () => {
it('allows 10 requests then blocks the 11th', async () => {
const ip = '10.0.2.1'
for (let i = 0; i < 10; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
}
const event = createMockEvent({
method: 'POST',
path: '/api/helcim/initialize-payment',
remoteAddress: ip
})
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429
})
})
})
describe('IP isolation', () => {
it('different IPs have separate rate limit counters', async () => {
// Exhaust limit for IP A
for (let i = 0; i < 5; i++) {
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: '10.0.3.1'
})
await rateLimitMiddleware(event)
}
// IP B should still be able to make requests
const event = createMockEvent({
method: 'POST',
path: '/api/auth/login',
remoteAddress: '10.0.3.2'
})
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
})
})
})

View file

@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createMockEvent } from '../helpers/createMockEvent.js'
import securityHeadersMiddleware from '../../../server/middleware/02.security-headers.js'
describe('security-headers middleware', () => {
const originalNodeEnv = process.env.NODE_ENV
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv
})
describe('always-present headers', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development'
})
it('sets X-Content-Type-Options to nosniff', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-content-type-options']).toBe('nosniff')
})
it('sets X-Frame-Options to DENY', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-frame-options']).toBe('DENY')
})
it('sets X-XSS-Protection to 0', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['x-xss-protection']).toBe('0')
})
it('sets Referrer-Policy', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['referrer-policy']).toBe('strict-origin-when-cross-origin')
})
it('sets Permissions-Policy', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['permissions-policy']).toBe('camera=(), microphone=(), geolocation=()')
})
})
describe('production-only headers', () => {
it('sets HSTS in production', () => {
process.env.NODE_ENV = 'production'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['strict-transport-security']).toBe('max-age=31536000; includeSubDomains')
})
it('does not set HSTS in development', () => {
process.env.NODE_ENV = 'development'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['strict-transport-security']).toBeUndefined()
})
it('sets CSP in production', () => {
process.env.NODE_ENV = 'production'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['content-security-policy']).toBeDefined()
})
it('does not set CSP in development', () => {
process.env.NODE_ENV = 'development'
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
expect(event._testSetHeaders['content-security-policy']).toBeUndefined()
})
})
describe('CSP directives', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production'
})
it('includes Helcim sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('myposjs.helcim.com')
expect(csp).toContain('api.helcim.com')
expect(csp).toContain('secure.helcim.com')
})
it('includes Cloudinary sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('res.cloudinary.com')
})
it('includes Plausible sources in CSP', () => {
const event = createMockEvent({ path: '/' })
securityHeadersMiddleware(event)
const csp = event._testSetHeaders['content-security-policy']
expect(csp).toContain('plausible.io')
})
})
})

34
tests/server/setup.js Normal file
View file

@ -0,0 +1,34 @@
import { vi } from 'vitest'
import {
getCookie,
setCookie,
getMethod,
getHeader,
getHeaders,
setHeader,
getRequestURL,
createError,
defineEventHandler,
readBody,
getQuery,
getRouterParam
} from 'h3'
// Register real h3 functions as globals so server code that relies on
// Nitro auto-imports can find them in the test environment.
vi.stubGlobal('getCookie', getCookie)
vi.stubGlobal('setCookie', setCookie)
vi.stubGlobal('getMethod', getMethod)
vi.stubGlobal('getHeader', getHeader)
vi.stubGlobal('getHeaders', getHeaders)
vi.stubGlobal('setHeader', setHeader)
vi.stubGlobal('getRequestURL', getRequestURL)
vi.stubGlobal('createError', createError)
vi.stubGlobal('defineEventHandler', defineEventHandler)
vi.stubGlobal('readBody', readBody)
vi.stubGlobal('getQuery', getQuery)
vi.stubGlobal('getRouterParam', getRouterParam)
vi.stubGlobal('useRuntimeConfig', () => ({
jwtSecret: 'test-jwt-secret'
}))

View file

@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../../../server/models/member.js', () => ({
default: { findById: vi.fn() }
}))
vi.mock('../../../server/utils/mongoose.js', () => ({
connectDB: vi.fn()
}))
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() }
}))
import Member from '../../../server/models/member.js'
import jwt from 'jsonwebtoken'
import { requireAuth, requireAdmin } from '../../../server/utils/auth.js'
import { createMockEvent } from '../helpers/createMockEvent.js'
describe('requireAuth', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws 401 when no auth-token cookie', async () => {
const event = createMockEvent({ path: '/api/test' })
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Authentication required'
})
})
it('throws 401 when JWT is invalid', async () => {
jwt.verify.mockImplementation(() => { throw new Error('invalid token') })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'bad-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Invalid or expired token'
})
})
it('throws 401 when member not found in DB', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(null)
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 401,
statusMessage: 'Member not found'
})
})
it('throws 403 when member is suspended', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue({ _id: 'member-123', status: 'suspended', role: 'member' })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Account is suspended'
})
})
it('throws 403 when member is cancelled', async () => {
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue({ _id: 'member-123', status: 'cancelled', role: 'member' })
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAuth(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Account is cancelled'
})
})
it('returns member when token and status are valid', async () => {
const mockMember = { _id: 'member-123', status: 'active', role: 'member', email: 'test@example.com' }
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/test',
cookies: { 'auth-token': 'valid-token' }
})
const result = await requireAuth(event)
expect(result).toEqual(mockMember)
})
})
describe('requireAdmin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws 403 when member is not admin', async () => {
const mockMember = { _id: 'member-123', status: 'active', role: 'member' }
jwt.verify.mockReturnValue({ memberId: 'member-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/admin/test',
cookies: { 'auth-token': 'valid-token' }
})
await expect(requireAdmin(event)).rejects.toMatchObject({
statusCode: 403,
statusMessage: 'Admin access required'
})
})
it('returns member when role is admin', async () => {
const mockMember = { _id: 'admin-123', status: 'active', role: 'admin', email: 'admin@example.com' }
jwt.verify.mockReturnValue({ memberId: 'admin-123' })
Member.findById.mockResolvedValue(mockMember)
const event = createMockEvent({
path: '/api/admin/test',
cookies: { 'auth-token': 'valid-token' }
})
const result = await requireAdmin(event)
expect(result).toEqual(mockMember)
})
})

View file

@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { escapeHtml } from '../../../server/utils/escapeHtml.js'
describe('escapeHtml', () => {
it('escapes ampersands', () => {
expect(escapeHtml('a&b')).toBe('a&amp;b')
})
it('escapes less-than signs', () => {
expect(escapeHtml('a<b')).toBe('a&lt;b')
})
it('escapes greater-than signs', () => {
expect(escapeHtml('a>b')).toBe('a&gt;b')
})
it('escapes double quotes', () => {
expect(escapeHtml('a"b')).toBe('a&quot;b')
})
it('escapes single quotes', () => {
expect(escapeHtml("a'b")).toBe('a&#39;b')
})
it('escapes all entities in a single string', () => {
expect(escapeHtml('<div class="x">&\'test\'')).toBe(
'&lt;div class=&quot;x&quot;&gt;&amp;&#39;test&#39;'
)
})
it('returns empty string for null', () => {
expect(escapeHtml(null)).toBe('')
})
it('returns empty string for undefined', () => {
expect(escapeHtml(undefined)).toBe('')
})
it('converts numbers to string', () => {
expect(escapeHtml(42)).toBe('42')
})
it('passes safe strings through unchanged', () => {
expect(escapeHtml('hello world')).toBe('hello world')
})
it('neutralizes script tag XSS payload', () => {
const payload = '<script>alert("xss")</script>'
const result = escapeHtml(payload)
expect(result).not.toContain('<script>')
expect(result).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
it('neutralizes img onerror XSS payload', () => {
const payload = '<img onerror="alert(1)" src=x>'
const result = escapeHtml(payload)
expect(result).not.toContain('<img')
expect(result).toBe('&lt;img onerror=&quot;alert(1)&quot; src=x&gt;')
})
})

25
vitest.config.js Normal file
View file

@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
projects: [
{
test: {
name: 'server',
include: ['tests/server/**/*.test.js'],
environment: 'node',
globals: true,
setupFiles: ['./tests/server/setup.js']
}
},
{
test: {
name: 'client',
include: ['tests/client/**/*.test.js'],
environment: 'jsdom',
globals: true
}
}
]
}
})