diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..045bbf4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + name: Check, Lint & Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: npm + + - run: npm ci + + - name: Type check + run: npm run check + + - name: Lint + run: npm run lint + + - name: Test + run: npm test diff --git a/README.md b/README.md index 1652999..16560d8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ A real-time social status app where you can share what you're up to with your fr ## Overview Rez lets you: + - Post a short status (up to 42 characters) so friends know what you're up to - See your friends' statuses and when they last updated them - Send, accept, and reject friend requests by username @@ -55,12 +56,13 @@ Rez lets you: | Feature | Details | |---|---| -| Authentication | Email/password with optional email confirmation | +| Authentication | Email/password with email confirmation, password reset, email change | | Status updates | 42-character limit, with quick-status presets | | Friend requests | Send by username, accept/reject/cancel | | Real-time sync | Supabase Realtime (optional, gracefully degrades) | | Friend ordering | Drag-and-drop reorder, persisted in `localStorage` | | Themes | 35 built-in DaisyUI themes, cookie-persisted | +| PWA | Installable on mobile and desktop via web app manifest | | Profile | Username (3–20 chars) + optional display name (up to 50 chars) | | Data export | Download all your data as JSON | | Account deletion | Password-confirmed, cascades all data including auth user | @@ -80,8 +82,10 @@ Rez lets you: | Avatars | `svelte-boring-avatars` (deterministic from user ID) | | Theme switching | `theme-change` | | Build tool | Vite 7 | +| Testing | [Vitest](https://vitest.dev/) | | Linting | ESLint 9 + `eslint-plugin-svelte` | | Formatting | Prettier + `prettier-plugin-svelte` + `prettier-plugin-tailwindcss` | +| CI | GitHub Actions (check, lint, test) | --- @@ -131,6 +135,8 @@ npm run check # Type-check the project npm run check:watch # Type-check in watch mode npm run lint # Check formatting + ESLint npm run format # Auto-format all files +npm test # Run Vitest tests +npm run test:watch # Run tests in watch mode ``` ### Building for Production @@ -200,7 +206,7 @@ In your Supabase dashboard under **Authentication → URL Configuration**: - **Site URL**: your production domain (e.g. `https://yourapp.com`) - **Redirect URLs**: add `https://yourapp.com/auth/confirm` (and `http://localhost:5173/auth/confirm` for local dev) -Email confirmation is supported. When a new user signs up, they receive a confirmation email that redirects to `/auth/confirm`, which validates the OTP token and then redirects to `/dashboard`. +The `/auth/confirm` endpoint handles multiple OTP types: signup confirmation, password recovery, and email change. For password resets, the recovery email links to `/auth/confirm?next=/auth/reset-password`, which verifies the token and redirects to the new-password form. --- @@ -229,7 +235,7 @@ src/ │ │ ├── Requests.svelte # Send/accept/reject/cancel requests │ │ └── RequestsSkeleton.svelte │ ├── profile/ -│ │ ├── api.ts # findUserByUsername, checkUsernameAvailability +│ │ ├── api.ts # checkUsernameAvailability │ │ └── validation.ts # Username/display-name constants, validators, ERROR_MESSAGES │ ├── realtime/ │ │ └── subscriptions.ts # RealtimeSubscriptionManager class @@ -247,6 +253,7 @@ src/ │ ├── DebugPanel.svelte # Floating debug log panel (dev/iOS/error) │ ├── Footer.svelte │ ├── Navigation.svelte # Sticky nav with logout + settings link +│ ├── RelativeTime.svelte # Live-ticking relative timestamp component │ ├── ThemeSelect.svelte # Theme picker component │ ├── Toast.svelte │ └── ToastContainer.svelte @@ -258,11 +265,14 @@ src/ │ ├── +page.svelte # Landing / hero page │ ├── auth/ │ │ ├── +layout.svelte -│ │ ├── +page.server.ts # login/signup form actions -│ │ ├── +page.svelte # Login/signup tab UI -│ │ ├── confirm/+server.ts # Email confirmation OTP handler +│ │ ├── +page.server.ts # login/signup/forgotPassword form actions +│ │ ├── +page.svelte # Login/signup tab UI with forgot password flow +│ │ ├── confirm/+server.ts # Email confirmation OTP handler (signup, recovery, email change) │ │ ├── error/+page.svelte # Auth error page -│ │ └── logout/+server.ts # POST endpoint: server-side sign out + cookie clear +│ │ ├── logout/+server.ts # POST endpoint: server-side sign out + cookie clear +│ │ └── reset-password/ +│ │ ├── +page.server.ts # Load function (session check) +│ │ └── +page.svelte # New password form after recovery │ └── dashboard/ │ ├── +layout.server.ts # Auth guard - redirects unauthenticated users to / │ ├── +layout.svelte # Dashboard layout with Navigation @@ -270,7 +280,7 @@ src/ │ ├── +page.svelte # Main dashboard page │ └── settings/ │ ├── +page.server.ts -│ └── +page.svelte # Settings page +│ └── +page.svelte # Settings page (profile, security, quick statuses, appearance, data) │ └── sql/ ├── functions/ @@ -292,7 +302,7 @@ src/ 2. **`+layout.server.ts`** passes `session` and raw cookies to the client. 3. **`+layout.ts`** creates a Supabase browser client (or server client during SSR) and exposes `supabase`, `session`, and `user` to all child layouts/pages. 4. **Dashboard page** receives only `session` from the server - all dashboard data (friends, statuses, requests) is loaded client-side via `DashboardDataLoader`. This keeps server load minimal and avoids serializing large data structures through the load chain. -5. **Real-time** updates are handled by `RealtimeSubscriptionManager`, which subscribes to `friend_requests`, `friends`, and `profiles` table changes. On any relevant event, a debounced refresh (300ms) reloads dashboard data. +5. **Real-time** updates are handled by `RealtimeSubscriptionManager`, which subscribes to `friend_requests`, `friends`, and `profiles` table changes. Updates are granular: profile/status changes are patched in-place from the realtime payload (zero DB calls), friend request changes trigger a targeted reload of just the request arrays, and friendship changes (rare, structural) trigger a full data reload. ### Database Schema @@ -323,8 +333,8 @@ Quick statuses and friend order are stored in the browser's `localStorage` rathe **Svelte 5 runes throughout** All components use Svelte 5's rune-based reactivity (`$state`, `$derived`, `$effect`, `$props`, `$bindable`). This is the modern Svelte 5 API and provides cleaner component logic with explicit reactivity. -**Debounced real-time refresh** -Rather than updating specific state slices on each Realtime event, the app does a full data reload with a 300ms debounce. This is simpler to reason about and handles batched events correctly, at the cost of slightly more database reads. +**Granular real-time updates** +Realtime events are handled with three strategies: profile/status changes are patched in-place directly from the event payload (zero database calls), friend request changes trigger a debounced (300ms) reload of just the request arrays (two small queries), and friendship changes trigger a full reload since they are rare structural events that affect the profiles subscription scope. **`adapter-auto`** Using `@sveltejs/adapter-auto` means the project deploys to any supported platform without configuration changes. Swap in a specific adapter if you need fine-grained control. @@ -338,7 +348,10 @@ Using `@sveltejs/adapter-auto` means the project deploys to any supported platfo - Auth uses Supabase's email/password flow via `@supabase/ssr`. - Session validation in `hooks.server.ts` calls both `getSession()` and `getUser()`. The `getUser()` call makes a network request to Supabase to cryptographically verify the JWT - this prevents accepting a locally-forged or replayed token. This pattern follows [Supabase's security recommendations](https://supabase.com/docs/guides/auth/server-side/nextjs#understanding-what-session-means). - `safeGetSession()` returns `{ session: null, user: null }` on any validation error rather than propagating the error upward. -- The auth guard in `hooks.server.ts` redirects unauthenticated users away from `/dashboard/**` and redirects authenticated users away from `/auth`. +- The auth guard has two layers: `hooks.server.ts` redirects unauthenticated users away from `/dashboard/**` to `/auth`, and `dashboard/+layout.server.ts` provides a secondary guard that redirects to `/`. Authenticated users hitting exactly `/auth` are redirected to `/dashboard` (sub-routes like `/auth/reset-password` are not affected). +- **Password reset**: Users request a reset link from the login page via `resetPasswordForEmail()`. The email links through `/auth/confirm` (OTP verification) and redirects to `/auth/reset-password` where they set a new password via `updateUser()`. The root layout's `onAuthStateChange` listener also handles the `PASSWORD_RECOVERY` event as a safety redirect. +- **Password change**: Authenticated users can change their password from Settings. The current password is verified via `signInWithPassword()` re-authentication before calling `updateUser()`. +- **Email change**: Authenticated users can request an email change from Settings via `updateUser({ email })`. Supabase sends a confirmation link to the new address; the existing `/auth/confirm` handler processes the `email_change` OTP type. - Logout hits both the client-side `supabase.auth.signOut()` and a server-side `POST /auth/logout` endpoint that manually clears all Supabase auth cookies. ### Row Level Security @@ -350,20 +363,23 @@ All four public tables have RLS enabled. The policies enforce: | `users` | All authenticated users can read | - | Own row only | Own row only | | `profiles` | All authenticated users can read | Own row only | Own row only | - | | `friends` | Only if `user_id` or `friend_id` matches | Only if `user_id` or `friend_id` matches | - | Only if `user_id` or `friend_id` matches | -| `friend_requests` | Own rows (as requester or target) | Own as requester | Own as target | Own as requester or target | +| `friend_requests` | Own rows (as requester or target) | Own as requester (rate-limited) | Own as target | Own as requester or target | The read-all policy on `users` and `profiles` is intentional - users need to search for others by username and view friends' statuses. +**Rate limiting**: A restrictive RLS policy on `friend_requests` limits each user to 20 INSERT operations per hour. This is enforced at the database level via a `RESTRICTIVE` policy that is ANDed with the permissive INSERT policy, backed by a composite index on `(requester_id, created_at)`. + ### Cookie Security Auth session cookies are set with: + - **`path: '/'`** - applies app-wide - **`secure: true`** in production (HTTPS only) - prevents cookie theft over plain HTTP - **`sameSite: 'lax'`** - blocks cross-site POST requests from sending the cookie (CSRF mitigation) - **`domain`** set to the request hostname in production - prevents session sharing across subdomains - Localhost/127.0.0.1: `domain` is omitted to avoid browser quirks -iOS Safari receives `sameSite: 'lax'` (same as all other clients), which avoids the more restrictive `none` behavior that can cause issues with in-app browsers. +All clients (including iOS Safari) receive `sameSite: 'lax'`, which avoids the more restrictive `none` behavior that can cause issues with in-app browsers. ### Database Functions @@ -418,10 +434,5 @@ These are purely client-side and device-specific - they are not synced to the da ## Potential Improvements **API** -- Introduce an API. View only, so people can make their own dashboards (e.g. TRMNL). - -**User Experience** -- No password reset or email change flow is exposed in the UI. -**Code Quality** -- No automated tests (unit or integration). +- Introduce an API. View only, so people can make their own dashboards (e.g. TRMNL). diff --git a/package-lock.json b/package-lock.json index 0307c6a..3e93c67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,6 @@ "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.53.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.1", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", @@ -35,7 +33,19 @@ "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1298,19 +1308,6 @@ "vite": "^6.3.0 || ^7.0.0" } }, - "node_modules/@tailwindcss/forms": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", - "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mini-svg-data-uri": "^1.2.3" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" - } - }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -1568,19 +1565,6 @@ "node": ">= 20" } }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", - "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" - } - }, "node_modules/@tailwindcss/vite": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", @@ -1596,6 +1580,17 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1603,6 +1598,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1930,6 +1932,117 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2003,6 +2116,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2041,6 +2164,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2194,9 +2327,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2224,6 +2357,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2503,6 +2643,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2513,6 +2663,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3143,16 +3303,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mini-svg-data-uri": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", - "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", - "dev": true, - "license": "MIT", - "bin": { - "mini-svg-data-uri": "cli.js" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3313,6 +3463,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3456,20 +3613,6 @@ "postcss": "^8.4.29" } }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3718,6 +3861,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", @@ -3743,6 +3893,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3899,6 +4063,23 @@ "integrity": "sha512-B/UdsgdHAGhSKHTAQnxg/etN0RaMDpehuJmZIjLMDVJ6DGIliRHGD6pODi1CXLQAN9GV0GSyB3G6yCuK05PkPQ==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3916,6 +4097,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4114,6 +4305,84 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4130,6 +4399,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 6e75cc1..d19d1ad 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", - "lint": "prettier --check . && eslint ." + "lint": "prettier --check . && eslint .", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@eslint/compat": "^1.4.1", @@ -19,8 +21,6 @@ "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.53.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.1", "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", @@ -34,7 +34,8 @@ "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.0.18" }, "dependencies": { "@supabase/ssr": "^0.8.0", diff --git a/src/app.css b/src/app.css index 5676928..da6e77a 100644 --- a/src/app.css +++ b/src/app.css @@ -1,6 +1,4 @@ @import 'tailwindcss'; -@plugin '@tailwindcss/forms'; -@plugin '@tailwindcss/typography'; @plugin "daisyui" { themes: all; } diff --git a/src/app.html b/src/app.html index f2516ae..1093ed8 100644 --- a/src/app.html +++ b/src/app.html @@ -2,8 +2,13 @@ - + + + + + + %sveltekit.head% diff --git a/src/database.types.ts b/src/database.types.ts index 69b5250..49455d6 100644 --- a/src/database.types.ts +++ b/src/database.types.ts @@ -51,31 +51,28 @@ export interface Database { }; Relationships: []; }; - friend_requests: { - Row: { - id: string; - requester_id: string; - target_id: string; - status: string; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - requester_id: string; - target_id: string; - status?: string; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - requester_id?: string; - target_id?: string; - status?: string; - created_at?: string; - updated_at?: string; - }; + friend_requests: { + Row: { + id: string; + requester_id: string; + target_id: string; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + requester_id: string; + target_id: string; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + requester_id?: string; + target_id?: string; + created_at?: string; + updated_at?: string; + }; Relationships: [ { foreignKeyName: 'friend_requests_requester_id_fkey'; @@ -130,7 +127,14 @@ export interface Database { [_ in never]: never; }; Functions: { - [_ in never]: never; + get_dashboard_data: { + Args: Record; + Returns: Json; + }; + delete_user_account: { + Args: Record; + Returns: undefined; + }; }; Enums: { [_ in never]: never; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6b0c0d8..de0b48b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,3 @@ -// Set NODE_TLS_REJECT_UNAUTHORIZED=0 only in development mode -// This allows self-signed certificates to be accepted if (process.env.NODE_ENV === 'development') { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; console.log('⚠️\tSSL certificate verification disabled for development'); @@ -12,21 +10,17 @@ import { type Handle, redirect } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import https from 'node:https'; -// Create a custom agent that allows self-signed certificates in development const isDev = process.env.NODE_ENV === 'development'; const httpsAgent = isDev ? new https.Agent({ rejectUnauthorized: false }) : undefined; -// Custom fetch function that uses our HTTPS agent in development const customFetch = (input: URL | RequestInfo, init?: RequestInit) => { if (isDev) { - // In development, use node-fetch with our custom agent return fetch(input, { ...init, // @ts-expect-error - Agent is not in standard RequestInit but works with Node.js fetch agent: httpsAgent }); } - // In production, use the standard fetch return fetch(input, init); }; @@ -38,33 +32,13 @@ const supabase: Handle = async ({ event, resolve }) => { cookiesToSet: { name: string; value: string; options?: Record }[] ) => { cookiesToSet.forEach(({ name, value, options }) => { - // Get the current host from the request const host = event.url.hostname; - // Detect iOS Safari user agent for special cookie handling - const userAgent = event.request.headers.get('user-agent') || ''; - const isIOS = /iPad|iPhone|iPod/.test(userAgent); - const isSafari = /Safari/.test(userAgent) && !/Chrome|CriOS|FxiOS/.test(userAgent); - const isIOSSafari = isIOS && isSafari; - - // SECURITY FIX: Set secure cookie options to prevent session sharing across devices/domains - // This addresses the critical vulnerability where users were sharing sessions - // iOS Safari compatibility: Use 'none' with secure for cross-site, or 'lax' for same-site - // For iOS Safari, we need to be more careful with sameSite settings const secureOptions = { ...options, path: '/', - // Note: We don't set httpOnly for auth tokens as Supabase needs client-side access - // Set secure flag in production (requires HTTPS) secure: event.url.protocol === 'https:', - // iOS Safari fix: Use 'lax' for same-site, but ensure secure is set properly - // For iOS Safari, 'lax' works better than 'none' for same-site requests - sameSite: (isIOSSafari && event.url.protocol === 'https:') - ? 'lax' as const - : 'lax' as const, - // Set domain to current host to prevent cross-domain sharing - // This ensures sessions are isolated per domain/device - // iOS Safari: Don't set domain for localhost, and be careful with subdomains + sameSite: 'lax' as const, domain: host.startsWith('localhost') || host.startsWith('127.0.0.1') ? undefined : host @@ -95,7 +69,6 @@ const supabase: Handle = async ({ event, resolve }) => { } = await event.locals.supabase.auth.getUser(); if (userError) { - // JWT validation has failed - following official docs pattern return { session: null, user: null }; } @@ -125,9 +98,7 @@ const authGuard: Handle = async ({ event, resolve }) => { return resolve(event); }; -// Handle Chrome DevTools requests to prevent 404 errors const handleDevToolsRequests: Handle = async ({ event, resolve }) => { - // Check if the request is for Chrome DevTools specific endpoints if (event.url.pathname.includes('/.well-known/appspecific/com.chrome.devtools')) { return new Response(JSON.stringify({ message: 'Not implemented' }), { status: 200, diff --git a/src/lib/dashboard/loader.ts b/src/lib/dashboard/loader.ts index 2113a25..231085b 100644 --- a/src/lib/dashboard/loader.ts +++ b/src/lib/dashboard/loader.ts @@ -2,7 +2,6 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database } from '../../database.types'; import { getQuickStatuses, type QuickStatus } from '../status/quick.js'; -// Types for the dashboard data export interface FriendRequest { id: string; requester_id: string; @@ -10,7 +9,6 @@ export interface FriendRequest { requester_display_name: string | null; } -// Types for joined queries interface FriendRequestWithUser { id: string; requester_id: string; @@ -80,11 +78,9 @@ export interface UserExportData { }; } -// Utility function to remove duplicates by ID const deduplicateById = (items: T[]): T[] => items.filter((item, index, self) => index === self.findIndex((other) => other.id === item.id)); -// Shape of the JSON object returned by the get_dashboard_data() RPC function. interface DashboardRpcResult { username: string; display_name: string | null; @@ -120,23 +116,6 @@ export class DashboardDataLoader { this.userId = userId; } - async loadUserProfile(): Promise<{ - username: string; - display_name: string | null; - status: string; - }> { - const [userData, profileData] = await Promise.all([ - this.supabase.from('users').select('username, display_name').eq('id', this.userId).single(), - this.supabase.from('profiles').select('status').eq('id', this.userId).single() - ]); - - return { - username: userData.data?.username || '', - display_name: userData.data?.display_name || null, - status: profileData.data?.status || '' - }; - } - async loadFriendRequests(): Promise { const { data } = await this.supabase .from('friend_requests') @@ -179,8 +158,6 @@ export class DashboardDataLoader { } async loadFriends(): Promise { - // Get all friendships where current user is either user_id or friend_id - // Since we now store only one record per friendship, we need to check both columns const { data: friendships } = await this.supabase .from('friends') .select('id, user_id, friend_id') @@ -190,31 +167,26 @@ export class DashboardDataLoader { return []; } - // Get friend IDs (the other person in each friendship) const friendIds = friendships.map((friendship) => { return friendship.user_id === this.userId ? friendship.friend_id : friendship.user_id; }); - // Fetch user details for all friends const { data: friendUsers } = await this.supabase .from('users') .select('id, username, display_name') .in('id', friendIds); - // Get statuses for all friends in parallel const { data: friendStatuses } = friendIds.length > 0 ? await this.supabase.from('profiles').select('id, status, updated_at').in('id', friendIds) : { data: [] }; - // Create maps for friend data const userMap = new Map(friendUsers?.map((user) => [user.id, user]) || []); const statusMap = new Map(friendStatuses?.map((profile) => [profile.id, profile.status]) || []); const statusUpdatedAtMap = new Map( friendStatuses?.map((profile) => [profile.id, profile.updated_at]) || [] ); - // Process and format friends const formattedFriends = friendIds.map((friendId) => { const user = userMap.get(friendId); return { @@ -230,14 +202,11 @@ export class DashboardDataLoader { } async loadQuickStatuses(): Promise { - // Load quick statuses from localStorage instead of database return getQuickStatuses(); } async loadAllData(): Promise { - const rpcResult = await this.supabase.rpc( - 'get_dashboard_data' as keyof Database['public']['Functions'] - ); + const rpcResult = await this.supabase.rpc('get_dashboard_data'); if (rpcResult.error) { throw rpcResult.error; @@ -260,7 +229,6 @@ export class DashboardDataLoader { } async exportUserData(): Promise { - // Load all user data including detailed user and profile information const [userData, profileData, friendRequests, sentFriendRequests, friends, quickStatuses] = await Promise.all([ this.supabase @@ -309,12 +277,8 @@ export class DashboardDataLoader { } async deleteUserAccount(): Promise { - // Use the database function to delete the user account - // This function handles all the deletion logic and can delete the auth user try { - const { error } = await this.supabase.rpc( - 'delete_user_account' as keyof Database['public']['Functions'] - ); + const { error } = await this.supabase.rpc('delete_user_account'); if (error) { throw new Error(`Failed to delete account: ${error.message}`); diff --git a/src/lib/friends/api.ts b/src/lib/friends/api.ts index fd05bca..4a2f2ff 100644 --- a/src/lib/friends/api.ts +++ b/src/lib/friends/api.ts @@ -1,13 +1,11 @@ import type { SupabaseClient } from '@supabase/supabase-js'; -// Friendship verification utility export async function verifyFriendshipExists( supabase: SupabaseClient, userId: string, friendId: string ): Promise { try { - // Check for the single friendship record (could be in either direction) const { data: friendship, error } = await supabase .from('friends') .select('id') @@ -28,7 +26,6 @@ export async function verifyFriendshipExists( } } -// Friend request utilities export async function checkExistingFriendRequest( supabase: SupabaseClient, requesterId: string, @@ -73,7 +70,6 @@ export async function checkIncomingFriendRequest( throw error; } - // If a request exists, it's pending (requests are deleted when accepted/rejected) return { exists: !!incomingRequest, isPending: !!incomingRequest diff --git a/src/lib/friends/components/DeleteModal.svelte b/src/lib/friends/components/DeleteModal.svelte index 9985b37..bb0ada0 100644 --- a/src/lib/friends/components/DeleteModal.svelte +++ b/src/lib/friends/components/DeleteModal.svelte @@ -11,7 +11,6 @@ $props(); -