Backend & hosting
WeaveForge separates domain logic (@weaveforge/core) from persistence, auth, and blob storage. The web app selects a backend provider at deploy time — same pattern as integrations.
The deployment this project runs on is self-hosted: Postgres 16 + PostgREST + Realtime on an
OCI VM, with MinIO for blobs and Supabase Auth as the identity provider (the stack verifies the
tokens Supabase signs). infra/oci/docker-compose.yml is the
whole data plane, and docs/running/oracle-shift.md is how it was moved there.
Hosted Supabase (managed Postgres + Auth + Storage) remains a supported target — the same migrations apply, and it needs none of the self-hosted prerequisites. Other targets:
- Postgres + your own auth (Neon, RDS, another VM, …)
- Cloudflare (Workers/Pages + Hyperdrive or D1 + R2 + Access)
Repository interfaces in @weaveforge/core are the swap boundary — not PostgREST query builders.
Production hostnames
Four names, one job each. Anything that talks to the data API has to be on the
list the API allows, so the split is not cosmetic — a page served from the wrong
host gets a CORS rejection, which the browser reports as TypeError: Failed to fetch, indistinguishable from a dead network.
| Host | Serves | Deployed from |
|---|---|---|
app.weaveforge.org |
the web app (and the Android TWA wraps this host) | apps/web on Vercel |
www.weaveforge.org |
the pitch site and the docs (/docs/*) |
apps/pitch on GitHub Pages (apps/pitch/public/CNAME) |
docs.weaveforge.org |
legacy docs address — redirects to www.weaveforge.org/docs/ |
DNS only |
api.weaveforge.org |
PostgREST data + realtime | the self-hosted box — oracle-shift-guide |
CORS_ALLOWED_ORIGINS on the API box must list https://app.weaveforge.org
and app://weaveforge — the desktop shell's renderer sends the latter as its
Origin, and the Caddyfile's allowlist (infra/oci/Caddyfile) names both.
Without the desktop origin the app can still work on this computer, but every
sign-in or sync attempt from it dies as "Could not reach api.weaveforge.org".
Preview deployments (*.vercel.app) are deliberately not on it: they would be
new origins on every deploy. Test previews against a local API, or add the one
preview host you need for as long as you need it.
Architecture
packages/core/ apps/web/src/
├── features/*/domain/ ├── backend/ ← Postgres repos, auth
│ IPaperRepository, … │ config.ts ← NEXT_PUBLIC_BACKEND_PROVIDER
├── storage/ │ wire-backend.ts
│ IBlobStore ├── storage/ ← blobs (separate layer)
├── backend/ │ config.ts ← BLOB_PROVIDER, R2, tiering
│ IAuthService │ wire-storage.ts
│ ICurrentUserProvider │ providers/supabase|s3|tiered/
│ IAdminUserProvisioner └── integrations/ ← Zotero, GitLab, …
Flow
readBackendConfig()+readStorageConfig()read env.wireBackend()constructs repositories and auth; callswireStorage()for blobs.bootstrap.tswires use-cases + facades.- Feature UI calls facades only — never Supabase SDK or storage SDK.
What is already abstracted (~80%)
| Layer | Port | Supabase adapter |
|---|---|---|
| All entities | IPaperRepository, IProjectRepository, … (16+ in core) |
Supabase*Repository |
| Auth (browser) | IAuthService |
SupabaseAuthService |
| Session (repos) | ICurrentUserProvider |
SupabaseSessionProvider |
| Admin create-user | IAdminUserProvisioner |
SupabaseAdminUserProvisioner |
| Images | IBlobStore → PaperImageStore |
wireStorage() → SupabaseBlobStore (see storage/) |
What stays Postgres-specific (for now)
- SQL migrations in
supabase/migrations/—auth.uid(), RLS policies,SECURITY DEFINERhelpers - Supabase adapters use PostgREST (
.from().select().eq())
A postgres provider reuses the same schema and reimplements adapters with pg or an HTTP API — no use-case changes.
Configuration
# Backend provider (default: supabase)
NEXT_PUBLIC_BACKEND_PROVIDER=supabase # supabase | postgres
# Supabase (when provider = supabase)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ... # server only — account creation, API and MCP tokens
SUPABASE_JWT_SECRET=<jwt secret> # server only — mints sessions for API and MCP tokens
# Postgres (when provider = postgres — server-side blob registry)
DATABASE_URL=postgres://user:pass@host:5432/thesis
# Bug reports filed from the app's error screen (optional)
GITHUB_ISSUES_TOKEN=github_pat_... # server only — fine-grained, issues: write
GITHUB_ISSUES_REPO=Satwik-Miyyapuram/weaveforge # optional; defaults to this repository
Without those two server-only values the app still signs people in, but the settings panels that issue SDK API tokens and MCP relay tokens answer 503: the token service has nothing to sign with. The JWT secret is the same one PostgREST is given in the shift guide.
Without GITHUB_ISSUES_TOKEN the error screens keep their other two escapes and
the report panel answers 503 naming that variable — a report is never silently
dropped.
Where that variable goes is worth being precise about, because the obvious
guess is wrong: the route that files reports (/api/report-issue) is served by
apps/web, which runs on Vercel — not on the API box. So:
| Context | Where to set it |
|---|---|
| Production | the apps/web project on Vercel → Settings → Environment Variables (GITHUB_ISSUES_TOKEN, optionally GITHUB_ISSUES_REPO). Redeploy for it to take effect. |
| Local development | apps/web/.env.local — Next only reads it from the app directory, and it is git-ignored. |
| The OCI box | nothing. infra/oci/docker-compose.yml runs the database, PostgREST, Realtime, the gateway and MinIO; the web app is not in it. Setting the token there would look configured and do nothing. |
| A packaged desktop build | nothing — it serves a static copy of the app, which has no server and therefore no report endpoint. The panel says so rather than failing silently. |
The token is a fine-grained PAT with issues: write on one repository. When it is
set, the app files an issue containing what the reader saw, what they added, and
the recent console.error/console.warn lines plus any uncaught error or
rejection, redacted for tokens, credentials, emails, account names in paths and
long opaque blobs. No account identity is attached, and the reader previews the
whole payload before sending. It is the only credential in this app that can write
anywhere.
NEXT_PUBLIC_BACKEND_PROVIDER=postgres requires DATABASE_URL and selects the server-side blob registry — see docs/running/postgres-provider.md. Default remains supabase.
It is not the self-hosting switch, and setting it in a deployed app breaks the browser bundle: the client repositories reach the database over HTTP through PostgREST, which a Postgres connection string cannot replace. To move a deployed app onto your own database, set NEXT_PUBLIC_DATA_URL — docs/running/oracle-shift.md.
Hosted Supabase (a fresh checkout's default, not this deployment)
Best for solo researchers and small labs: free tier, magic-link auth, RLS, zero ops. This is what the env examples in the repository assume; the production deployment is the self-hosted stack at the top of this page, which needs none of the Supabase Storage or Supabase Postgres pieces.
- Create a Supabase project.
- Apply migrations (
supabase db pushor SQL editor). - Set env vars above.
- Run
npm run dev.
User-facing setup: README §3–5.
Self-hosted Postgres (Oracle Cloud, VPS, Neon)
Goal: Keep the same schema and RLS model; replace Supabase Auth/PostgREST with your stack.
Steps to add a postgres provider
Host Postgres — apply all files in
supabase/migrations/(they are plain PostgreSQL;auth.usersbecomes your identity table or you add auserstable and adjust RLS).Auth — implement
IAuthService+ICurrentUserProvider:- Issue JWTs with
sub= user uuid. - Set
request.jwt.claim.subper connection (or replaceauth.uid()withcurrent_setting('app.user_id')in a migration fork).
- Issue JWTs with
Repositories — copy a
Supabase*Repository→Postgres*Repositoryusingpg/ Drizzle / Kysely. Same tables, same columns; enforceproject_idfilters in app code if you drop RLS.Blob store — implement
IBlobStoreunderapps/web/src/storage/providers/(S3/R2, tiered). Wired viawireStorage(), notwire-backend.tsdirectly.Admin provisioner — implement
IAdminUserProvisioner(create user +profilesrow).Wire — add
case "postgres":inwire-backend.ts; blob adapter inwire-storage.ts.Deploy — set
NEXT_PUBLIC_BACKEND_PROVIDER=postgresandDATABASE_URLfor server-side code. The browser needs a data API of its own; seeoracle-shift-guide.md.
Oracle Cloud free tier (sketch)
| Component | Suggestion |
|---|---|
| Compute | ARM VM (Always Free) — Docker Compose |
| Database | Postgres 16 on VM or Oracle Autonomous (paid) |
| Object storage | OCI Object Storage — IBlobStore |
| TLS | Caddy / nginx reverse proxy |
| App | next build + next start on VM, or container |
Auth: self-hosted Keycloak, Authentik, or simple JWT + bcrypt — wired through IAuthService.
Cloudflare (sketch)
| Component | Suggestion |
|---|---|
| Frontend | Cloudflare Pages — deploy Next.js (static + server functions) |
| Database | Hyperdrive → external Postgres (Neon, your OCI VM) or D1 (requires schema/RLS rework) |
| Blobs | R2 — implement IBlobStore with S3 API |
| Auth | Cloudflare Access + service token, or Auth0/Clerk as IAuthService |
| API routes | Workers for /api/admin/create-user with service binding to Hyperdrive |
Recommended path on Cloudflare: Hyperdrive + existing Postgres migrations + new Postgres*Repository adapters — avoids rewriting RLS in D1.
Workers constraint: browser cannot hold service-role keys; keep privileged ops in Worker routes behind IAdminUserProvisioner.
Adding a new backend provider (checklist)
| Step | Action |
|---|---|
| 1 | Add id to BackendProviderId in backend/config.ts |
| 2 | Create backend/providers/<name>/wire-<name>-backend.ts |
| 3 | Implement IAuthService, ICurrentUserProvider, IAdminUserProvisioner |
| 4 | Implement repository interfaces in wire-supabase-backend.ts |
| 5 | Implement blob adapters in storage/providers/ + wire-storage.ts |
| 6 | Add case in wire-backend.ts |
| 7 | Document env vars in .env.local.example |
| 8 | Run contract tests (in-memory + live integration) |
Do not abstract PostgREST per-table — one adapter class per repository is the right granularity.
Python SDK
The SDK does not touch the database, so a backend swap costs it nothing.
python/weaveforge/container.py wires everything against the web app's
/api/sdk/* endpoints using a single bearer token, which is what keeps Supabase
URLs and keys out of training environments. Whatever the web app runs on behind
those endpoints, the SDK is unchanged.
Testing
npm run build:core
npm test -w @weaveforge/core
npm test -w @weaveforge/web
npm run check:solid
Live Supabase contract tests: set WEAVEFORGE_SUPABASE_URL, WEAVEFORGE_SUPABASE_ANON_KEY, and either WEAVEFORGE_TOKEN (preferred) or legacy WEAVEFORGE_EMAIL / WEAVEFORGE_PASSWORD.
Related
storage/README.md— blob layer (R2 hot, OCI cold tiering)plans/completed/migration-plan.md— phased self-host plan- DESIGN.md — SOLID, composition root, repository contracts
- integrations.md — third-party services (Zotero, GitLab, …)
- dev.md — feature modules and facades