Compare commits
No commits in common. "e4813075b7168ac2e4aa3e696cd7fd4fc246cde4" and "d5c95ace0aa883341a27447a3fc243a10af7b23b" have entirely different histories.
e4813075b7
...
d5c95ace0a
92 changed files with 697 additions and 4761 deletions
|
|
@ -21,8 +21,3 @@ 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
2
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,9 @@ const handleLogin = async () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
|
|
||||||
if (err.statusCode === 500) {
|
if (err.statusCode === 404) {
|
||||||
|
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";
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,9 @@ const handleLogin = async () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error:', err)
|
console.error('Login error:', err)
|
||||||
|
|
||||||
if (err.statusCode === 500) {
|
if (err.statusCode === 404) {
|
||||||
|
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.'
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,12 @@
|
||||||
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 ''
|
||||||
const raw = marked(markdown, {
|
return marked(markdown, {
|
||||||
breaks: true,
|
breaks: true,
|
||||||
gfm: true
|
gfm: true
|
||||||
})
|
})
|
||||||
return DOMPurify.sanitize(raw, {
|
|
||||||
ALLOWED_TAGS,
|
|
||||||
ALLOWED_ATTR,
|
|
||||||
ALLOW_DATA_ATTR: false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
// Skip middleware in server-side rendering to avoid errors
|
||||||
if (process.server) return
|
if (process.server) return
|
||||||
|
|
||||||
const { isAuthenticated, memberData, checkMemberStatus } = useAuth()
|
// TODO: Temporarily disabled for testing - enable when authentication is set up
|
||||||
|
// Check if user is authenticated (you'll need to implement proper auth state)
|
||||||
|
// const isAuthenticated = useCookie('auth-token').value
|
||||||
|
|
||||||
if (!isAuthenticated.value) {
|
// if (!isAuthenticated) {
|
||||||
await checkMemberStatus()
|
// throw createError({
|
||||||
}
|
// statusCode: 401,
|
||||||
|
// statusMessage: 'Authentication required'
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
if (!isAuthenticated.value) {
|
// TODO: Add proper role-based authorization
|
||||||
return navigateTo('/')
|
// For now, we assume anyone with a valid token is an admin
|
||||||
}
|
|
||||||
|
|
||||||
if (memberData.value?.role !== 'admin') {
|
|
||||||
return navigateTo('/members')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -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: process.env.NODE_ENV !== 'production' },
|
devtools: { enabled: true },
|
||||||
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
|
modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"],
|
||||||
build: {
|
build: {
|
||||||
transpile: ["vue-cal"],
|
transpile: ["vue-cal"],
|
||||||
|
|
@ -14,15 +14,12 @@ 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 || "",
|
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-in-production",
|
||||||
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
1742
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -7,9 +7,7 @@
|
||||||
"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",
|
||||||
|
|
@ -19,17 +17,15 @@
|
||||||
"@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",
|
||||||
|
|
@ -37,11 +33,6 @@
|
||||||
"zod": "^4.1.3"
|
"zod": "^4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/test-utils": "^4.0.0",
|
"@tailwindcss/typography": "^0.5.19"
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
|
||||||
"@types/oidc-provider": "^9.5.0",
|
|
||||||
"jsdom": "^28.1.0",
|
|
||||||
"vitest": "^4.0.18"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,24 @@
|
||||||
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 { requireAdmin } from '../../utils/auth.js'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await requireAdmin(event)
|
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||||
|
// 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
|
||||||
|
|
@ -49,7 +62,6 @@ 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'
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
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'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await requireAdmin(event)
|
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||||
|
// 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()
|
||||||
|
|
@ -13,7 +26,6 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
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'
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,37 @@
|
||||||
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";
|
import jwt from "jsonwebtoken";
|
||||||
import { validateBody } from "../../utils/validateBody.js";
|
|
||||||
import { adminEventCreateSchema } from "../../utils/schemas.js";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const admin = await requireAdmin(event);
|
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||||
|
// const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
const body = await validateBody(event, adminEventCreateSchema);
|
// if (!token) {
|
||||||
|
// 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.email,
|
createdBy: "admin@ghostguild.org", // TODO: Use actual authenticated user
|
||||||
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
|
||||||
|
|
@ -50,7 +67,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
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'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await requireAdmin(event)
|
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||||
|
// 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')
|
||||||
|
|
||||||
|
|
@ -21,7 +32,6 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,44 @@
|
||||||
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'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await requireAdmin(event)
|
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||||
|
// 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) {
|
||||||
if (error.statusCode) throw error
|
console.error('❌ API: Error fetching event:', 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'
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
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'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await requireAdmin(event)
|
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||||
|
// 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)
|
||||||
|
|
@ -59,7 +70,6 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
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 { requireAdmin } from '../../utils/auth.js'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await requireAdmin(event)
|
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||||
|
// 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()
|
||||||
|
|
@ -13,7 +26,6 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
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'
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
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 { requireAdmin } from '../../utils/auth.js'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await requireAdmin(event)
|
// TODO: Temporarily disabled auth for testing - enable when authentication is set up
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
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)
|
||||||
|
|
@ -24,7 +22,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.email
|
createdBy: 'admin' // TODO: Get from authentication
|
||||||
})
|
})
|
||||||
|
|
||||||
await newSeries.save()
|
await newSeries.save()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
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')
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
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')
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ 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);
|
||||||
|
|
||||||
|
|
@ -12,26 +10,28 @@ export default defineEventHandler(async (event) => {
|
||||||
// Connect to database
|
// Connect to database
|
||||||
await connectDB();
|
await connectDB();
|
||||||
|
|
||||||
const { email } = await validateBody(event, emailSchema);
|
const { email } = await readBody(event);
|
||||||
|
|
||||||
const GENERIC_MESSAGE = "If this email is registered, we've sent a login link.";
|
if (!email) {
|
||||||
|
throw createError({
|
||||||
const member = await Member.findOne({ email });
|
statusCode: 400,
|
||||||
|
statusMessage: "Email is required",
|
||||||
if (!member) {
|
});
|
||||||
// Return same response shape to prevent enumeration
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: GENERIC_MESSAGE,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate magic link token (use runtime config for consistency with verify/requireAuth)
|
const member = await Member.findOne({ email });
|
||||||
const config = useRuntimeConfig(event);
|
if (!member) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: "No account found with that email address",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate magic link token
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ memberId: member._id },
|
{ memberId: member._id },
|
||||||
config.jwtSecret,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: "15m" },
|
{ expiresIn: "15m" }, // Shorter expiry for security
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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: GENERIC_MESSAGE,
|
message: "Login link sent to your email",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send email:", error);
|
console.error("Failed to send email:", error);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
// Clear the auth token cookie (flags must match login for proper clearing)
|
// Clear the auth token cookie
|
||||||
setCookie(event, 'auth-token', '', {
|
setCookie(event, 'auth-token', '', {
|
||||||
httpOnly: true,
|
httpOnly: false, // Match the original cookie settings
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: false, // Don't require HTTPS in development
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 0 // Expire immediately
|
maxAge: 0 // Expire immediately
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,37 @@
|
||||||
import { requireAuth } from "../../utils/auth.js";
|
import jwt from "jsonwebtoken";
|
||||||
|
import Member from "../../models/member.js";
|
||||||
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const member = await requireAuth(event);
|
await connectDB();
|
||||||
|
|
||||||
|
const token = getCookie(event, "auth-token");
|
||||||
|
console.log("Auth check - token found:", !!token);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.log("No auth token found in cookies");
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Not authenticated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
const member = await Member.findById(decoded.memberId).select("-__v");
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: "Member not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_id: member._id,
|
_id: member._id,
|
||||||
id: member._id,
|
id: member._id,
|
||||||
email: member.email,
|
email: member.email,
|
||||||
name: member.name,
|
name: member.name,
|
||||||
role: member.role || 'member',
|
|
||||||
circle: member.circle,
|
circle: member.circle,
|
||||||
contributionTier: member.contributionTier,
|
contributionTier: member.contributionTier,
|
||||||
membershipLevel: `${member.circle}-${member.contributionTier}`,
|
membershipLevel: `${member.circle}-${member.contributionTier}`,
|
||||||
|
|
@ -27,4 +50,11 @@ export default defineEventHandler(async (event) => {
|
||||||
// Peer support
|
// Peer support
|
||||||
peerSupport: member.peerSupport,
|
peerSupport: member.peerSupport,
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Token verification error:", err);
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Invalid or expired token",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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 }
|
|
||||||
})
|
|
||||||
|
|
@ -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, useRuntimeConfig().jwtSecret)
|
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||||
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (member.status === 'suspended' || member.status === 'cancelled') {
|
console.log('✅ Auth status check - member found:', member.email)
|
||||||
return { authenticated: false, member: null, reason: 'account_' + member.status }
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
member: {
|
member: {
|
||||||
|
|
@ -34,6 +34,7 @@ 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 }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -18,9 +18,8 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify the JWT token (use runtime config for consistency with login/requireAuth)
|
// Verify the JWT token
|
||||||
const config = useRuntimeConfig(event)
|
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||||
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) {
|
||||||
|
|
@ -33,22 +32,23 @@ 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 },
|
||||||
config.jwtSecret,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn: '30d' }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set the session cookie
|
// Set the session cookie
|
||||||
setCookie(event, 'auth-token', sessionToken, {
|
setCookie(event, 'auth-token', sessionToken, {
|
||||||
httpOnly: true,
|
httpOnly: false, // Allow JavaScript access for debugging in development
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: false, // Don't require HTTPS in development
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 60 * 60 * 24 * 7 // 7 days
|
maxAge: 60 * 60 * 24 * 30 // 30 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'
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ 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) => {
|
||||||
|
|
@ -11,7 +9,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 validateBody(event, eventRegistrationSchema);
|
const body = await readBody(event);
|
||||||
|
|
||||||
if (!identifier) {
|
if (!identifier) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -20,6 +18,14 @@ 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ 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',
|
||||||
|
|
@ -43,6 +44,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -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, useRuntimeConfig().jwtSecret)
|
decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ 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 {
|
||||||
|
|
@ -53,7 +55,8 @@ export default defineEventHandler(async (event) => {
|
||||||
throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`)
|
throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await testResponse.json()
|
const testData = 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({
|
||||||
|
|
@ -105,19 +108,23 @@ export default defineEventHandler(async (event) => {
|
||||||
email: body.email,
|
email: body.email,
|
||||||
helcimCustomerId: customerData.id
|
helcimCustomerId: customerData.id
|
||||||
},
|
},
|
||||||
config.jwtSecret,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn: '24h' }
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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,
|
httpOnly: true, // Server-only for security
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: false, // Don't require HTTPS in development
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 60 * 60 * 24 * 7, // 7 days (matches verify.get.js and refresh.post.js)
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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, useRuntimeConfig().jwtSecret)
|
decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
@ -59,6 +59,7 @@ 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) {
|
||||||
|
|
@ -76,11 +77,12 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (searchError) {
|
} catch (searchError) {
|
||||||
console.error('Error searching for customer:', searchError)
|
console.log('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: {
|
||||||
|
|
@ -105,6 +107,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
// 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;
|
||||||
|
|
@ -44,6 +43,8 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ 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: {
|
||||||
|
|
@ -16,13 +18,17 @@ 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'
|
statusMessage: `Failed to fetch payment plans: ${errorText}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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'
|
||||||
|
|
||||||
|
|
@ -73,7 +72,6 @@ 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)
|
||||||
|
|
@ -93,8 +91,11 @@ 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 },
|
||||||
|
|
@ -106,6 +107,8 @@ 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)
|
||||||
|
|
||||||
|
|
@ -116,8 +119,11 @@ 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) {
|
||||||
|
|
@ -129,6 +135,8 @@ 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 },
|
||||||
{
|
{
|
||||||
|
|
@ -160,6 +168,8 @@ 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 = ''
|
||||||
|
|
@ -187,6 +197,10 @@ 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',
|
||||||
|
|
@ -196,11 +210,47 @@ 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:', subscriptionResponse.status)
|
console.error('Subscription creation failed:')
|
||||||
|
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) {
|
||||||
// Plan might not exist -- update member status and proceed
|
console.log('Plan might not exist. Trying to get list of available payment plans...')
|
||||||
|
|
||||||
|
// 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 },
|
||||||
{
|
{
|
||||||
|
|
@ -237,6 +287,7 @@ 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]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ 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: {
|
||||||
|
|
@ -16,13 +18,17 @@ 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'
|
statusMessage: `Failed to fetch subscriptions: ${errorText}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
46
server/api/helcim/test-connection.get.js
Normal file
46
server/api/helcim/test-connection.get.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
77
server/api/helcim/test-subscription.get.js
Normal file
77
server/api/helcim/test-subscription.get.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
// 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,38 @@
|
||||||
// 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 validateBody(event, paymentVerifySchema)
|
const body = await readBody(event)
|
||||||
|
|
||||||
const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN
|
// Validate required fields
|
||||||
|
if (!body.cardToken || !body.customerId) {
|
||||||
if (!helcimToken) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Helcim API token not configured'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the card token by fetching the customer's cards from Helcim
|
|
||||||
const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}/cards`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'api-token': helcimToken
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
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({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: 'Payment method not found or does not belong to this customer'
|
statusMessage: 'Card token and customer ID are required'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Payment verification request:', {
|
||||||
|
customerId: body.customerId,
|
||||||
|
cardToken: body.cardToken ? 'present' : 'missing'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Since HelcimPay.js already verified the payment and we have the card token,
|
||||||
|
// we can just return success. The card is already associated with the customer.
|
||||||
|
console.log('Payment already verified through HelcimPay.js, returning success')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
cardToken: body.cardToken,
|
cardToken: body.cardToken,
|
||||||
message: 'Payment verified with Helcim'
|
message: 'Payment verified successfully through HelcimPay.js'
|
||||||
}
|
}
|
||||||
} 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.statusMessage || 'Failed to verify payment'
|
statusMessage: error.message || 'Failed to verify payment'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -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, config.jwtSecret);
|
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
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'
|
||||||
|
|
||||||
|
|
@ -16,7 +14,7 @@ async function inviteToSlack(member) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(`Processing Slack invitation for member`)
|
console.log(`Processing Slack invitation for ${member.email}...`)
|
||||||
|
|
||||||
const inviteResult = await slackService.inviteUserToSlack(
|
const inviteResult = await slackService.inviteUserToSlack(
|
||||||
member.email,
|
member.email,
|
||||||
|
|
@ -47,13 +45,13 @@ async function inviteToSlack(member) {
|
||||||
inviteResult.status
|
inviteResult.status
|
||||||
)
|
)
|
||||||
|
|
||||||
console.warn(`Slack invitation processed: ${inviteResult.status}`)
|
console.log(`Successfully processed Slack invitation for ${member.email}: ${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: ${inviteResult.error}`)
|
console.error(`Failed to process Slack invitation for ${member.email}: ${inviteResult.error}`)
|
||||||
// Don't throw error - member creation should still succeed
|
// Don't throw error - member creation should still succeed
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -75,11 +73,11 @@ export default defineEventHandler(async (event) => {
|
||||||
// Ensure database is connected
|
// Ensure database is connected
|
||||||
await connectDB()
|
await connectDB()
|
||||||
|
|
||||||
const validatedData = await validateBody(event, memberCreateSchema)
|
const body = await readBody(event)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if member already exists
|
// Check if member already exists
|
||||||
const existingMember = await Member.findOne({ email: validatedData.email })
|
const existingMember = await Member.findOne({ email: body.email })
|
||||||
if (existingMember) {
|
if (existingMember) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
|
|
@ -87,18 +85,20 @@ export default defineEventHandler(async (event) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = new Member(validatedData)
|
const member = new Member(body)
|
||||||
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(validatedData.contributionTier)) {
|
if (requiresPayment(body.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) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
currentMemberId = decoded.memberId;
|
currentMemberId = decoded.memberId;
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -46,11 +46,9 @@ 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: escaped, $options: "i" } },
|
{ name: { $regex: search, $options: "i" } },
|
||||||
{ bio: { $regex: escaped, $options: "i" } },
|
{ bio: { $regex: search, $options: "i" } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,12 +60,11 @@ 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: escaped, $options: "i" } },
|
{ name: { $regex: search, $options: "i" } },
|
||||||
{ bio: { $regex: escaped, $options: "i" } },
|
{ bio: { $regex: search, $options: "i" } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,34 @@
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
import Member from "../../models/member.js";
|
import Member from "../../models/member.js";
|
||||||
import { requireAuth } from "../../utils/auth.js";
|
import { connectDB } from "../../utils/mongoose.js";
|
||||||
import { validateBody } from "../../utils/validateBody.js";
|
|
||||||
import { memberProfileUpdateSchema } from "../../utils/schemas.js";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const authedMember = await requireAuth(event);
|
await connectDB();
|
||||||
const memberId = authedMember._id;
|
|
||||||
|
|
||||||
const body = await validateBody(event, memberProfileUpdateSchema);
|
const token = getCookie(event, "auth-token");
|
||||||
|
|
||||||
// Profile fields from validated body
|
if (!token) {
|
||||||
const profileFields = [
|
throw createError({
|
||||||
|
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",
|
||||||
|
|
@ -19,9 +37,10 @@ export default defineEventHandler(async (event) => {
|
||||||
"location",
|
"location",
|
||||||
"socialLinks",
|
"socialLinks",
|
||||||
"showInDirectory",
|
"showInDirectory",
|
||||||
|
"helcimCustomerId",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Privacy fields from validated body
|
// Define privacy fields
|
||||||
const privacyFields = [
|
const privacyFields = [
|
||||||
"pronounsPrivacy",
|
"pronounsPrivacy",
|
||||||
"timeZonePrivacy",
|
"timeZonePrivacy",
|
||||||
|
|
@ -34,10 +53,10 @@ export default defineEventHandler(async (event) => {
|
||||||
"lookingForPrivacy",
|
"lookingForPrivacy",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build update object from validated data
|
// Build update object
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
|
|
||||||
profileFields.forEach((field) => {
|
allowedFields.forEach((field) => {
|
||||||
if (body[field] !== undefined) {
|
if (body[field] !== undefined) {
|
||||||
updateData[field] = body[field];
|
updateData[field] = body[field];
|
||||||
}
|
}
|
||||||
|
|
@ -75,7 +94,7 @@ export default defineEventHandler(async (event) => {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
statusMessage: "Member not found",
|
message: "Member not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,11 +117,10 @@ 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,
|
||||||
statusMessage: "Failed to update profile",
|
message: "Failed to update profile",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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, useRuntimeConfig().jwtSecret);
|
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
jwt.verify(token, useRuntimeConfig().jwtSecret);
|
jwt.verify(token, process.env.JWT_SECRET);
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
isAuthenticated = false;
|
isAuthenticated = false;
|
||||||
|
|
|
||||||
68
server/api/slack/test-bot.get.ts
Normal file
68
server/api/slack/test-bot.get.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({ statusCode: 401, statusMessage: "Invalid token" });
|
throw createError({ statusCode: 401, statusMessage: "Invalid token" });
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
||||||
|
|
@ -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, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, continue as non-member
|
// Token invalid, continue as non-member
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
||||||
|
|
@ -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, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, continue as non-member
|
// Token invalid, continue as non-member
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
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();
|
||||||
|
|
@ -18,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -27,7 +25,14 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await validateBody(event, updateCreateSchema);
|
const body = await readBody(event);
|
||||||
|
|
||||||
|
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({
|
||||||
|
|
@ -43,7 +48,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let memberId;
|
let memberId;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
memberId = decoded.memberId;
|
memberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
|
||||||
|
|
@ -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, useRuntimeConfig().jwtSecret);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
currentMemberId = decoded.memberId;
|
currentMemberId = decoded.memberId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, continue as non-member
|
// Token invalid, continue as non-member
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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({
|
||||||
|
|
@ -10,7 +9,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -39,15 +37,6 @@ 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')}`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -22,11 +22,6 @@ 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"],
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
|
|
||||||
// Traefik terminates TLS — tell the provider we're on https
|
|
||||||
req.headers["x-forwarded-proto"] = "https";
|
|
||||||
|
|
||||||
const callback = provider.callback() as Function;
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
callback(req, res, (err: unknown) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
// The provider's routes config includes the /oidc prefix,
|
|
||||||
// so pass the full path through without stripping.
|
|
||||||
|
|
||||||
// Traefik terminates TLS — tell the provider we're on https
|
|
||||||
req.headers["x-forwarded-proto"] = "https";
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
/**
|
|
||||||
* 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" });
|
|
||||||
});
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
/**
|
|
||||||
* 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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
const ESCAPE_MAP = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
}
|
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
|
|
||||||
// Trust X-Forwarded-Proto from Traefik reverse proxy
|
|
||||||
proxy: true,
|
|
||||||
|
|
||||||
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 },
|
|
||||||
},
|
|
||||||
|
|
||||||
// Mount all OIDC endpoints under /oidc prefix
|
|
||||||
routes: {
|
|
||||||
authorization: "/oidc/auth",
|
|
||||||
backchannel_authentication: "/oidc/backchannel",
|
|
||||||
code_verification: "/oidc/device",
|
|
||||||
device_authorization: "/oidc/device/auth",
|
|
||||||
end_session: "/oidc/session/end",
|
|
||||||
introspection: "/oidc/token/introspection",
|
|
||||||
jwks: "/oidc/jwks",
|
|
||||||
pushed_authorization_request: "/oidc/request",
|
|
||||||
registration: "/oidc/reg",
|
|
||||||
revocation: "/oidc/token/revocation",
|
|
||||||
token: "/oidc/token",
|
|
||||||
userinfo: "/oidc/me",
|
|
||||||
},
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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);
|
||||||
|
|
||||||
|
|
@ -34,7 +33,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 ${escapeHtml(eventData.title)}`,
|
subject: `You're registered for ${eventData.title}`,
|
||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -106,9 +105,9 @@ export async function sendEventRegistrationEmail(registration, eventData) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Hi ${escapeHtml(registration.name)},</p>
|
<p>Hi ${registration.name},</p>
|
||||||
|
|
||||||
<p>Thank you for registering for <strong>${escapeHtml(eventData.title)}</strong>!</p>
|
<p>Thank you for registering for <strong>${eventData.title}</strong>!</p>
|
||||||
|
|
||||||
<div class="event-details">
|
<div class="event-details">
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
|
|
@ -123,11 +122,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">${escapeHtml(eventData.location)}</div>
|
<div class="value">${eventData.location}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${eventData.description ? `<p>${escapeHtml(eventData.description)}</p>` : ""}
|
${eventData.description ? `<p>${eventData.description}</p>` : ""}
|
||||||
|
|
||||||
${
|
${
|
||||||
registration.ticketType &&
|
registration.ticketType &&
|
||||||
|
|
@ -149,7 +148,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;">${escapeHtml(registration.paymentId)}</div>
|
<div class="value" style="font-size: 12px; font-family: monospace;">${registration.paymentId}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -212,7 +211,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: ${escapeHtml(eventData.title)}`,
|
subject: `Registration cancelled: ${eventData.title}`,
|
||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -265,9 +264,9 @@ export async function sendEventCancellationEmail(registration, eventData) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Hi ${escapeHtml(registration.name)},</p>
|
<p>Hi ${registration.name},</p>
|
||||||
|
|
||||||
<p>Your registration for <strong>${escapeHtml(eventData.title)}</strong> has been cancelled.</p>
|
<p>Your registration for <strong>${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>
|
||||||
|
|
||||||
|
|
@ -333,7 +332,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 ${escapeHtml(eventData.title)}!`,
|
subject: `A spot opened up for ${eventData.title}!`,
|
||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -414,9 +413,9 @@ export async function sendWaitlistNotificationEmail(waitlistEntry, eventData) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>Hi ${escapeHtml(waitlistEntry.name)},</p>
|
<p>Hi ${waitlistEntry.name},</p>
|
||||||
|
|
||||||
<p>Great news! A spot has become available for <strong>${escapeHtml(eventData.title)}</strong>, and you're on the waitlist.</p>
|
<p>Great news! A spot has become available for <strong>${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;">
|
||||||
|
|
@ -427,7 +426,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">${escapeHtml(eventData.title)}</div>
|
<div class="value">${eventData.title}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
|
|
@ -442,7 +441,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">${escapeHtml(eventData.location)}</div>
|
<div class="value">${eventData.location}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -530,7 +529,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 ${escapeHtml(series.title)}`,
|
subject: `Your Series Pass for ${series.title}`,
|
||||||
html: `
|
html: `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -621,10 +620,10 @@ export async function sendSeriesPassConfirmation(options) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${escapeHtml(name)},</p>
|
<p style="font-size: 18px; margin-bottom: 10px;">Hi ${name},</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Great news! Your series pass for <strong>${escapeHtml(series.title)}</strong> is confirmed.
|
Great news! Your series pass for <strong>${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>
|
||||||
|
|
||||||
|
|
@ -648,7 +647,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">${escapeHtml(series.title)}</div>
|
<div class="value">${series.title}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
${
|
||||||
|
|
@ -656,7 +655,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">${escapeHtml(series.description)}</div>
|
<div class="value">${series.description}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -677,7 +676,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;">${escapeHtml(paymentId)}</div>
|
<div class="value" style="font-family: monospace; font-size: 14px;">${paymentId}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ""
|
: ""
|
||||||
|
|
@ -700,7 +699,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}: ${escapeHtml(event.title)}
|
Event ${index + 1}: ${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)}
|
||||||
|
|
@ -709,7 +708,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;">
|
||||||
📍 ${escapeHtml(event.location)}
|
📍 ${event.location}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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('')
|
|
||||||
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('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
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'] })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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'
|
|
||||||
}))
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { escapeHtml } from '../../../server/utils/escapeHtml.js'
|
|
||||||
|
|
||||||
describe('escapeHtml', () => {
|
|
||||||
it('escapes ampersands', () => {
|
|
||||||
expect(escapeHtml('a&b')).toBe('a&b')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('escapes less-than signs', () => {
|
|
||||||
expect(escapeHtml('a<b')).toBe('a<b')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('escapes greater-than signs', () => {
|
|
||||||
expect(escapeHtml('a>b')).toBe('a>b')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('escapes double quotes', () => {
|
|
||||||
expect(escapeHtml('a"b')).toBe('a"b')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('escapes single quotes', () => {
|
|
||||||
expect(escapeHtml("a'b")).toBe('a'b')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('escapes all entities in a single string', () => {
|
|
||||||
expect(escapeHtml('<div class="x">&\'test\'')).toBe(
|
|
||||||
'<div class="x">&'test''
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
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('<script>alert("xss")</script>')
|
|
||||||
})
|
|
||||||
|
|
||||||
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('<img onerror="alert(1)" src=x>')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue