diff --git a/.github/workflows/netlify-deploy.yml b/.github/workflows/netlify-deploy.yml index f3dcf18..328fe66 100644 --- a/.github/workflows/netlify-deploy.yml +++ b/.github/workflows/netlify-deploy.yml @@ -31,6 +31,7 @@ jobs: VITE_USE_SUPABASE: true PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }} PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.PUBLIC_SUPABASE_PUBLISHABLE_KEY }} + SUPABASE_JWT_SECRET: ${{ secrets.SUPABASE_JWT_SECRET }} - name: Deploy to Netlify uses: nwtgck/actions-netlify@v3.0 diff --git a/bun.lock b/bun.lock index 0b017e5..3212fe3 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,9 @@ "@reown/appkit": "^1.8.14", "@reown/appkit-adapter-wagmi": "^1.8.14", "@supabase/supabase-js": "^2.84.0", + "@types/jsonwebtoken": "^9.0.10", "@wagmi/core": "^2.22.1", + "jsonwebtoken": "^9.0.3", "lucide-svelte": "^0.554.0", "qrcode": "^1.5.4", "viem": "^2.39.0", @@ -657,6 +659,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -829,6 +833,8 @@ "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bufferutil": ["bufferutil@4.0.9", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw=="], @@ -945,6 +951,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "eciesjs": ["eciesjs@0.4.16", "", { "dependencies": { "@ecies/ciphers": "^0.2.4", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -1267,6 +1275,12 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keccak": ["keccak@3.0.4", "", { "dependencies": { "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0", "readable-stream": "^3.6.0" } }, "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -1321,8 +1335,22 @@ "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], diff --git a/package.json b/package.json index 695adf0..b02e1c1 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,9 @@ "@reown/appkit": "^1.8.14", "@reown/appkit-adapter-wagmi": "^1.8.14", "@supabase/supabase-js": "^2.84.0", + "@types/jsonwebtoken": "^9.0.10", "@wagmi/core": "^2.22.1", + "jsonwebtoken": "^9.0.3", "lucide-svelte": "^0.554.0", "qrcode": "^1.5.4", "viem": "^2.39.0", diff --git a/src/app.d.ts b/src/app.d.ts index 520c421..9b8b560 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,11 +3,20 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + user: { + sub: string; + user_metadata: { + address: string; + }; + iat: number; + exp: number; + } | null; + } // interface PageData {} // interface PageState {} // interface Platform {} } } -export {}; +export { }; diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..263ed04 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,21 @@ +import { SUPABASE_JWT_SECRET } from '$env/static/private'; +import type { Handle } from '@sveltejs/kit'; +import jwt from 'jsonwebtoken'; + +export const handle: Handle = async ({ event, resolve }) => { + const authHeader = event.request.headers.get('authorization'); + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + + if (token) { + try { + const decodedPayload = jwt.verify(token, SUPABASE_JWT_SECRET, { algorithms: ['HS256'] }) as any; + event.locals.user = decodedPayload; + } catch (error) { + event.locals.user = null; + } + } else { + event.locals.user = null; + } + + return resolve(event); +}; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..8a3a7f5 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,55 @@ +import { getWalletClient } from "@wagmi/core"; +import type { Address } from "viem"; + +const AUTH_TOKEN_KEY = 'auth_token'; + +export async function signInWithWallet(address: Address, config: any): Promise { + try { + const timestamp = Date.now(); + const message = `Sign in to Gnosis Split\n\nTimestamp: ${timestamp}`; + + const walletClient = await getWalletClient(config, { account: address }); + if (!walletClient) { + throw new Error('Failed to get wallet client'); + } + + const signature = await walletClient.signMessage({ + message, + account: address + }); + + const response = await fetch('/api/auth/signin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message, + signature, + address, + timestamp + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Sign-in failed'); + } + + const { token } = await response.json(); + + localStorage.setItem(AUTH_TOKEN_KEY, token); + + return token; + } catch (error) { + throw error; + } +} + +export function getAuthToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(AUTH_TOKEN_KEY); +} + +export function clearAuth(): void { + if (typeof window === 'undefined') return; + localStorage.removeItem(AUTH_TOKEN_KEY); +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index b6df11d..fc2aa13 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,10 +1,29 @@ import { browser } from '$app/environment'; import type { Split, Participant } from './types'; +import { getAuthToken } from './auth'; const USE_SUPABASE = import.meta.env.VITE_USE_SUPABASE === 'true'; const STORAGE_KEY = 'gnosisSplits'; const API_BASE = '/api/splits'; +export function getAuthHeaders(): Record { + const token = getAuthToken(); + if (!token) { + return {}; + } + return { + 'Authorization': `Bearer ${token}` + }; +} + +export function getHeaders(additionalHeaders?: Record): Record { + return { + 'Content-Type': 'application/json', + ...getAuthHeaders(), + ...additionalHeaders + }; +} + export async function getSplits(userAddress?: string): Promise { if (!USE_SUPABASE) { if (!browser) return []; @@ -23,7 +42,9 @@ export async function getSplits(userAddress?: string): Promise { } else { try { const url = userAddress ? `${API_BASE}?address=${encodeURIComponent(userAddress)}` : API_BASE; - const response = await fetch(url); + const response = await fetch(url, { + headers: getHeaders() + }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); console.error('API error:', response.status, errorData); @@ -55,7 +76,7 @@ export async function saveSplit(split: Omit): Promise const response = await fetch(API_BASE, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: getHeaders(), body: JSON.stringify(split) }); @@ -71,7 +92,9 @@ export async function getSplit(id: string, userAddress?: string): Promise Split, const url = userAddress ? `${API_BASE}/${id}?address=${encodeURIComponent(userAddress)}` : `${API_BASE}/${id}`; const response = await fetch(url, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: getHeaders(), body: JSON.stringify(updated) }); @@ -126,7 +149,8 @@ export async function deleteSplit(id: string): Promise { } } else { const response = await fetch(`${API_BASE}/${id}`, { - method: 'DELETE' + method: 'DELETE', + headers: getHeaders() }); if (!response.ok) throw new Error('Failed to delete split'); @@ -154,7 +178,7 @@ export async function updateSplitParticipants(splitId: string, participants: Par const response = await fetch(`${API_BASE}/${splitId}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: getHeaders(), body: JSON.stringify({ participants }) }); diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts new file mode 100644 index 0000000..70de0a4 --- /dev/null +++ b/src/lib/stores/auth.ts @@ -0,0 +1,64 @@ +import { writable, get } from 'svelte/store'; +import { address } from './wallet'; +import { signInWithWallet, getAuthToken, clearAuth } from '$lib/auth'; +import { config } from '$lib/appkit'; +import type { Address } from 'viem'; + +let lastAddress: Address | string | null = null; + +export const isAuthenticated = writable(false); +export const isSigningIn = writable(false); +export const signInError = writable(null); + +export async function attemptSignIn(walletAddress: Address | string) { + try { + isSigningIn.set(true); + signInError.set(null); + + if (!config) { + throw new Error('Wagmi config not initialized. Please refresh the page.'); + } + + await signInWithWallet(walletAddress as Address, config); + + isAuthenticated.set(true); + } catch (error) { + const message = error instanceof Error ? error.message : 'Sign-in failed'; + signInError.set(message); + } finally { + isSigningIn.set(false); + } +} + +export function checkAuthStatus() { + const token = getAuthToken(); + isAuthenticated.set(!!token); +} + +if (typeof window !== 'undefined') { + checkAuthStatus(); +} + +address.subscribe(async ($address) => { + if (lastAddress && lastAddress !== $address) { + clearAuth(); + isAuthenticated.set(false); + signInError.set(null); + } + + if (!$address) { + clearAuth(); + isAuthenticated.set(false); + signInError.set(null); + lastAddress = null; + return; + } + + lastAddress = $address; + + const currentlyAuthenticated = get(isAuthenticated); + + if (!currentlyAuthenticated) { + await attemptSignIn($address); + } +}); diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index e632caa..275be92 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,5 +1,6 @@ import { writable } from 'svelte/store'; import type { Split } from './types'; +import { getHeaders } from './storage'; export const createSplitStore = (splitId: string, userAddress?: string) => { const { subscribe, set } = writable(null); @@ -11,7 +12,7 @@ export const createSplitStore = (splitId: string, userAddress?: string) => { const load = async () => { try { const url = userAddress ? `/api/splits/${splitId}?address=${encodeURIComponent(userAddress)}` : `/api/splits/${splitId}`; - const response = await fetch(url); + const response = await fetch(url, { headers: getHeaders() }); if (!response.ok) { console.error('Failed to load split'); return; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5c3e29f..3890741 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import favicon from '$lib/assets/favicon.svg'; import BottomNav from '$lib/components/BottomNav.svelte'; import '$lib/appkit'; + import '$lib/stores/auth'; import { Toaster } from '$lib/components/ui/sonner'; import Github from 'lucide-svelte/icons/github'; diff --git a/src/routes/api/auth/signin/+server.ts b/src/routes/api/auth/signin/+server.ts new file mode 100644 index 0000000..c202410 --- /dev/null +++ b/src/routes/api/auth/signin/+server.ts @@ -0,0 +1,83 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { verifyMessage, getAddress } from 'viem'; +import jwt from 'jsonwebtoken'; +import { SUPABASE_JWT_SECRET } from '$env/static/private'; + +const JWT_SECRET = SUPABASE_JWT_SECRET; +const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes +const EXPECTED_MESSAGE_PREFIX = 'Sign in to Gnosis Split'; + +function validateMessage(message: string, requestTimestamp: number | string): { valid: boolean; extractedTimestamp?: number; error?: string } { + if (!message.includes(EXPECTED_MESSAGE_PREFIX)) { + return { valid: false, error: 'Invalid message format' }; + } + + const timestampMatch = message.match(/Timestamp:\s*(\d+)/); + if (!timestampMatch || !timestampMatch[1]) { + return { valid: false, error: 'Missing timestamp in message' }; + } + + const extractedTimestamp = parseInt(timestampMatch[1], 10); + if (isNaN(extractedTimestamp)) { + return { valid: false, error: 'Invalid timestamp format in message' }; + } + + const requestTs = typeof requestTimestamp === 'number' ? requestTimestamp : parseInt(requestTimestamp as string, 10); + if (extractedTimestamp !== requestTs) { + return { valid: false, error: 'Timestamp mismatch between message and request' }; + } + + const now = Date.now(); + const timeDiff = now - extractedTimestamp; + if (timeDiff > TIMESTAMP_TOLERANCE_MS || timeDiff < 0) { + return { valid: false, error: 'Message timestamp too old or invalid' }; + } + + return { valid: true, extractedTimestamp }; +} + +export const POST: RequestHandler = async ({ request }) => { + try { + if (!JWT_SECRET) { + return json({ error: 'Server misconfigured' }, { status: 500 }); + } + + const { message, signature, address, timestamp } = await request.json(); + + const messageValidation = validateMessage(message, timestamp); + if (!messageValidation.valid) { + return json({ error: messageValidation.error }, { status: 400 }); + } + + const isValidSignature = await verifyMessage({ + address, + message, + signature + }); + + if (!isValidSignature) { + return json({ error: 'Invalid signature' }, { status: 401 }); + } + + const checksumAddress = getAddress(address); + + const token = jwt.sign( + { + sub: checksumAddress, + user_metadata: { + address: checksumAddress + } + }, + JWT_SECRET, + { + expiresIn: '1d', + algorithm: 'HS256' + } + ); + + return json({ token }, { status: 200 }); + } catch (error) { + return json({ error: 'Sign-in failed' }, { status: 400 }); + } +}; diff --git a/src/routes/api/splits/+server.ts b/src/routes/api/splits/+server.ts index 4405715..c446a59 100644 --- a/src/routes/api/splits/+server.ts +++ b/src/routes/api/splits/+server.ts @@ -3,24 +3,23 @@ import { getSupabase } from '$lib/server/supabase'; import type { RequestHandler } from '@sveltejs/kit'; import type { Split } from '$lib/types'; import { SplitCreateSchema } from '$lib/validation'; +import { getAddress } from 'viem'; -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ locals }) => { try { - const userAddress = url.searchParams.get('address'); + const userAddress = locals.user?.user_metadata?.address; if (!userAddress) { - console.warn('GET /api/splits - No user address provided'); - return json([], { status: 200 }); + return json({ error: 'Unauthorized' }, { status: 401 }); } - console.log('GET /api/splits - Starting for address:', userAddress); const supabase = getSupabase(); - console.log('Supabase client created successfully'); + const checksumAddress = getAddress(userAddress); const { data, error } = await supabase .from('splits') .select('*') - .eq('payer_address', userAddress) + .eq('payer_address', checksumAddress) .order('created_at', { ascending: false }); if (error) { @@ -56,8 +55,13 @@ export const GET: RequestHandler = async ({ url }) => { } }; -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, locals }) => { try { + const userAddress = locals.user?.user_metadata?.address; + if (!userAddress) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + const body = await request.json(); const validationResult = SplitCreateSchema.safeParse(body); @@ -69,6 +73,11 @@ export const POST: RequestHandler = async ({ request }) => { } const split = validationResult.data; + + if (getAddress(split.payerAddress) !== getAddress(userAddress)) { + return json({ error: 'Forbidden: can only create splits for yourself' }, { status: 403 }); + } + const supabase = getSupabase(); const { data, error } = await supabase diff --git a/src/routes/api/splits/[id]/+server.ts b/src/routes/api/splits/[id]/+server.ts index c4e6429..f4840d9 100644 --- a/src/routes/api/splits/[id]/+server.ts +++ b/src/routes/api/splits/[id]/+server.ts @@ -3,12 +3,17 @@ import { getSupabase } from '$lib/server/supabase'; import type { RequestHandler } from '@sveltejs/kit'; import type { Split, Participant } from '$lib/types'; import { SplitUpdateSchema, ParticipantsUpdateSchema } from '$lib/validation'; +import { getAddress } from 'viem'; -export const GET: RequestHandler = async ({ params, url }) => { +export const GET: RequestHandler = async ({ params, locals }) => { if (!params.id) throw Error('Error when getting split'); + const userAddress = locals.user?.user_metadata?.address; + if (!userAddress) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + try { - const userAddress = url.searchParams.get('address'); const supabase = getSupabase(); const { data, error } = await supabase .from('splits') @@ -20,16 +25,15 @@ export const GET: RequestHandler = async ({ params, url }) => { return json({ error: 'Split not found' }, { status: 404 }); } - if (userAddress) { - const isCreator = data.payer_address.toLowerCase() === userAddress.toLowerCase(); - const participants = data.participants as unknown as Participant[]; - const isParticipant = participants?.some( - (p: Participant) => p.address.toLowerCase() === userAddress.toLowerCase() - ); + const checksumUserAddress = getAddress(userAddress); + const isCreator = data.payer_address === checksumUserAddress; + const participants = data.participants as unknown as Participant[]; + const isParticipant = participants?.some( + (p: Participant) => getAddress(p.address) === checksumUserAddress + ); - if (!isCreator && !isParticipant) { - return json({ error: 'Unauthorized' }, { status: 403 }); - } + if (!isCreator && !isParticipant) { + return json({ error: 'Forbidden' }, { status: 403 }); } const split: Split = { @@ -51,9 +55,14 @@ export const GET: RequestHandler = async ({ params, url }) => { } }; -export const PUT: RequestHandler = async ({ params, request }) => { +export const PUT: RequestHandler = async ({ params, request, locals }) => { if (!params.id) throw Error("Error when updating split"); + const userAddress = locals.user?.user_metadata?.address; + if (!userAddress) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + try { const body = await request.json(); const validationResult = SplitUpdateSchema.safeParse(body); @@ -65,9 +74,31 @@ export const PUT: RequestHandler = async ({ params, request }) => { ); } - const updatedSplit = validationResult.data; const supabase = getSupabase(); + const { data: splitData, error: fetchError } = await supabase + .from('splits') + .select('*') + .eq('id', params.id) + .single(); + + if (fetchError || !splitData) { + return json({ error: 'Split not found' }, { status: 404 }); + } + + const checksumUserAddress = getAddress(userAddress); + const isCreator = splitData.payer_address === checksumUserAddress; + const participants = splitData.participants as unknown as Participant[]; + const isParticipant = participants?.some( + (p: Participant) => getAddress(p.address) === checksumUserAddress + ); + + if (!isCreator && !isParticipant) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + + const updatedSplit = validationResult.data; + const { data, error } = await supabase .from('splits') .update({ @@ -111,11 +142,34 @@ export const PUT: RequestHandler = async ({ params, request }) => { } }; -export const DELETE: RequestHandler = async ({ params }) => { +export const DELETE: RequestHandler = async ({ params, locals }) => { if (!params.id) throw Error('Error when deleting split'); + const userAddress = locals.user?.user_metadata?.address; + if (!userAddress) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + try { const supabase = getSupabase(); + + const { data: splitData, error: fetchError } = await supabase + .from('splits') + .select('*') + .eq('id', params.id) + .single(); + + if (fetchError || !splitData) { + return json({ error: 'Split not found' }, { status: 404 }); + } + + const checksumUserAddress = getAddress(userAddress); + const isCreator = splitData.payer_address === checksumUserAddress; + + if (!isCreator) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + const { error } = await supabase .from('splits') .delete() @@ -132,21 +186,38 @@ export const DELETE: RequestHandler = async ({ params }) => { } }; -export const PATCH: RequestHandler = async ({ params, request }) => { +export const PATCH: RequestHandler = async ({ params, request, locals }) => { if (!params.id) throw Error('Error when updating participants'); + const userAddress = locals.user?.user_metadata?.address; + if (!userAddress) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + try { const supabase = getSupabase(); - const { data: existingSplit, error: fetchError } = await supabase + + const { data: splitData, error: fetchError } = await supabase .from('splits') - .select('id') + .select('*') .eq('id', params.id) .single(); - if (fetchError || !existingSplit) { + if (fetchError || !splitData) { return json({ error: 'Split not found' }, { status: 404 }); } + const checksumUserAddress = getAddress(userAddress); + const isCreator = splitData.payer_address === checksumUserAddress; + const participants = splitData.participants as unknown as Participant[]; + const isParticipant = participants?.some( + (p: Participant) => getAddress(p.address) === checksumUserAddress + ); + + if (!isCreator && !isParticipant) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + const body = await request.json(); const validationResult = ParticipantsUpdateSchema.safeParse(body); @@ -157,11 +228,11 @@ export const PATCH: RequestHandler = async ({ params, request }) => { ); } - const { participants } = validationResult.data; + const { participants: newParticipants } = validationResult.data; const { error } = await supabase .from('splits') - .update({ participants: participants as any }) + .update({ participants: newParticipants as any }) .eq('id', params.id); if (error) {