prep for the nook

This commit is contained in:
2026-08-26 17:41:57 -04:00
parent 898c891bd5
commit e4a9be4899
27 changed files with 1234 additions and 20 deletions

View File

@@ -14,10 +14,16 @@ import {
import {
ConnectionFactory,
LineageConnectionFactory,
NessaConnectionFactory
NessaConnectionFactory,
NookConnectionFactory
} from "~/server/db-connections";
// Re-export connection factories to avoid circular import with auth.ts
export { ConnectionFactory, LineageConnectionFactory, NessaConnectionFactory };
export {
ConnectionFactory,
LineageConnectionFactory,
NessaConnectionFactory,
NookConnectionFactory
};
export async function LineageDBInit() {
const turso = createAPIClient({

View File

@@ -1,9 +1,10 @@
import { createClient } from "@libsql/client/web";
import { createClient, type Client } from "@libsql/client/web";
import { env } from "~/env/server";
let mainDBConnection: ReturnType<typeof createClient> | null = null;
let lineageDBConnection: ReturnType<typeof createClient> | null = null;
let nessaDBConnection: ReturnType<typeof createClient> | null = null;
let mainDBConnection: Client | null = null;
let lineageDBConnection: Client | null = null;
let nessaDBConnection: Client | null = null;
let nookDBConnection: Client | null = null;
export function ConnectionFactory() {
if (!mainDBConnection) {
@@ -37,3 +38,14 @@ export function NessaConnectionFactory() {
}
return nessaDBConnection;
}
export function NookConnectionFactory() {
if (!nookDBConnection) {
const config = {
url: env.NOOK_DB_URL,
authToken: env.NOOK_DB_TOKEN
};
nookDBConnection = createClient(config);
}
return nookDBConnection;
}

114
src/server/nook.ts Normal file
View File

@@ -0,0 +1,114 @@
import { NookConnectionFactory } from "~/server/db-connections";
import { env } from "~/env/server";
import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
/**
* The Nook license schema + issueLicense helper.
*
* Kept out of `database.ts` to avoid circular imports: this module owns the
* dedicated The Nook Turso DB and the Ed25519 license-key signing. The schema
* is bootstrapped idempotently (CREATE TABLE IF NOT EXISTS) so no migration
* tool is needed.
*
* The license key is a compact printable string:
*
* key = payloadJson + "." + base64url(ed25519-signature(payloadJson))
*
* Canonicalization is load-bearing. The Swift client verifies the EXACT
* payload bytes (the substring before the last "."), never a re-serialization
* of the decoded JSON — key order must stay stable. Without a compact,
* deterministic payload this breaks, so `issueLicense` builds the payload as
* a hand-ordered literal and `JSON.stringify`s it in place.
*/
interface IssueLicenseResult {
key: string;
id: string;
}
const PAYLOAD_VERSION = 1;
export const nookSchemaBootstrap: Promise<unknown> = (async () => {
const conn = NookConnectionFactory();
await conn.execute(`
CREATE TABLE IF NOT EXISTS licenses (
id TEXT PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
stripe_session_id TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
)
`);
await conn.execute(`
CREATE TABLE IF NOT EXISTS activations (
id TEXT PRIMARY KEY,
license_id TEXT NOT NULL REFERENCES licenses(id),
device_fingerprint TEXT NOT NULL,
device_name TEXT NOT NULL,
activated_at TEXT NOT NULL,
deactivated_at TEXT
)
`);
await conn.execute(`
CREATE TABLE IF NOT EXISTS trials (
fingerprint TEXT PRIMARY KEY,
started_at TEXT NOT NULL
)
`);
})();
function privateKeyObject() {
return createPrivateKey({
key: Buffer.from(env.NOOK_LICENSE_PRIVATE_KEY, "base64"),
format: "der",
type: "pkcs8"
});
}
function signPayload(payload: string): string {
const signature = sign(null, Buffer.from(payload, "utf8"), privateKeyObject());
return Buffer.from(signature).toString("base64url");
}
/** Re-verifies a license key's Ed25519 signature server-side (defense in depth). */
export function verifyLicenseKey(key: string): boolean {
const token = key.match(/^([^]*?)\.([A-Za-z0-9_-]+)$/);
if (!token) return false;
const [, payload, sigB64] = token;
const publicKey = createPublicKey(privateKeyObject());
let signature: Buffer;
try {
signature = Buffer.from(sigB64!, "base64url");
} catch {
return false;
}
return verify(null, Buffer.from(payload!, "utf8"), publicKey, signature);
}
/**
* Issues a license key for a completed Stripe checkout session.
*
* Caller is responsible for the uniqueness/idempotency of `stripeSessionId`
* (the `licenses.stripe_session_id` column is UNIQUE; the webhook catches the
* conflict and skips re-emailing).
*/
export async function issueLicense(
email: string,
stripeSessionId: string
): Promise<IssueLicenseResult> {
const id = crypto.randomUUID();
const payload = JSON.stringify({
v: PAYLOAD_VERSION,
lid: id,
email: email,
iat: Math.floor(Date.now() / 1000)
});
const key = `${payload}.${signPayload(payload)}`;
await NookConnectionFactory().execute({
sql: `INSERT INTO licenses (id, key, email, stripe_session_id, created_at, revoked)
VALUES (?, ?, ?, ?, ?, 0)`,
args: [id, key, email, stripeSessionId, new Date().toISOString()]
});
return { key, id };
}