Skip to content

Commit 63e0904

Browse files
authored
feat(db): migrate from Supabase to Neon Postgres + Drizzle ORM (phase 1) (#313)
2 parents 1a4c089 + bf49732 commit 63e0904

146 files changed

Lines changed: 16390 additions & 11467 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1-
# Supabase Configuration
2-
# You can find these values in your Supabase Dashboard under Project Settings > API
3-
NEXT_PUBLIC_SUPABASE_URL=your-project-url-here.supabase.co
4-
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
1+
# ── Database (Neon Postgres) ──────────────────────────────────────────────────
2+
# Pooled connection string from the Neon console (Connection Details → Pooled).
3+
# Used by the app (Drizzle over Neon's serverless driver), the keeper scripts,
4+
# and `npm run db:migrate`. SERVER-ONLY.
5+
DATABASE_URL=postgres://user:password@ep-xxx-pooler.region.aws.neon.tech/neondb?sslmode=require
6+
7+
# ── Sessions ──────────────────────────────────────────────────────────────────
8+
# Signs the HttpOnly session cookie (HS256 JWT) issued after SEP-10 sign-in.
9+
# At least 32 characters. Rotating it signs everyone out.
10+
# openssl rand -base64 48
11+
SESSION_SECRET=
12+
13+
# ── File storage (Vercel Blob) ────────────────────────────────────────────────
14+
# KYC documents are stored as private blobs. Create a Blob store in the Vercel
15+
# dashboard (Storage → Blob) and copy its read/write token.
16+
BLOB_READ_WRITE_TOKEN=
517

618
# Application Configuration
7-
# This is used for OAuth redirects and email confirmation links
19+
# Public origin of the app (referral links, e-mail links, SIWS domain default).
820
# Local development: http://localhost:3000
921
# Production: https://your-domain.com
1022
NEXT_PUBLIC_SITE_URL=http://localhost:3000
@@ -48,20 +60,17 @@ NEXT_PUBLIC_SIWS_DOMAIN=localhost:3000
4860
# SEP-10 server signing key (S...). SERVER-ONLY — generate a dedicated key,
4961
# never reuse the platform admin key. stellar keys generate trustlend-siws --global
5062
SIWS_SERVER_SECRET=
51-
#
52-
# Secret used to deterministically derive each wallet's Supabase auth password
53-
# (HMAC-SHA256 of the address). SERVER-ONLY — use a long random value and never
54-
# rotate without a migration plan (rotating invalidates existing wallet logins).
55-
SIWS_PASSWORD_SECRET=
5663
NEXT_PUBLIC_STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org
5764
NEXT_PUBLIC_STELLAR_FRIENDBOT_URL=https://friendbot.stellar.org
5865

5966
# Auth Role Persistence
6067
# These keys are used internally to manage role-based redirects
6168
NEXT_PUBLIC_PENDING_ROLE_KEY=trustlend_pending_role
6269

63-
# Comma-separated allowlist for Trade Vault admin panel access
64-
# Example: admin1@tradevault.com,admin2@tradevault.com
70+
# Comma-separated allowlist for the admin panel. Accepts e-mail addresses and
71+
# Stellar public keys (G...), since accounts are wallet-based. An allowlisted
72+
# account must ALSO have profiles.role = 'admin'.
73+
# Example: GABC...XYZ,admin@example.com
6574
TRADE_VAULT_ADMIN_EMAILS=
6675

6776
# ── Soroban RPC ───────────────────────────────────────────────────────────────
@@ -122,9 +131,6 @@ ADMIN_SECRET_KEY=
122131
DEFAULT_GRACE_PERIOD_DAYS=7
123132
DEFAULT_INSURANCE_PAYOUT_DAYS=60
124133

125-
# Supabase service-role key — required by all crons for trusted DB access.
126-
SUPABASE_SERVICE_ROLE_KEY=
127-
128134
# ── Decentralized Credit Oracle ───────────────────────────────────────────────
129135
# The authorized oracle is the only account allowed to post off-chain credit
130136
# scores on-chain (via `submit_credit_score`). Register it once after deploy:
@@ -150,8 +156,8 @@ ORACLE_SECRET_KEY=
150156
# liquidation transactions, plus NEXT_PUBLIC_LENDING_CONTRACT_ID /
151157
# NEXT_PUBLIC_REPUTATION_CONTRACT_ID / NEXT_PUBLIC_ADMIN_ADDRESS.
152158
#
153-
# Where to source open loans from: "db" (Supabase, default) or "chain"
154-
# (iterate the LendingContract directly — no Supabase needed).
159+
# Where to source open loans from: "db" (the database, default) or "chain"
160+
# (iterate the LendingContract directly — no database needed).
155161
LIQUIDATION_SOURCE=db
156162
# Evaluate only; never submit a liquidation transaction. Useful for staging.
157163
LIQUIDATION_DRY_RUN=false
@@ -288,10 +294,10 @@ ORACLE_DISCORD_WEBHOOK_URL=
288294
# In CI these are repository *secrets*, not values in this file. Restore
289295
# instructions and bucket setup live in docs/disaster-recovery.md.
290296
#
291-
# Direct Postgres connection string. Use the DIRECT connection (port 5432), not
292-
# the pooled/pgbouncer one — pg_dump needs session-level features the pooler
293-
# does not provide. Supabase: Project Settings → Database → Connection string.
294-
DATABASE_URL=
297+
# pg_dump needs the DIRECT (non-pooled) Neon connection string — the same
298+
# DATABASE_URL as above with the "-pooler" segment removed from the host. Set
299+
# BACKUP_DATABASE_URL when it differs; otherwise DATABASE_URL is used.
300+
BACKUP_DATABASE_URL=
295301
#
296302
# Passphrase used to encrypt each dump with AES-256 before upload.
297303
# ⚠️ Store this in a password manager as well as in CI. If it is lost, every

.github/workflows/ci.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jobs:
4040
runs-on: ubuntu-latest
4141
env:
4242
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
43+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
4344
steps:
4445
- name: Checkout Code
4546
uses: actions/checkout@v4
@@ -59,16 +60,25 @@ jobs:
5960
- name: Lint
6061
run: npm run lint
6162

63+
- name: Unit tests
64+
run: npm test
65+
6266
- name: Build Next.js
63-
# We set this environment variable because dummy keys are needed for the build if relying on env
67+
# Placeholder env so the build never needs real secrets.
6468
env:
65-
NEXT_PUBLIC_SUPABASE_URL: "https://example.supabase.co"
66-
NEXT_PUBLIC_SUPABASE_ANON_KEY: "dummy-key"
6769
NEXT_PUBLIC_STELLAR_NETWORK: "testnet"
6870
NEXT_PUBLIC_STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org"
6971
NEXT_PUBLIC_ADMIN_ADDRESS: "GAJRNUO6HSMQG4FNHNWQVRXJZJZ7QRA7HXPYYB6H5PTA3EAAJXJNZD7U"
7072
run: npm run build
7173

74+
# Apply pending Drizzle migrations to the production database before the
75+
# deploy goes live. No-op until the DATABASE_URL secret exists.
76+
- name: Migrate database
77+
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && env.DATABASE_URL != '' }}
78+
env:
79+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
80+
run: npm run db:migrate
81+
7282
- name: Deploy to Vercel
7383
if: ${{ env.VERCEL_TOKEN != '' }}
7484
run: npx vercel --prod --yes --token=${{ env.VERCEL_TOKEN }}

