From ad3748090b601323c46cbe789a89cec0bf5e2a18 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:56:37 +0000 Subject: [PATCH 1/3] feat(db): move from Turso/libSQL to Postgres via @profullstack/libsql-pg Production reads DATABASE_URL=postgres://... through @profullstack/libsql-pg, which keeps the @libsql/client surface every query here was written against, rewrites the SQLite idioms per statement and runs the CREATE TABLEs in initSchema through its schema converter. Local runs and the tests keep a libSQL file (@libsql/client is now a devDependency, loaded lazily for file: URLs; TURSO_DATABASE_URL still names one). libsql:// is refused with a message pointing at the move. Dialect fixes: the waitlist sort orders by lower(email) instead of COLLATE NOCASE (no such collation in Postgres; same order on both), and addColumnIfMissing also accepts Postgres's "already exists" message. Co-Authored-By: Claude Fable 5.1 --- .env.example | 9 ++++--- README.md | 12 ++++----- bun.lock | 33 ++++++++++++++++++++++++- lib/db.ts | 60 +++++++++++++++++++++++++++++++++++++-------- lib/dns/registry.ts | 2 +- lib/media.ts | 2 +- lib/moshpit-name.ts | 2 +- next.config.mjs | 3 +++ package.json | 3 ++- 9 files changed, 101 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 9204fef..03247ba 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://postgres:password@localhost:5432/moshcoding +# 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/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/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", From 6e7c238a5dd0622faec080ba5cf85a3adeaa01d0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 16:59:26 +0000 Subject: [PATCH 2/3] .env.example: no credential-shaped placeholder in the Postgres URL (ThreatCrush) --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 03247ba..0d2bfb3 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # 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://postgres:password@localhost:5432/moshcoding +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 From 551143186e289112db146594963555afd6075ef7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 17:02:32 +0000 Subject: [PATCH 3/3] invitations: read expires_at in either timestamp format Postgres returns created_at/expires_at as ISO strings; appending "Z" to one made an Invalid Date, whose getTime() compares as never expired. --- app/api/invitations/accept/route.ts | 6 +++++- lib/api-key-time.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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/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`