diff --git a/.env.example b/.env.example index 9204fef..0d2bfb3 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ -# Turso / libSQL — the waitlist database -# Create a DB at https://turso.tech then: turso db show --url / turso db tokens create -TURSO_DATABASE_URL=libsql://your-db.turso.io -TURSO_AUTH_TOKEN=your-token +# The database: Postgres in production (the client is @profullstack/libsql-pg); +# a local libSQL file for development and the tests. Turso is no longer read: +# the data was copied to Postgres with `npx libsql-pg copy` (2026-09). +DATABASE_URL=postgres://localhost:5432/moshcoding # add user:password@ before the host as needed +# DATABASE_URL=file:./local.db # Login with CoinPayPortal (OAuth). Register a client at # https://coinpayportal.com/dashboard/oauth/new with redirect_uri = diff --git a/README.md b/README.md index bef96c1..97e2911 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ moshcoding.com/?dn=yourdomain.com its own origin can only affect its own site. On `moshcoding.com/?dn=` (preview + masked-iframe forwarding) the document is *our* origin and any account can park any domain name, so the code stays off there. -- **Waitlist** — `POST /api/waitlist { email, dn? }`, stored in **libSQL / Turso** - (`signups` table, unique per email+domain) via `@libsql/client`. Domain owners +- **Waitlist** — `POST /api/waitlist { email, dn? }`, stored in **Postgres** + (`signups` table, unique per email+domain) via `@profullstack/libsql-pg`. Domain owners can filter confirmed/pending signups and export the current view as CSV. - **Login** — "Log in with CoinPayPortal" (OAuth2 Auth Code + PKCE); email captured to a `users` table. Self-disables until the `COINPAY_*` + `SESSION_SECRET` env vars are set. @@ -79,24 +79,24 @@ moshcoding.com/?dn=yourdomain.com ### Run it (Bun) ```bash -cp .env.example .env # fill in TURSO + COINPAY + SESSION vars +cp .env.example .env # fill in DATABASE_URL + COINPAY + SESSION vars bun install bun run dev # http://localhost:8080 # tenant demo: http://localhost:8080/?dn=killer-startup.io ``` -Env: `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN` (required) · `COINPAY_ISSUER`, +Env: `DATABASE_URL` (required; `postgres://...`, or `file:...` locally) · `COINPAY_ISSUER`, `COINPAY_CLIENT_ID`, `COINPAY_CLIENT_SECRET`, `SESSION_SECRET`, `APP_BASE_URL` (for login) · `PORT` (default 8080) · `MOSHPIT_RESOLVE_MODE` (`clearnet` default — a real extension outranks the pit; `moshpit` lets a registered name win anyway, see -[docs/moshpit-dns.md](docs/moshpit-dns.md)). Turso tables are created +[docs/moshpit-dns.md](docs/moshpit-dns.md)). Tables are created automatically on first request. ### Deploy (Railway) Builds from the **Dockerfile** (`oven/bun` → `bun run build` → `bun run start`); `railway.json` sets the start command + `/api/me` health check. Set the env vars above in -the service variables — no volume needed, Turso is the database. +the service variables — no volume needed, Postgres is the database. ## Moshpit DNS diff --git a/app/api/invitations/accept/route.ts b/app/api/invitations/accept/route.ts index 94e35ef..41ae4fd 100644 --- a/app/api/invitations/accept/route.ts +++ b/app/api/invitations/accept/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { normalizeApiKeyTimestamp } from "@/lib/api-key-time"; import { db } from "@/lib/db"; import { requireUser, unauthorized, bad } from "@/lib/api"; @@ -20,7 +21,10 @@ export async function POST(req: NextRequest) { if (!inv.rows.length) return bad("invitation not found", 404); const row: any = inv.rows[0]; if (row.accepted_at) return bad("invitation already used", 409); - if (new Date(row.expires_at + "Z").getTime() < Date.now()) return bad("invitation expired", 410); + // expires_at is SQLite's "YYYY-MM-DD HH:MM:SS" on a file database and ISO on + // Postgres; normalizeApiKeyTimestamp reads both (a blind + "Z" made the ISO + // form Invalid Date, which read as "never expires"). + if (new Date(normalizeApiKeyTimestamp(String(row.expires_at))).getTime() < Date.now()) return bad("invitation expired", 410); if (u.email && String(row.email).toLowerCase() !== u.email.toLowerCase()) { return bad("this invitation is for a different email", 403); } diff --git a/bun.lock b/bun.lock index 40f21f4..dd434e2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,14 @@ "": { "name": "moshcoding", "dependencies": { - "@libsql/client": "^0.15.7", + "@profullstack/libsql-pg": "^0.1.3", "@profullstack/stack": "0.1.3", "next": "^15.1.6", "react": "^19.0.0", "react-dom": "^19.0.0", }, "devDependencies": { + "@libsql/client": "^0.15.7", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -122,6 +123,8 @@ "@profullstack/emailer": ["@profullstack/emailer@1.0.1", "", {}, "sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg=="], + "@profullstack/libsql-pg": ["@profullstack/libsql-pg@0.1.3", "", { "dependencies": { "@profullstack/libsql-pg": "^0.1.1", "pg": "^8.13.0" }, "optionalDependencies": { "@libsql/client": "^0.15.0" }, "bin": { "libsql-pg": "bin/libsql-pg.js" } }, "sha512-9VuaWQKDxj5gy304DQlailRJ9M76vqC1Pv+z+Vjm/FiHrJ/VwYI3ycnL0GgjUF4971ZEUXY6Dw2EISdU1FtKjQ=="], + "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], "@profullstack/stack": ["@profullstack/stack@0.1.3", "", { "dependencies": { "@profullstack/emailer": "^1.0.1", "@profullstack/referrals": "^0.1.0" }, "peerDependencies": { "@supabase/ssr": ">=0.5.0", "next": ">=13.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@supabase/ssr", "next", "react"] }, "sha512-NOseYE5cWMwH75/luUE46kJLyAd1xD+DSgGgwhxvgNMoJYfZ9W9vKmqdcGA/4Lgahzf7hWfjNvmLtVIzAqwhJw=="], @@ -162,10 +165,34 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="], + + "pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], @@ -180,6 +207,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -192,6 +221,8 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], } } diff --git a/lib/api-key-time.ts b/lib/api-key-time.ts index 597d8c6..4ee8b6c 100644 --- a/lib/api-key-time.ts +++ b/lib/api-key-time.ts @@ -1,4 +1,8 @@ -/** Normalize timestamps written by SQLite's datetime() as UTC ISO strings. */ +/** + * Normalize timestamps written by SQLite's datetime() ("YYYY-MM-DD HH:MM:SS", + * UTC) as ISO strings; a value that is already ISO (what Postgres returns) is + * passed through. + */ export function normalizeApiKeyTimestamp(value: string): string { return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(value) ? `${value.replace(" ", "T")}Z` diff --git a/lib/db.ts b/lib/db.ts index e8d382c..dcb23da 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,15 +1,49 @@ -import { createClient, type Client } from "@libsql/client"; +import type { Client } from "@libsql/client"; +import { createClient as createPostgresClient } from "@profullstack/libsql-pg"; import { randomBytes } from "node:crypto"; +import { createRequire } from "node:module"; import type { WaitlistSort } from "./waitlist-filter"; let _db: Client | undefined; +const POSTGRES_URL = /^postgres(ql)?:\/\//i; +const require_ = createRequire(import.meta.url); -/** Lazily-created singleton libSQL/Turso client. */ +/** + * The database URL: `DATABASE_URL=postgres://...` in production (the shared + * Postgres cluster on dev2) or a `file:` path for local runs and the tests. + * `TURSO_DATABASE_URL` is still read as a fallback name for a `file:` URL; a + * `libsql://` value is refused, because the data left Turso for Postgres in + * 2026-09. + */ +export function databaseUrl(): string { + const url = process.env.DATABASE_URL || process.env.TURSO_DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set (postgres://... in production, file:... locally)"); + if (!POSTGRES_URL.test(url) && !url.startsWith("file:")) { + throw new Error( + `DATABASE_URL must be a postgres:// URL (production) or a file: path (local); got "${url.split(":")[0]}:". ` + + "Turso/libsql:// is no longer supported: the data lives in Postgres now.", + ); + } + return url; +} + +/** + * Lazily-created singleton client. Postgres goes through @profullstack/libsql-pg, + * which keeps the @libsql/client surface every query here was written against + * and rewrites the SQLite idioms per statement (the CREATE TABLEs in initSchema + * go through its schema converter). A `file:` URL loads @libsql/client lazily: + * it is a devDependency, so the production image needs neither it nor its + * native binding. + */ export function db(): Client { if (_db) return _db; - const url = process.env.TURSO_DATABASE_URL; - if (!url) throw new Error("TURSO_DATABASE_URL is not set"); - _db = createClient({ url, authToken: process.env.TURSO_AUTH_TOKEN }); + const url = databaseUrl(); + if (POSTGRES_URL.test(url)) { + _db = createPostgresClient({ url }) as unknown as Client; + } else { + const { createClient } = require_("@libsql/client") as typeof import("@libsql/client"); + _db = createClient({ url }); + } return _db; } @@ -457,12 +491,16 @@ async function initSchema(): Promise { await d.execute(`CREATE INDEX IF NOT EXISTS idx_media_dn ON media (dn, created_at)`); } -/** Adds ADD COLUMN, ignoring the error when the column already exists. */ +/** + * Adds ADD COLUMN, ignoring the error when the column already exists (SQLite's + * "duplicate column name"; on Postgres the client rewrites the statement to + * ADD COLUMN IF NOT EXISTS, and the message form is covered anyway). + */ async function addColumnIfMissing(table: string, column: string, type: string): Promise { try { await db().execute(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); } catch (err: any) { - if (!/duplicate column name/i.test(String(err?.message))) throw err; + if (!/duplicate column name|already exists/i.test(String(err?.message))) throw err; } } @@ -1099,9 +1137,11 @@ export async function listDomainSignups( ): Promise<{ email: string; verified: boolean; ref: string | null; created_at: string }[]> { await ensureSchema(); const orderBy: Record = { - newest: "created_at DESC, email COLLATE NOCASE ASC, email ASC", - oldest: "created_at ASC, email COLLATE NOCASE ASC, email ASC", - email: "email COLLATE NOCASE ASC, email ASC, created_at DESC", + // lower(email) rather than COLLATE NOCASE: Postgres has no such collation, + // and lower() sorts the same way on both databases. + newest: "created_at DESC, lower(email) ASC, email ASC", + oldest: "created_at ASC, lower(email) ASC, email ASC", + email: "lower(email) ASC, email ASC, created_at DESC", }; const res = await db().execute({ sql: `SELECT email, verified_at, ref, created_at FROM signups WHERE dn = ? ORDER BY ${orderBy[sort]} LIMIT ?`, diff --git a/lib/dns/registry.ts b/lib/dns/registry.ts index 4977d58..c550f1d 100644 --- a/lib/dns/registry.ts +++ b/lib/dns/registry.ts @@ -1,6 +1,6 @@ // The resolver's client for the Moshpit registry. // -// Over HTTP rather than straight into Turso on purpose. The registry is +// Over HTTP rather than straight into the database on purpose. The registry is // authoritative and the gateway is not (PRD 0004 R2), and that boundary only // means something if a resolver is a *reader* of the registry — which is what // makes it self-hostable by anyone (R8) without handing out database diff --git a/lib/media.ts b/lib/media.ts index 4382228..6589546 100644 --- a/lib/media.ts +++ b/lib/media.ts @@ -1,6 +1,6 @@ // Uploaded media (mp4 reels & clips). Bytes live on the filesystem under // DATA_DIR — the SAME Railway volume that caches generated hero images -// (see lib/genart.ts) — while the row metadata lives in Turso (lib/db.ts). +// (see lib/genart.ts) — while the row metadata lives in the database (lib/db.ts). // Mount a volume at DATA_DIR in production or uploads won't survive a redeploy. import fs from "node:fs"; import path from "node:path"; diff --git a/lib/moshpit-name.ts b/lib/moshpit-name.ts index ec74a63..effa8af 100644 --- a/lib/moshpit-name.ts +++ b/lib/moshpit-name.ts @@ -1,7 +1,7 @@ // Validation and policy for Moshpit TLD names. // // Deliberately free of any database import so it can be tested — and reused by -// a client — without a Turso connection. lib/moshpit.ts owns the storage. +// a client — without a database connection. lib/moshpit.ts owns the storage. /** * Names nobody may claim, whatever the PRD's first-come-first-served rule says. diff --git a/next.config.mjs b/next.config.mjs index 41d902b..6d40a2d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -4,6 +4,9 @@ // env changes apply without a rebuild. const nextConfig = { reactStrictMode: true, + // The database drivers stay out of the server bundle: pg (under + // @profullstack/libsql-pg) and, for local file: databases, the native libSQL client. + serverExternalPackages: ["@profullstack/libsql-pg", "pg", "@libsql/client"], // brand assets are large PNGs served straight from /public poweredByHeader: false, }; diff --git a/package.json b/package.json index 797dabf..6b9479d 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,14 @@ "dns": "bun run scripts/moshpit-dns.ts" }, "dependencies": { - "@libsql/client": "^0.15.7", + "@profullstack/libsql-pg": "^0.1.3", "@profullstack/stack": "0.1.3", "next": "^15.1.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { + "@libsql/client": "^0.15.7", "@types/node": "^22.10.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0",