.github/workflows/e2e-playwright.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,13 @@ jobs:
3333
- name: Build Next.js Application
3434
# We supply dummy configuration since tests intercept the actual calls
3535
env:
36-
NEXT_PUBLIC_SUPABASE_URL: "https://example.supabase.co"
37-
NEXT_PUBLIC_SUPABASE_ANON_KEY: "dummy-key"
3836
NEXT_PUBLIC_STELLAR_NETWORK: "testnet"
3937
NEXT_PUBLIC_STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org"
4038
NEXT_PUBLIC_ADMIN_ADDRESS: "GAJRNUO6HSMQG4FNHNWQVRXJZJZ7QRA7HXPYYB6H5PTA3EAAJXJNZD7U"
4139
run: npm run build
4240

4341
- name: Run Playwright tests
4442
env:
45-
NEXT_PUBLIC_SUPABASE_URL: "https://example.supabase.co"
46-
NEXT_PUBLIC_SUPABASE_ANON_KEY: "dummy-key"
4743
NEXT_PUBLIC_STELLAR_NETWORK: "testnet"
4844
NEXT_PUBLIC_STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org"
4945
NEXT_PUBLIC_ADMIN_ADDRESS: "GAJRNUO6HSMQG4FNHNWQVRXJZJZ7QRA7HXPYYB6H5PTA3EAAJXJNZD7U"

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js" />
1111
<img src="https://img.shields.io/badge/React-19-20232A?logo=react" alt="React" />
1212
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
13-
<img src="https://img.shields.io/badge/Supabase-Backend-3ECF8E?logo=supabase&logoColor=white" alt="Supabase" />
13+
<img src="https://img.shields.io/badge/Neon-Postgres-00E599?logo=postgresql&logoColor=white" alt="Neon Postgres" />
1414
<img src="https://img.shields.io/badge/Stellar-Testnet-08B5E5" alt="Stellar" />
1515
<img src="https://img.shields.io/badge/Soroban-Smart%20Contracts-111827" alt="Soroban" />
1616
<img src="https://img.shields.io/badge/Stellar%20Wave-Issues%20in%20the%20stellar%20wave%20Program-6366f1" alt="Stellar Wave" />
@@ -61,7 +61,7 @@ TrustLend is designed as a foundational layer for decentralized, inclusive credi
6161

