Supabase
- Self-hosted Supabase on the client's Azure VM (via docker compose)
- Public endpoint:
https://api.jarilloengineering.com - Architecture, infrastructure details are in
software-design.md
Client
Singleton is defined in src/lib/supabaseClient.ts
- use using the official supabase javascript client(not axios)
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const supabase = createClient(
process.env.EXPO_PUBLIC_SUPABASE_URL!,
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
}
);
Auth
// Sign in
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
// Sign out
await supabase.auth.signOut();
// Current user / session
const { data: { user } } = await supabase.auth.getUser();
const { data: { session } } = await supabase.auth.getSession();
// Listener (used in AuthContext)
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') setUser(session?.user ?? null);
else if (event === 'SIGNED_OUT') { setUser(null); router.replace('/login'); }
});
return () => subscription.unsubscribe();
Edge Functions (The Security Proxy)
Sensitive operations and administrative tasks are offloaded to Supabase Edge Functions. This ensures that service-role keys and third-party API keys (like Azure) never reach the client.
Admin: Create User
const { error } = await supabase.functions.invoke('admin-create-user', {
body: { email, password, role },
});
Azure OpenAI Proxy
const { data, error } = await supabase.functions.invoke('azure-openai', {
body: { messages, temperature, max_tokens },
});
Azure Speech Proxy
const { data, error } = await supabase.functions.invoke('azure-speech', {
body: { storagePath, language },
});
Database
All DB calls should be done from src/services/
- screens should not call supabase.from() directly
// SELECT with filter + pagination
const { data, error } = await supabase
.from('cases')
.select('*, client:clients(id, first_name, last_name)')
.eq('status', 'open')
.order('created_at', { ascending: false })
.range(0, 9);
// INSERT
const { data, error } = await supabase
.from('cases')
.insert({ title, client_id, status: 'open', assigned_to: userId })
.select()
.single();
// UPDATE
const { data, error } = await supabase
.from('cases')
.update({ status: 'closed' })
.eq('id', caseId)
.select()
.single();
// DELETE
const { error } = await supabase.from('cases').delete().eq('id', caseId);
Filter Reference
.eq('col', val) .neq('col', val)
.gt / .gte / .lt / .lte
.ilike('col', '%pat%') // case-insensitive LIKE
.in('col', [v1, v2])
.is('col', null)
.order('col', { ascending: false })
.limit(10)
.range(0, 9) // pagination
Storage
Audio recordings go in the private bucket audio-recordings
- use signed URLs over public URLs
// Upload
const filePath = `${userId}/${caseId}/${Date.now()}.m4a`;
const { error } = await supabase.storage
.from('audio-recordings')
.upload(filePath, blob, { contentType: 'audio/m4a', upsert: false });
// Signed URL (valid 1 hour)
const { data } = await supabase.storage
.from('audio-recordings')
.createSignedUrl(filePath, 3600);
// Delete
await supabase.storage.from('audio-recordings').remove([filePath]);
Realtime
useEffect(() => {
const sub = supabase
.channel('cases-changes')
.on('postgres_changes', { event: '*', schema: 'public', table: 'cases' }, (payload) => {
if (payload.eventType === 'INSERT') setCases((p) => [payload.new as Case, ...p]);
else if (payload.eventType === 'UPDATE')
setCases((p) => p.map((c) => (c.id === payload.new.id ? payload.new as Case : c)));
else if (payload.eventType === 'DELETE')
setCases((p) => p.filter((c) => c.id !== payload.old.id));
})
.subscribe();
return () => { supabase.removeChannel(sub); };
}, []);
Row Level Security (RLS)
RLS ensures that authorization is enforced at the database layer, independent of the frontend or API implementation
RLS is enabled and enforced on relevant tables in the database
Source of truth (what is deployed):
fieldnotes-ai-app/supabase/migrations/*_remote_schema.sql
Human-maintained policy definitions (for readability/review):
fieldnotes-ai-app/supabase/policies/cases_rls.sqlfieldnotes-ai-app/supabase/policies/clients_rls.sql
Example intent:
-- Practitioners see only their own cases
CREATE POLICY "practitioners_select_own_cases" ON public.cases FOR SELECT
USING (assigned_to = auth.uid());
-- Admins see all (role stored in JWT user_metadata)
CREATE POLICY "admins_select_all_cases" ON public.cases FOR SELECT
USING ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin');
````
> Note: policy files under `supabase/policies/` should match what exists in the deployed schema (see migrations dump).
---
## Error Handling
```typescript
const { data, error } = await supabase.from('cases').select('*');
if (error) {
console.error(`[caseService] ${error.code}: ${error.message}`);
throw error;
}
| http codes | meaning |
|---|---|
| 401 | Not authenticated (missing/invalid JWT) |
| 403 | Forbidden (often RLS policy blocked) |
| 409 | Conflict (e.g., unique constraint violation) |
| 5xx | Server-side error |
VM Access (Backend Only)
SSH Config (~/.ssh/config)
- add this to your config file:
Host supabase-vm
HostName <vm-ip>
User <username>
IdentityFile ~/.ssh/<keyfile>.pem
IdentitiesOnly yes
- check discord for these credentials
Supabase Studio(to access supabase studio)
ssh -L 8001:localhost:8000 supabase-vm
# open http://localhost:8001 on local web browser
# credentials: cat ~/supabase-deployment/supabase/docker/.env | grep DASHBOARD
DB Access
- Shouldnt do, except in exceptional circumstances
ssh -L 5433:localhost:5432 supabase-vm
psql -h localhost -p 5433 -U postgres -d postgres
# or pgAdmin/DBeaver: host=localhost, port=5433
Docker Operations
ssh supabase-vm && cd ~/supabase-deployment/supabase/docker
docker compose ps
docker compose logs -f [service]
docker compose restart [service]