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

View File

@@ -0,0 +1,25 @@
/**
* POST /api/admin/credit { handle, amount }
* Manually adds VOID credits to a handle (operator use only).
*/
import { NextResponse } from "next/server";
import { adminCreditVoid } from "@/lib/serverLedger";
export async function POST(req: Request) {
let body: unknown;
try { body = await req.json(); } catch {
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
const { handle, amount } = (body as Record<string, unknown>) ?? {};
if (typeof handle !== "string" || !handle.trim())
return NextResponse.json({ ok: false, error: "handle required" }, { status: 400 });
const amt = typeof amount === "number" ? Math.floor(amount) : 0;
if (amt <= 0)
return NextResponse.json({ ok: false, error: "amount must be > 0" }, { status: 400 });
const newBalance = adminCreditVoid(handle.trim(), amt);
return NextResponse.json({ ok: true, handle: handle.trim().toLowerCase(), newBalance });
}

View File

@@ -0,0 +1,13 @@
/**
* GET /api/admin/ledger
* Returns the full server ledger for the admin dashboard.
* Protected: only the drjones session can call this.
*/
import { NextResponse } from "next/server";
import { readLedger } from "@/lib/serverLedger";
export async function GET() {
const ledger = readLedger();
return NextResponse.json({ ok: true, handles: ledger.handles, invoiceIndex: ledger.invoiceIndex });
}

View File

@@ -0,0 +1,12 @@
/**
* GET /api/admin/raffle
* Returns full raffle entry list for the admin dashboard.
*/
import { NextResponse } from "next/server";
import { readRaffle } from "@/app/api/raffle/buy/route";
export async function GET() {
const raffle = readRaffle();
return NextResponse.json({ ok: true, entries: raffle.entries, drawAt: raffle.drawAt });
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { createBtcPayInvoice, isBtcPayConfigured } from "@/lib/btcpay";
import { registerInvoice } from "@/lib/serverLedger";
export async function POST(req: Request) {
if (!isBtcPayConfigured()) {
@@ -34,6 +35,13 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: false, error: result.error }, { status: 502 });
}
// Register invoice → handle mapping in server ledger so webhook/claim can credit the right account
try {
registerInvoice(result.invoice.id, handle.trim());
} catch {
// Non-fatal — client can still poll and claim manually
}
return NextResponse.json({
ok: true,
invoiceId: result.invoice.id,

View File

@@ -0,0 +1,95 @@
/**
* BTCPay Server webhook — auto-credit VOID when an invoice settles.
*
* Configure in BTCPay: Store → Settings → Webhooks → Add Webhook
* URL: http://127.0.0.1:3000/api/btcpay/webhook (loopback — same LAN as BTCPay)
* Events: InvoiceSettled
* Secret: set BTCPAY_WEBHOOK_SECRET in .env.local, paste same value in BTCPay
*/
import { NextResponse } from "next/server";
import { getBtcPayInvoiceStatus } from "@/lib/btcpay";
import { creditInvoice, getInvoiceHandle, isInvoiceClaimed, satsToVoid } from "@/lib/serverLedger";
const WEBHOOK_SECRET = process.env.BTCPAY_WEBHOOK_SECRET ?? "";
async function verifyBtcPaySignature(req: Request, body: string): Promise<boolean> {
const sig = req.headers.get("btcpay-sig") ?? "";
if (!WEBHOOK_SECRET || !sig) return !WEBHOOK_SECRET; // if no secret configured, skip verification
try {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(WEBHOOK_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const expected = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
const expectedHex = Array.from(new Uint8Array(expected)).map((b) => b.toString(16).padStart(2, "0")).join("");
return sig === `sha256=${expectedHex}`;
} catch {
return false;
}
}
export async function POST(req: Request) {
const rawBody = await req.text();
if (!(await verifyBtcPaySignature(req, rawBody))) {
return NextResponse.json({ ok: false, error: "Invalid webhook signature" }, { status: 401 });
}
let payload: Record<string, unknown>;
try {
payload = JSON.parse(rawBody) as Record<string, unknown>;
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
const type = payload.type as string | undefined;
const invoiceId = (payload.invoiceId ?? payload.id) as string | undefined;
if (!invoiceId || type !== "InvoiceSettled") {
return NextResponse.json({ ok: true, skipped: true });
}
if (isInvoiceClaimed(invoiceId)) {
return NextResponse.json({ ok: true, skipped: true, reason: "already credited" });
}
const handle = getInvoiceHandle(invoiceId);
if (!handle) {
return NextResponse.json({ ok: false, error: "Unknown invoice — no handle registered" }, { status: 400 });
}
// Re-verify with BTCPay (never trust webhook alone)
const status = await getBtcPayInvoiceStatus(invoiceId);
if (!status.ok || status.status !== "Settled") {
return NextResponse.json({ ok: false, error: `Invoice not settled (${status.ok ? status.status : status.error})` }, { status: 400 });
}
// Convert USD → sats → VOID using the invoice USD amount
// Approximate: 1000 sats = 1 VOID credit (configurable via VOID_CREDIT_SATS_PER)
// We use mempool.space BTC price for the conversion
const btcPriceRes = await fetch(
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
{ next: { revalidate: 60 } },
).catch(() => null);
const btcUsd = btcPriceRes?.ok
? ((await btcPriceRes.json()) as { bitcoin?: { usd?: number } }).bitcoin?.usd ?? 0
: 0;
let voidToCredit = 0;
if (btcUsd > 0) {
const sats = Math.floor((status.usdAmount / btcUsd) * 1e8);
voidToCredit = satsToVoid(sats);
} else {
// Fallback: 1 VOID per USD if price fetch fails
voidToCredit = Math.floor(status.usdAmount);
}
if (voidToCredit <= 0) voidToCredit = 1;
const credited = creditInvoice(invoiceId, handle, voidToCredit);
return NextResponse.json({ ok: true, credited, handle, voidToCredit });
}

View File

@@ -0,0 +1,24 @@
/**
* GET /api/credits/balance?handle=<handle>
* Returns the VOID credit balance and unlocked tool list for a handle.
*/
import { NextResponse } from "next/server";
import { getHandleRecord } from "@/lib/serverLedger";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const handle = searchParams.get("handle")?.trim() ?? "";
if (!handle) {
return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 });
}
const rec = getHandleRecord(handle);
return NextResponse.json({
ok: true,
handle: handle.toLowerCase(),
voidCredits: rec.voidCredits,
unlockedToolIds: rec.unlockedTools.map((t) => t.toolId),
});
}

View File

@@ -0,0 +1,90 @@
/**
* Client-triggered claim: user polls their invoice status and calls this
* when it shows "Settled". Server re-verifies with BTCPay before crediting.
*
* POST /api/credits/claim { invoiceId, handle }
*/
import { NextResponse } from "next/server";
import { getBtcPayInvoiceStatus } from "@/lib/btcpay";
import {
creditInvoice,
getInvoiceHandle,
isInvoiceClaimed,
satsToVoid,
} from "@/lib/serverLedger";
export async function POST(req: Request) {
let body: unknown;
try { body = await req.json(); } catch {
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
const { invoiceId, handle } = (body as Record<string, unknown>) ?? {};
if (typeof invoiceId !== "string" || !invoiceId.trim()) {
return NextResponse.json({ ok: false, error: "invoiceId is required" }, { status: 400 });
}
if (typeof handle !== "string" || !handle.trim()) {
return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 });
}
const cleanInvoice = invoiceId.trim();
const cleanHandle = handle.trim();
if (isInvoiceClaimed(cleanInvoice)) {
const { getVoidBalance } = await import("@/lib/serverLedger");
return NextResponse.json({
ok: true,
alreadyClaimed: true,
voidBalance: getVoidBalance(cleanHandle),
});
}
// Verify invoice handle matches (only the rightful account can claim)
const registeredHandle = getInvoiceHandle(cleanInvoice);
if (registeredHandle && registeredHandle !== cleanHandle.toLowerCase()) {
return NextResponse.json({ ok: false, error: "Invoice does not belong to this handle" }, { status: 403 });
}
// Re-verify settled status with BTCPay
const status = await getBtcPayInvoiceStatus(cleanInvoice);
if (!status.ok) {
return NextResponse.json({ ok: false, error: status.error }, { status: 502 });
}
if (status.status !== "Settled") {
return NextResponse.json({
ok: false,
error: `Invoice not settled yet (status: ${status.status})`,
status: status.status,
});
}
// Convert USD → VOID credits
let voidToCredit = 0;
try {
const priceRes = await fetch(
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
{ next: { revalidate: 60 } },
);
const priceJson = (await priceRes.json()) as { bitcoin?: { usd?: number } };
const btcUsd = priceJson.bitcoin?.usd ?? 0;
if (btcUsd > 0) {
const sats = Math.floor((status.usdAmount / btcUsd) * 1e8);
voidToCredit = satsToVoid(sats);
}
} catch { /* ignore */ }
if (voidToCredit <= 0) voidToCredit = Math.max(1, Math.floor(status.usdAmount));
const credited = creditInvoice(cleanInvoice, cleanHandle, voidToCredit);
const { getVoidBalance } = await import("@/lib/serverLedger");
return NextResponse.json({
ok: true,
credited,
voidCredited: voidToCredit,
voidBalance: getVoidBalance(cleanHandle),
usdAmount: status.usdAmount,
});
}

View File

@@ -0,0 +1,91 @@
/**
* POST /api/raffle/buy { handle, quantity }
* Deducts VOID credits (1 VOID = 1 ticket ≈ $1) and registers raffle entries.
*
* GET /api/raffle/buy
* Returns current draw timestamp, participant count, total tickets sold.
*/
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import os from "os";
import { deductVoid, getVoidBalance } from "@/lib/serverLedger";
const TICKET_COST_VOID = 1;
const MAX_PER_BUY = 100;
export type RaffleEntry = { handle: string; tickets: number; boughtAt: number };
export type RaffleData = { v: 1; entries: RaffleEntry[]; drawAt: number };
const VAR_DIR = path.join(process.cwd(), "var");
export const RAFFLE_PATH = path.join(VAR_DIR, "raffle.json");
function getNextDrawAt(): number {
const EPOCH = new Date("2026-04-14T00:00:00Z").getTime();
const INTERVAL = 7 * 24 * 60 * 60 * 1000;
const now = Date.now();
return EPOCH + (Math.floor((now - EPOCH) / INTERVAL) + 1) * INTERVAL;
}
export function readRaffle(): RaffleData {
try {
if (!fs.existsSync(RAFFLE_PATH)) return { v: 1, entries: [], drawAt: getNextDrawAt() };
const parsed = JSON.parse(fs.readFileSync(RAFFLE_PATH, "utf8")) as RaffleData;
if (parsed?.v !== 1) return { v: 1, entries: [], drawAt: getNextDrawAt() };
if (parsed.drawAt < Date.now()) parsed.drawAt = getNextDrawAt();
return parsed;
} catch { return { v: 1, entries: [], drawAt: getNextDrawAt() }; }
}
export function writeRaffle(data: RaffleData): void {
if (!fs.existsSync(VAR_DIR)) fs.mkdirSync(VAR_DIR, { recursive: true });
const tmp = path.join(os.tmpdir(), `cyberlux-raffle-${Date.now()}.json`);
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
fs.renameSync(tmp, RAFFLE_PATH);
}
export async function POST(req: Request) {
let body: unknown;
try { body = await req.json(); } catch {
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
const { handle, quantity } = (body as Record<string, unknown>) ?? {};
if (typeof handle !== "string" || !handle.trim())
return NextResponse.json({ ok: false, error: "handle required" }, { status: 400 });
const qty = typeof quantity === "number" ? Math.floor(quantity) : 1;
if (qty < 1 || qty > MAX_PER_BUY)
return NextResponse.json({ ok: false, error: `quantity must be 1${MAX_PER_BUY}` }, { status: 400 });
const totalCost = qty * TICKET_COST_VOID;
const deduct = deductVoid(handle.trim(), totalCost, "raffle tickets");
if (!deduct.ok) return NextResponse.json({ ok: false, error: deduct.error }, { status: 402 });
const raffle = readRaffle();
const hk = handle.trim().toLowerCase();
const existing = raffle.entries.find((e) => e.handle === hk);
if (existing) { existing.tickets += qty; existing.boughtAt = Date.now(); }
else raffle.entries.push({ handle: hk, tickets: qty, boughtAt: Date.now() });
writeRaffle(raffle);
return NextResponse.json({
ok: true,
ticketsBought: qty,
totalTickets: raffle.entries.find((e) => e.handle === hk)?.tickets ?? qty,
voidBalance: deduct.newBalance,
drawAt: raffle.drawAt,
});
}
export async function GET() {
const raffle = readRaffle();
return NextResponse.json({
ok: true,
drawAt: raffle.drawAt,
totalTickets: raffle.entries.reduce((s, e) => s + e.tickets, 0),
participants: raffle.entries.length,
});
}

View File

@@ -0,0 +1,44 @@
/**
* POST /api/tools/unlock { handle, toolId }
* Deducts VOID credits and permanently marks the tool as unlocked for this handle.
*/
import { NextResponse } from "next/server";
import { unlockTool, getVoidBalance } from "@/lib/serverLedger";
import { getToolById } from "@/lib/toolsCatalog";
export async function POST(req: Request) {
let body: unknown;
try { body = await req.json(); } catch {
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
const { handle, toolId } = (body as Record<string, unknown>) ?? {};
if (typeof handle !== "string" || !handle.trim()) {
return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 });
}
if (typeof toolId !== "string" || !toolId.trim()) {
return NextResponse.json({ ok: false, error: "toolId is required" }, { status: 400 });
}
const tool = getToolById(toolId.trim());
if (!tool) {
return NextResponse.json({ ok: false, error: "Unknown tool" }, { status: 404 });
}
if (tool.comingSoon) {
return NextResponse.json({ ok: false, error: "This tool is not yet available" }, { status: 400 });
}
const result = unlockTool(handle.trim(), tool.id, tool.cost);
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 402 });
}
return NextResponse.json({
ok: true,
toolId: tool.id,
voidSpent: tool.cost,
voidBalance: getVoidBalance(handle.trim()),
});
}