6262
## 🏗️ Architecture & Workflow
6363

64-
TrustLend uses a practical hybrid architecture: **fast UX off-chain** (Supabase/Next.js) combined with **trust-critical logic on-chain** (Soroban/Stellar). The diagram below maps every component and data flow across all six layers of the platform.
64+
TrustLend uses a practical hybrid architecture: **fast UX off-chain** (Next.js + Neon Postgres) combined with **trust-critical logic on-chain** (Soroban/Stellar). The diagram below maps every component and data flow across all six layers of the platform.
6565

6666
```mermaid
6767
flowchart TB
@@ -84,7 +84,7 @@ flowchart TB
8484
subgraph Backend["⚙️ Backend Layer (Next.js)"]
8585
direction TB
8686
SA[("📡 Server Actions & API Routes<br/>app/actions + app/api")]
87-
SB[("🗄️ Supabase<br/>PostgreSQL · Auth · RLS · Storage")]
87+
SB[("🗄️ Neon Postgres<br/>Drizzle ORM · sessions · Vercel Blob")]
8888
RM[("🔌 Soroban Client<br/>lib/stellar/soroban.ts")]
8989
SC[("🔐 Server-side Contract Invoker<br/>lib/stellar/server-contract.ts")]
9090
RC[("⚡ Redis Cache<br/>Simulation result cache")]
@@ -210,7 +210,7 @@ flowchart LR
210210
style S fill:#3b82f6,color:#fff
211211
```
212212

213-
1. **Onboarding:** User signs up via Supabase Auth, connects a Stellar wallet (Freighter / xBull / Albedo on desktop, or any WalletConnect v2 mobile wallet such as LOBSTR by scanning a QR code), completes KYC verification, and their on-chain reputation profile is initialized.
213+
1. **Onboarding:** User signs in with their Stellar wallet (SEP-10 challenge signature; no passwords) (Freighter / xBull / Albedo on desktop, or any WalletConnect v2 mobile wallet such as LOBSTR by scanning a QR code), completes KYC verification, and their on-chain reputation profile is initialized.
214214
2. **Borrowing:** Borrower submits a loan request. The Next.js backend calls `ReputationContract.calculate_max_loan` and `calculate_interest_rate` to determine eligibility and terms.
215215
3. **Lending:** Lender reviews the request in the marketplace, approves it, and the `LendingContract.approve_loan` is called. Funds are locked via `EscrowContract.create_escrow_hold`.
216216
4. **Disbursement:** After the 1-hour revocation window expires, the admin confirms disbursement. `EscrowContract.confirm_disbursement` releases funds to the borrower, and `LendingContract.activate_loan` marks the loan as active.
@@ -221,7 +221,7 @@ flowchart LR
221221

222222
| Automation | Trigger | Action |
223223
|---|---|---|
224-
| **Payment-Due Scheduler** | Vercel Cron (hourly) | Queries Supabase for loans due within 48h → Sends webhook & email |
224+
| **Payment-Due Scheduler** | Vercel Cron (daily) | Queries the database for loans due within 48h → Sends webhook & email |
225225
| **Default Management** | Vercel Cron (daily) | Checks overdue loans against ledger time → Marks defaulted on-chain → Proposes insurance payout via MultiSigAdmin (requires N-of-M human approval) |
226226
| **Liquidation Keeper** | Manual / cron | Monitors LTV ratios against dynamic thresholds → Liquidates under-collateralized positions → Posts Slack/Discord alerts |
227227
| **Oracle Credit Score** | Manual / cron | Posts verified off-chain credit scores to the Reputation contract |
@@ -233,7 +233,7 @@ flowchart LR
233233
| Layer | Technology |
234234
|---|---|
235235
| **Frontend** | Next.js 16, React 19, TypeScript, Tailwind CSS 4, Framer Motion |
236-
| **Backend & DB** | Supabase (Auth, Postgres RLS, Storage) |
236+
| **Backend & DB** | Neon Postgres + Drizzle ORM, SEP-10 wallet sessions (`jose`), Vercel Blob for KYC files |
237237
| **Blockchain** | Stellar Testnet, Soroban RPC, Horizon API |
238238
| **Wallet** | Freighter Wallet, xBull, Albedo, WalletConnect v2 for mobile wallets (`@creit.tech/stellar-wallets-kit`) |
239239
| **Smart Contracts** | Rust (Soroban, `wasm32v1-none`) — 8 contracts deployed |
@@ -293,7 +293,7 @@ npm run deploy:testnet:dry
293293
### What it writes
294294

