Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Turso / libSQL — the waitlist database
# Create a DB at https://turso.tech then: turso db show <name> --url / turso db tokens create <name>
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 =
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ moshcoding.com/?dn=yourdomain.com
its own origin can only affect its own site. On `moshcoding.com/?dn=<domain>`
(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.
Expand All @@ -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

Expand Down
6 changes: 5 additions & 1 deletion app/api/invitations/accept/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);
}
Expand Down
33 changes: 32 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion lib/api-key-time.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
60 changes: 50 additions & 10 deletions lib/db.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down Expand Up @@ -457,12 +491,16 @@ async function initSchema(): Promise<void> {
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<void> {
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;
}
}

Expand Down Expand Up @@ -1099,9 +1137,11 @@ export async function listDomainSignups(
): Promise<{ email: string; verified: boolean; ref: string | null; created_at: string }[]> {
await ensureSchema();
const orderBy: Record<WaitlistSort, string> = {
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 ?`,
Expand Down
2 changes: 1 addition & 1 deletion lib/dns/registry.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/media.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion lib/moshpit-name.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
3 changes: 3 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading