Replace remote with local repo

This commit is contained in:
drjones
2026-04-26 22:28:40 -07:00
parent 04d64fb993
commit e7c9da944e
57 changed files with 3658 additions and 3971 deletions

218
lib/serverLedger.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* Server-side VOID credit ledger.
* Stored in var/server-ledger.json (gitignored, created on first write).
* Uses atomic rename for writes — safe on a single Next.js server.
*
* VOID credits are the authoritative balance for tool unlocks.
* Rate: VOID_CREDIT_SATS_PER env var (default 1000 sats = 1 VOID).
*/
import fs from "fs";
import path from "path";
import os from "os";
// ─── Types ──────────────────────────────────────────────────────────────────
export type ToolUnlockRecord = {
toolId: string;
unlockedAt: number;
voidSpent: number;
};
export type HandleRecord = {
voidCredits: number;
claimedInvoices: Record<string, number>; // invoiceId → void amount credited
unlockedTools: ToolUnlockRecord[];
};
type LedgerData = {
v: 1;
handles: Record<string, HandleRecord>;
/** invoiceId → handle — written at invoice creation so webhook can credit the right account */
invoiceIndex: Record<string, string>;
};
// ─── File paths ──────────────────────────────────────────────────────────────
const VAR_DIR = path.join(process.cwd(), "var");
const LEDGER_PATH = path.join(VAR_DIR, "server-ledger.json");
// ─── Helpers ─────────────────────────────────────────────────────────────────
function ensureVarDir() {
if (!fs.existsSync(VAR_DIR)) fs.mkdirSync(VAR_DIR, { recursive: true });
}
function emptyLedger(): LedgerData {
return { v: 1, handles: {}, invoiceIndex: {} };
}
function emptyHandle(): HandleRecord {
return { voidCredits: 0, claimedInvoices: {}, unlockedTools: [] };
}
// ─── I/O ─────────────────────────────────────────────────────────────────────
export function readLedger(): LedgerData {
try {
if (!fs.existsSync(LEDGER_PATH)) return emptyLedger();
const raw = fs.readFileSync(LEDGER_PATH, "utf8");
const parsed = JSON.parse(raw) as LedgerData;
if (parsed?.v !== 1) return emptyLedger();
parsed.handles ??= {};
parsed.invoiceIndex ??= {};
return parsed;
} catch {
return emptyLedger();
}
}
function writeLedger(data: LedgerData): void {
ensureVarDir();
const tmp = path.join(os.tmpdir(), `cyberlux-ledger-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
fs.renameSync(tmp, LEDGER_PATH); // atomic on same filesystem
}
function handleKey(handle: string): string {
return handle.trim().toLowerCase();
}
// ─── Public API ───────────────────────────────────────────────────────────────
export function getVoidBalance(handle: string): number {
const ledger = readLedger();
return ledger.handles[handleKey(handle)]?.voidCredits ?? 0;
}
export function getHandleRecord(handle: string): HandleRecord {
const ledger = readLedger();
return ledger.handles[handleKey(handle)] ?? emptyHandle();
}
/** Register invoice → handle mapping when invoice is created. */
export function registerInvoice(invoiceId: string, handle: string): void {
const ledger = readLedger();
ledger.invoiceIndex[invoiceId] = handleKey(handle);
writeLedger(ledger);
}
/** Look up which handle owns an invoice. */
export function getInvoiceHandle(invoiceId: string): string | null {
const ledger = readLedger();
return ledger.invoiceIndex[invoiceId] ?? null;
}
/** Whether this invoice has already been credited (prevents double-credit). */
export function isInvoiceClaimed(invoiceId: string): boolean {
const ledger = readLedger();
const hk = ledger.invoiceIndex[invoiceId];
if (!hk) return false;
return invoiceId in (ledger.handles[hk]?.claimedInvoices ?? {});
}
/**
* Credit VOID credits to a handle for a settled invoice.
* Returns false if invoice already credited.
*/
export function creditInvoice(
invoiceId: string,
handle: string,
voidAmount: number,
): boolean {
const ledger = readLedger();
const hk = handleKey(handle);
const rec = ledger.handles[hk] ?? emptyHandle();
if (invoiceId in rec.claimedInvoices) return false;
rec.voidCredits = Math.max(0, (rec.voidCredits ?? 0) + Math.max(0, Math.floor(voidAmount)));
rec.claimedInvoices[invoiceId] = Math.floor(voidAmount);
ledger.handles[hk] = rec;
ledger.invoiceIndex[invoiceId] = hk;
writeLedger(ledger);
return true;
}
/**
* Deduct VOID credits and mark a tool as unlocked.
* Returns { ok: false } if insufficient balance.
*/
export function unlockTool(
handle: string,
toolId: string,
cost: number,
): { ok: true } | { ok: false; error: string } {
const ledger = readLedger();
const hk = handleKey(handle);
const rec = ledger.handles[hk] ?? emptyHandle();
if (rec.unlockedTools.some((t) => t.toolId === toolId)) {
return { ok: true }; // already unlocked, idempotent
}
if (rec.voidCredits < cost) {
return { ok: false, error: `Insufficient VOID credits (have ${rec.voidCredits}, need ${cost})` };
}
rec.voidCredits -= cost;
rec.unlockedTools.push({ toolId, unlockedAt: Date.now(), voidSpent: cost });
ledger.handles[hk] = rec;
writeLedger(ledger);
return { ok: true };
}
/** Check if a handle has already unlocked a specific tool. */
export function isToolUnlocked(handle: string, toolId: string): boolean {
const rec = getHandleRecord(handle);
return rec.unlockedTools.some((t) => t.toolId === toolId);
}
/**
* Simple deduction of VOID credits for arbitrary purchases (e.g. raffle tickets).
* Returns { ok: false } if balance is insufficient.
*/
export function deductVoid(
handle: string,
amount: number,
reason?: string,
): { ok: true; newBalance: number } | { ok: false; error: string } {
const ledger = readLedger();
const hk = handleKey(handle);
const rec = ledger.handles[hk] ?? emptyHandle();
if (rec.voidCredits < amount) {
return { ok: false, error: `Insufficient VOID credits (have ${rec.voidCredits}, need ${amount}${reason ? ` for ${reason}` : ""})` };
}
rec.voidCredits -= amount;
ledger.handles[hk] = rec;
writeLedger(ledger);
return { ok: true, newBalance: rec.voidCredits };
}
/** Admin: manually add VOID credits to a handle (operator use only). */
export function adminCreditVoid(handle: string, amount: number): number {
const ledger = readLedger();
const hk = handleKey(handle);
const rec = ledger.handles[hk] ?? emptyHandle();
rec.voidCredits = Math.max(0, rec.voidCredits + Math.max(0, Math.floor(amount)));
ledger.handles[hk] = rec;
writeLedger(ledger);
return rec.voidCredits;
}
/** Return all handle records for admin views. */
export function getAllHandles(): Record<string, HandleRecord> {
return readLedger().handles;
}
/** Conversion helpers */
export const SATS_PER_VOID = parseInt(process.env.VOID_CREDIT_SATS_PER ?? "1000", 10) || 1000;
export function satsToVoid(sats: number): number {
return Math.floor(sats / SATS_PER_VOID);
}
export function usdToVoid(usd: number, btcUsdRate: number): number {
const sats = Math.floor((usd / btcUsdRate) * 1e8);
return satsToVoid(sats);
}