295295
Contract IDs land directly in `.env.local`. Keys already present are updated **in
296-
place** — your Supabase keys, API secrets and comments are left untouched, and a
296+
place** — your database URL, API secrets and comments are left untouched, and a
297297
`.env.local.bak` is taken first. A reference copy also goes to `.env.contracts`.
298298

299299
| Contract | Env key |
@@ -368,8 +368,8 @@ TrustLend includes an automated scheduler that checks for loans with payment dea
368368

369369
### How It Works
370370

371-
1. An external scheduler (Vercel Cron or any HTTP trigger) calls `POST /api/cron/payment-due` hourly.
372-
2. The route queries Supabase for `active` or `funded` loans with `due_at` between now and +48 hours.
371+
1. An external scheduler (Vercel Cron or any HTTP trigger) calls `POST /api/cron/payment-due` daily.
372+
2. The route queries the database for `active` or `funded` loans with `due_at` between now and +48 hours.
373373
3. A POST webhook is sent to `WEBHOOK_NOTIFICATION_URL` for each qualifying loan.
374374
4. The loan's `metadata.payment_due_notified_at` is set to prevent duplicate notifications.
375375
5. Per-loan errors are logged without stopping the rest of the batch.
@@ -380,7 +380,7 @@ TrustLend includes an automated scheduler that checks for loans with payment dea
380380
|---|---|
381381
| `WEBHOOK_NOTIFICATION_URL` | URL of the notification service that receives payment-due webhook POSTs |
382382
| `CRON_SECRET` | Secret token used to authenticate scheduler requests (`Authorization: Bearer <value>`) |
383-
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service-role key (required for RLS-bypassing loan queries) |
383+
| `DATABASE_URL` | Neon Postgres connection string |
384384
| `RESEND_API_KEY` | Optional Resend API key for borrower email notifications |
385385
| `RESEND_FROM_EMAIL` | Verified sender address used for TrustLend emails |
386386
| `RESEND_REPLY_TO_EMAIL` | Optional reply-to address for support responses |

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ If you discover a security vulnerability within TrustLend, **do NOT open a publi
4545
- **Frontend/UI Bugs:** Visual presentation flaws, CSS issues, or non-security UI bugs without impact on user funds or data.
4646
- **Denial of Service (DoS):** Volumetric DoS/DDoS attacks against infrastructure or public Stellar RPC endpoints not caused by application design flaws.
4747
- **Social Engineering:** Phishing, spam, or social engineering attacks targeted at TrustLend maintainers or users.
48-
- **Third-Party Dependencies:** Vulnerabilities in underlying infrastructure (e.g. Stellar Core, Soroban SDK, Supabase platform) unless directly exploitable through TrustLend code logic.
48+
- **Third-Party Dependencies:** Vulnerabilities in underlying infrastructure (e.g. Stellar Core, Soroban SDK, Neon, Vercel) unless directly exploitable through TrustLend code logic.
4949
- **Known Issues:** Vulnerabilities already reported, tracked in public issues/PRs, or previously disclosed in security audit reports.
5050

5151
---

__tests__/api/analytics.test.ts

Lines changed: 29 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import {
55
clearAnalyticsMemoryCacheForTests,
66
} from "@/lib/analytics-cache";
77

8-
const mockGetServiceRoleClient = vi.fn();
8+
import { createFakeDb } from "../helpers/fake-db";
9+
10+
const mockGetDb = vi.fn();
911
const mockGetCachedPlatformAnalytics = vi.fn();
1012
const mockSetCachedPlatformAnalytics = vi.fn();
1113

