Replace remote with local repo
This commit is contained in:
25
app/api/admin/credit/route.ts
Normal file
25
app/api/admin/credit/route.ts
Normal 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 });
|
||||
}
|
||||
13
app/api/admin/ledger/route.ts
Normal file
13
app/api/admin/ledger/route.ts
Normal 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 });
|
||||
}
|
||||
12
app/api/admin/raffle/route.ts
Normal file
12
app/api/admin/raffle/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
95
app/api/btcpay/webhook/route.ts
Normal file
95
app/api/btcpay/webhook/route.ts
Normal 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 });
|
||||
}
|
||||
24
app/api/credits/balance/route.ts
Normal file
24
app/api/credits/balance/route.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
90
app/api/credits/claim/route.ts
Normal file
90
app/api/credits/claim/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
91
app/api/raffle/buy/route.ts
Normal file
91
app/api/raffle/buy/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
44
app/api/tools/unlock/route.ts
Normal file
44
app/api/tools/unlock/route.ts
Normal 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()),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user