feat(pwa): move app.logicsrc.com from Turso/libSQL to Postgres via @profullstack/libsql-pg (#218)

Production reads DATABASE_URL (postgres://) through @profullstack/libsql-pg,
which keeps the @libsql/client surface; no query changed. A missing,
non-Postgres or leftover libsql:// URL fails at boot in production. Dev and
tests keep libSQL (file:/:memory:) as a devDependency. Postgres migrations in
src/migrations-pg/ (converted with libsql-pg convert-schema, same file names
so the copied _migrations ledger matches); migrate.mjs picks the dialect.
Tests run against Postgres with PWA_TEST_DATABASE_URL.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-25 09:28:40 -07:00 • committed by GitHub
parent c535aaa32c
commit 4ac750e65b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 507 additions and 29 deletions

View file

@ -1,16 +1,40 @@
# @logicsrc/pwa — LogicSRC credentials
Express + libSQL/Turso app for **team credential sharing**: auth (email/password,
Express app on Postgres for **team credential sharing**: auth (email/password,
passkeys, CoinPay OAuth, sessions, `lsk_` CLI API keys) + end-to-end-encrypted
team vaults. Zero-knowledge — the server only stores ciphertext, per-member
sealed vault keys, and identity public keys. Decryption happens in the
`logicsrc` CLI.
```bash
cp .env.example .env # set SESSION_SECRET; TURSO_* for prod (else local file db)
cp .env.example .env # set SESSION_SECRET; DATABASE_URL=postgres://… for prod (else a local libSQL file db)
npm install
npm start # migrates on boot, serves on :8080
```
## Database
Production runs on Postgres: `DATABASE_URL` must be a `postgres://` URL and the
app refuses to start on anything else there (a leftover `libsql://` value is
called out by name). The client is
[`@profullstack/libsql-pg`](https://github.com/profullstack/libsql-pg), which
keeps the `@libsql/client` surface the code was written against and rewrites
the remaining SQLite idioms per statement. Development and the tests use libSQL
itself (`file:./data/local.db` by default, `:memory:` in tests).
Migrations run at boot and live in two dialect copies with the same file names:
`src/migrations/` (SQLite) and `src/migrations-pg/` (Postgres, generated with
`npx libsql-pg convert-schema` and reviewed). `npm run migrate` applies the copy
matching `DATABASE_URL`. To move an existing Turso database:
```bash
DATABASE_URL=postgres://… npm run migrate # schema
npx libsql-pg copy --from "$TURSO_DATABASE_URL" --token "$TURSO_AUTH_TOKEN" \
--to "$DATABASE_URL" --verify # rows
```
Set `PWA_TEST_DATABASE_URL=postgres://…` to run the test suite against a real
Postgres (it drops and recreates the `public` schema of that database).
The CLI connects with `LOGICSRC_API=<origin> logicsrc login` (browser OAuth-PKCE
loopback → an `lsk_` key). See `docs/credential-sharing.md` in the repo root.

View file

@ -3,7 +3,7 @@
"version": "0.1.1",
"private": true,
"type": "module",
"description": "LogicSRC credentials — Express + libSQL/Turso app: auth + end-to-end-encrypted team credential sharing.",
"description": "LogicSRC credentials — Express app on Postgres (@profullstack/libsql-pg): auth + end-to-end-encrypted team credential sharing.",
"engines": {
"node": ">=20"
},
@ -14,10 +14,13 @@
"test": "node --test"
},
"dependencies": {
"@libsql/client": "^0.14.0",
"@profullstack/libsql-pg": "^0.1.2",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.1.0",
"cookie-parser": "^1.4.7",
"express": "^4.21.2"
},
"devDependencies": {
"@libsql/client": "^0.14.0"
}
}

View file

@ -36,9 +36,12 @@ export const config = {
rpName: "LogicSRC",
sessionSecret: process.env.SESSION_SECRET || "dev-insecure-secret-change-me",
db: {
// Turso (libSQL) in prod; a local file for dev. TURSO_* takes precedence.
url: process.env.TURSO_DATABASE_URL || process.env.DATABASE_URL || "file:./data/local.db",
authToken: process.env.TURSO_AUTH_TOKEN || process.env.DATABASE_AUTH_TOKEN || undefined,
// Postgres (postgres://) in production, through @profullstack/libsql-pg; a
// local libSQL file or `:memory:` for dev and tests. DATABASE_URL wins. A
// leftover TURSO_DATABASE_URL is still read so that db.mjs can refuse it
// with a message that names it, rather than silently opening a file db.
url: process.env.DATABASE_URL || process.env.TURSO_DATABASE_URL || "file:./data/local.db",
authToken: process.env.DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || undefined,
},
resend: {
apiKey: process.env.RESEND_API_KEY || "",

View file

@ -1,17 +1,58 @@
// libSQL (SQLite / Turso) client + a tiny query helper.
import { createClient } from "@libsql/client";
// Database client + a tiny query helper.
//
// Production runs on Postgres through @profullstack/libsql-pg, which keeps the
// @libsql/client surface (execute / batch / rows / rowsAffected) over a `pg`
// pool and rewrites the SQLite idioms in our statements per query, so no
// caller changed when the app left Turso. Development and the test suite keep
// using libSQL itself (a local file or `:memory:`), which is a devDependency.
import fs from "node:fs";
import path from "node:path";
import { createClient as createPgClient } from "@profullstack/libsql-pg";
import { config } from "./config.mjs";
// For a local file: url, make sure the directory exists.
if (config.db.url.startsWith("file:")) {
const p = config.db.url.slice("file:".length);
const dir = path.dirname(path.resolve(config.root, p));
fs.mkdirSync(dir, { recursive: true });
const POSTGRES = /^postgres(ql)?:\/\//i;
/** True when `url` is a Postgres DSN (what production must use). */
export const isPostgresUrl = (url) => POSTGRES.test(url);
/**
* Fail fast on a database URL the app cannot run on. A missing or non-Postgres
* URL in production is a deploy error, not a condition to limp along under:
* there is no fallback to a file database there.
*/
export function assertDatabaseUrl(url = config.db.url, env = config.env) {
if (isPostgresUrl(url)) return url;
if (/^libsql:/i.test(url) || /\.turso\.io/i.test(url)) {
throw new Error(
"DATABASE_URL must be a postgres:// URL. A libsql:// (Turso) URL was found" +
(process.env.TURSO_DATABASE_URL ? " in TURSO_DATABASE_URL" : "") +
"; the app moved to Postgres. Copy the data with `npx libsql-pg copy` and unset TURSO_*.",
);
}
if (env === "production") {
throw new Error(`DATABASE_URL must be a postgres:// URL in production, got "${url.split(":")[0]}:"`);
}
if (url === ":memory:" || url.startsWith("file:")) return url;
throw new Error(`Unsupported DATABASE_URL "${url.split(":")[0]}:": use postgres://, file: or :memory:`);
}
export const db = createClient({ url: config.db.url, authToken: config.db.authToken });
async function open() {
const url = assertDatabaseUrl();
if (isPostgresUrl(url)) return createPgClient({ url, dialect: "sqlite" });
// Local libSQL for dev and tests only (devDependency, never loaded in production).
if (url.startsWith("file:")) {
const dir = path.dirname(path.resolve(config.root, url.slice("file:".length)));
fs.mkdirSync(dir, { recursive: true });
}
const { createClient } = await import("@libsql/client");
return createClient({ url, authToken: config.db.authToken });
}
export const db = await open();
/** True when the live client talks to Postgres. */
export const isPostgres = isPostgresUrl(config.db.url);
/** Run a statement; returns the raw result. */
export const run = (sql, args = []) => db.execute({ sql, args });

View file

@ -1,11 +1,19 @@
// Apply SQL migrations in order. Idempotent — tracks applied files in _migrations.
//
// Two copies of every migration exist, one per dialect, with the SAME file
// names: `migrations/` (SQLite, for the local dev/test database) and
// `migrations-pg/` (Postgres, generated with `npx libsql-pg convert-schema`
// and reviewed). The ledger is keyed by file name, so a database copied from
// Turso with `libsql-pg copy` carries its `_migrations` rows across and the
// Postgres side recognises them as applied.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { db, run, all } from "./db.mjs";
import { db, run, all, isPostgres } from "./db.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const DIR = path.join(HERE, "migrations");
export const MIGRATIONS_DIR = path.join(HERE, isPostgres ? "migrations-pg" : "migrations");
const DIR = MIGRATIONS_DIR;
export async function migrate() {
// Bootstrap the tracking table (the first migration also declares it IF NOT EXISTS).
@ -16,7 +24,7 @@ export async function migrate() {
for (const file of files) {
if (done.has(file)) { console.log(`· ${file} (already applied)`); continue; }
const sql = fs.readFileSync(path.join(DIR, file), "utf8");
// libSQL executes one statement per call — split on semicolons at line ends.
// One statement per call (libSQL requires it; the Postgres shim binds per statement) — split on semicolons at line ends.
const statements = sql.split(/;\s*(?:\n|$)/).map((s) => s.trim()).filter(Boolean);
for (const stmt of statements) await run(stmt);
await run(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`, [file, Date.now()]);

View file

@ -0,0 +1,53 @@
-- Converted from SQLite by @profullstack/libsql-pg. Review every TODO before applying.
-- Types: INTEGER -> bigint, REAL -> double precision, BLOB -> bytea, BOOLEAN -> boolean,
-- DATETIME/TIMESTAMP -> timestamptz, TEXT -> text; INTEGER PRIMARY KEY -> identity.
create table if not exists users (
id text PRIMARY KEY,
email text UNIQUE,
password_hash text,
coinpay_sub text UNIQUE,
display_name text,
created_at bigint NOT NULL
);
create table if not exists webauthn_credentials (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key text NOT NULL,
counter bigint NOT NULL DEFAULT 0,
transports text,
created_at bigint NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id);
create table if not exists sessions (
token text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at bigint NOT NULL,
expires_at bigint NOT NULL
);
create table if not exists api_keys (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text,
token_hash text NOT NULL,
prefix text NOT NULL,
created_at bigint NOT NULL,
last_used_at bigint
);
CREATE INDEX IF NOT EXISTS idx_apikeys_user ON api_keys(user_id);
create table if not exists cli_auth_codes (
code text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_challenge text NOT NULL,
redirect_uri text NOT NULL,
name text,
used bigint NOT NULL DEFAULT 0,
created_at bigint NOT NULL,
expires_at bigint NOT NULL
);

View file

@ -0,0 +1,89 @@
-- Converted from SQLite by @profullstack/libsql-pg. Review every TODO before applying.
-- Types: INTEGER -> bigint, REAL -> double precision, BLOB -> bytea, BOOLEAN -> boolean,
-- DATETIME/TIMESTAMP -> timestamptz, TEXT -> text; INTEGER PRIMARY KEY -> identity.
create table if not exists credshare_keys (
user_id text PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
public_key text NOT NULL,
updated_at bigint NOT NULL
);
create table if not exists credshare_teams (
id text PRIMARY KEY,
slug text NOT NULL UNIQUE,
name text NOT NULL,
created_by text NOT NULL REFERENCES users(id),
created_at bigint NOT NULL
);
create table if not exists credshare_members (
id text PRIMARY KEY,
team_id text NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
user_id text REFERENCES users(id) ON DELETE SET NULL,
email text NOT NULL,
role text NOT NULL DEFAULT 'member',
status text NOT NULL DEFAULT 'invited',
invited_by text REFERENCES users(id),
joined_at bigint,
created_at bigint NOT NULL,
UNIQUE(team_id, email)
);
CREATE INDEX IF NOT EXISTS idx_credshare_members_team ON credshare_members(team_id);
CREATE INDEX IF NOT EXISTS idx_credshare_members_user ON credshare_members(user_id);
create table if not exists credshare_invites (
id text PRIMARY KEY,
team_id text NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
email text NOT NULL,
role text NOT NULL DEFAULT 'member',
token_hash text NOT NULL UNIQUE,
created_by text NOT NULL REFERENCES users(id),
expires_at bigint NOT NULL,
accepted_at bigint,
created_at bigint NOT NULL
);
create table if not exists credshare_vaults (
id text PRIMARY KEY,
team_id text NOT NULL REFERENCES credshare_teams(id) ON DELETE CASCADE,
name text NOT NULL,
created_by text NOT NULL REFERENCES users(id),
created_at bigint NOT NULL,
UNIQUE(team_id, name)
);
create table if not exists credshare_vault_grants (
vault_id text NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
wrapped_dek text NOT NULL,
granted_by text NOT NULL REFERENCES users(id),
created_at bigint NOT NULL,
PRIMARY KEY (vault_id, user_id)
);
create table if not exists credshare_secrets (
vault_id text NOT NULL REFERENCES credshare_vaults(id) ON DELETE CASCADE,
name text NOT NULL,
nonce text NOT NULL,
ciphertext text NOT NULL,
fingerprint text NOT NULL,
version bigint NOT NULL,
updated_by text NOT NULL REFERENCES users(id),
updated_at bigint NOT NULL,
PRIMARY KEY (vault_id, name)
);
create table if not exists credshare_audit (
id text PRIMARY KEY,
team_id text,
vault_id text,
actor_user_id text NOT NULL REFERENCES users(id),
action text NOT NULL,
key_name text,
fingerprint text,
created_at bigint NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_credshare_audit_vault ON credshare_audit(vault_id, created_at DESC);

View file

@ -0,0 +1,16 @@
-- Converted from SQLite by @profullstack/libsql-pg. Review every TODO before applying.
-- Types: INTEGER -> bigint, REAL -> double precision, BLOB -> bytea, BOOLEAN -> boolean,
-- DATETIME/TIMESTAMP -> timestamptz, TEXT -> text; INTEGER PRIMARY KEY -> identity.
create table if not exists cli_device_codes (
device_code_hash text PRIMARY KEY,
user_code text NOT NULL UNIQUE,
user_id text REFERENCES users(id) ON DELETE CASCADE,
name text,
status text NOT NULL DEFAULT 'pending',
created_at bigint NOT NULL,
expires_at bigint NOT NULL,
last_polled_at bigint
);
CREATE INDEX IF NOT EXISTS idx_cli_device_user_code ON cli_device_codes(user_code);

View file

@ -7,7 +7,7 @@
//
// Runs against an in-memory libSQL database, so DATABASE_URL must be set before
// anything imports db.mjs.
process.env.DATABASE_URL = ":memory:";
process.env.DATABASE_URL = process.env.PWA_TEST_DATABASE_URL || ":memory:";
import test from "node:test";
import assert from "node:assert/strict";
@ -17,13 +17,19 @@ import { dirname, join } from "node:path";
import express from "express";
const here = dirname(fileURLToPath(import.meta.url));
const { db, run, get, all } = await import("../src/db.mjs");
const { db, run, get, all, isPostgres } = await import("../src/db.mjs");
const { credshareRouter } = await import("../src/routes/credshare.mjs");
/** Apply the schema this router depends on. */
async function migrate() {
// Against a real Postgres (PWA_TEST_DATABASE_URL) start from an empty schema;
// run with --test-concurrency=1 then, the files share one database.
if (isPostgres) {
await db.execute("DROP SCHEMA public CASCADE");
await db.execute("CREATE SCHEMA public");
}
for (const file of ["001_auth.sql", "002_credshare.sql"]) {
const sql = readFileSync(join(here, "..", "src", "migrations", file), "utf8");
const sql = readFileSync(join(here, "..", "src", isPostgres ? "migrations-pg" : "migrations", file), "utf8");
for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) {
await db.execute(statement);
}

View file

@ -13,7 +13,7 @@
//
// Runs against an in-memory libSQL database, so DATABASE_URL must be set before
// anything imports db.mjs.
process.env.DATABASE_URL = ":memory:";
process.env.DATABASE_URL = process.env.PWA_TEST_DATABASE_URL || ":memory:";
import test from "node:test";
import assert from "node:assert/strict";
@ -23,12 +23,18 @@ import { dirname, join } from "node:path";
import express from "express";
const here = dirname(fileURLToPath(import.meta.url));
const { db, run } = await import("../src/db.mjs");
const { db, run, isPostgres } = await import("../src/db.mjs");
const { credshareRouter } = await import("../src/routes/credshare.mjs");
async function migrate() {
// Against a real Postgres (PWA_TEST_DATABASE_URL) start from an empty schema;
// run with --test-concurrency=1 then, the files share one database.
if (isPostgres) {
await db.execute("DROP SCHEMA public CASCADE");
await db.execute("CREATE SCHEMA public");
}
for (const file of ["001_auth.sql", "002_credshare.sql"]) {
const sql = readFileSync(join(here, "..", "src", "migrations", file), "utf8");
const sql = readFileSync(join(here, "..", "src", isPostgres ? "migrations-pg" : "migrations", file), "utf8");
for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) {
await db.execute(statement);
}

View file

@ -1,6 +1,6 @@
// Integration coverage for team-member CRUD, invite-key rotation, and the
// dashboard controls that expose those operations.
process.env.DATABASE_URL = ":memory:";
process.env.DATABASE_URL = process.env.PWA_TEST_DATABASE_URL || ":memory:";
import test from "node:test";
import assert from "node:assert/strict";
@ -10,13 +10,19 @@ import { dirname, join } from "node:path";
import express from "express";
const here = dirname(fileURLToPath(import.meta.url));
const { db, run, get } = await import("../src/db.mjs");
const { db, run, get, isPostgres } = await import("../src/db.mjs");
const { sha256 } = await import("../src/lib/crypto.mjs");
const { credshareRouter } = await import("../src/routes/credshare.mjs");
const { pagesRouter } = await import("../src/routes/pages.mjs");
// Against a real Postgres (PWA_TEST_DATABASE_URL) start from an empty schema;
// run with --test-concurrency=1 then, the files share one database.
if (isPostgres) {
await db.execute("DROP SCHEMA public CASCADE");
await db.execute("CREATE SCHEMA public");
}
for (const file of ["001_auth.sql", "002_credshare.sql"]) {
const sql = readFileSync(join(here, "..", "src", "migrations", file), "utf8");
const sql = readFileSync(join(here, "..", "src", isPostgres ? "migrations-pg" : "migrations", file), "utf8");
for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) await db.execute(statement);
}