From c369c55cc420ef86acb811daaa6e748558493462 Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 18:54:05 +0800 Subject: [PATCH 01/18] fix: move api calls to backend for better security --- .env.example | 8 ++ src/lib/server/supabase.ts | 8 ++ src/lib/storage.ts | 135 +++++++++----------------- src/lib/supabase.ts | 67 +++---------- src/routes/api/splits/+server.ts | 75 ++++++++++++++ src/routes/api/splits/[id]/+server.ts | 123 +++++++++++++++++++++++ 6 files changed, 269 insertions(+), 147 deletions(-) create mode 100644 src/lib/server/supabase.ts create mode 100644 src/routes/api/splits/+server.ts create mode 100644 src/routes/api/splits/[id]/+server.ts diff --git a/.env.example b/.env.example index f4bc94d..96cb1e1 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,14 @@ VITE_PROJECT_ID= VITE_MOCK_GNOSIS_PAY=true +# Supabase (server-only - DO NOT use VITE_ prefix, never expose to client) +# Get SUPABASE_URL and SUPABASE_SECRET_API_KEY from your Supabase project settings +# Secret API Key: Settings > API > Project API keys > Secret (sb_secret_*) +# See: https://supabase.com/docs/guides/api/api-keys#overview +SUPABASE_URL= +SUPABASE_SECRET_API_KEY= +VITE_USE_SUPABASE=true + # Blockscout API for payment verification # For Chiado testnet VITE_BLOCKSCOUT_BASE=https://gnosis-chiado.blockscout.com/api/v2 diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts new file mode 100644 index 0000000..9e2cd7a --- /dev/null +++ b/src/lib/server/supabase.ts @@ -0,0 +1,8 @@ +import { createClient } from '@supabase/supabase-js'; +import { SUPABASE_URL, SUPABASE_SECRET_API_KEY } from '$env/static/private'; +import type { Database } from '$lib/supabase-types'; + +export const supabase = createClient( + SUPABASE_URL, + SUPABASE_SECRET_API_KEY +); diff --git a/src/lib/storage.ts b/src/lib/storage.ts index d22f402..73cc697 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,9 +1,9 @@ import { browser } from '$app/environment'; import type { Split, Participant } from './types'; -import { supabase } from './supabase'; const USE_SUPABASE = import.meta.env.VITE_USE_SUPABASE === 'true'; const STORAGE_KEY = 'gnosisSplits'; +const API_BASE = '/api/splits'; export async function getSplits(): Promise { if (!USE_SUPABASE) { @@ -17,24 +17,14 @@ export async function getSplits(): Promise { return []; } } else { - const { data, error } = await supabase - .from('splits') - .select('*') - .order('created_at', { ascending: false }); - if (error) throw new Error("Error when getting splits: ", error); - if (!data) return []; - - return data.map((row) => ({ - id: row.id, - description: row.description, - totalAmount: row.total_amount, - date: row.date, - payerAddress: row.payer_address, - participants: row.participants as unknown as Split['participants'], - payments: row.payments as unknown as Split['payments'], - createdAt: row.created_at as unknown as Split['createdAt'], - sourceTxId: row.source_tx_id || undefined - })); + try { + const response = await fetch(API_BASE); + if (!response.ok) throw new Error('Failed to fetch splits'); + return await response.json(); + } catch (error) { + console.error('Failed to load splits:', error); + return []; + } } } @@ -54,33 +44,14 @@ export async function saveSplit(split: Omit): Promise } } - const { data, error } = await supabase - .from('splits') - .insert({ - description: split.description, - total_amount: split.totalAmount, - date: split.date, - payer_address: split.payerAddress, - participants: split.participants as any, - payments: split.payments as any, - source_tx_id: split.sourceTxId - }) - .select() - .single(); - - if (error) throw error; - - return { - id: data.id, - description: data.description, - totalAmount: data.total_amount, - date: data.date, - payerAddress: data.payer_address, - participants: data.participants as unknown as Split['participants'], - payments: data.payments as unknown as Split['payments'], - createdAt: data.created_at as unknown as Split['createdAt'], - sourceTxId: data.source_tx_id || undefined - }; + const response = await fetch(API_BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(split) + }); + + if (!response.ok) throw new Error('Failed to create split'); + return await response.json(); } export async function getSplit(id: string): Promise { @@ -89,29 +60,14 @@ export async function getSplit(id: string): Promise { return splits.find((s) => s.id === id); } - const { data, error } = await supabase - .from('splits') - .select('*') - .eq('id', id) - .single() - - if (error || !data) return undefined; - - const { id: foundId, description, total_amount, date, payer_address, participants, payments, created_at, source_tx_id } = data; - - const foundSplit = { - id: foundId, - description, - totalAmount: total_amount, - date, - payerAddress: payer_address, - participants: participants as unknown as Split['participants'], - payments: payments as unknown as Split['payments'], - createdAt: created_at as unknown as Split['createdAt'], - sourceTxId: source_tx_id || undefined, + try { + const response = await fetch(`${API_BASE}/${id}`); + if (!response.ok) return undefined; + return await response.json(); + } catch (error) { + console.error('Failed to load split:', error); + return undefined; } - - return foundSplit; } export async function updateSplit(id: string, updater: (split: Split) => Split): Promise { @@ -135,20 +91,13 @@ export async function updateSplit(id: string, updater: (split: Split) => Split): if (!split) return; const updated = updater(split); - const { error } = await supabase - .from('splits') - .update({ - description: updated.description, - total_amount: updated.totalAmount, - date: updated.date, - payer_address: updated.payerAddress, - participants: updated.participants as any, - payments: updated.payments as any, - source_tx_id: updated.sourceTxId - }) - .eq('id', id); - - if (error) throw error; + const response = await fetch(`${API_BASE}/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updated) + }); + + if (!response.ok) throw new Error('Failed to update split'); } } @@ -165,8 +114,11 @@ export async function deleteSplit(id: string): Promise { throw error; } } else { - const { error } = await supabase.from('splits').delete().eq('id', id); - if (error) throw error; + const response = await fetch(`${API_BASE}/${id}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Failed to delete split'); } } @@ -189,13 +141,14 @@ export async function updateSplitParticipants(splitId: string, participants: Par } } - const { error } = await supabase - .from('splits') - .update({ participants: participants as any }) - .eq('id', splitId); + const response = await fetch(`${API_BASE}/${splitId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ participants }) + }); - if (error) { - console.error('Failed to update participants', error); - throw error; + if (!response.ok) { + console.error('Failed to update participants'); + throw new Error('Failed to update participants'); } } diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index c0989a3..cd32608 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,12 +1,5 @@ -import { createClient } from '@supabase/supabase-js'; import { writable } from 'svelte/store'; import type { Split } from './types'; -import type { Database } from './supabase-types'; - -const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; - -export const supabase = createClient(supabaseUrl, supabaseAnonKey); export const createSplitStore = (splitId: string) => { const { subscribe, set } = writable(null); @@ -16,62 +9,24 @@ export const createSplitStore = (splitId: string) => { } const load = async () => { - const { data, error } = await supabase.from('splits').select('*').eq('id', splitId).single(); - - if (error) { - console.error('Failed to load split: ', error); - return; + try { + const response = await fetch(`/api/splits/${splitId}`); + if (!response.ok) { + console.error('Failed to load split'); + return; + } + const split = await response.json(); + set(split); + } catch (error) { + console.error('Failed to load split:', error); } - - set({ - id: data.id, - description: data.description, - totalAmount: data.total_amount, - date: data.date, - payerAddress: data.payer_address, - participants: data.participants as unknown as Split['participants'], - payments: data.payments as unknown as Split['payments'], - sourceTxId: data.source_tx_id || undefined, - createdAt: data.created_at as unknown as Split['createdAt'], - }); }; load(); - const channel = supabase - .channel(`split-${splitId}`) - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'splits', - filter: `id=eq.${splitId}` - }, - (payload) => { - if (payload.eventType === 'UPDATE' || payload.eventType === 'INSERT') { - const row = payload.new as Database['public']['Tables']['splits']['Row']; - set({ - id: row.id, - description: row.description, - totalAmount: row.total_amount, - date: row.date, - payerAddress: row.payer_address, - participants: row.participants as unknown as Split['participants'], - payments: row.payments as unknown as Split['payments'], - sourceTxId: row.source_tx_id || undefined, - createdAt: row.created_at as unknown as Split['createdAt'], - }); - } - } - ) - .subscribe((status) => { - if (status === 'SUBSCRIBED') console.log('Realtime connected for split', splitId); - }); - return { subscribe, refresh: load, - unsubscribe: () => supabase.removeChannel(channel) + unsubscribe: () => { } }; }; diff --git a/src/routes/api/splits/+server.ts b/src/routes/api/splits/+server.ts new file mode 100644 index 0000000..843f6d7 --- /dev/null +++ b/src/routes/api/splits/+server.ts @@ -0,0 +1,75 @@ +import { json } from '@sveltejs/kit'; +import { supabase } from '$lib/server/supabase'; +import type { RequestHandler } from '@sveltejs/kit'; +import type { Split } from '$lib/types'; + +export const GET: RequestHandler = async () => { + try { + const { data, error } = await supabase + .from('splits') + .select('*') + .order('created_at', { ascending: false }); + + if (error) { + return json({ error: 'Failed to fetch splits' }, { status: 500 }); + } + + const splits: Split[] = (data || []).map((row) => ({ + id: row.id, + description: row.description, + totalAmount: row.total_amount, + date: row.date, + payerAddress: row.payer_address, + participants: row.participants as unknown as Split['participants'], + payments: row.payments as unknown as Split['payments'], + createdAt: row.created_at as unknown as Split['createdAt'], + sourceTxId: row.source_tx_id || undefined + })); + + return json(splits); + } catch (error) { + console.error('Error fetching splits:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; + +export const POST: RequestHandler = async ({ request }) => { + try { + const split = await request.json(); + + const { data, error } = await supabase + .from('splits') + .insert({ + description: split.description, + total_amount: split.totalAmount, + date: split.date, + payer_address: split.payerAddress, + participants: split.participants, + payments: split.payments, + source_tx_id: split.sourceTxId + }) + .select() + .single(); + + if (error) { + return json({ error: 'Failed to create split' }, { status: 500 }); + } + + const newSplit: Split = { + id: data.id, + description: data.description, + totalAmount: data.total_amount, + date: data.date, + payerAddress: data.payer_address, + participants: data.participants as unknown as Split['participants'], + payments: data.payments as unknown as Split['payments'], + createdAt: data.created_at as unknown as Split['createdAt'], + sourceTxId: data.source_tx_id || undefined + }; + + return json(newSplit, { status: 201 }); + } catch (error) { + console.error('Error creating split:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; diff --git a/src/routes/api/splits/[id]/+server.ts b/src/routes/api/splits/[id]/+server.ts new file mode 100644 index 0000000..251a686 --- /dev/null +++ b/src/routes/api/splits/[id]/+server.ts @@ -0,0 +1,123 @@ +import { json } from '@sveltejs/kit'; +import { supabase } from '$lib/server/supabase'; +import type { RequestHandler } from '@sveltejs/kit'; +import type { Split, Participant } from '$lib/types'; + +export const GET: RequestHandler = async ({ params }) => { + if (!params.id) throw Error('Error when getting split'); + + try { + const { data, error } = await supabase + .from('splits') + .select('*') + .eq('id', params.id) + .single(); + + if (error || !data) { + return json({ error: 'Split not found' }, { status: 404 }); + } + + const split: Split = { + id: data.id, + description: data.description, + totalAmount: data.total_amount, + date: data.date, + payerAddress: data.payer_address, + participants: data.participants as unknown as Split['participants'], + payments: data.payments as unknown as Split['payments'], + createdAt: data.created_at as unknown as Split['createdAt'], + sourceTxId: data.source_tx_id || undefined + }; + + return json(split); + } catch (error) { + console.error('Error fetching split:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; + +export const PUT: RequestHandler = async ({ params, request }) => { + if (!params.id) throw Error("Error when updating split"); + + try { + const updatedSplit = await request.json(); + + const { data, error } = await supabase + .from('splits') + .update({ + description: updatedSplit.description, + total_amount: updatedSplit.totalAmount, + date: updatedSplit.date, + payer_address: updatedSplit.payerAddress, + participants: updatedSplit.participants, + payments: updatedSplit.payments, + source_tx_id: updatedSplit.sourceTxId + }) + .eq('id', params.id) + .select() + .single(); + + if (error || !data) { + return json({ error: 'Failed to update split' }, { status: 500 }); + } + + const split: Split = { + id: data.id, + description: data.description, + totalAmount: data.total_amount, + date: data.date, + payerAddress: data.payer_address, + participants: data.participants as unknown as Split['participants'], + payments: data.payments as unknown as Split['payments'], + createdAt: data.created_at as unknown as Split['createdAt'], + sourceTxId: data.source_tx_id || undefined + }; + + return json(split); + } catch (error) { + console.error('Error updating split:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; + +export const DELETE: RequestHandler = async ({ params }) => { + if (!params.id) throw Error('Error when deleting split'); + + try { + const { error } = await supabase + .from('splits') + .delete() + .eq('id', params.id); + + if (error) { + return json({ error: 'Failed to delete split' }, { status: 500 }); + } + + return json({ success: true }, { status: 200 }); + } catch (error) { + console.error('Error deleting split:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; + +export const PATCH: RequestHandler = async ({ params, request }) => { + if (!params.id) throw Error('Error when updating participants'); + + try { + const { participants } = await request.json() as { participants: Participant[] }; + + const { error } = await supabase + .from('splits') + .update({ participants: participants as any }) + .eq('id', params.id); + + if (error) { + return json({ error: 'Failed to update participants' }, { status: 500 }); + } + + return json({ success: true }, { status: 200 }); + } catch (error) { + console.error('Error updating participants:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; From 69b6740efe908054c3fc3852acdbbb2190514962 Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 20:23:31 +0800 Subject: [PATCH 02/18] fix: move supabase calls to backend with request validations --- bun.lock | 7 +++++- package.json | 3 ++- src/lib/validation.ts | 32 +++++++++++++++++++++++++++ src/routes/api/splits/+server.ts | 17 +++++++++++--- src/routes/api/splits/[id]/+server.ts | 31 +++++++++++++++++++++----- 5 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 src/lib/validation.ts diff --git a/bun.lock b/bun.lock index 4671d7d..0b017e5 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "qrcode": "^1.5.4", "viem": "^2.39.0", "wagmi": "^2.19.4", + "zod": "^4.1.13", }, "devDependencies": { "@eslint/compat": "^1.4.0", @@ -1828,7 +1829,7 @@ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - "zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="], + "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], "zustand": ["zustand@5.0.0", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ=="], @@ -1902,6 +1903,8 @@ "@reown/appkit-ui/qrcode": ["qrcode@1.5.3", "", { "dependencies": { "dijkstrajs": "^1.0.1", "encode-utf8": "^1.0.3", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg=="], + "@reown/appkit-wallet/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="], + "@rollup/plugin-babel/rollup": ["rollup@2.79.2", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ=="], "@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], @@ -2230,6 +2233,8 @@ "@walletconnect/ethereum-provider/@reown/appkit/@reown/appkit-wallet/@walletconnect/logger": ["@walletconnect/logger@2.1.2", "", { "dependencies": { "@walletconnect/safe-json": "^1.0.2", "pino": "7.11.0" } }, "sha512-aAb28I3S6pYXZHQm5ESB+V6rDqIYfsnHaQyzFbwUUBFY4H0OXx/YtTl8lvhUNhMMfb9UxbwEBS253TlXUYJWSw=="], + "@walletconnect/ethereum-provider/@reown/appkit/@reown/appkit-wallet/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="], + "@walletconnect/ethereum-provider/@reown/appkit/@walletconnect/types/@walletconnect/logger": ["@walletconnect/logger@2.1.2", "", { "dependencies": { "@walletconnect/safe-json": "^1.0.2", "pino": "7.11.0" } }, "sha512-aAb28I3S6pYXZHQm5ESB+V6rDqIYfsnHaQyzFbwUUBFY4H0OXx/YtTl8lvhUNhMMfb9UxbwEBS253TlXUYJWSw=="], "@walletconnect/ethereum-provider/@reown/appkit/@walletconnect/universal-provider/@walletconnect/logger": ["@walletconnect/logger@2.1.2", "", { "dependencies": { "@walletconnect/safe-json": "^1.0.2", "pino": "7.11.0" } }, "sha512-aAb28I3S6pYXZHQm5ESB+V6rDqIYfsnHaQyzFbwUUBFY4H0OXx/YtTl8lvhUNhMMfb9UxbwEBS253TlXUYJWSw=="], diff --git a/package.json b/package.json index f5cb8e7..ffe1123 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "lucide-svelte": "^0.554.0", "qrcode": "^1.5.4", "viem": "^2.39.0", - "wagmi": "^2.19.4" + "wagmi": "^2.19.4", + "zod": "^4.1.13" } } diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..9e81631 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const ParticipantSchema = z.object({ + address: z.string().min(1, 'Address is required'), + name: z.string().optional(), + amount: z.number().positive('Amount must be positive') +}); + +export const PaymentSchema = z.object({ + address: z.string().min(1, 'Address is required'), + txHash: z.string().optional() +}); + +export const SplitCreateSchema = z.object({ + description: z.string().min(1, 'Description is required'), + totalAmount: z.number().positive('Total amount must be positive'), + date: z.string().min(1, 'Date is required'), + payerAddress: z.string().min(1, 'Payer address is required'), + participants: z.array(ParticipantSchema).min(1, 'At least one participant is required'), + payments: z.array(PaymentSchema).default([]), + sourceTxId: z.string().optional() +}); + +export const SplitUpdateSchema = SplitCreateSchema.partial(); + +export const ParticipantsUpdateSchema = z.object({ + participants: z.array(ParticipantSchema).min(1, 'At least one participant is required') +}); + +export type SplitCreate = z.infer; +export type SplitUpdate = z.infer; +export type ParticipantsUpdate = z.infer; diff --git a/src/routes/api/splits/+server.ts b/src/routes/api/splits/+server.ts index 843f6d7..dce89ae 100644 --- a/src/routes/api/splits/+server.ts +++ b/src/routes/api/splits/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import { supabase } from '$lib/server/supabase'; import type { RequestHandler } from '@sveltejs/kit'; import type { Split } from '$lib/types'; +import { SplitCreateSchema } from '$lib/validation'; export const GET: RequestHandler = async () => { try { @@ -35,7 +36,17 @@ export const GET: RequestHandler = async () => { export const POST: RequestHandler = async ({ request }) => { try { - const split = await request.json(); + const body = await request.json(); + const validationResult = SplitCreateSchema.safeParse(body); + + if (!validationResult.success) { + return json( + { error: 'Validation failed', details: validationResult.error.flatten }, + { status: 400 } + ); + } + + const split = validationResult.data; const { data, error } = await supabase .from('splits') @@ -44,8 +55,8 @@ export const POST: RequestHandler = async ({ request }) => { total_amount: split.totalAmount, date: split.date, payer_address: split.payerAddress, - participants: split.participants, - payments: split.payments, + participants: split.participants as any, + payments: split.payments as any, source_tx_id: split.sourceTxId }) .select() diff --git a/src/routes/api/splits/[id]/+server.ts b/src/routes/api/splits/[id]/+server.ts index 251a686..60b8d0b 100644 --- a/src/routes/api/splits/[id]/+server.ts +++ b/src/routes/api/splits/[id]/+server.ts @@ -1,7 +1,8 @@ import { json } from '@sveltejs/kit'; import { supabase } from '$lib/server/supabase'; import type { RequestHandler } from '@sveltejs/kit'; -import type { Split, Participant } from '$lib/types'; +import type { Split } from '$lib/types'; +import { SplitUpdateSchema, ParticipantsUpdateSchema } from '$lib/validation'; export const GET: RequestHandler = async ({ params }) => { if (!params.id) throw Error('Error when getting split'); @@ -40,7 +41,17 @@ export const PUT: RequestHandler = async ({ params, request }) => { if (!params.id) throw Error("Error when updating split"); try { - const updatedSplit = await request.json(); + const body = await request.json(); + const validationResult = SplitUpdateSchema.safeParse(body); + + if (!validationResult.success) { + return json( + { error: 'Validation failed', details: validationResult.error.flatten }, + { status: 400 } + ); + } + + const updatedSplit = validationResult.data; const { data, error } = await supabase .from('splits') @@ -49,8 +60,8 @@ export const PUT: RequestHandler = async ({ params, request }) => { total_amount: updatedSplit.totalAmount, date: updatedSplit.date, payer_address: updatedSplit.payerAddress, - participants: updatedSplit.participants, - payments: updatedSplit.payments, + participants: updatedSplit.participants as any, + payments: updatedSplit.payments as any, source_tx_id: updatedSplit.sourceTxId }) .eq('id', params.id) @@ -104,7 +115,17 @@ export const PATCH: RequestHandler = async ({ params, request }) => { if (!params.id) throw Error('Error when updating participants'); try { - const { participants } = await request.json() as { participants: Participant[] }; + const body = await request.json(); + const validationResult = ParticipantsUpdateSchema.safeParse(body); + + if (!validationResult.success) { + return json( + { error: 'Validation failed', details: validationResult.error.flatten }, + { status: 400 } + ); + } + + const { participants } = validationResult.data; const { error } = await supabase .from('splits') From ba4b58a2c6f249650070f61f838a0e6fc9c4f37a Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 20:39:25 +0800 Subject: [PATCH 03/18] fix: handle error statuses correctly --- src/routes/api/splits/[id]/+server.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/routes/api/splits/[id]/+server.ts b/src/routes/api/splits/[id]/+server.ts index 60b8d0b..4247f13 100644 --- a/src/routes/api/splits/[id]/+server.ts +++ b/src/routes/api/splits/[id]/+server.ts @@ -68,8 +68,13 @@ export const PUT: RequestHandler = async ({ params, request }) => { .select() .single(); - if (error || !data) { - return json({ error: 'Failed to update split' }, { status: 500 }); + if (error) { + console.error('Database error updating split:', error); + return json({ error: 'Failed to update split', details: error.message }, { status: 500 }); + } + + if (!data) { + return json({ error: 'Split not found' }, { status: 404 }); } const split: Split = { @@ -115,6 +120,16 @@ export const PATCH: RequestHandler = async ({ params, request }) => { if (!params.id) throw Error('Error when updating participants'); try { + const { data: existingSplit, error: fetchError } = await supabase + .from('splits') + .select('id') + .eq('id', params.id) + .single(); + + if (fetchError || !existingSplit) { + return json({ error: 'Split not found' }, { status: 404 }); + } + const body = await request.json(); const validationResult = ParticipantsUpdateSchema.safeParse(body); @@ -133,7 +148,8 @@ export const PATCH: RequestHandler = async ({ params, request }) => { .eq('id', params.id); if (error) { - return json({ error: 'Failed to update participants' }, { status: 500 }); + console.error('Database error updating participants:', error); + return json({ error: 'Failed to update participants', details: error.message }, { status: 500 }); } return json({ success: true }, { status: 200 }); From b08ecbc244f76cee618c03960c729d085eea7715 Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 20:49:34 +0800 Subject: [PATCH 04/18] ci: configure all non-public env variables as private --- svelte.config.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/svelte.config.js b/svelte.config.js index 4490605..af4e0a2 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -16,7 +16,10 @@ const config = { // instead of creating a single one for the entire app. // if `edge` is true, this option cannot be used split: false - }) + }), + env: { + privatePrefix: '' + } } }; From 5c4eb3169b101e5137d398c7af63113ea1392c28 Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 21:00:06 +0800 Subject: [PATCH 05/18] ci: add secret envs to netlify-deploy workflow --- .github/workflows/netlify-deploy.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/netlify-deploy.yml b/.github/workflows/netlify-deploy.yml index 2fd68a3..f7d4744 100644 --- a/.github/workflows/netlify-deploy.yml +++ b/.github/workflows/netlify-deploy.yml @@ -28,6 +28,9 @@ jobs: run: bun run build env: VITE_MOCK_GNOSIS_PAY: true + VITE_USE_SUPABASE: true + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_SECRET_API_KEY: ${{ secrets.SUPABASE_SECRET_API_KEY }} - name: Deploy to Netlify uses: nwtgck/actions-netlify@v3.0 From 96a1c8d8a7c6726f493baaa60e1e4c7823384f02 Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 21:03:22 +0800 Subject: [PATCH 06/18] fix: avoid leaking database details via error --- src/routes/api/splits/[id]/+server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/api/splits/[id]/+server.ts b/src/routes/api/splits/[id]/+server.ts index 4247f13..11b4151 100644 --- a/src/routes/api/splits/[id]/+server.ts +++ b/src/routes/api/splits/[id]/+server.ts @@ -70,7 +70,7 @@ export const PUT: RequestHandler = async ({ params, request }) => { if (error) { console.error('Database error updating split:', error); - return json({ error: 'Failed to update split', details: error.message }, { status: 500 }); + return json({ error: 'Failed to update split' }, { status: 500 }); } if (!data) { @@ -149,7 +149,7 @@ export const PATCH: RequestHandler = async ({ params, request }) => { if (error) { console.error('Database error updating participants:', error); - return json({ error: 'Failed to update participants', details: error.message }, { status: 500 }); + return json({ error: 'Failed to update participants' }, { status: 500 }); } return json({ success: true }, { status: 200 }); From cab04af8bae5635d1001dd7bd0fe002b6f177d0f Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 21:13:33 +0800 Subject: [PATCH 07/18] fix: avoid putting secrets into build by lazy-loaded supabase client --- src/lib/server/supabase.ts | 17 ++++++++++++----- src/routes/api/splits/+server.ts | 4 +++- src/routes/api/splits/[id]/+server.ts | 6 +++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts index 9e2cd7a..9467b25 100644 --- a/src/lib/server/supabase.ts +++ b/src/lib/server/supabase.ts @@ -1,8 +1,15 @@ import { createClient } from '@supabase/supabase-js'; -import { SUPABASE_URL, SUPABASE_SECRET_API_KEY } from '$env/static/private'; import type { Database } from '$lib/supabase-types'; +import { env } from '$env/dynamic/private'; -export const supabase = createClient( - SUPABASE_URL, - SUPABASE_SECRET_API_KEY -); +let _supabase: ReturnType> | null = null; + +export function getSupabase() { + if (!_supabase) { + _supabase = createClient( + env.SUPABASE_URL || '', + env.SUPABASE_SECRET_API_KEY || '' + ); + } + return _supabase; +} diff --git a/src/routes/api/splits/+server.ts b/src/routes/api/splits/+server.ts index dce89ae..59e61ac 100644 --- a/src/routes/api/splits/+server.ts +++ b/src/routes/api/splits/+server.ts @@ -1,11 +1,12 @@ import { json } from '@sveltejs/kit'; -import { supabase } from '$lib/server/supabase'; +import { getSupabase } from '$lib/server/supabase'; import type { RequestHandler } from '@sveltejs/kit'; import type { Split } from '$lib/types'; import { SplitCreateSchema } from '$lib/validation'; export const GET: RequestHandler = async () => { try { + const supabase = getSupabase(); const { data, error } = await supabase .from('splits') .select('*') @@ -47,6 +48,7 @@ export const POST: RequestHandler = async ({ request }) => { } const split = validationResult.data; + const supabase = getSupabase(); const { data, error } = await supabase .from('splits') diff --git a/src/routes/api/splits/[id]/+server.ts b/src/routes/api/splits/[id]/+server.ts index 11b4151..c59c75d 100644 --- a/src/routes/api/splits/[id]/+server.ts +++ b/src/routes/api/splits/[id]/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { supabase } from '$lib/server/supabase'; +import { getSupabase } from '$lib/server/supabase'; import type { RequestHandler } from '@sveltejs/kit'; import type { Split } from '$lib/types'; import { SplitUpdateSchema, ParticipantsUpdateSchema } from '$lib/validation'; @@ -8,6 +8,7 @@ export const GET: RequestHandler = async ({ params }) => { if (!params.id) throw Error('Error when getting split'); try { + const supabase = getSupabase(); const { data, error } = await supabase .from('splits') .select('*') @@ -52,6 +53,7 @@ export const PUT: RequestHandler = async ({ params, request }) => { } const updatedSplit = validationResult.data; + const supabase = getSupabase(); const { data, error } = await supabase .from('splits') @@ -100,6 +102,7 @@ export const DELETE: RequestHandler = async ({ params }) => { if (!params.id) throw Error('Error when deleting split'); try { + const supabase = getSupabase(); const { error } = await supabase .from('splits') .delete() @@ -120,6 +123,7 @@ export const PATCH: RequestHandler = async ({ params, request }) => { if (!params.id) throw Error('Error when updating participants'); try { + const supabase = getSupabase(); const { data: existingSplit, error: fetchError } = await supabase .from('splits') .select('id') From d09d8f600de6f3cdd4d99a57c7831dcfc9144a4c Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 21:28:46 +0800 Subject: [PATCH 08/18] ci: try to expose secret envs --- src/lib/server/supabase.ts | 13 +++++++++---- src/lib/storage.ts | 6 +++++- src/routes/api/splits/+server.ts | 3 ++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts index 9467b25..0fdb814 100644 --- a/src/lib/server/supabase.ts +++ b/src/lib/server/supabase.ts @@ -6,10 +6,15 @@ let _supabase: ReturnType> | null = null; export function getSupabase() { if (!_supabase) { - _supabase = createClient( - env.SUPABASE_URL || '', - env.SUPABASE_SECRET_API_KEY || '' - ); + const url = env.SUPABASE_URL; + const key = env.SUPABASE_SECRET_API_KEY; + + if (!url || !key) { + console.error('Missing Supabase credentials. SUPABASE_URL:', !!url, 'SUPABASE_SECRET_API_KEY:', !!key); + throw new Error('Missing Supabase environment variables'); + } + + _supabase = createClient(url, key); } return _supabase; } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 73cc697..1c36651 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -19,7 +19,11 @@ export async function getSplits(): Promise { } else { try { const response = await fetch(API_BASE); - if (!response.ok) throw new Error('Failed to fetch splits'); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + console.error('API error:', response.status, errorData); + throw new Error(`Failed to fetch splits: ${response.status}`); + } return await response.json(); } catch (error) { console.error('Failed to load splits:', error); diff --git a/src/routes/api/splits/+server.ts b/src/routes/api/splits/+server.ts index 59e61ac..6d37145 100644 --- a/src/routes/api/splits/+server.ts +++ b/src/routes/api/splits/+server.ts @@ -13,7 +13,8 @@ export const GET: RequestHandler = async () => { .order('created_at', { ascending: false }); if (error) { - return json({ error: 'Failed to fetch splits' }, { status: 500 }); + console.error('Supabase error fetching splits:', error); + return json({ error: 'Failed to fetch splits', details: error.message }, { status: 500 }); } const splits: Split[] = (data || []).map((row) => ({ From f19f8ec7b4740e77e9521aa99188eb95624ced58 Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 21:44:26 +0800 Subject: [PATCH 09/18] fix: server error on /splits page --- netlify.toml | 8 ++++-- src/lib/server/supabase.ts | 13 +++++++--- vite.config.ts | 53 +++++++++++++++++++------------------- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/netlify.toml b/netlify.toml index 74eba01..b8b23f7 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,3 +1,7 @@ [build] - command = "npm run build" - publish = "build" + command = "bun run build" + +[build.environment] + BUN_VERSION = "1.2.15" + VITE_USE_SUPABASE = "true" + VITE_MOCK_GNOSIS_PAY = "true" diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts index 0fdb814..6d44db0 100644 --- a/src/lib/server/supabase.ts +++ b/src/lib/server/supabase.ts @@ -6,12 +6,17 @@ let _supabase: ReturnType> | null = null; export function getSupabase() { if (!_supabase) { - const url = env.SUPABASE_URL; - const key = env.SUPABASE_SECRET_API_KEY; + const url = env.SUPABASE_URL || process.env.SUPABASE_URL; + const key = env.SUPABASE_SECRET_API_KEY || process.env.SUPABASE_SECRET_API_KEY; if (!url || !key) { - console.error('Missing Supabase credentials. SUPABASE_URL:', !!url, 'SUPABASE_SECRET_API_KEY:', !!key); - throw new Error('Missing Supabase environment variables'); + console.error('Missing Supabase credentials', { + urlFromEnv: !!env.SUPABASE_URL, + urlFromProcess: !!process.env.SUPABASE_URL, + keyFromEnv: !!env.SUPABASE_SECRET_API_KEY, + keyFromProcess: !!process.env.SUPABASE_SECRET_API_KEY + }); + throw new Error(`Missing Supabase environment variables. URL: ${!!url}, KEY: ${!!key}`); } _supabase = createClient(url, key); diff --git a/vite.config.ts b/vite.config.ts index 4d1d2ea..3de8761 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,31 +6,32 @@ import { VitePWA } from 'vite-plugin-pwa'; export default defineConfig({ plugins: [ tailwindcss(), - sveltekit(), - VitePWA({ - registerType: 'autoUpdate', - devOptions: { enabled: true }, - workbox: { - globPatterns: ['**/*.{js,css,html,ico,svg,png}'], - runtimeCaching: [ - { - urlPattern: /^https:\/\/.*\.(?:png|jpg|jpeg|svg|gif)$/, - handler: 'CacheFirst', - options: { cacheName: 'images' } - } - ] - }, - manifest: { - name: 'Gnosis Split', - short_name: 'GnoSplit', - icons: [ - { - src: '/gnosis-split-meta.jpg', - sizes: 'any', - type: 'image/jpeg' - } - ] - } - }) + sveltekit() + // Temporarily disabled PWA due to build issues with Netlify adapter + // VitePWA({ + // registerType: 'autoUpdate', + // devOptions: { enabled: true }, + // workbox: { + // globPatterns: ['**/*.{js,css,html,ico,svg,png}'], + // runtimeCaching: [ + // { + // urlPattern: /^https:\/\/.*\.(?:png|jpg|jpeg|svg|gif)$/, + // handler: 'CacheFirst', + // options: { cacheName: 'images' } + // } + // ] + // }, + // manifest: { + // name: 'Gnosis Split', + // short_name: 'GnoSplit', + // icons: [ + // { + // src: '/gnosis-split-meta.jpg', + // sizes: 'any', + // type: 'image/jpeg' + // } + // ] + // } + // }) ] }); From f9064557aa26acdbaf81be602b718b1a375507ab Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 22:01:51 +0800 Subject: [PATCH 10/18] fix: netlify config --- netlify.toml | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/netlify.toml b/netlify.toml index b8b23f7..7630292 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,5 +1,6 @@ [build] command = "bun run build" + publish = ".svelte-kit/build" [build.environment] BUN_VERSION = "1.2.15" diff --git a/package.json b/package.json index ffe1123..695adf0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "packageManger": "bun@1.1.38", + "packageManager": "bun@1.2.15", "scripts": { "dev": "vite dev", "build": "vite build", From 6b9b5422bade0a49fe83979cd300e5d011227683 Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 22:20:55 +0800 Subject: [PATCH 11/18] ci: make supabaseUrl detectable --- .env.example | 12 +++++++----- src/lib/server/supabase.ts | 23 +++++++++++------------ src/routes/api/splits/+server.ts | 20 ++++++++++++++++---- svelte.config.js | 3 ++- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 96cb1e1..8fbc9b9 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,14 @@ VITE_PROJECT_ID= VITE_MOCK_GNOSIS_PAY=true -# Supabase (server-only - DO NOT use VITE_ prefix, never expose to client) -# Get SUPABASE_URL and SUPABASE_SECRET_API_KEY from your Supabase project settings -# Secret API Key: Settings > API > Project API keys > Secret (sb_secret_*) +# Supabase (server-only - DO NOT expose to client, PRIVATE_ prefix for static private vars) +# Get these from your Supabase project settings +# PRIVATE_SUPABASE_URL: Settings > General > Project URL +# PRIVATE_SUPABASE_SERVICE_ROLE_KEY: Settings > API > Project API keys > Service Role (not anon) +# Note: Use PRIVATE_ prefix so SvelteKit loads them via $env/static/private at build time # See: https://supabase.com/docs/guides/api/api-keys#overview -SUPABASE_URL= -SUPABASE_SECRET_API_KEY= +PRIVATE_SUPABASE_URL= +PRIVATE_SUPABASE_SERVICE_ROLE_KEY= VITE_USE_SUPABASE=true # Blockscout API for payment verification diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts index 6d44db0..e942c6c 100644 --- a/src/lib/server/supabase.ts +++ b/src/lib/server/supabase.ts @@ -1,25 +1,24 @@ import { createClient } from '@supabase/supabase-js'; +import { PRIVATE_SUPABASE_URL, PRIVATE_SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private'; import type { Database } from '$lib/supabase-types'; -import { env } from '$env/dynamic/private'; let _supabase: ReturnType> | null = null; export function getSupabase() { if (!_supabase) { - const url = env.SUPABASE_URL || process.env.SUPABASE_URL; - const key = env.SUPABASE_SECRET_API_KEY || process.env.SUPABASE_SECRET_API_KEY; + const supabaseUrl = PRIVATE_SUPABASE_URL; + const supabaseKey = PRIVATE_SUPABASE_SERVICE_ROLE_KEY; - if (!url || !key) { - console.error('Missing Supabase credentials', { - urlFromEnv: !!env.SUPABASE_URL, - urlFromProcess: !!process.env.SUPABASE_URL, - keyFromEnv: !!env.SUPABASE_SECRET_API_KEY, - keyFromProcess: !!process.env.SUPABASE_SECRET_API_KEY - }); - throw new Error(`Missing Supabase environment variables. URL: ${!!url}, KEY: ${!!key}`); + if (!supabaseUrl || !supabaseKey) { + throw new Error( + `Missing Supabase env vars. URL: ${!!supabaseUrl}, KEY: ${!!supabaseKey}. ` + + `Make sure PRIVATE_SUPABASE_URL and PRIVATE_SUPABASE_SERVICE_ROLE_KEY are set in Netlify.` + ); } - _supabase = createClient(url, key); + _supabase = createClient(supabaseUrl, supabaseKey, { + auth: { autoRefreshToken: false, persistSession: false } // Server-side: no auth persistence + }); } return _supabase; } diff --git a/src/routes/api/splits/+server.ts b/src/routes/api/splits/+server.ts index 6d37145..9ea865c 100644 --- a/src/routes/api/splits/+server.ts +++ b/src/routes/api/splits/+server.ts @@ -6,15 +6,26 @@ import { SplitCreateSchema } from '$lib/validation'; export const GET: RequestHandler = async () => { try { + console.log('GET /api/splits - Starting'); const supabase = getSupabase(); + console.log('Supabase client created successfully'); + const { data, error } = await supabase .from('splits') .select('*') .order('created_at', { ascending: false }); if (error) { - console.error('Supabase error fetching splits:', error); - return json({ error: 'Failed to fetch splits', details: error.message }, { status: 500 }); + console.error('Supabase error fetching splits:', { + message: error.message, + code: (error as any).code, + status: (error as any).status + }); + return json({ + error: 'Failed to fetch splits', + details: error.message, + code: (error as any).code + }, { status: 500 }); } const splits: Split[] = (data || []).map((row) => ({ @@ -31,8 +42,9 @@ export const GET: RequestHandler = async () => { return json(splits); } catch (error) { - console.error('Error fetching splits:', error); - return json({ error: 'Internal server error' }, { status: 500 }); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Error fetching splits:', errorMessage); + return json({ error: 'Internal server error', details: errorMessage }, { status: 500 }); } }; diff --git a/svelte.config.js b/svelte.config.js index af4e0a2..a65a6ac 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -18,7 +18,8 @@ const config = { split: false }), env: { - privatePrefix: '' + dir: process.cwd(), + privatePrefix: 'PRIVATE_' } } }; From 860cd348067887e8f841d076b476a6df0b688426 Mon Sep 17 00:00:00 2001 From: ical10 Date: Sun, 7 Dec 2025 22:54:45 +0800 Subject: [PATCH 12/18] ci: fix supabase access by using publishable key --- .env.example | 11 +++++------ .github/workflows/netlify-deploy.yml | 6 +++--- src/lib/server/supabase.ts | 9 +++++---- svelte.config.js | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 8fbc9b9..f3616ce 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,13 @@ VITE_PROJECT_ID= VITE_MOCK_GNOSIS_PAY=true -# Supabase (server-only - DO NOT expose to client, PRIVATE_ prefix for static private vars) +# Supabase (Publishable Key is safe to expose) # Get these from your Supabase project settings -# PRIVATE_SUPABASE_URL: Settings > General > Project URL -# PRIVATE_SUPABASE_SERVICE_ROLE_KEY: Settings > API > Project API keys > Service Role (not anon) -# Note: Use PRIVATE_ prefix so SvelteKit loads them via $env/static/private at build time +# VITE_SUPABASE_URL: Settings > General > Project URL +# VITE_SUPABASE_PUBLISHABLE_KEY: Settings > API > Project API keys > Publishable Key (sb_publishable_*) # See: https://supabase.com/docs/guides/api/api-keys#overview -PRIVATE_SUPABASE_URL= -PRIVATE_SUPABASE_SERVICE_ROLE_KEY= +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= VITE_USE_SUPABASE=true # Blockscout API for payment verification diff --git a/.github/workflows/netlify-deploy.yml b/.github/workflows/netlify-deploy.yml index f7d4744..c4ae95c 100644 --- a/.github/workflows/netlify-deploy.yml +++ b/.github/workflows/netlify-deploy.yml @@ -29,13 +29,13 @@ jobs: env: VITE_MOCK_GNOSIS_PAY: true VITE_USE_SUPABASE: true - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_SECRET_API_KEY: ${{ secrets.SUPABASE_SECRET_API_KEY }} + VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} + VITE_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_KEY }} - name: Deploy to Netlify uses: nwtgck/actions-netlify@v3.0 with: - publish-dir: './.svelte-kit/output' + publish-dir: './.svelte-kit/build' production-branch: main github-token: ${{ secrets.GITHUB_TOKEN }} deploy-message: 'Deploy from GitHub Actions' diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts index e942c6c..a5f01c3 100644 --- a/src/lib/server/supabase.ts +++ b/src/lib/server/supabase.ts @@ -1,18 +1,19 @@ import { createClient } from '@supabase/supabase-js'; -import { PRIVATE_SUPABASE_URL, PRIVATE_SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private'; +import { VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; import type { Database } from '$lib/supabase-types'; let _supabase: ReturnType> | null = null; export function getSupabase() { if (!_supabase) { - const supabaseUrl = PRIVATE_SUPABASE_URL; - const supabaseKey = PRIVATE_SUPABASE_SERVICE_ROLE_KEY; + // Use public env vars (baked in at build time) + const supabaseUrl = VITE_SUPABASE_URL; + const supabaseKey = VITE_SUPABASE_PUBLISHABLE_KEY; if (!supabaseUrl || !supabaseKey) { throw new Error( `Missing Supabase env vars. URL: ${!!supabaseUrl}, KEY: ${!!supabaseKey}. ` + - `Make sure PRIVATE_SUPABASE_URL and PRIVATE_SUPABASE_SERVICE_ROLE_KEY are set in Netlify.` + `Make sure VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are set.` ); } diff --git a/svelte.config.js b/svelte.config.js index a65a6ac..3c81524 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -19,7 +19,7 @@ const config = { }), env: { dir: process.cwd(), - privatePrefix: 'PRIVATE_' + publicPrefix: 'VITE_' } } }; From cd22654583f09fe01f9d93cc7c0d04e51a8b24e1 Mon Sep 17 00:00:00 2001 From: ical10 Date: Mon, 8 Dec 2025 10:17:26 +0800 Subject: [PATCH 13/18] fix: update env vars --- .env.example | 10 +++++----- .github/workflows/netlify-deploy.yml | 4 ++-- src/lib/server/supabase.ts | 8 ++++---- svelte.config.js | 6 +----- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index f3616ce..049e3b7 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,13 @@ VITE_PROJECT_ID= VITE_MOCK_GNOSIS_PAY=true -# Supabase (Publishable Key is safe to expose) +# Supabase # Get these from your Supabase project settings -# VITE_SUPABASE_URL: Settings > General > Project URL -# VITE_SUPABASE_PUBLISHABLE_KEY: Settings > API > Project API keys > Publishable Key (sb_publishable_*) +# PUBLIC_SUPABASE_URL: Settings > General > Project URL +# PUBLIC_SUPABASE_PUBLISHABLE_KEY: Settings > API > Project API keys > Publishable Key (sb_publishable_*) # See: https://supabase.com/docs/guides/api/api-keys#overview -VITE_SUPABASE_URL= -VITE_SUPABASE_PUBLISHABLE_KEY= +PUBLIC_SUPABASE_URL= +PUBLIC_SUPABASE_PUBLISHABLE_KEY= VITE_USE_SUPABASE=true # Blockscout API for payment verification diff --git a/.github/workflows/netlify-deploy.yml b/.github/workflows/netlify-deploy.yml index c4ae95c..f3dcf18 100644 --- a/.github/workflows/netlify-deploy.yml +++ b/.github/workflows/netlify-deploy.yml @@ -29,8 +29,8 @@ jobs: env: VITE_MOCK_GNOSIS_PAY: true VITE_USE_SUPABASE: true - VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} - VITE_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_KEY }} + PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }} + PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.PUBLIC_SUPABASE_PUBLISHABLE_KEY }} - name: Deploy to Netlify uses: nwtgck/actions-netlify@v3.0 diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts index a5f01c3..646d2fd 100644 --- a/src/lib/server/supabase.ts +++ b/src/lib/server/supabase.ts @@ -1,5 +1,5 @@ import { createClient } from '@supabase/supabase-js'; -import { VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; import type { Database } from '$lib/supabase-types'; let _supabase: ReturnType> | null = null; @@ -7,13 +7,13 @@ let _supabase: ReturnType> | null = null; export function getSupabase() { if (!_supabase) { // Use public env vars (baked in at build time) - const supabaseUrl = VITE_SUPABASE_URL; - const supabaseKey = VITE_SUPABASE_PUBLISHABLE_KEY; + const supabaseUrl = PUBLIC_SUPABASE_URL; + const supabaseKey = PUBLIC_SUPABASE_PUBLISHABLE_KEY; if (!supabaseUrl || !supabaseKey) { throw new Error( `Missing Supabase env vars. URL: ${!!supabaseUrl}, KEY: ${!!supabaseKey}. ` + - `Make sure VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are set.` + `Make sure PUBLIC_SUPABASE_URL and PUBLIC_SUPABASE_PUBLISHABLE_KEY are set.` ); } diff --git a/svelte.config.js b/svelte.config.js index 3c81524..4490605 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -16,11 +16,7 @@ const config = { // instead of creating a single one for the entire app. // if `edge` is true, this option cannot be used split: false - }), - env: { - dir: process.cwd(), - publicPrefix: 'VITE_' - } + }) } }; From 27d42b28c22f079524bf43ce9ad54b04bd230b5a Mon Sep 17 00:00:00 2001 From: ical10 Date: Mon, 8 Dec 2025 10:48:36 +0800 Subject: [PATCH 14/18] fix: better type-safety for supabase client --- src/lib/server/supabase.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts index 646d2fd..c9f9a9e 100644 --- a/src/lib/server/supabase.ts +++ b/src/lib/server/supabase.ts @@ -1,10 +1,10 @@ -import { createClient } from '@supabase/supabase-js'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY } from '$env/static/public'; import type { Database } from '$lib/supabase-types'; -let _supabase: ReturnType> | null = null; +let _supabase: SupabaseClient | undefined; -export function getSupabase() { +export function getSupabase(): SupabaseClient { if (!_supabase) { // Use public env vars (baked in at build time) const supabaseUrl = PUBLIC_SUPABASE_URL; From 7a5c6abf0f6008412c63d8dff0b45d282b0e45d9 Mon Sep 17 00:00:00 2001 From: ical10 Date: Mon, 8 Dec 2025 11:04:05 +0800 Subject: [PATCH 15/18] fix: filter splits by payer_address --- src/lib/storage.ts | 18 ++++++++++++------ src/routes/api/splits/+server.ts | 12 ++++++++++-- src/routes/api/splits/[id]/+server.ts | 7 ++++++- src/routes/splits/+page.svelte | 3 ++- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 1c36651..ce45c6b 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -5,20 +5,25 @@ const USE_SUPABASE = import.meta.env.VITE_USE_SUPABASE === 'true'; const STORAGE_KEY = 'gnosisSplits'; const API_BASE = '/api/splits'; -export async function getSplits(): Promise { +export async function getSplits(userAddress?: string): Promise { if (!USE_SUPABASE) { if (!browser) return []; try { const data = localStorage.getItem(STORAGE_KEY); - return data ? JSON.parse(data) : []; + const splits = data ? JSON.parse(data) : []; + if (userAddress) { + return splits.filter((s: Split) => s.payerAddress.toLowerCase() === userAddress.toLowerCase()); + } + return splits; } catch (error) { console.error('Failed to load splits:', error); return []; } } else { try { - const response = await fetch(API_BASE); + const url = userAddress ? `${API_BASE}?address=${encodeURIComponent(userAddress)}` : API_BASE; + const response = await fetch(url); if (!response.ok) { const errorData = await response.json().catch(() => ({})); console.error('API error:', response.status, errorData); @@ -58,14 +63,15 @@ export async function saveSplit(split: Omit): Promise return await response.json(); } -export async function getSplit(id: string): Promise { +export async function getSplit(id: string, userAddress?: string): Promise { if (!USE_SUPABASE) { - const splits = await getSplits(); + const splits = await getSplits(userAddress); return splits.find((s) => s.id === id); } try { - const response = await fetch(`${API_BASE}/${id}`); + const url = userAddress ? `${API_BASE}/${id}?address=${encodeURIComponent(userAddress)}` : `${API_BASE}/${id}`; + const response = await fetch(url); if (!response.ok) return undefined; return await response.json(); } catch (error) { diff --git a/src/routes/api/splits/+server.ts b/src/routes/api/splits/+server.ts index 9ea865c..4405715 100644 --- a/src/routes/api/splits/+server.ts +++ b/src/routes/api/splits/+server.ts @@ -4,15 +4,23 @@ import type { RequestHandler } from '@sveltejs/kit'; import type { Split } from '$lib/types'; import { SplitCreateSchema } from '$lib/validation'; -export const GET: RequestHandler = async () => { +export const GET: RequestHandler = async ({ url }) => { try { - console.log('GET /api/splits - Starting'); + const userAddress = url.searchParams.get('address'); + + if (!userAddress) { + console.warn('GET /api/splits - No user address provided'); + return json([], { status: 200 }); + } + + console.log('GET /api/splits - Starting for address:', userAddress); const supabase = getSupabase(); console.log('Supabase client created successfully'); const { data, error } = await supabase .from('splits') .select('*') + .eq('payer_address', userAddress) .order('created_at', { ascending: false }); if (error) { diff --git a/src/routes/api/splits/[id]/+server.ts b/src/routes/api/splits/[id]/+server.ts index c59c75d..4b35a2d 100644 --- a/src/routes/api/splits/[id]/+server.ts +++ b/src/routes/api/splits/[id]/+server.ts @@ -4,10 +4,11 @@ import type { RequestHandler } from '@sveltejs/kit'; import type { Split } from '$lib/types'; import { SplitUpdateSchema, ParticipantsUpdateSchema } from '$lib/validation'; -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, url }) => { if (!params.id) throw Error('Error when getting split'); try { + const userAddress = url.searchParams.get('address'); const supabase = getSupabase(); const { data, error } = await supabase .from('splits') @@ -19,6 +20,10 @@ export const GET: RequestHandler = async ({ params }) => { return json({ error: 'Split not found' }, { status: 404 }); } + if (userAddress && data.payer_address.toLowerCase() !== userAddress.toLowerCase()) { + return json({ error: 'Unauthorized' }, { status: 403 }); + } + const split: Split = { id: data.id, description: data.description, diff --git a/src/routes/splits/+page.svelte b/src/routes/splits/+page.svelte index f9822d2..ffea694 100644 --- a/src/routes/splits/+page.svelte +++ b/src/routes/splits/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import AuthGuard from '$lib/components/AuthGuard.svelte'; import { getSplits } from '$lib/storage'; + import { address } from '$lib/stores/wallet'; import type { Split } from '$lib/types'; import { getPaymentStatus } from '$lib/utils'; import { Button } from '$lib/components/ui/button'; @@ -20,7 +21,7 @@ async function loadSplits() { loading = true; try { - const allSplits = await getSplits(); + const allSplits = await getSplits($address); splits = allSplits.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); } catch (error) { console.error('Error when fetching splits:', error); From c25a86d6487ef7bce7304fb84508459851380abb Mon Sep 17 00:00:00 2001 From: ical10 Date: Mon, 8 Dec 2025 11:39:58 +0800 Subject: [PATCH 16/18] fix: maintain proper authorization check for pages --- src/lib/supabase.ts | 5 +++-- src/routes/api/splits/[id]/+server.ts | 14 +++++++++++--- src/routes/split/[id]/+page.svelte | 4 ++-- src/routes/split/payment/+page.svelte | 3 +-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index cd32608..e632caa 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -1,7 +1,7 @@ import { writable } from 'svelte/store'; import type { Split } from './types'; -export const createSplitStore = (splitId: string) => { +export const createSplitStore = (splitId: string, userAddress?: string) => { const { subscribe, set } = writable(null); if (import.meta.env.VITE_USE_SUPABASE !== 'true') { @@ -10,7 +10,8 @@ export const createSplitStore = (splitId: string) => { const load = async () => { try { - const response = await fetch(`/api/splits/${splitId}`); + const url = userAddress ? `/api/splits/${splitId}?address=${encodeURIComponent(userAddress)}` : `/api/splits/${splitId}`; + const response = await fetch(url); if (!response.ok) { console.error('Failed to load split'); return; diff --git a/src/routes/api/splits/[id]/+server.ts b/src/routes/api/splits/[id]/+server.ts index 4b35a2d..c4e6429 100644 --- a/src/routes/api/splits/[id]/+server.ts +++ b/src/routes/api/splits/[id]/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import { getSupabase } from '$lib/server/supabase'; import type { RequestHandler } from '@sveltejs/kit'; -import type { Split } from '$lib/types'; +import type { Split, Participant } from '$lib/types'; import { SplitUpdateSchema, ParticipantsUpdateSchema } from '$lib/validation'; export const GET: RequestHandler = async ({ params, url }) => { @@ -20,8 +20,16 @@ export const GET: RequestHandler = async ({ params, url }) => { return json({ error: 'Split not found' }, { status: 404 }); } - if (userAddress && data.payer_address.toLowerCase() !== userAddress.toLowerCase()) { - return json({ error: 'Unauthorized' }, { status: 403 }); + 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() + ); + + if (!isCreator && !isParticipant) { + return json({ error: 'Unauthorized' }, { status: 403 }); + } } const split: Split = { diff --git a/src/routes/split/[id]/+page.svelte b/src/routes/split/[id]/+page.svelte index 762ad51..ceb5690 100644 --- a/src/routes/split/[id]/+page.svelte +++ b/src/routes/split/[id]/+page.svelte @@ -33,9 +33,9 @@ } if (USE_SUPABASE === 'true') { - splitStore = createSplitStore(id); + splitStore = createSplitStore(id, $walletAddress); } else { - const loaded = await getSplit(id); + const loaded = await getSplit(id, $walletAddress); if (!loaded) { goto('/splits'); return; diff --git a/src/routes/split/payment/+page.svelte b/src/routes/split/payment/+page.svelte index 5feef9c..2fa72b8 100644 --- a/src/routes/split/payment/+page.svelte +++ b/src/routes/split/payment/+page.svelte @@ -13,7 +13,6 @@ import { config } from '$lib/appkit'; import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; - import { Badge } from '$lib/components/ui/badge'; import * as Avatar from '$lib/components/ui/avatar'; import { toast } from 'svelte-sonner'; @@ -32,7 +31,7 @@ return; } - const loaded = await getSplit(splitId); + const loaded = await getSplit(splitId, $walletAddress || participantAddr); if (!loaded) { goto('/splits'); return; From 0ec8e0dc22b100ee961fce038371b2e85c8352c7 Mon Sep 17 00:00:00 2001 From: ical10 Date: Mon, 8 Dec 2025 12:13:42 +0800 Subject: [PATCH 17/18] fix: persists connected wallet after refresh --- src/lib/stores/wallet.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/stores/wallet.ts b/src/lib/stores/wallet.ts index 18069da..c28ec08 100644 --- a/src/lib/stores/wallet.ts +++ b/src/lib/stores/wallet.ts @@ -10,6 +10,16 @@ export const appKitStore = writable({ }); if (browser && appKit) { + const account = appKit.getAccount?.(); + const state = appKit.getState?.(); + + appKitStore.set({ + open: state?.open ?? false, + selectedNetworkId: state?.selectedNetworkId, + address: account?.address, + isConnected: account?.isConnected ?? false + }); + appKit.subscribeState((state) => { appKitStore.update((current) => ({ ...current, From 58e635e2b10403e069651c38e691f6711bf143b9 Mon Sep 17 00:00:00 2001 From: ical10 Date: Mon, 8 Dec 2025 12:27:29 +0800 Subject: [PATCH 18/18] fix: pass walletAddress for more robust authorization check on API --- src/lib/blockscoutVerifier.ts | 2 +- src/lib/storage.ts | 7 ++++--- src/routes/split/[id]/+page.svelte | 6 +++--- src/routes/split/payment/+page.svelte | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/lib/blockscoutVerifier.ts b/src/lib/blockscoutVerifier.ts index d0823ef..6213926 100644 --- a/src/lib/blockscoutVerifier.ts +++ b/src/lib/blockscoutVerifier.ts @@ -83,7 +83,7 @@ export async function verifyAndMarkXDAIPaid(splitId: string, split: Split) { await updateSplit(splitId, (s) => ({ ...s, payments: newPayments - })); + }), split.payerAddress); } } catch (error) { console.error('Blockscout verification error:', error); diff --git a/src/lib/storage.ts b/src/lib/storage.ts index ce45c6b..b6df11d 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -80,7 +80,7 @@ export async function getSplit(id: string, userAddress?: string): Promise Split): Promise { +export async function updateSplit(id: string, updater: (split: Split) => Split, userAddress?: string): Promise { if (!USE_SUPABASE) { if (!browser) return; @@ -97,11 +97,12 @@ export async function updateSplit(id: string, updater: (split: Split) => Split): throw error; } } else { - const split = await getSplit(id); + const split = await getSplit(id, userAddress); if (!split) return; const updated = updater(split); - const response = await fetch(`${API_BASE}/${id}`, { + const url = userAddress ? `${API_BASE}/${id}?address=${encodeURIComponent(userAddress)}` : `${API_BASE}/${id}`; + const response = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updated) diff --git a/src/routes/split/[id]/+page.svelte b/src/routes/split/[id]/+page.svelte index ceb5690..81f3bb8 100644 --- a/src/routes/split/[id]/+page.svelte +++ b/src/routes/split/[id]/+page.svelte @@ -73,7 +73,7 @@ await verifyAndMarkXDAIPaid(id, split); if (USE_SUPABASE !== 'true') { - const loaded = await getSplit(id); + const loaded = await getSplit(id, $walletAddress || undefined); if (loaded && splitStore) { splitStore = { subscribe: (cb: (value: Split | null) => void) => { @@ -166,10 +166,10 @@ await updateSplit(split.id, (s) => ({ ...s, payments: [...s.payments, { address: myPart.address, txHash: hash }] - })); + }), $walletAddress || undefined); if (USE_SUPABASE !== 'true') { - const loaded = await getSplit(split.id); + const loaded = await getSplit(split.id, $walletAddress || undefined); if (loaded && splitStore) { splitStore = { subscribe: (cb: (value: Split | null) => void) => { diff --git a/src/routes/split/payment/+page.svelte b/src/routes/split/payment/+page.svelte index 2fa72b8..c668615 100644 --- a/src/routes/split/payment/+page.svelte +++ b/src/routes/split/payment/+page.svelte @@ -87,9 +87,9 @@ await updateSplit(split.id, (s) => ({ ...s, payments: [...s.payments, { address: participant!.address, txHash: hash }] - })); + }), $walletAddress || undefined); - const updated = await getSplit(split.id); + const updated = await getSplit(split.id, $walletAddress || undefined); if (updated) { split = updated; isPaid = true;