12-
vi.mock("@/lib/supabase/server", () => ({
13-
getServiceRoleClient: () => mockGetServiceRoleClient(),
14+
vi.mock("@/lib/db/client", () => ({
15+
getDb: () => mockGetDb(),
1416
}));
1517

1618
vi.mock("@/lib/analytics-cache", () => ({
@@ -19,77 +21,27 @@ vi.mock("@/lib/analytics-cache", () => ({
1921
clearAnalyticsMemoryCacheForTests: vi.fn(),
2022
}));
2123

22-
function createClientStub() {
23-
return {
24-
from: (table: string) => ({
25-
select: () => {
26-
if (table === "loans") {
27-
return Promise.resolve({
28-
data: [
29-
{ principal_amount: 1000, status: "funded" },
30-
{ principal_amount: 2500, status: "active" },
31-
{ principal_amount: 999, status: "requested" },
32-
],
33-
error: null,
34-
});
35-
}
36-
37-
if (table === "pool_positions") {
38-
return Promise.resolve({
39-
data: [
40-
{ principal_amount: 4000, earned_interest: 120, status: "active" },
41-
{ principal_amount: 500, earned_interest: 40, status: "closed" },
42-
],
43-
error: null,
44-
});
45-
}
46-
47-
if (table === "loan_repayments") {
48-
return Promise.resolve({
49-
data: [
50-
{ amount: 600 },
51-
{ amount: 500 },
52-
],
53-
error: null,
54-
});
55-
}
56-
57-
if (table === "ledger_transactions") {
58-
return Promise.resolve({
59-
data: [
60-
{
61-
amount: 1200,
62-
user_id: "u1",
63-
status: "confirmed",
64-
created_at: new Date().toISOString(),
65-
},
66-
{
67-
amount: 800,
68-
user_id: "u2",
69-
status: "confirmed",
70-
created_at: new Date().toISOString(),
71-
},
72-
{
73-
amount: 300,
74-
user_id: "u3",
75-
status: "pending",
76-
created_at: new Date().toISOString(),
77-
},
78-
],
79-
error: null,
80-
});
81-
}
82-
83-
return Promise.resolve({ data: [], error: null });
84-
},
85-
}),
86-
} as unknown as {
87-
from: (
88-
table: string,
89-
) => {
90-
select: () => Promise<{ data: unknown[]; error: null }>;
91-
};
92-
};
24+
/**
25+
* fetchPlatformAnalytics runs four queries in parallel (loans, pool positions,
26+
* repayments, ledger); queue the results in that order.
27+
*/
28+
function createDbStub() {
29+
const db = createFakeDb();
30+
db.queue([
31+
{ principal_amount: "1000", status: "funded" },
32+
{ principal_amount: "2500", status: "active" },
33+
]);
34+
db.queue([
35+
{ principal_amount: "4000", earned_interest: "120", status: "active" },
36+
{ principal_amount: "500", earned_interest: "40", status: "closed" },
37+
]);
38+
db.queue([{ amount: "600" }, { amount: "500" }]);
39+
db.queue([
40+
{ amount: "1200", user_id: "u1", status: "confirmed", created_at: new Date() },
41+
{ amount: "800", user_id: "u2", status: "confirmed", created_at: new Date() },
42+
{ amount: "300", user_id: "u3", status: "pending", created_at: new Date() },
43+
]);
44+
return db;
9345
}
9446

9547
describe("GET /api/analytics", () => {
@@ -121,15 +73,15 @@ describe("GET /api/analytics", () => {
12173
} as unknown as NextRequest);
12274

12375
expect(response.status).toBe(200);
124-
expect(mockGetServiceRoleClient).not.toHaveBeenCalled();
76+
expect(mockGetDb).not.toHaveBeenCalled();
12577
expect(mockSetCachedPlatformAnalytics).not.toHaveBeenCalled();
12678
expect(await response.json()).toEqual(cachedPayload);
12779
expect(response.headers.get("x-analytics-cache")).toBe("hit");
12880
});
12981

13082
it("returns aggregated platform metrics and stores them in cache", async () => {
13183
mockGetCachedPlatformAnalytics.mockResolvedValue(null);
132-
mockGetServiceRoleClient.mockReturnValue(createClientStub());
84+
mockGetDb.mockReturnValue(createDbStub());
13385

13486
const response = await GET({
13587
nextUrl: new URL("http://localhost/api/analytics"),
@@ -154,7 +106,7 @@ describe("GET /api/analytics", () => {
154106

155107
it("returns a service unavailable response when the client cannot be created", async () => {
156108
mockGetCachedPlatformAnalytics.mockResolvedValue(null);
157-
mockGetServiceRoleClient.mockReturnValue(null);
109+
mockGetDb.mockReturnValue(null);
158110

159111
const response = await GET({
160112
nextUrl: new URL("http://localhost/api/analytics"),

0 commit comments

Comments
 (0)