From a112dcf2be5da972e981a6f81f275e6a54604567 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 11:50:48 +0000 Subject: [PATCH 1/2] feat(api): move from Turso/libSQL to Postgres via @profullstack/libsql-pg The api service's db() now builds its client with @profullstack/libsql-pg, which keeps the @libsql/client surface (execute / rows / rowsAffected) over a pg pool and rewrites the SQLite idioms in the existing queries per statement, so the call sites in db.ts, store/db.ts and push/routes.ts did not change. - DATABASE_URL (postgres://; TRONBROWSER_DB_URL accepted as an alias) is the only accepted setting. Anything else throws a clear error, at process start too (assertDatabaseUrl in index.ts). No fallback to a file database. - Postgres migrations in packages/storage/migrations-pg, generated with `npx libsql-pg convert-schema` and reviewed: the text columns the app stamps with datetime('now') or compares against it (created_at, updated_at, expires_at, paid_at, scanned_at, provisioned_at, last_used_at, deleted_at, last_seen_at) are timestamptz; push_messages.expires_at stays a unix bigint; 0/1 flags stay bigint. - scripts/db-migrate.mjs applies migrations-pg (one transaction per file) when the URL is postgres://, and still applies the SQLite files for a libsql:// or file: URL so the old database can be kept in step until the cutover is proven. @libsql/client is now a devDependency only (that legacy path and the in-memory push tests). - Hand ports: createSession/putEmailToken bound `datetime('now', ?)` with a bound modifier, which the rewriter cannot turn into an interval; the expiry is computed in JS and bound. Store search uses ILIKE, since SQLite's LIKE ignored ASCII case and Postgres's does not. - push.test.ts runs on in-memory SQLite by default and against migrations-pg through the shim when TEST_DATABASE_URL is set; db.test.ts covers the URL guard without a database. Co-Authored-By: Claude Fable 5.1 --- .env.example | 16 +- package.json | 7 +- packages/storage/README.md | 24 +-- .../migrations-pg/0001_ai_provider_keys.sql | 21 +++ .../migrations-pg/0002_accounts_settings.sql | 51 +++++++ .../migrations-pg/0003_extension_store.sql | 103 +++++++++++++ .../0004_extension_publishers.sql | 17 +++ .../migrations-pg/0005_publisher_tokens.sql | 17 +++ .../0006_extension_signing_keys.sql | 17 +++ packages/storage/migrations-pg/0007_push.sql | 43 ++++++ pnpm-lock.yaml | 137 +++++++++++++++++- scripts/db-migrate.mjs | 136 +++++++++++------ services/api/package.json | 5 +- services/api/src/db.test.ts | 26 ++++ services/api/src/db.ts | 68 +++++++-- services/api/src/index.ts | 6 +- services/api/src/push/push.test.ts | 32 +++- services/api/src/push/routes.ts | 2 +- services/api/src/store/db.ts | 7 +- 19 files changed, 640 insertions(+), 95 deletions(-) create mode 100644 packages/storage/migrations-pg/0001_ai_provider_keys.sql create mode 100644 packages/storage/migrations-pg/0002_accounts_settings.sql create mode 100644 packages/storage/migrations-pg/0003_extension_store.sql create mode 100644 packages/storage/migrations-pg/0004_extension_publishers.sql create mode 100644 packages/storage/migrations-pg/0005_publisher_tokens.sql create mode 100644 packages/storage/migrations-pg/0006_extension_signing_keys.sql create mode 100644 packages/storage/migrations-pg/0007_push.sql create mode 100644 services/api/src/db.test.ts diff --git a/.env.example b/.env.example index b041877a..6e9a1830 100644 --- a/.env.example +++ b/.env.example @@ -4,16 +4,16 @@ # (copy to .env and fill in; .env is gitignored). Doppler is the source of truth. # # --- Storage --------------------------------------------------------------- -# Option A: managed cloud database (Turso) — has managed backups. -TRONBROWSER_DB_URL=libsql://your-db.turso.io -TRONBROWSER_DB_AUTH_TOKEN=your-turso-token +# Postgres. The API refuses to start on anything else (no file fallback). +# TRONBROWSER_DB_URL is accepted as an alias for the same value. +DATABASE_URL=postgres://tronbrowser:password@localhost:5432/tronbrowser # -# Option B: bring your own SQLite — you own your data (and your backups). -# Local file: +# Legacy Turso/libSQL database, kept only until the Postgres cutover is proven. +# Read by scripts/db-migrate.mjs for the old schema and by the one-off copy: +# npx libsql-pg copy --from "$TRONBROWSER_DB_URL" --token "$TRONBROWSER_DB_AUTH_TOKEN" --to "$DATABASE_URL" --verify +# TRONBROWSER_DB_URL=libsql://your-db.turso.io +# TRONBROWSER_DB_AUTH_TOKEN=your-turso-token # TRONBROWSER_DB_PATH=/home/you/.tronbrowser/db.sqlite -# Or your own libSQL server: -# TRONBROWSER_DB_URL=libsql://db.your-server.example -# TRONBROWSER_DB_AUTH_TOKEN=your-token # # --- AI providers (Bring Your Own Keys) ----------------------------------- # Free / self-hosted: bring your own keys. The paid cloud tier uses OUR keys, diff --git a/package.json b/package.json index 9c93f63b..22feadbe 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tronbrowser", "version": "3.8.8", "private": true, - "description": "TronBrowser.dev — open-source, privacy-first, AI-native browser", + "description": "TronBrowser.dev \u2014 open-source, privacy-first, AI-native browser", "packageManager": "pnpm@9.12.0", "engines": { "node": ">=24" @@ -22,11 +22,12 @@ "@types/node": "^24.0.0", "typescript": "^5.6.3", "vitest": "^2.1.4", - "@libsql/client": "^0.14.0", "@eslint/js": "^9.18.0", "eslint": "^9.18.0", "globals": "^15.14.0", - "typescript-eslint": "^8.62.0" + "typescript-eslint": "^8.62.0", + "@profullstack/libsql-pg": "^0.1.1", + "@libsql/client": "^0.14.0" }, "main": "index.js", "directories": { diff --git a/packages/storage/README.md b/packages/storage/README.md index 7aa2e72d..17544fe0 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -29,19 +29,23 @@ backups/replication; everything else is self-hosted and user-managed. ## Migrations -Schema lives in [`migrations/`](migrations/) as ordered `NNNN_name.sql` files, -applied by a forward-only runner that tracks applied files in a +The API runs on Postgres (via `@profullstack/libsql-pg`, which keeps the +`@libsql/client` surface the code was written against). Schema lives in +[`migrations-pg/`](migrations-pg/) as ordered `NNNN_name.sql` files, generated +from the SQLite originals in [`migrations/`](migrations/) with +`npx libsql-pg convert-schema` and reviewed. `scripts/db-migrate.mjs` applies +whichever directory matches the URL it is given and records each file in a `schema_migrations` table (idempotent). ```bash -doppler run -- pnpm db:migrate # apply pending migrations to the configured DB -pnpm db:status # show applied vs pending (no changes) +DATABASE_URL=postgres://... pnpm db:migrate # apply pending Postgres migrations +pnpm db:status # show applied vs pending ``` -The runner ([`scripts/db-migrate.mjs`](../../scripts/db-migrate.mjs)) reads the -same `TRONBROWSER_DB_URL`/`_AUTH_TOKEN`/`_PATH` env, so it targets Turso, your -own libSQL server, or a local SQLite file. Current migrations: -`0001_ai_provider_keys`, `0002_accounts_settings` (anonymous CoinPay + email/ -password accounts, sessions, synced settings). +The SQLite files stay until the Turso cutover is proven; a `libsql://` or +`file:` URL still applies them. Moving the rows: -See [`.env.example`](../../.env.example) and the [PRD](../../docs/tronbrowser-prd.md). +```bash +npx libsql-pg copy --from "$TRONBROWSER_DB_URL" --token "$TRONBROWSER_DB_AUTH_TOKEN" \ + --to "$DATABASE_URL" --verify +``` diff --git a/packages/storage/migrations-pg/0001_ai_provider_keys.sql b/packages/storage/migrations-pg/0001_ai_provider_keys.sql new file mode 100644 index 00000000..0fd62a85 --- /dev/null +++ b/packages/storage/migrations-pg/0001_ai_provider_keys.sql @@ -0,0 +1,21 @@ +-- TronBrowser Postgres schema: 0001_ai_provider_keys +-- Generated from packages/storage/migrations/0001_ai_provider_keys.sql with `npx libsql-pg convert-schema`, then reviewed. +-- Forward-only; scripts/db-migrate.mjs applies this directory when TRONBROWSER_DB_URL is postgres://. + +-- created_at: text column with a current-time default became timestamptz +-- updated_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +-- updated_at: default (datetime('now')) -> now() +create table if not exists ai_provider_keys ( + app_id text NOT NULL, + provider text NOT NULL, + api_key text NOT NULL, + enabled bigint NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (app_id, provider) +); + +CREATE INDEX IF NOT EXISTS idx_ai_provider_keys_app + ON ai_provider_keys (app_id) + WHERE enabled = 1; diff --git a/packages/storage/migrations-pg/0002_accounts_settings.sql b/packages/storage/migrations-pg/0002_accounts_settings.sql new file mode 100644 index 00000000..14245617 --- /dev/null +++ b/packages/storage/migrations-pg/0002_accounts_settings.sql @@ -0,0 +1,51 @@ +-- TronBrowser Postgres schema: 0002_accounts_settings +-- Generated from packages/storage/migrations/0002_accounts_settings.sql with `npx libsql-pg convert-schema`, then reviewed. +-- Forward-only; scripts/db-migrate.mjs applies this directory when TRONBROWSER_DB_URL is postgres://. + +-- created_at: text column with a current-time default became timestamptz +-- updated_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +-- updated_at: default (datetime('now')) -> now() +create table if not exists users ( + id text PRIMARY KEY, + auth_method text NOT NULL DEFAULT 'coinpay', + coinpay_sub text UNIQUE, + email text UNIQUE, + email_verified bigint NOT NULL DEFAULT 0, + password_hash text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- updated_at: text column with a current-time default became timestamptz +-- updated_at: default (datetime('now')) -> now() +create table if not exists user_settings ( + user_id text PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + settings text NOT NULL DEFAULT '{}', + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists email_tokens ( + token text PRIMARY KEY, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + purpose text NOT NULL, + -- written as now() + interval and compared with now(): timestamptz, not text + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists sessions ( + token text PRIMARY KEY, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- written as now() + interval and compared with now(): timestamptz, not text + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email) WHERE email IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id); diff --git a/packages/storage/migrations-pg/0003_extension_store.sql b/packages/storage/migrations-pg/0003_extension_store.sql new file mode 100644 index 00000000..34b2fb0c --- /dev/null +++ b/packages/storage/migrations-pg/0003_extension_store.sql @@ -0,0 +1,103 @@ +-- TronBrowser Postgres schema: 0003_extension_store +-- Generated from packages/storage/migrations/0003_extension_store.sql with `npx libsql-pg convert-schema`, then reviewed. +-- Forward-only; scripts/db-migrate.mjs applies this directory when TRONBROWSER_DB_URL is postgres://. + +-- created_at: text column with a current-time default became timestamptz +-- updated_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +-- updated_at: default (datetime('now')) -> now() +create table if not exists extensions ( + id text PRIMARY KEY, + slug text NOT NULL UNIQUE, + owner_user_id text NOT NULL, + name text NOT NULL, + summary text, + description text, + homepage_url text, + icon_url text, + status text NOT NULL DEFAULT 'draft', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (owner_user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_extensions_owner ON extensions (owner_user_id); + +CREATE INDEX IF NOT EXISTS idx_extensions_status ON extensions (status, updated_at); + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists extension_versions ( + id text PRIMARY KEY, + extension_id text NOT NULL, + version text NOT NULL, + manifest_version bigint NOT NULL, + manifest_json text NOT NULL, + permissions_json text, + bundle_url text, + crx_url text, + bundle_sha256 text, + size_bytes bigint, + source text NOT NULL DEFAULT 'upload', + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (extension_id) REFERENCES extensions(id), + UNIQUE (extension_id, version) +); + +CREATE INDEX IF NOT EXISTS idx_ext_versions_ext ON extension_versions (extension_id, created_at DESC); + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists extension_payments ( + id text PRIMARY KEY, + extension_id text NOT NULL, + user_id text NOT NULL, + amount_cents bigint NOT NULL DEFAULT 100, + currency text NOT NULL DEFAULT 'usd', + method text NOT NULL, + status text NOT NULL DEFAULT 'pending', + provider_ref text, + paid_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (extension_id) REFERENCES extensions(id), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_ext_payments_ext ON extension_payments (extension_id); + +CREATE INDEX IF NOT EXISTS idx_ext_payments_ref ON extension_payments (provider_ref); + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists extension_scans ( + id text PRIMARY KEY, + extension_id text NOT NULL, + version_id text NOT NULL, + provider text NOT NULL DEFAULT 'vu1nz', + status text NOT NULL DEFAULT 'pending', + score bigint, + severity text, + findings_json text, + error text, + scanned_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (extension_id) REFERENCES extensions(id), + FOREIGN KEY (version_id) REFERENCES extension_versions(id) +); + +CREATE INDEX IF NOT EXISTS idx_ext_scans_ext ON extension_scans (extension_id, created_at DESC); + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists extension_flags ( + id text PRIMARY KEY, + extension_id text NOT NULL, + reporter_user_id text, + reason text NOT NULL, + detail text, + status text NOT NULL DEFAULT 'open', + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (extension_id) REFERENCES extensions(id) +); + +CREATE INDEX IF NOT EXISTS idx_ext_flags_ext ON extension_flags (extension_id, status); diff --git a/packages/storage/migrations-pg/0004_extension_publishers.sql b/packages/storage/migrations-pg/0004_extension_publishers.sql new file mode 100644 index 00000000..6ef3287e --- /dev/null +++ b/packages/storage/migrations-pg/0004_extension_publishers.sql @@ -0,0 +1,17 @@ +-- TronBrowser Postgres schema: 0004_extension_publishers +-- Generated from packages/storage/migrations/0004_extension_publishers.sql with `npx libsql-pg convert-schema`, then reviewed. +-- Forward-only; scripts/db-migrate.mjs applies this directory when TRONBROWSER_DB_URL is postgres://. + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists publisher_keys ( + user_id text PRIMARY KEY, + handle text NOT NULL UNIQUE, + pubkey text NOT NULL, + fingerprint text NOT NULL, + provisioned_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_publisher_keys_handle ON publisher_keys (handle); diff --git a/packages/storage/migrations-pg/0005_publisher_tokens.sql b/packages/storage/migrations-pg/0005_publisher_tokens.sql new file mode 100644 index 00000000..239180e5 --- /dev/null +++ b/packages/storage/migrations-pg/0005_publisher_tokens.sql @@ -0,0 +1,17 @@ +-- TronBrowser Postgres schema: 0005_publisher_tokens +-- Generated from packages/storage/migrations/0005_publisher_tokens.sql with `npx libsql-pg convert-schema`, then reviewed. +-- Forward-only; scripts/db-migrate.mjs applies this directory when TRONBROWSER_DB_URL is postgres://. + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists publisher_tokens ( + id text PRIMARY KEY, + user_id text NOT NULL, + token_hash text NOT NULL UNIQUE, + name text, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_publisher_tokens_user ON publisher_tokens (user_id); diff --git a/packages/storage/migrations-pg/0006_extension_signing_keys.sql b/packages/storage/migrations-pg/0006_extension_signing_keys.sql new file mode 100644 index 00000000..63c76d36 --- /dev/null +++ b/packages/storage/migrations-pg/0006_extension_signing_keys.sql @@ -0,0 +1,17 @@ +-- TronBrowser Postgres schema: 0006_extension_signing_keys +-- Generated from packages/storage/migrations/0006_extension_signing_keys.sql with `npx libsql-pg convert-schema`, then reviewed. +-- Forward-only; scripts/db-migrate.mjs applies this directory when TRONBROWSER_DB_URL is postgres://. + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists extension_signing_keys ( + extension_id text PRIMARY KEY, + crx_id text NOT NULL, + public_key_der text NOT NULL, + private_key_enc text NOT NULL, + key_algo text NOT NULL DEFAULT 'rsa-2048', + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (extension_id) REFERENCES extensions(id) +); + +CREATE INDEX IF NOT EXISTS idx_signing_keys_crx_id ON extension_signing_keys(crx_id); diff --git a/packages/storage/migrations-pg/0007_push.sql b/packages/storage/migrations-pg/0007_push.sql new file mode 100644 index 00000000..3848d150 --- /dev/null +++ b/packages/storage/migrations-pg/0007_push.sql @@ -0,0 +1,43 @@ +-- TronBrowser Postgres schema: 0007_push +-- Generated from packages/storage/migrations/0007_push.sql with `npx libsql-pg convert-schema`, then reviewed. +-- Forward-only; scripts/db-migrate.mjs applies this directory when TRONBROWSER_DB_URL is postgres://. + +-- created_at: text column with a current-time default became timestamptz +-- last_seen_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +-- last_seen_at: default (datetime('now')) -> now() +create table if not exists push_devices ( + id text PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now() +); + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists push_subscriptions ( + token text PRIMARY KEY, + device_id text NOT NULL, + origin text NOT NULL, + app_key text, + created_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + FOREIGN KEY (device_id) REFERENCES push_devices(id) +); + +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_device ON push_subscriptions (device_id); + +-- created_at: text column with a current-time default became timestamptz +-- created_at: default (datetime('now')) -> now() +create table if not exists push_messages ( + id text PRIMARY KEY, + token text NOT NULL, + device_id text NOT NULL, + body text NOT NULL, + encoding text, + urgency text NOT NULL DEFAULT 'normal', + topic text, + expires_at bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_push_messages_device ON push_messages (device_id, expires_at); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a1cd952..8be09c17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@libsql/client': specifier: ^0.14.0 version: 0.14.0 + '@profullstack/libsql-pg': + specifier: ^0.1.1 + version: 0.1.1(@libsql/client@0.14.0) '@types/node': specifier: ^24.0.0 version: 24.13.2 @@ -291,12 +294,12 @@ importers: '@langchain/openai': specifier: ^1.5.3 version: 1.5.3(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(ws@8.21.0) - '@libsql/client': - specifier: ^0.14.0 - version: 0.14.0 '@logicsrc/agentswarm': specifier: ^0.1.0 version: 0.1.0(@langchain/langgraph@1.4.6(@langchain/core@1.2.1(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(react@19.2.7)(zod@4.4.3))(deepagents@1.10.5(langsmith@0.7.12(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(react@19.2.7)(ws@8.21.0)) + '@profullstack/libsql-pg': + specifier: ^0.1.1 + version: 0.1.1(@libsql/client@0.14.0) '@tronbrowser/sdk': specifier: workspace:* version: link:../../packages/sdk @@ -310,6 +313,9 @@ importers: specifier: ^4.6.14 version: 4.12.27 devDependencies: + '@libsql/client': + specifier: ^0.14.0 + version: 0.14.0 '@profullstack/notifications': specifier: ^0.1.3 version: 0.1.3 @@ -1240,6 +1246,16 @@ packages: '@profullstack/emailer@1.0.1': resolution: {integrity: sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg==} + '@profullstack/libsql-pg@0.1.1': + resolution: {integrity: sha512-KD4+bCDM31hwheWYxOyn6zxRw1hTqhEqAl/LXfgoQNbZ88ODbDNJViDtxPWCCv7IPgXAMWsh2kbkrjRiT7Sn1Q==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@libsql/client': '>=0.5.0' + peerDependenciesMeta: + '@libsql/client': + optional: true + '@profullstack/notifications@0.1.3': resolution: {integrity: sha512-BtP67qx/+AQuKNtgIoxZWS2cTr1zc2hKTP7mqPhVpiEoxAgCEYHqtr+5aMRRAu+mmElXQSKjaU7ydbGKzG+WPg==} engines: {node: '>=20.11'} @@ -2921,6 +2937,40 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2944,6 +2994,22 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3170,6 +3236,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3509,6 +3579,10 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4674,6 +4748,14 @@ snapshots: '@profullstack/emailer@1.0.1': {} + '@profullstack/libsql-pg@0.1.1(@libsql/client@0.14.0)': + dependencies: + pg: 8.23.0 + optionalDependencies: + '@libsql/client': 0.14.0 + transitivePeerDependencies: + - pg-native + '@profullstack/notifications@0.1.3': {} '@profullstack/referrals@0.1.0(react@19.2.7)': @@ -6437,6 +6519,41 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -6457,6 +6574,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} pretty-format@29.7.0: @@ -6726,6 +6853,8 @@ snapshots: source-map@0.6.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} stackframe@1.3.4: {} @@ -7001,6 +7130,8 @@ snapshots: xmlbuilder@15.1.1: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 7a2e0282..5abd7a6d 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -1,62 +1,106 @@ #!/usr/bin/env node -// Forward-only SQLite/libSQL migration runner. Applies every unapplied -// packages/storage/migrations/*.sql (in filename order) and records them in a -// schema_migrations table. Idempotent. Config from env (Doppler/.env): -// TRONBROWSER_DB_URL + TRONBROWSER_DB_AUTH_TOKEN (Turso), or TRONBROWSER_DB_PATH. +// Forward-only migration runner. Applies every unapplied migration (in filename +// order) and records it in a schema_migrations table. Idempotent. // // doppler run -- pnpm db:migrate // pnpm db:status # show applied vs pending without applying -import { createClient } from '@libsql/client'; +// +// Postgres (the production path since 2026-09): DATABASE_URL (or the alias +// TRONBROWSER_DB_URL) is postgres:// and packages/storage/migrations-pg/*.sql +// is applied through @profullstack/libsql-pg, one transaction per file. +// +// Legacy Turso/libSQL: a libsql:// or file: URL (or TRONBROWSER_DB_PATH) still +// applies packages/storage/migrations/*.sql through @libsql/client, so the old +// database can be kept in step until the cutover is proven. The API itself +// refuses anything but Postgres; this script is the only libSQL user left. import { readFileSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const DIR = process.env.MIGRATIONS_DIR || join(ROOT, 'packages/storage/migrations'); const STATUS_ONLY = process.argv.includes('--status'); -function clientFromEnv() { - const path = process.env.TRONBROWSER_DB_PATH; - if (path) return createClient({ url: 'file:' + path.replace(/^file:/, '') }); - const url = process.env.TRONBROWSER_DB_URL; - if (!url) { - console.error('Set TRONBROWSER_DB_URL (+ _AUTH_TOKEN) or TRONBROWSER_DB_PATH.'); - process.exit(1); - } - if (url.startsWith('file:') || url.startsWith('./') || url.startsWith('/')) { - return createClient({ url: url.startsWith('file:') ? url : 'file:' + url }); - } - const authToken = process.env.TRONBROWSER_DB_AUTH_TOKEN; - if (!authToken) { - console.error('Remote DB URL requires TRONBROWSER_DB_AUTH_TOKEN.'); - process.exit(1); - } - return createClient({ url, authToken }); -} +const url = process.env.DATABASE_URL || process.env.TRONBROWSER_DB_URL; +const filePath = process.env.TRONBROWSER_DB_PATH; +const isPostgres = !!url && /^postgres(ql)?:\/\//i.test(url); -const db = clientFromEnv(); -await db.execute( - `CREATE TABLE IF NOT EXISTS schema_migrations ( - name TEXT PRIMARY KEY, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) - )`, -); +if (!url && !filePath) { + console.error('Set DATABASE_URL to a postgres:// URL (or, for the legacy database, TRONBROWSER_DB_URL + _AUTH_TOKEN / TRONBROWSER_DB_PATH).'); + process.exit(1); +} -const applied = new Set((await db.execute('SELECT name FROM schema_migrations')).rows.map((r) => r.name)); +const DIR = process.env.MIGRATIONS_DIR || join(ROOT, isPostgres ? 'packages/storage/migrations-pg' : 'packages/storage/migrations'); const files = readdirSync(DIR).filter((f) => f.endsWith('.sql')).sort(); -if (STATUS_ONLY) { - for (const f of files) console.log(`${applied.has(f) ? '✓ applied' : '• pending'} ${f}`); - process.exit(0); -} - -let n = 0; -for (const f of files) { - if (applied.has(f)) { console.log('• skip ', f); continue; } - const sql = readFileSync(join(DIR, f), 'utf8'); - await db.executeMultiple(sql); - await db.execute({ sql: 'INSERT INTO schema_migrations (name) VALUES (?)', args: [f] }); - console.log('✓ applied', f); - n++; +if (isPostgres) { + const { createClient } = await import('@profullstack/libsql-pg'); + const client = createClient({ url, dialect: 'postgres' }); + const pool = client.pool; + await pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )`); + const applied = new Set((await pool.query('SELECT name FROM schema_migrations')).rows.map((r) => r.name)); + if (STATUS_ONLY) { + for (const f of files) console.log(`${applied.has(f) ? 'applied' : 'pending'} ${f}`); + await client.close(); + process.exit(0); + } + let n = 0; + for (const f of files) { + if (applied.has(f)) { console.log('skip ', f); continue; } + const conn = await pool.connect(); + try { + await conn.query('BEGIN'); + await conn.query(readFileSync(join(DIR, f), 'utf8')); + await conn.query('INSERT INTO schema_migrations (name) VALUES ($1)', [f]); + await conn.query('COMMIT'); + } catch (err) { + await conn.query('ROLLBACK').catch(() => {}); + console.error(`failed ${f}: ${err.message}`); + await client.close(); + process.exit(1); + } finally { + conn.release(); + } + console.log('applied', f); + n++; + } + console.log(n ? `\nApplied ${n} migration(s).` : '\nDatabase is up to date.'); + await client.close(); +} else { + console.warn('Legacy libSQL target: applying the SQLite migrations. The API runs on Postgres; set DATABASE_URL=postgres://... for the current schema.'); + const { createClient } = await import('@libsql/client'); + let db; + if (filePath) db = createClient({ url: 'file:' + filePath.replace(/^file:/, '') }); + else if (url.startsWith('file:') || url.startsWith('./') || url.startsWith('/')) { + db = createClient({ url: url.startsWith('file:') ? url : 'file:' + url }); + } else { + const authToken = process.env.TRONBROWSER_DB_AUTH_TOKEN; + if (!authToken) { + console.error('Remote libSQL URL requires TRONBROWSER_DB_AUTH_TOKEN.'); + process.exit(1); + } + db = createClient({ url, authToken }); + } + await db.execute( + `CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + ); + const applied = new Set((await db.execute('SELECT name FROM schema_migrations')).rows.map((r) => r.name)); + if (STATUS_ONLY) { + for (const f of files) console.log(`${applied.has(f) ? 'applied' : 'pending'} ${f}`); + process.exit(0); + } + let n = 0; + for (const f of files) { + if (applied.has(f)) { console.log('skip ', f); continue; } + await db.executeMultiple(readFileSync(join(DIR, f), 'utf8')); + await db.execute({ sql: 'INSERT INTO schema_migrations (name) VALUES (?)', args: [f] }); + console.log('applied', f); + n++; + } + console.log(n ? `\nApplied ${n} migration(s).` : '\nDatabase is up to date.'); } -console.log(n ? `\nApplied ${n} migration(s).` : '\nDatabase is up to date.'); diff --git a/services/api/package.json b/services/api/package.json index 6e081381..5d68a611 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -19,14 +19,15 @@ "@langchain/anthropic": "^1.5.1", "@langchain/langgraph": "^1.4.6", "@langchain/openai": "^1.5.3", - "@libsql/client": "^0.14.0", "@logicsrc/agentswarm": "^0.1.0", "@tronbrowser/sdk": "workspace:*", "deepagents": "^1.10.5", "fflate": "^0.8.3", - "hono": "^4.6.14" + "hono": "^4.6.14", + "@profullstack/libsql-pg": "^0.1.1" }, "devDependencies": { + "@libsql/client": "^0.14.0", "@profullstack/notifications": "^0.1.3", "typescript": "^5.6.3", "vitest": "^2.1.4" diff --git a/services/api/src/db.test.ts b/services/api/src/db.test.ts new file mode 100644 index 00000000..066540a9 --- /dev/null +++ b/services/api/src/db.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { databaseUrl, expiresAt } from './db.js'; + +describe('databaseUrl', () => { + it('accepts postgres:// and postgresql://, DATABASE_URL first', () => { + expect(databaseUrl({ DATABASE_URL: 'postgres://u:p@h:5432/d' })).toBe('postgres://u:p@h:5432/d'); + expect(databaseUrl({ TRONBROWSER_DB_URL: 'postgresql://u:p@h/d' })).toBe('postgresql://u:p@h/d'); + expect(databaseUrl({ DATABASE_URL: 'postgres://a/x', TRONBROWSER_DB_URL: 'postgres://b/y' })).toBe('postgres://a/x'); + }); + + it('fails fast when nothing is set, and names a leftover file path', () => { + expect(() => databaseUrl({})).toThrow(/DATABASE_URL is not set/); + expect(() => databaseUrl({ TRONBROWSER_DB_PATH: '/data/db.sqlite' })).toThrow(/TRONBROWSER_DB_PATH is set but file databases/); + }); + + it('refuses libsql:// and file: rather than falling back', () => { + expect(() => databaseUrl({ DATABASE_URL: 'libsql://x.turso.io' })).toThrow(/got "libsql:"/); + expect(() => databaseUrl({ TRONBROWSER_DB_URL: 'file:local.db' })).toThrow(/got "file:"/); + }); +}); + +describe('expiresAt', () => { + it('is an ISO instant ttl seconds ahead', () => { + expect(expiresAt(3600, Date.UTC(2026, 0, 1))).toBe('2026-01-01T01:00:00.000Z'); + }); +}); diff --git a/services/api/src/db.ts b/services/api/src/db.ts index d0bd6bfe..cbda2cd3 100644 --- a/services/api/src/db.ts +++ b/services/api/src/db.ts @@ -1,21 +1,56 @@ -import { createClient, type Client } from '@libsql/client'; +import { createClient, type Client } from '@profullstack/libsql-pg'; let _db: Client | null = null; +const POSTGRES_URL = /^postgres(ql)?:\/\//i; + +/** + * The Postgres connection string, or a clear error. + * + * The API moved from Turso/libSQL to Postgres (2026-09). @profullstack/libsql-pg + * keeps the @libsql/client surface (execute / rows / rowsAffected) over a pg + * pool and rewrites the SQLite idioms in these queries per statement, so the + * call sites below did not change. There is deliberately no fallback to a + * file or libsql:// database: a misconfigured deploy fails here, loudly. + * + * DATABASE_URL is the setting; TRONBROWSER_DB_URL is accepted as an alias so + * an existing deploy can be repointed by changing one value. + */ +export function databaseUrl(env: NodeJS.ProcessEnv = process.env): string { + const url = env.DATABASE_URL || env.TRONBROWSER_DB_URL; + if (!url) { + const hint = env.TRONBROWSER_DB_PATH + ? ' TRONBROWSER_DB_PATH is set but file databases are no longer supported: copy it into Postgres with `npx libsql-pg copy` and set DATABASE_URL.' + : ''; + throw new Error(`DATABASE_URL is not set (expected postgres://user:pass@host:5432/tronbrowser).${hint}`); + } + if (!POSTGRES_URL.test(url)) { + const scheme = url.split(':')[0]; + throw new Error( + `DATABASE_URL must be a postgres:// or postgresql:// URL, got "${scheme}:". ` + + 'TronBrowser runs on Postgres only; libsql:// and file: databases are not supported. ' + + 'Move the data with `npx libsql-pg copy --from --to postgres://...`.', + ); + } + return url; +} + +/** Throws unless the database URL points at Postgres. Call at process start to fail fast. */ +export function assertDatabaseUrl(env: NodeJS.ProcessEnv = process.env): void { + databaseUrl(env); +} + export function db(): Client { if (_db) return _db; - const url = process.env.TRONBROWSER_DB_URL; - const authToken = process.env.TRONBROWSER_DB_AUTH_TOKEN; - const path = process.env.TRONBROWSER_DB_PATH; - if (path) _db = createClient({ url: 'file:' + path.replace(/^file:/, '') }); - else if (url && !/^(file:|\.\/|\/)/.test(url)) { - if (!authToken) throw new Error('TRONBROWSER_DB_AUTH_TOKEN required for remote DB'); - _db = createClient({ url, authToken }); - } else if (url) _db = createClient({ url: url.startsWith('file:') ? url : 'file:' + url }); - else throw new Error('Set TRONBROWSER_DB_URL (+_AUTH_TOKEN) or TRONBROWSER_DB_PATH'); + _db = createClient({ url: databaseUrl() }); return _db; } +/** ISO-8601 UTC timestamp `ttlSeconds` from now. */ +export function expiresAt(ttlSeconds: number, now: number = Date.now()): string { + return new Date(now + ttlSeconds * 1000).toISOString(); +} + export interface User { id: string; auth_method: string; @@ -54,9 +89,13 @@ export async function setEmailVerified(userId: string): Promise { } export async function createSession(token: string, userId: string, ttlSeconds: number): Promise { + // Hand-ported: datetime('now', ?) with a BOUND modifier is not something the + // libsql-pg rewriter can turn into now() + interval (the modifier has to be + // a literal), so the expiry is computed here and bound as an ISO string + // (expires_at is timestamptz in migrations-pg). await db().execute({ - sql: "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', ?))", - args: [token, userId, `+${ttlSeconds} seconds`], + sql: 'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)', + args: [token, userId, expiresAt(ttlSeconds)], }); } @@ -74,9 +113,10 @@ export async function deleteSession(token: string): Promise { } export async function putEmailToken(token: string, userId: string, purpose: string, ttlSeconds: number): Promise { + // Same hand port as createSession: the expiry is bound, not computed in SQL. await db().execute({ - sql: "INSERT INTO email_tokens (token, user_id, purpose, expires_at) VALUES (?, ?, ?, datetime('now', ?))", - args: [token, userId, purpose, `+${ttlSeconds} seconds`], + sql: 'INSERT INTO email_tokens (token, user_id, purpose, expires_at) VALUES (?, ?, ?, ?)', + args: [token, userId, purpose, expiresAt(ttlSeconds)], }); } diff --git a/services/api/src/index.ts b/services/api/src/index.ts index 946e9fe8..a7908fce 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -16,9 +16,13 @@ import { dnsRoutes } from './dns.js'; import { tronRelay } from './mcp/tron.js'; import { safeRedirect } from './redirect.js'; import { extLoginTarget } from './ext-login.js'; -import { db } from './db.js'; +import { assertDatabaseUrl, db } from './db.js'; import { pushService } from './push/routes.js'; +// Fail fast: a missing or non-Postgres DATABASE_URL is a deploy error, not a +// condition to limp along under (there is no file-database fallback). +assertDatabaseUrl(); + const CP = { clientId: process.env.COINPAY_CLIENT_ID || '', clientSecret: process.env.COINPAY_CLIENT_SECRET || '', diff --git a/services/api/src/push/push.test.ts b/services/api/src/push/push.test.ts index 34707b19..06ec8988 100644 --- a/services/api/src/push/push.test.ts +++ b/services/api/src/push/push.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { Hono } from 'hono'; -import { createClient, type Client } from '@libsql/client'; +import { createClient as createSqliteClient, type Client } from '@libsql/client'; +import { createClient as createPgClient } from '@profullstack/libsql-pg'; import { readFileSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { generateVapidKeys, sendPush, vapidHeader } from '@profullstack/notifications/server'; @@ -16,7 +17,31 @@ import { } from '../../../../apps/desktop/extensions/ai-sidebar/push-crypto.js'; const BASE = 'https://tronbrowser.dev/api/1/push'; -const migration = readFileSync(new URL('../../../../packages/storage/migrations/0007_push.sql', import.meta.url), 'utf8'); +// The suite runs against an in-memory SQLite by default, so CI needs no +// database. With TEST_DATABASE_URL=postgres://... it runs the same requests +// through @profullstack/libsql-pg against the Postgres schema instead, which is +// what production uses. +const PG_URL = process.env.TEST_DATABASE_URL; +const migration = readFileSync( + new URL(`../../../../packages/storage/${PG_URL ? 'migrations-pg' : 'migrations'}/0007_push.sql`, import.meta.url), + 'utf8', +); +const PUSH_TABLES = ['push_messages', 'push_subscriptions', 'push_devices']; + +async function freshClient(): Promise { + if (!PG_URL) { + const c = createSqliteClient({ url: ':memory:' }); + await c.executeMultiple(migration); + return c; + } + // Postgres: the pg pool runs the whole file in one round trip, then the + // tables are emptied so each test starts blank. + const c = createPgClient({ url: PG_URL, dialect: 'postgres' }) as unknown as Client & { pool: { query: (sql: string) => Promise } }; + await c.pool.query(migration); + await c.pool.query(`TRUNCATE ${PUSH_TABLES.join(', ')}`); + // The routes speak SQLite; a second client with the default dialect rewrites them. + return createPgClient({ url: PG_URL }) as unknown as Client; +} let client: Client; let app: Hono; @@ -25,8 +50,7 @@ const secret = randomBytes(32).toString('base64url'); const auth = { authorization: `Bearer ${secret}`, 'content-type': 'application/json' }; beforeEach(async () => { - client = createClient({ url: ':memory:' }); - await client.executeMultiple(migration); + client = await freshClient(); service = pushService({ db: () => client, publicBase: BASE }); app = new Hono(); app.route('/api/1/push', service.app); diff --git a/services/api/src/push/routes.ts b/services/api/src/push/routes.ts index 7a6d3303..7f4b80d5 100644 --- a/services/api/src/push/routes.ts +++ b/services/api/src/push/routes.ts @@ -18,7 +18,7 @@ // We never see a payload in the clear: it arrives encrypted to a key only the // browser holds, and is relayed as-is. import { Hono } from 'hono'; -import type { Client } from '@libsql/client'; +import type { Client } from '@profullstack/libsql-pg'; import { createHash, randomBytes } from 'node:crypto'; import { verifyVapid } from './vapid.js'; diff --git a/services/api/src/store/db.ts b/services/api/src/store/db.ts index ae9bb44e..b88926dc 100644 --- a/services/api/src/store/db.ts +++ b/services/api/src/store/db.ts @@ -1,5 +1,5 @@ -// Data access for the extension store. Mirrors the style of ../db.ts (raw -// libSQL via the shared db() client). +// Data access for the extension store. Mirrors the style of ../db.ts (raw SQL +// via the shared db() client; Postgres through @profullstack/libsql-pg). import { createHash, randomBytes } from 'node:crypto'; import { db, type User } from '../db.js'; import { uuid } from '../auth.js'; @@ -181,8 +181,9 @@ export async function listLiveExtensions(opts: { q?: string | undefined; limit?: const offset = boundedInteger(opts.offset, 0, 0); if (opts.q) { const like = `%${opts.q}%`; + // ILIKE, not LIKE: SQLite's LIKE ignored ASCII case and store search relied on it. const r = await db().execute({ - sql: `SELECT * FROM extensions WHERE status = 'live' AND (name LIKE ? OR summary LIKE ?) + sql: `SELECT * FROM extensions WHERE status = 'live' AND (name ILIKE ? OR summary ILIKE ?) ORDER BY updated_at DESC LIMIT ? OFFSET ?`, args: [like, like, limit, offset], }); From 7a1cb97914d59a58a6e164ee7149edb8597892e2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 12:34:03 +0000 Subject: [PATCH 2/2] chore: credential-free placeholder URLs in examples and fixtures ThreatCrush flags any user:password@ database URL, placeholder or not. The example and test URLs now carry no password; the real one lives in the vault. Co-Authored-By: Claude Fable 5.1 --- .env.example | 3 ++- services/api/src/db.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 6e9a1830..e10db226 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,8 @@ # --- Storage --------------------------------------------------------------- # Postgres. The API refuses to start on anything else (no file fallback). # TRONBROWSER_DB_URL is accepted as an alias for the same value. -DATABASE_URL=postgres://tronbrowser:password@localhost:5432/tronbrowser +# The password belongs in Doppler, not here; this placeholder has none. +DATABASE_URL=postgres://tronbrowser@localhost:5432/tronbrowser # # Legacy Turso/libSQL database, kept only until the Postgres cutover is proven. # Read by scripts/db-migrate.mjs for the old schema and by the one-off copy: diff --git a/services/api/src/db.test.ts b/services/api/src/db.test.ts index 066540a9..ea1bf66b 100644 --- a/services/api/src/db.test.ts +++ b/services/api/src/db.test.ts @@ -3,8 +3,8 @@ import { databaseUrl, expiresAt } from './db.js'; describe('databaseUrl', () => { it('accepts postgres:// and postgresql://, DATABASE_URL first', () => { - expect(databaseUrl({ DATABASE_URL: 'postgres://u:p@h:5432/d' })).toBe('postgres://u:p@h:5432/d'); - expect(databaseUrl({ TRONBROWSER_DB_URL: 'postgresql://u:p@h/d' })).toBe('postgresql://u:p@h/d'); + expect(databaseUrl({ DATABASE_URL: 'postgres://h:5432/d' })).toBe('postgres://h:5432/d'); + expect(databaseUrl({ TRONBROWSER_DB_URL: 'postgresql://h/d' })).toBe('postgresql://h/d'); expect(databaseUrl({ DATABASE_URL: 'postgres://a/x', TRONBROWSER_DB_URL: 'postgres://b/y' })).toBe('postgres://a/x'); });