Add self-serve billing: tiers, credits, BTCPay and Zaprite

This commit is contained in:
Keysat
2026-06-13 13:36:05 -05:00
parent 84d56c94c9
commit 0aa648706e
17 changed files with 3781 additions and 116 deletions
+305
View File
@@ -0,0 +1,305 @@
// Thin wrapper around BTCPay's Greenfield API for the Recap credit-
// purchase flow. Three things this module owns:
//
// 1. createInvoice() — POST /api/v1/stores/{storeId}/invoices to
// mint an invoice priced in sats. Embeds install_id + credits
// in invoice metadata so the webhook later knows which install
// and how many credits to grant.
//
// 2. getInvoice() — GET /api/v1/stores/{storeId}/invoices/{id} for
// polling settlement state from the Recap UI side.
//
// 3. validateWebhookSignature() — HMAC-SHA256 check on the
// BTCPay-Sig header so we only trust webhook posts that came
// from BTCPay with the operator-configured secret.
//
// Operator config (set via StartOS "Set BTCPay Connection" action):
// relay_btcpay_base_url e.g. https://btcpay.keysat.xyz
// relay_btcpay_store_id uuid-shaped store id from BTCPay
// relay_btcpay_api_key Greenfield token (scope: btcpay.store.canCreateInvoice + canViewInvoices)
// relay_btcpay_webhook_secret shared secret for webhook HMAC
import crypto from "crypto";
// All sats here are quoted in the BTCPay store's display currency
// (which the operator set to "SATS"). BTCPay accepts the integer
// amount + currency code "SATS" and prices the invoice in sats
// directly — no fiat conversion involved.
const CURRENCY = "SATS";
export class BtcPayError extends Error {
constructor(status, message, body) {
super(message);
this.name = "BtcPayError";
this.status = status;
this.body = body;
}
}
// Create a new invoice for `sats` SATS, tagging it with the install
// + credit-package metadata so the webhook (later) knows what to
// grant. Returns the raw Greenfield invoice object — caller picks
// out `id`, `checkoutLink`, `expirationTime`, etc.
export async function createInvoice({
baseURL,
storeId,
apiKey,
sats,
credits,
installId,
// Optional license fingerprint captured at buy time. When the buyer
// has an active Pro/Max license, the credit-grant should land on
// their license-keyed credit pool (which is shared across every
// install that uses the license) rather than the install-keyed pool.
// Stashed in invoice metadata so the webhook handler can route the
// credit to the right pool even after a restart.
licenseFingerprint = null,
packageLabel = "",
redirectURL = null,
redirectAutomatically = false,
}) {
assertConfigured({ baseURL, storeId, apiKey });
const body = {
amount: String(sats),
currency: CURRENCY,
metadata: {
// Top-level metadata fields are surfaced in webhook events and
// are what the webhook handler reads to credit the install.
install_id: installId,
license_fingerprint: licenseFingerprint || null,
credits,
package_label: packageLabel,
product: "recap_credits",
},
checkout: {
// Buyers should be able to leave the checkout page back to
// Recap after a successful payment. The Recap UI is also
// polling so the modal will update even if they don't follow
// this redirect; this is a courtesy that closes the loop
// when the buyer left the BTCPay tab in the foreground.
redirectURL: redirectURL || undefined,
// Auto-redirect once the invoice settles. Without this the
// buyer has to click "Return to merchant" themselves; with it
// the checkout page navigates back to the redirectURL as
// soon as payment confirms.
redirectAutomatically: redirectAutomatically === true,
// Speed up small-amount flows by enabling Lightning. Operator
// has Lightning configured on this store per their setup
// message; we just nudge the checkout to prefer it.
defaultPaymentMethod: "BTC-LightningLike",
},
};
return postInvoice({ baseURL, storeId, apiKey, body });
}
// Create a BTCPay invoice for a self-serve Pro/Max subscription period. The
// metadata (product:"recap_tier_subscription", user_id, tier, period_days)
// is what the webhook reads to call extendUserTier on settlement.
export async function createTierInvoice({
baseURL,
storeId,
apiKey,
sats,
userId,
tier,
periodDays,
redirectURL = null,
redirectAutomatically = false,
}) {
assertConfigured({ baseURL, storeId, apiKey });
const body = {
amount: String(sats),
currency: CURRENCY,
metadata: {
product: "recap_tier_subscription",
user_id: userId,
tier,
period_days: periodDays,
},
checkout: {
redirectURL: redirectURL || undefined,
redirectAutomatically: redirectAutomatically === true,
defaultPaymentMethod: "BTC-LightningLike",
},
};
return postInvoice({ baseURL, storeId, apiKey, body });
}
// Shared invoice POST. Both createInvoice (credits) + createTierInvoice
// (subscriptions) build their own body and hand it here.
async function postInvoice({ baseURL, storeId, apiKey, body }) {
const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `token ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
const text = await res.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {}
if (!res.ok) {
throw new BtcPayError(
res.status,
`BTCPay createInvoice ${res.status}: ${text?.slice(0, 300) || res.statusText}`,
parsed
);
}
return parsed;
}
export async function getInvoice({ baseURL, storeId, apiKey, invoiceId }) {
assertConfigured({ baseURL, storeId, apiKey });
const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices/${encodeURIComponent(invoiceId)}`;
const res = await fetch(url, {
headers: { Authorization: `token ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
const text = await res.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {}
if (!res.ok) {
throw new BtcPayError(
res.status,
`BTCPay getInvoice ${res.status}: ${text?.slice(0, 300) || res.statusText}`,
parsed
);
}
return parsed;
}
// Fetch the per-payment-method breakdown for an invoice. This is the
// endpoint that returns the actual BOLT11 invoice string + Lightning
// payment link, which we need to render an inline QR on the Recap
// side (Phase 1 of the inline-payment migration). Caller decides
// which paymentMethod to extract from the array — for the credits
// flow we only care about BTC-LightningNetwork.
//
// Note: BTCPay generates the Lightning invoice asynchronously on
// some store configurations, so there's a small window after
// createInvoice() where the LN destination may not yet be populated.
// Caller can retry once with a short backoff if needed, or fall
// back to the checkout_url for the buyer to use BTCPay's hosted page.
export async function getInvoicePaymentMethods({
baseURL,
storeId,
apiKey,
invoiceId,
}) {
assertConfigured({ baseURL, storeId, apiKey });
const url = `${trimSlash(baseURL)}/api/v1/stores/${encodeURIComponent(storeId)}/invoices/${encodeURIComponent(invoiceId)}/payment-methods`;
const res = await fetch(url, {
headers: { Authorization: `token ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
const text = await res.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {}
if (!res.ok) {
throw new BtcPayError(
res.status,
`BTCPay getInvoicePaymentMethods ${res.status}: ${text?.slice(0, 300) || res.statusText}`,
parsed
);
}
return Array.isArray(parsed) ? parsed : [];
}
// Helper: pick the Lightning BOLT11 + payment link out of a
// payment-methods array. Returns { bolt11, paymentLink } or null
// if no usable LN method exists (e.g., store doesn't have LN
// configured, or BTCPay hasn't generated it yet). The shape varies
// across BTCPay versions, particularly between 1.x and 2.x:
// • BTCPay 1.x uses `paymentMethod` with values like
// "BTC-LightningNetwork" or "BTC_LightningLike"
// • BTCPay 2.x uses `paymentMethodId` with values like
// "BTC-LN" (BOLT11-direct) or "BTC-LNURL" (LNURL-pay)
// We accept either field name. Among LN-ish entries we prefer
// BOLT11-direct over LNURL because every Lightning wallet supports
// BOLT11 but LNURL needs an LNURL-pay capable wallet (less common,
// and BTCPay's LNURL entry sometimes has a null destination until
// the buyer initiates a scan, which we can't render to a QR).
export function pickLightningFromPaymentMethods(methods) {
if (!Array.isArray(methods)) return null;
const candidates = methods.filter((m) => {
const id = String(m?.paymentMethodId || m?.paymentMethod || "");
return (
id === "BTC-LN" ||
id === "BTC-LightningNetwork" ||
id === "BTC_LightningLike" ||
/lightning/i.test(id) ||
/-LN$/i.test(id) ||
/-LNURL$/i.test(id)
);
});
// Prefer BOLT11-direct over LNURL. Stable sort: LNURL last,
// everything else preserves original order.
candidates.sort((a, b) => {
const aId = String(a?.paymentMethodId || a?.paymentMethod || "");
const bId = String(b?.paymentMethodId || b?.paymentMethod || "");
const aLnurl = /lnurl/i.test(aId) ? 1 : 0;
const bLnurl = /lnurl/i.test(bId) ? 1 : 0;
return aLnurl - bLnurl;
});
for (const m of candidates) {
const destination = String(m?.destination || "").trim();
if (!destination) continue; // skip empty (BTC-LNURL before scan)
if (!/^ln[a-z0-9]+$/i.test(destination)) continue; // must look like a bolt11
const paymentLink =
typeof m.paymentLink === "string" && m.paymentLink
? m.paymentLink
: `lightning:${destination}`;
return { bolt11: destination, paymentLink };
}
return null;
}
// Webhook signature validation. BTCPay sends `BTCPay-Sig: sha256=<hex>`
// where <hex> is HMAC-SHA256(rawBody, webhookSecret). We re-compute
// and constant-time-compare. Returns true on match, false otherwise.
//
// Caller is responsible for passing the RAW request body bytes (not
// the parsed JSON object) — Express's body-parser doesn't preserve
// the exact bytes so we register a raw-body capture for the webhook
// route specifically. See routes/credits.js.
export function validateWebhookSignature({ rawBody, signatureHeader, secret }) {
if (!secret) return false;
if (typeof signatureHeader !== "string") return false;
const m = signatureHeader.match(/^sha256\s*=\s*([0-9a-f]{64})$/i);
if (!m) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const provided = m[1].toLowerCase();
if (provided.length !== expected.length) return false;
try {
return crypto.timingSafeEqual(
Buffer.from(provided, "hex"),
Buffer.from(expected, "hex")
);
} catch {
return false;
}
}
function assertConfigured({ baseURL, storeId, apiKey }) {
if (!baseURL || !storeId || !apiKey) {
throw new Error(
"BTCPay is not configured — set base URL, store ID, and API key via the StartOS 'Set BTCPay Connection' action"
);
}
}
function trimSlash(s) {
return (s || "").replace(/\/$/, "");
}
+487 -59
View File
@@ -1,21 +1,40 @@
// Credit ledger keyed by install-id. JSON-file backed (single file at
// /data/credits.json). Write throughput is low — at most one mutation
// per relay request — so a plain JSON file with mutex-style serial
// writes is plenty. Swap to SQLite if a single relay starts seeing
// dozens of req/sec sustained.
// Credit ledger. JSON-file backed (single file at /data/credits.json).
// Write throughput is low — at most one mutation per relay request —
// so a plain JSON file with mutex-style serial writes is plenty. Swap
// to SQLite if a single relay starts seeing dozens of req/sec sustained.
//
// Per-install row shape:
// {
// install_id: "uuid",
// tier_snapshot: "core" | "pro" | "max", // last-seen tier
// lifetime_consumed: number, // total Core credits ever used
// lifetime_gemini_consumed: number, // Core credits served by Gemini
// last_renewal_at: ISO-8601 string, // start of current billing period
// monthly_consumed: number, // total this period (paid tiers)
// monthly_gemini_consumed: number, // Gemini-only this period
// last_active_at: ISO-8601 string,
// }
// ── Key model ────────────────────────────────────────────────────────
// Free-tier (Core) rows are keyed by install_id. Paid-tier (Pro / Max)
// rows are keyed by a stable fingerprint of the license key — so a
// single Pro license activated on two installs (e.g. cloud account
// AND self-hosted instance) drains the SAME monthly pool instead of
// getting two independent budgets.
//
// `getCreditKey({ installId, license })` resolves to:
// - `lic:<fingerprint>` when license.tier is "pro" or "max"
// - `inst:<installId>` otherwise (anonymous, invalid, or Core)
//
// Rows still carry `install_id` (last-seen install that touched them)
// for diagnostics, but the LEDGER KEY is what determines pool identity.
//
// ── Migration / backwards compatibility ──────────────────────────────
// Existing pre-refactor rows are keyed by bare install_id. We leave
// them in place — they continue to serve correctly for Core users
// (whose key is now `inst:<installId>`, which still matches the legacy
// bare-installId row because getOrCreateRow looks up by the resolved
// key first and falls back to the legacy installId only when no
// `inst:<...>` row exists yet — see lookupRow() below).
//
// Existing Pro/Max installs keep using their legacy installId-keyed
// row until they next interact with the relay AFTER the new build is
// live. The first such interaction will create a fresh `lic:<fp>`
// row; the old installId row continues to exist as orphaned ledger
// state. Self-heals within one billing cycle for licensed users.
// NO retroactive migration — operator policy is "tolerate the value
// leak for one month rather than risk a buggy bulk-migration on real
// customer balances".
//
// ── Billing-period anchor ────────────────────────────────────────────
// Billing periods are CALENDAR-ANNIVERSARY, not calendar-month. A user
// whose first paid request lands on the 17th of October renews on the
// 17th of every subsequent month — not the 1st. This matches how typical
@@ -27,12 +46,52 @@
import fs from "fs/promises";
import path from "path";
import crypto from "crypto";
let dataDir = "/data";
let ledgerPath = "/data/credits.json";
let ledger = { rows: {} };
let writing = null; // serializes concurrent writes
// ── License fingerprint helpers ─────────────────────────────────────
// Centralized hash so every caller (credits.js, job-credits.js,
// audit-log entries) derives the SAME identifier from the SAME license.
// 16 hex chars = 64 bits — plenty against collision and short enough
// to stay readable in log lines and admin-dashboard tables.
//
// The "raw key" we hash is the licenseUuid resolved by keysat-client
// when available, otherwise a stable stringified form of the resolved
// license object. licenseUuid is the field set by the offline verifier
// and is stable across reactivations and across machines — exactly
// what we want for a per-user pool identifier.
export function licenseFingerprint(license) {
if (!license) return null;
const seed = license.licenseUuid || license.license_uuid || null;
if (!seed) return null;
return crypto
.createHash("sha256")
.update(String(seed))
.digest("hex")
.slice(0, 16);
}
// Resolve the ledger-key for a given (install, license) pair. Paid
// tiers route to `lic:<fp>` so a single license activated on multiple
// installs shares ONE monthly pool. Anonymous / invalid / Core (and
// any paid case missing a fingerprint, e.g. licenseUuid couldn't be
// extracted) fall back to the install-scoped key `inst:<installId>`.
export function getCreditKey({ installId, license }) {
const tier = license?.tier || "core";
if (tier === "pro" || tier === "max") {
const fp = licenseFingerprint(license);
if (fp) return `lic:${fp}`;
}
if (!installId) {
throw new Error("getCreditKey: installId required (no license fingerprint either)");
}
return `inst:${installId}`;
}
export async function initCredits({ dataDir: dd }) {
if (dd) dataDir = dd;
ledgerPath = path.join(dataDir, "credits.json");
@@ -149,10 +208,17 @@ function ensureRenewalRollover(row) {
return rolled;
}
function blankRow(installId) {
function blankRow({ installId, license }) {
const now = new Date();
const fp = licenseFingerprint(license);
return {
install_id: installId,
// install_id captures the LAST install that touched this row.
// For `lic:<fp>` rows that's whichever install most recently
// committed against the license; for `inst:<installId>` rows it
// matches the key. Kept on the row for diagnostics / dashboard
// display — not used for ledger lookup.
install_id: installId || null,
license_fingerprint: fp,
tier_snapshot: "core",
lifetime_consumed: 0,
lifetime_gemini_consumed: 0,
@@ -161,9 +227,38 @@ function blankRow(installId) {
monthly_consumed: 0,
monthly_gemini_consumed: 0,
last_active_at: now.toISOString(),
// Top-up credits the user bought via BTCPay. Never expire (per
// operator policy). Consumed AFTER the tier allotment so the
// user always gets their monthly/lifetime allowance first.
purchased_balance: 0,
purchased_total_ever: 0,
};
}
// Look up a row by its resolved credit-key, with one back-compat fallback:
// when the key is `inst:<installId>` and no such row exists, check for
// a legacy row stored under bare `<installId>` (pre-refactor format).
// Returns { row, key } where `key` is the actual key under which the
// row lives in ledger.rows. Returns { row: null } when nothing was found.
//
// The fallback is one-directional only: we DO NOT promote a legacy
// install-keyed row to a `lic:<fp>` key just because the caller now
// has a license. That's intentional — see the migration note at the
// top of the file. A previously-Pro install that re-presents its
// license will silently start a fresh license-keyed pool; its old
// install-keyed row stays put with whatever monthly_consumed it had,
// usable again next time it falls back to Core.
function lookupRow(key) {
if (ledger.rows[key]) return { row: ledger.rows[key], key };
if (key.startsWith("inst:")) {
const bareInstall = key.slice("inst:".length);
if (ledger.rows[bareInstall]) {
return { row: ledger.rows[bareInstall], key: bareInstall };
}
}
return { row: null, key };
}
async function persist() {
// Coalesce concurrent writes — multiple in-flight mutations resolve
// against the same persisted snapshot in fifo order.
@@ -180,16 +275,94 @@ async function persist() {
}
}
// Returns the row for an install, creating + persisting a blank one
// if this is the first time we've seen it.
export async function getOrCreateRow(installId) {
if (!installId) throw new Error("getOrCreateRow: installId required");
let row = ledger.rows[installId];
// Returns the row for an (install, license) pair, creating + persisting
// a blank one if this is the first time we've seen its resolved
// credit-key. The credit-key is `lic:<fingerprint>` for paid tiers and
// `inst:<installId>` otherwise — so a single Pro license activated on
// two installs shares one row.
//
// When the credit-key is `inst:<installId>` and no row exists under
// that key, we fall back to a legacy bare-installId row (pre-refactor
// format) before creating a new one — keeps existing Core users on
// their existing balances without any retroactive migration. See the
// "Migration / backwards compatibility" comment at the top of the file.
//
// `creditKey` is an optional explicit override. It bypasses
// getCreditKey() and looks up directly under the supplied key. Used by
// job-credits.refundJob to route a refund to the SAME row a charge
// landed on even when the original license object isn't in scope
// anymore (e.g. after a relay restart, refund time only knows the
// stored fingerprint, not the raw licenseUuid).
export async function getOrCreateRow({
installId,
license,
creditKey = null,
} = {}) {
if (!installId && !license && !creditKey) {
throw new Error("getOrCreateRow: installId, license, or creditKey required");
}
const key = creditKey || getCreditKey({ installId, license });
let { row } = lookupRow(key);
let dirty = false;
if (!row) {
row = blankRow(installId);
ledger.rows[installId] = row;
row = blankRow({ installId, license });
// For explicit-creditKey rows whose license object isn't available
// (refund-after-restart), stamp the fingerprint extracted from the
// key itself onto the row so the dashboard surfaces it.
if (creditKey && key.startsWith("lic:") && !row.license_fingerprint) {
row.license_fingerprint = key.slice("lic:".length);
}
// When creating a fresh `lic:<fp>` row but an install row already
// exists for this installId, seed the new row's lifetime_consumed
// from the install row. Why: applyTierPromotion below treats this
// moment as a Core → Paid upgrade and transfers `coreQuota.lifetime
// - lifetime_consumed` as leftover bonus credits. If we left
// lifetime_consumed at 0, the user would get the FULL Core lifetime
// cap as bonus on top of their Pro monthly allotment — effectively
// double-credited (they already burned some of those Core credits
// on the install row before upgrading). Carrying over
// lifetime_consumed lines the math up so the leftover transfer
// reflects the REAL unused-Core balance at the moment of upgrade.
//
// Special case: when the install row's tier_snapshot is ALREADY
// paid (Pro/Max), this is a legacy-Pro user landing on a fresh
// license row post-refactor. They already received any Core-leftover
// transfer on the install row when they first upgraded; doing it
// again here would double-issue. We flag the new row by pre-flipping
// its tier_snapshot to the install row's snapshot — applyTierPromotion
// bails out when tier_snapshot is already non-Core, so the transfer
// skips. Purchased balance carries forward so any top-up credits
// the user had stay accessible on the new license-keyed row.
if (key.startsWith("lic:") && installId) {
const { row: installRow } = lookupRow(`inst:${installId}`);
if (installRow) {
const installAlreadyPaid =
installRow.tier_snapshot === "pro" ||
installRow.tier_snapshot === "max";
row.lifetime_consumed = installRow.lifetime_consumed || 0;
row.lifetime_gemini_consumed = installRow.lifetime_gemini_consumed || 0;
if (installAlreadyPaid) {
row.tier_snapshot = installRow.tier_snapshot;
row.purchased_balance = installRow.purchased_balance || 0;
row.purchased_total_ever = installRow.purchased_total_ever || 0;
}
}
}
ledger.rows[key] = row;
dirty = true;
} else {
// Keep the most recently seen install/fingerprint stamped on the
// row so the admin dashboard can show "which device of this user
// last touched this license pool" without trawling audit logs.
if (installId && row.install_id !== installId) {
row.install_id = installId;
dirty = true;
}
const fp = licenseFingerprint(license);
if (fp && row.license_fingerprint !== fp) {
row.license_fingerprint = fp;
dirty = true;
}
}
if (ensureRenewalRollover(row)) dirty = true;
if (dirty) await persist();
@@ -198,16 +371,26 @@ export async function getOrCreateRow(installId) {
// Compute the remaining balance for a row against its tier's quota.
// Returns:
// { remaining: number | null, capped: "lifetime" | "monthly" | "none", gemini_remaining: number | null }
// `null` for remaining means "unlimited" (Max tier total).
// `null` for gemini_remaining means "no Gemini cap on this tier" — the
// router can always pick Gemini.
// {
// remaining: number | null, // tier portion only; null = unlimited
// capped: "lifetime" | "monthly" | "none",
// gemini_remaining: number | null, // null = no Gemini cap on this tier
// purchased: number, // top-up credits the user bought via BTCPay
// total: number | null, // remaining + purchased; null = unlimited
// }
//
// Spend order is implemented by callers: tier portion is debited
// first (commitCredit increments lifetime_consumed / monthly_consumed);
// only when that hits zero do we touch purchased_balance. This keeps
// the user's purchased credits as a true durable top-up rather than
// crowding out the monthly allotment they're already entitled to.
export function computeRemaining(row, quota) {
const tier = row.tier_snapshot;
const tierQuota = quota[tier] || quota.core;
const purchased = Math.max(0, row.purchased_balance || 0);
if (tierQuota.lifetime != null) {
const remaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0));
const tierRemaining = Math.max(0, tierQuota.lifetime - (row.lifetime_consumed || 0));
// Core tier may carve out a portion of the lifetime budget for
// Gemini specifically (geminiCapLifetime). When set, remaining
// Gemini credits = cap - lifetime_gemini_consumed; the rest of
@@ -222,17 +405,19 @@ export function computeRemaining(row, quota) {
tierQuota.geminiCapLifetime - (row.lifetime_gemini_consumed || 0)
);
return {
remaining,
remaining: tierRemaining,
capped: "lifetime",
gemini_remaining: geminiRemaining,
purchased,
total: tierRemaining + purchased,
};
}
let remaining;
let tierRemaining;
if (tierQuota.monthly == null) {
remaining = null; // unlimited
tierRemaining = null; // unlimited
} else {
remaining = Math.max(0, tierQuota.monthly - (row.monthly_consumed || 0));
tierRemaining = Math.max(0, tierQuota.monthly - (row.monthly_consumed || 0));
}
const geminiRemaining =
tierQuota.geminiCapMonthly == null
@@ -240,9 +425,11 @@ export function computeRemaining(row, quota) {
: Math.max(0, tierQuota.geminiCapMonthly - (row.monthly_gemini_consumed || 0));
return {
remaining,
remaining: tierRemaining,
capped: "monthly",
gemini_remaining: geminiRemaining,
purchased,
total: tierRemaining == null ? null : tierRemaining + purchased,
};
}
@@ -270,13 +457,22 @@ export function computeRemaining(row, quota) {
export function planBackend(row, quota, { hasHardware, preference = "gemini_first" }) {
const balance = computeRemaining(row, quota);
// Out of credits entirely?
if (balance.remaining === 0) {
// Out of credits entirely? Tier allotment exhausted AND no purchased
// top-up remaining. (balance.total === null means unlimited.)
if (balance.total === 0) {
return { allowed: false, backend: null, reason: "out_of_credits" };
}
// Gemini availability has two paths: either the tier's Gemini-cap
// portion has headroom (gemini_remaining > 0 or null) OR the user
// has purchased top-up credits. Purchased credits bypass the per-
// tier Gemini cap because the operator has already been paid for
// those calls — the cap exists to bound free/comped Gemini spend,
// not paid-for spend.
const geminiAvailable =
balance.gemini_remaining === null || balance.gemini_remaining > 0;
balance.gemini_remaining === null ||
balance.gemini_remaining > 0 ||
balance.purchased > 0;
switch (preference) {
case "hardware_only":
@@ -339,34 +535,266 @@ export function planBackend(row, quota, { hasHardware, preference = "gemini_firs
// upgrades on the 17th gets renewals on the 17th going forward, not
// at some earlier date that happens to be when their install_id was
// first seen.
export async function commitCredit(installId, { backend, tier }) {
const row = await getOrCreateRow(installId);
const wasCorePromotion =
tier !== "core" && row.tier_snapshot === "core";
row.tier_snapshot = tier;
if (wasCorePromotion) {
const now = new Date();
row.last_renewal_at = now.toISOString();
row.anniversary_day = now.getUTCDate();
row.monthly_consumed = 0;
row.monthly_gemini_consumed = 0;
}
// Inverse of commitCredit — returns one charged credit back to the
// install's ledger when the work that consumed it ended up failing.
// Mirrors commitCredit field-by-field so the same row that was
// incremented gets decremented; floors at 0 so we never accidentally
// hand a user negative consumption from a buggy refund sequence.
//
// Called from the route handlers via job-credits.refundJob when a
// backend call fails after the credit was already charged (typical
// case: transcribe succeeded + committed, analyze failed, so the
// job's credit needs to be returned because the summary didn't
// actually complete).
export async function refundCredit({
installId,
license,
creditKey = null,
backend,
tier,
}) {
const row = await getOrCreateRow({ installId, license, creditKey });
// Mirror commitCredit's spend order: tier bucket gets refunded
// first (which is where the credit was charged); only if the tier
// counter is already at zero do we credit back to purchased_balance
// (which means the original commit came out of the top-up bucket).
if (tier === "core") {
row.lifetime_consumed = (row.lifetime_consumed || 0) + 1;
if (backend === "gemini") {
row.lifetime_gemini_consumed = (row.lifetime_gemini_consumed || 0) + 1;
if ((row.lifetime_consumed || 0) > 0) {
row.lifetime_consumed -= 1;
if (backend === "gemini" && (row.lifetime_gemini_consumed || 0) > 0) {
row.lifetime_gemini_consumed -= 1;
}
} else {
row.purchased_balance = (row.purchased_balance || 0) + 1;
}
} else {
row.monthly_consumed = (row.monthly_consumed || 0) + 1;
if (backend === "gemini") {
row.monthly_gemini_consumed = (row.monthly_gemini_consumed || 0) + 1;
if ((row.monthly_consumed || 0) > 0) {
row.monthly_consumed -= 1;
if (backend === "gemini" && (row.monthly_gemini_consumed || 0) > 0) {
row.monthly_gemini_consumed -= 1;
}
} else {
row.purchased_balance = (row.purchased_balance || 0) + 1;
}
}
row.last_active_at = new Date().toISOString();
await persist();
}
// For the admin dashboard.
export function snapshotAll() {
return Object.values(ledger.rows).map((r) => ({ ...r }));
// Loads the quota for the install's tier so we can decide whether
// to debit the tier portion or the purchased top-up portion. Imported
// lazily to avoid a circular dep with config.js → credits.js.
async function getCommitQuota(tier) {
const { getTierQuotas } = await import("./config.js");
const all = await getTierQuotas();
return all[tier] || all.core;
}
// Apply the Core → paid-tier promotion bookkeeping in a single place.
// Idempotent: only fires the FIRST time we see a paid tier on a row
// whose tier_snapshot is still "core". On promotion we:
// - Anchor the user's billing-anniversary to right now so monthly
// renewals line up with the upgrade moment (not their install
// creation date).
// - Zero out monthly counters so the user gets their full first
// month, even if they made it past the Core lifetime cap by
// burning some monthly counter earlier.
// - Transfer any UNUSED Core lifetime credits into purchased_balance.
// This way the 6 leftover credits a Core user had don't vanish on
// upgrade — they stack on top of the paid tier's monthly allotment
// as durable bonus credit. Total after upgrade = monthly cap +
// leftover Core credits + any prior top-up purchases.
// - Flip tier_snapshot to the new tier last so the spend-order check
// below routes the next debit to the right bucket.
//
// Mutates `row` in place AND persists the ledger when a promotion
// fires — so the leftover transfer survives a relay restart even if
// the calling route doesn't otherwise persist (the /relay/balance
// route, for example, mutates tier_snapshot in memory without a
// follow-up persist).
//
// Returns true if a promotion was applied, false otherwise.
export async function applyTierPromotion(row, newTier) {
if (newTier === "core") return false;
if (row.tier_snapshot !== "core") return false;
// Compute leftover Core credits BEFORE we flip tier_snapshot. If
// Core's lifetime cap isn't set (unlimited), there's nothing to
// transfer — the user already had unlimited.
const coreQuota = await getCommitQuota("core");
let transferred = 0;
if (typeof coreQuota.lifetime === "number" && coreQuota.lifetime > 0) {
transferred = Math.max(
0,
coreQuota.lifetime - (row.lifetime_consumed || 0)
);
if (transferred > 0) {
row.purchased_balance = (row.purchased_balance || 0) + transferred;
row.purchased_total_ever =
(row.purchased_total_ever || 0) + transferred;
}
}
const now = new Date();
row.last_renewal_at = now.toISOString();
row.anniversary_day = now.getUTCDate();
row.monthly_consumed = 0;
row.monthly_gemini_consumed = 0;
row.tier_snapshot = newTier;
row.last_active_at = now.toISOString();
await persist();
if (transferred > 0) {
console.log(
`[credits] tier promotion core → ${newTier} for ${row.install_id || row.license_fingerprint || "(unknown)"}: ` +
`transferred ${transferred} leftover Core credit(s) to purchased_balance ` +
`(now ${row.purchased_balance})`
);
}
return true;
}
export async function commitCredit({ installId, license, creditKey = null, backend, tier }) {
const row = await getOrCreateRow({ installId, license, creditKey });
const promoted = await applyTierPromotion(row, tier);
// If no promotion fired, applyTierPromotion left tier_snapshot
// untouched (it only flips on Core → paid). Still want to keep the
// snapshot current for paid → paid moves (Pro → Max, etc.) so the
// ledger reflects the most recent license tier seen.
if (!promoted) {
row.tier_snapshot = tier;
}
// Spend order: tier allotment first, purchased top-up second.
// Figure out whether THIS credit comes out of the tier bucket or
// the purchased bucket by checking remaining tier headroom against
// the current quota.
const tierQuota = await getCommitQuota(tier);
let tierHasRoom = false;
if (tier === "core") {
tierHasRoom =
tierQuota.lifetime == null ||
(row.lifetime_consumed || 0) < tierQuota.lifetime;
} else {
tierHasRoom =
tierQuota.monthly == null ||
(row.monthly_consumed || 0) < tierQuota.monthly;
}
if (tierHasRoom) {
if (tier === "core") {
row.lifetime_consumed = (row.lifetime_consumed || 0) + 1;
if (backend === "gemini") {
row.lifetime_gemini_consumed = (row.lifetime_gemini_consumed || 0) + 1;
}
} else {
row.monthly_consumed = (row.monthly_consumed || 0) + 1;
if (backend === "gemini") {
row.monthly_gemini_consumed = (row.monthly_gemini_consumed || 0) + 1;
}
}
} else {
// Tier allotment exhausted — debit the purchased top-up. Capped
// at zero so a refundCredit miss can't bring this negative.
row.purchased_balance = Math.max(0, (row.purchased_balance || 0) - 1);
}
row.last_active_at = new Date().toISOString();
await persist();
}
// Add purchased credits to the install's top-up bucket. Used by the
// BTCPay webhook after a successful invoice settlement. Idempotent
// at the webhook layer via processed-invoice tracking (the webhook
// handler dedupes by invoice_id before calling this).
// Purchased credits land on whichever row backs the buying install at
// the time of purchase. The caller passes (installId, license, creditKey),
// in priority order: an explicit creditKey wins, otherwise the resolved
// (installId, license) decides.
//
// Why creditKey is accepted as an explicit override: the BTCPay webhook
// re-enters this path AFTER a restart, with only invoice metadata in
// hand (install_id + license_fingerprint stashed at buy time, no live
// license object). The webhook constructs `lic:<fp>` from the stored
// fingerprint and passes it as creditKey so the credit lands on the
// SAME pool the buyer was looking at when they minted the invoice.
//
// Anonymous / Core buyers (no fingerprint stashed) fall through to the
// install-keyed row — the credit follows the install. Once they
// upgrade to Pro/Max later, applyTierPromotion transfers any leftover
// Core tier credits to purchased_balance — see commitCredit's path.
export async function addPurchasedCredits({
installId,
license = null,
creditKey = null,
amount,
}) {
if (!Number.isFinite(amount) || amount <= 0) return null;
const row = await getOrCreateRow({ installId, license, creditKey });
row.purchased_balance = (row.purchased_balance || 0) + amount;
row.purchased_total_ever = (row.purchased_total_ever || 0) + amount;
row.last_active_at = new Date().toISOString();
await persist();
return row.purchased_balance;
}
// ── Cloud user tier (core-decoupling) ───────────────────────────────
// The relay is the source of truth for a cloud user's Pro/Max tier,
// stored on the user's credit row (keyed `user:<id>`). Set by the
// operator (today) and the self-serve purchase flow (later slice).
// Operator-set a cloud user's tier. Resets the monthly counters and
// anchors the renewal to now (so the monthly cycle starts on the grant
// date), mirroring applyTierPromotion. `expiresAt` is stored for
// reporting / future self-serve billing but NOT auto-enforced in this
// slice — to revoke, set tier back to "core".
export async function setUserTier({ userId, tier, expiresAt = null }) {
if (!userId) throw new Error("setUserTier: userId required");
const t = tier === "pro" || tier === "max" ? tier : "core";
const row = await getOrCreateRow({ creditKey: `user:${userId}` });
const now = new Date();
row.tier_snapshot = t;
row.monthly_consumed = 0;
row.monthly_gemini_consumed = 0;
row.last_renewal_at = now.toISOString();
row.anniversary_day = now.getUTCDate();
row.subscription_expires_at = expiresAt || null;
row.last_active_at = now.toISOString();
await persist();
return row;
}
// Buy / extend a PREPAID PERIOD of `tier` (self-serve subscriptions). The
// new expiry extends from whichever is later — now, or the user's current
// (still-active) expiry — so paying early ADDS time rather than resetting
// it. `periodDays` defaults to 30. Both payment rails (BTCPay + Zaprite)
// land here on a settled payment. Returns the updated row.
export async function extendUserTier({ userId, tier, periodDays = 30 }) {
if (!userId) throw new Error("extendUserTier: userId required");
const t = tier === "pro" || tier === "max" ? tier : "core";
const now = Date.now();
const row = await getOrCreateRow({ creditKey: `user:${userId}` });
const curExp = row.subscription_expires_at
? new Date(row.subscription_expires_at).getTime()
: 0;
const base = Math.max(now, Number.isFinite(curExp) ? curExp : 0);
const expiresAt = new Date(
base + periodDays * 24 * 60 * 60 * 1000,
).toISOString();
return setUserTier({ userId, tier: t, expiresAt });
}
// Read a cloud user's credit row (creates a blank Core row if none yet).
export async function getUserCreditRow(userId) {
if (!userId) throw new Error("getUserCreditRow: userId required");
return getOrCreateRow({ creditKey: `user:${userId}` });
}
// For the admin dashboard. Includes the ledger-key (`credit_key`) so
// the dashboard can render "license pool" vs "install pool" rows
// distinctly — license-keyed rows aggregate spend across every install
// that uses the same license, install-keyed rows aggregate one install.
export function snapshotAll() {
return Object.entries(ledger.rows).map(([credit_key, r]) => ({
credit_key,
...r,
}));
}
+153 -25
View File
@@ -1,58 +1,186 @@
// Job-id deduplication. Recap mints a UUID per summarize job (the
// transcribe + analyze pair) and sends it in X-Recap-Job-Id on every
// relay call. The first call with a given (install_id, job_id) tuple
// relay call. The first call with a given (creditKey, job_id) tuple
// reserves a credit; subsequent calls with the same tuple are free
// until the job_id expires (1 hour).
//
// Stored in-memory only — not persisted across restarts because (a)
// a restart breaks all in-flight Recap streams anyway and (b) the
// worst-case outcome of a "lost reservation" is the user being
// charged for a single retry, which is acceptable.
// Keyed by creditKey (`lic:<fp>` for paid tiers, `inst:<installId>`
// otherwise) — the SAME key the credits.js ledger uses — so a
// transcribe that landed on the cloud account's install can be
// recognized + refunded by a follow-up analyze landing from the
// self-hosted install of the same license. The credit-key plumbing
// is what unifies them; the job-id is the dedup grain.
//
// Persisted to disk at /data/jobs.json so the refund logic survives
// container restarts. The earlier in-memory-only version had a bug:
// when transcribe charged a credit (marking the job in memory), the
// relay restarted, and then analyze tried to refund the failed call,
// `lookupJob` returned null (memory wiped) and refundJob did
// nothing. Credits stuck on the ledger. Disk persistence fixes that
// — a restart-and-resume operator-side never loses refund state.
import fs from "fs/promises";
import path from "path";
import { refundCredit, getCreditKey } from "./credits.js";
const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
// Map<install_id|job_id, { backend, tier, charged_at, refunded }>
// Map<creditKey|job_id, { backend, tier, install_id, license_fingerprint, charged_at, refunded }>
// install_id + license_fingerprint are stored alongside the credit-key
// so refundCredit can route the refund to the SAME ledger row that
// commitCredit charged — getOrCreateRow needs license context to
// resolve a `lic:<fp>` row.
const jobs = new Map();
let dataDir = "/data";
let jobsPath = "/data/jobs.json";
let writing = null; // serializes concurrent writes
function key(installId, jobId) {
return `${installId}|${jobId}`;
function key(creditKey, jobId) {
return `${creditKey}|${jobId}`;
}
// On a new request: returns { charged: true } if this is the first call
// for the job (caller must commit a credit), or { charged: false,
// backend, tier } if it's a retry/follow-up.
export function lookupJob(installId, jobId) {
if (!installId || !jobId) return null;
// Boot-time load. Called from server/index.js before any route hits.
// If the file is missing or corrupt, start empty — same effective
// state as a fresh install. Expired entries are pruned during load.
export async function initJobCredits({ dataDir: dd } = {}) {
if (dd) dataDir = dd;
jobsPath = path.join(dataDir, "jobs.json");
try {
const raw = await fs.readFile(jobsPath, "utf8");
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.entries)) {
const cutoff = Date.now() - JOB_TTL_MS;
for (const entry of parsed.entries) {
if (
entry &&
typeof entry.key === "string" &&
typeof entry.charged_at === "number" &&
entry.charged_at >= cutoff
) {
const { key: k, ...rest } = entry;
jobs.set(k, rest);
}
}
console.log(`[job-credits] loaded ${jobs.size} jobs from ${jobsPath}`);
}
} catch (err) {
if (err.code !== "ENOENT") {
console.warn(`[job-credits] failed to read ${jobsPath}: ${err?.message || err}`);
}
}
}
async function persist() {
// Coalesce concurrent writes — same pattern as credits.js. Each
// mutation triggers a serialized write; outstanding writes
// resolve against the latest in-memory snapshot.
if (writing) await writing;
writing = (async () => {
const entries = [];
pruneExpired();
for (const [k, v] of jobs) {
entries.push({ key: k, ...v });
}
const tmp = jobsPath + ".tmp";
await fs.writeFile(tmp, JSON.stringify({ entries }), { mode: 0o600 });
await fs.rename(tmp, jobsPath);
})();
try {
await writing;
} finally {
writing = null;
}
}
// On a new request: returns existing reservation (caller must NOT
// double-charge) or null (caller should commit a credit). The caller
// passes the same (installId, license) pair it would pass to credits.js
// — we resolve to the credit-key internally so the dedup grain matches
// the ledger key.
export function lookupJob({ installId, license, creditKey = null, jobId }) {
if (!jobId) return null;
pruneExpired();
const k = key(installId, jobId);
const ck = creditKey || getCreditKey({ installId, license });
const k = key(ck, jobId);
const existing = jobs.get(k);
if (existing && !existing.refunded) return existing;
return null;
}
// Mark a job as having been charged. Idempotent — second call for the
// same (install_id, job_id) is a no-op.
export function markJobCharged(installId, jobId, { backend, tier }) {
if (!installId || !jobId) return;
// same (creditKey, job_id) is a no-op. Stores enough context on the
// reservation that a later refundJob can reconstruct the same credit-key
// (and therefore find the same ledger row) without the caller needing
// to repeat the license.
export async function markJobCharged({ installId, license, creditKey = null, jobId, backend, tier }) {
if (!jobId) return;
pruneExpired();
const k = key(installId, jobId);
const ck = creditKey || getCreditKey({ installId, license });
const k = key(ck, jobId);
if (jobs.has(k) && !jobs.get(k).refunded) return;
// Pull the license fingerprint off the credit-key so refund time
// doesn't need to recompute it (we'd no longer have the license
// object in scope on a restart-resume refund).
const license_fingerprint =
ck.startsWith("lic:") ? ck.slice("lic:".length) : null;
jobs.set(k, {
backend,
tier,
install_id: installId || null,
license_fingerprint,
charged_at: Date.now(),
refunded: false,
});
try {
await persist();
} catch (err) {
console.error(`[job-credits] persist failed after mark: ${err?.message || err}`);
}
}
// Refund a previously charged credit for a failed job. Future calls
// with the same job_id will be treated as new (since the reservation
// is no longer valid).
export function refundJob(installId, jobId) {
if (!installId || !jobId) return;
const k = key(installId, jobId);
// Refund a previously charged credit for a failed job. Returns the
// credit to the persistent ledger AND marks the in-memory job record
// as refunded so a subsequent same-job_id call is treated as new.
//
// Idempotent: refunding an already-refunded job is a no-op, so call
// sites can fire-and-forget on every error path without needing to
// track whether they were the FIRST error path to refund.
//
// The refund routes back to the SAME row that was charged because we
// stored the credit-key components (install_id + license_fingerprint)
// at mark time — refundCredit reconstructs the key via getOrCreateRow,
// which in turn calls getCreditKey on the same inputs.
export async function refundJob({ installId, license, creditKey = null, jobId }) {
if (!jobId) return;
const ck = creditKey || getCreditKey({ installId, license });
const k = key(ck, jobId);
const existing = jobs.get(k);
if (existing) existing.refunded = true;
if (!existing || existing.refunded) return;
existing.refunded = true;
try {
// Route the refund back to the SAME row that was charged. The
// credit-key was computed at mark time from the same (installId,
// license) the caller is passing now — `ck` here equals the
// mark-time key whenever the caller is consistent. Pass it as an
// explicit creditKey override so refundCredit doesn't need to
// re-derive it from a license object the caller might not have
// (e.g. some error paths refund without re-resolving the license).
await refundCredit({
installId: existing.install_id || installId || null,
creditKey: ck,
backend: existing.backend,
tier: existing.tier,
});
} catch (err) {
console.error(
`[job-credits] refundCredit failed for ${ck}|${jobId}: ${err?.message || err}`
);
}
try {
await persist();
} catch (err) {
console.error(`[job-credits] persist failed after refund: ${err?.message || err}`);
}
}
function pruneExpired() {
+11 -6
View File
@@ -11,15 +11,20 @@
// margin math — Google has been known to adjust prices ~quarterly.
export const GEMINI_PRICING = {
// Pro family — best for analysis.
"gemini-3.1-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 },
"gemini-3-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 },
// The five supported models, verified against Google's official
// docs (ai.google.dev/gemini-api/docs/models) on 2026-05-12.
// Retired model IDs (gemini-3-pro-preview shut down 2026-03-09,
// gemini-2.0-flash deprecated) intentionally omitted — they should
// never appear in cost calc here for current calls.
// Flash family — best speed/cost for transcription, common for
// analysis when sub-Pro quality is acceptable.
// Pro tier — best for analysis.
"gemini-3.1-pro-preview": { input: 5.0, output: 25.0, thinking: 25.0 },
"gemini-2.5-pro": { input: 1.25, output: 10.0, thinking: 10.0 },
// Flash tier — best speed/cost for transcription + cheap analysis.
"gemini-3-flash-preview": { input: 0.3, output: 2.5, thinking: 2.5 },
"gemini-2.5-flash": { input: 0.3, output: 2.5, thinking: 2.5 },
"gemini-2.0-flash": { input: 0.1, output: 0.4, thinking: 0.4 },
"gemini-3.1-flash-lite": { input: 0.1, output: 0.4, thinking: 0.4 },
// Fallback used when an unknown model id appears (e.g. operator
// typed a custom model name in setBackendRouting). Conservative —
+43 -26
View File
@@ -1,47 +1,64 @@
// GET /relay/balance — peek at the current install's credit balance +
// tier WITHOUT charging anything. Recap clients call this to populate
// the "N credits remaining · Tier: X" banner before the user runs any
// transcribe/analyze, so the display is accurate on first load instead
// of saying "balance unknown — no relay calls yet".
// GET /relay/balance — peek at the caller's credit balance + tier WITHOUT
// charging anything. Recap clients call this to populate the
// "N credits remaining · Tier: X" banner.
//
// Same auth surface as the metered endpoints:
// X-Recap-Install-Id (required)
// Authorization (optional Bearer LIC1-... — absent = Core)
// Two auth surfaces (see identity.js):
// - cloud: X-Recap-User-Id + X-Recap-Operator-Key → user:<id> pool,
// tier is the relay's stored (operator-set) value.
// - license: X-Recap-Install-Id (+ optional Bearer license) → legacy
// license/install pool, tier from the license.
//
// Returns the standard envelope shape with result=null and
// credit_charged=0. The license-resolution path is identical to
// /relay/transcribe and /relay/analyze, so the cached online check
// against keysat happens here too — but no row mutation, no job-id
// reservation, no upstream backend call.
// Returns the standard envelope (result=null, credit_charged=0). No row
// mutation beyond keeping tier_snapshot synced on the license path.
import express from "express";
import { resolveLicense } from "../keysat-client.js";
import { getOrCreateRow } from "../credits.js";
import { resolveIdentity, identityTier } from "../identity.js";
import { getOrCreateRow, applyTierPromotion } from "../credits.js";
import { envelope, errorEnvelope } from "./envelope.js";
export function balanceRouter() {
const router = express.Router();
router.get("/balance", async (req, res) => {
const installId = req.header("X-Recap-Install-Id");
const auth = req.header("Authorization");
if (!installId) {
let identity;
try {
identity = await resolveIdentity(req);
} catch (err) {
const e = await errorEnvelope({
error: err?.message || "auth_error",
statusHint: err?.status || 401,
});
return res.status(e.statusHint || 401).json(e.body);
}
if (identity.kind === "license" && !identity.installId) {
const e = await errorEnvelope({
error: "missing X-Recap-Install-Id header",
statusHint: 400,
});
return res.status(400).json(e.body);
}
const license = await resolveLicense(auth);
const tier = license.tier;
// Touch the row so tier_snapshot reflects the most recently seen
// license tier — same as the metered endpoints do — but commit
// nothing.
const row = await getOrCreateRow(installId);
row.tier_snapshot = tier;
const row = await getOrCreateRow({
creditKey: identity.creditKey,
installId: identity.installId,
license: identity.license,
});
// License path: fire the Core→paid promotion bookkeeping (this is
// typically the FIRST relay call after a license activation) and keep
// tier_snapshot synced to the license. Cloud path: the tier is the
// relay's stored, operator-set value — leave it untouched.
if (identity.kind === "license") {
const tier = identity.license.tier;
const promoted = await applyTierPromotion(row, tier);
if (!promoted) row.tier_snapshot = tier;
}
const tier = identityTier(identity, row);
const body = await envelope({
result: null,
installId,
creditKey: identity.creditKey,
installId: identity.installId,
license: identity.license,
tier,
creditCharged: 0,
});
+626
View File
@@ -0,0 +1,626 @@
// One-click BTCPay setup. Three admin endpoints + a tiny standalone
// HTML page that drives the flow:
//
// POST /admin/btcpay/start — generates an authorize URL
// the operator opens in a
// browser, with all the
// right scopes pre-checked.
// Body: { btcpay_url }
// Returns: { authorize_url }
//
// POST /admin/btcpay/callback — receives the API key from
// BTCPay after the operator
// approves. Body comes from
// BTCPay's POST redirect.
// Stores key in config, returns
// { stores: [...] } so the
// operator can pick which
// store to use.
//
// POST /admin/btcpay/finalize — sets the chosen store, then
// creates the webhook
// automatically. Body:
// { store_id, public_relay_url }
// Stores webhook secret in
// config too. Returns
// { ok: true, webhook_url }.
//
// This replaces the 4-step manual setup (generate API key in BTCPay,
// generate webhook + copy secret, copy store ID, paste all four into
// Set BTCPay Connection action) with: paste BTCPay URL → click
// approve → pick store → done.
import express from "express";
import crypto from "crypto";
import fs from "fs/promises";
import path from "path";
import { getConfigSnapshot } from "../config.js";
import { rescanSettledInvoices } from "./credits.js";
export function btcpaySetupRouter({ dataDir }) {
const router = express.Router();
// Step 1: build the authorize URL. The operator hits this from the
// setup page (or by calling it directly via curl) with their
// BTCPay base URL, and we return the URL they should open in a
// browser to grant Recap Relay the permissions it needs.
//
// BTCPay's API Key Authorization Flow:
// GET /api-keys/authorize
// ?permissions=btcpay.store.cancreateinvoice
// &permissions=btcpay.store.canviewinvoices
// &permissions=btcpay.store.webhooks.canmodifywebhooks
// &applicationName=Recap+Relay
// &applicationIdentifier=recap-relay
// &strict=true — buyer can't tweak the scopes we asked
// &selectiveStores=true — let buyer pick the target store
// &redirect=<our callback> (POST redirect, not GET)
router.post("/start", express.json(), async (req, res) => {
const btcpayUrl = (req.body?.btcpay_url || "").trim();
if (!btcpayUrl || !/^https?:\/\//i.test(btcpayUrl)) {
return res.status(400).json({
error: "btcpay_url_required",
message: "Provide btcpay_url (e.g. https://btcpay.keysat.xyz).",
});
}
// If BTCPay is co-installed on the same Start9 box, the setup
// discovery file gives us the internal hostname (resolvable from
// inside this container) — needed because the browser-facing
// URL (mDNS .local or clearnet) often isn't resolvable from
// inside the docker network. We persist BOTH so the callback +
// finalize phases know which URL to use for server-to-server
// BTCPay API calls.
const internalUrl = await readDiscoveredInternalUrl(dataDir);
const publicUrl = await readDiscoveredPublicUrl(dataDir);
const state = crypto.randomBytes(24).toString("hex");
const baseRelayUrl = absoluteRelayUrl(req);
const callbackUrl = `${baseRelayUrl}/admin/btcpay/callback?state=${state}`;
// Permissions mirror Keysat's working set. Specifically:
// - canviewstoresettings: required to list stores (we call
// /api/v1/stores in the picker page)
// - canmodifystoresettings: required to register the webhook
// (BTCPay treats webhook management as a store setting, not
// a separate scope)
// - canviewinvoices / cancreateinvoice / canmodifyinvoices:
// the actual credit-purchase flow
const params = new URLSearchParams();
params.append("permissions", "btcpay.store.canviewstoresettings");
params.append("permissions", "btcpay.store.canmodifystoresettings");
params.append("permissions", "btcpay.store.canviewinvoices");
params.append("permissions", "btcpay.store.cancreateinvoice");
params.append("permissions", "btcpay.store.canmodifyinvoices");
params.append("applicationName", "Recap Relay");
params.append("applicationIdentifier", "recap-relay");
params.append("strict", "true");
params.append("selectiveStores", "true");
params.append("redirect", callbackUrl);
const authorizeUrl =
btcpayUrl.replace(/\/$/, "") + "/api-keys/authorize?" + params.toString();
await stashSetupContext(dataDir, {
btcpay_url: btcpayUrl,
btcpay_internal_url: internalUrl,
btcpay_public_url: publicUrl,
state,
});
res.json({ authorize_url: authorizeUrl, callback_url: callbackUrl });
});
// Step 2: BTCPay POST-redirects the operator's browser back here
// with the API key + permissions in the body. Body shape:
// { apiKey: "...", permissions: ["btcpay.store.X:STOREID", ...] }
//
// We persist the key into config, look up the store list, and
// return it so the operator can pick which store to use.
router.post(
"/callback",
express.urlencoded({ extended: true }),
express.json(),
async (req, res) => {
const body = req.body || {};
const apiKey =
body.apiKey ||
body.api_key ||
(typeof body === "string" ? body : null);
const permissions = Array.isArray(body.permissions)
? body.permissions
: [];
if (!apiKey || typeof apiKey !== "string") {
// Show a plain HTML response since this is a browser redirect.
return res.status(400).send(html("Authorization failed", `
<p>BTCPay didn't return an API key — likely you clicked Deny,
or your BTCPay version doesn't support the authorization flow.
You can still set up manually via the StartOS action.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
const ctx = await readSetupContext(dataDir);
const btcpayUrl = ctx?.btcpay_url;
const expectedState = ctx?.state;
const providedState = (req.query?.state || "").toString();
if (!btcpayUrl || !expectedState) {
return res.status(400).send(html("Setup context lost", `
<p>No BTCPay URL is on file — re-start the setup from the
dashboard.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
if (
!providedState ||
providedState.length !== expectedState.length ||
!crypto.timingSafeEqual(
Buffer.from(providedState),
Buffer.from(expectedState)
)
) {
return res.status(403).send(html("Bad state token", `
<p>The authorization callback didn't carry a matching state
token. This usually means someone else's setup is in flight,
or the link was tampered with. Re-start setup from the
dashboard.</p>
<p><a href="/dashboard.html">Back to dashboard</a></p>
`));
}
// Preserve the fields /start stashed (especially
// btcpay_internal_url), don't overwrite the whole object.
// Without the merge, the picker page's /stores call falls back
// to btcpay_url (mDNS) which can't resolve inside the docker
// container → "getaddrinfo ENOTFOUND".
const mergedCtx = {
...(ctx || {}),
btcpay_url: btcpayUrl,
api_key: apiKey,
permissions,
};
await stashSetupContext(dataDir, mergedCtx);
// ── Auto-finalize when the operator authorized for exactly
// one store ──
// BTCPay's authorize page already has a built-in store picker
// when we pass selectiveStores=true. The granted permissions
// encode the chosen store ID(s) in `btcpay.store.X:STOREID`
// suffixes. When only one store was selected, our second
// picker is redundant — skip straight to finalize.
const storeIds = extractStoreIdsFromPermissions(permissions);
if (storeIds.length === 1) {
const baseRelayUrl = absoluteRelayUrl(req);
const result = await finalizeBtcpay({
ctx: mergedCtx,
storeId: storeIds[0],
dataDir,
baseRelayUrl,
});
if (result.ok) {
return res.send(html("Connected", `
<h1 style="color:#86efac;">✓ BTCPay connected successfully.</h1>
<p>You can close this tab and return to the Recap Relay
dashboard. The webhook is wired up automatically and
users can now top up their credit balance via Lightning.</p>
`));
}
// Auto-finalize failed (e.g. webhook creation hiccup). Fall
// through to the manual picker so the operator can retry.
console.warn(
`[btcpay/callback] auto-finalize failed for sole store ${storeIds[0]}: ${result.error} ${result.message?.slice(0, 200)}`
);
}
// Hand the operator a tiny picker page that calls back into
// /admin/btcpay/finalize once they pick.
return res.send(html("Pick your store", `
<h1>Pick your Recap store</h1>
<p>Authorization successful. One last step — pick which BTCPay
store invoices should be created against, and the relay will
finish wiring everything up (including the webhook) for you.</p>
<div id="status">Loading stores…</div>
<div id="stores"></div>
<script>
(async () => {
const r = await fetch("/admin/btcpay/stores");
const data = await r.json().catch(() => ({}));
if (!r.ok || !Array.isArray(data.stores)) {
document.getElementById("status").innerText =
"Couldn't list stores: " + (data.message || data.error || r.status);
return;
}
const list = document.getElementById("stores");
data.stores.forEach((s) => {
const btn = document.createElement("button");
btn.textContent = s.name + " (" + s.id.slice(0, 8) + "…)";
btn.style.cssText =
"display:block;margin:8px 0;padding:10px 14px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;border-radius:8px;cursor:pointer;font-size:13px;width:100%;text-align:left;";
btn.onclick = async () => {
btn.disabled = true;
btn.textContent = "Setting up webhook…";
const r2 = await fetch("/admin/btcpay/finalize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ store_id: s.id }),
});
const data2 = await r2.json().catch(() => ({}));
if (!r2.ok) {
document.getElementById("status").innerHTML =
'<span style="color:#fca5a5;">Setup failed: ' +
(data2.message || data2.error || r2.status) + '</span>';
btn.disabled = false;
btn.textContent = s.name + " (" + s.id.slice(0, 8) + "…)";
return;
}
document.getElementById("status").innerHTML =
'<span style="color:#86efac;">✓ Connected to ' +
s.name + ' and webhook auto-created. You can close this tab.</span>';
document.getElementById("stores").style.display = "none";
};
list.appendChild(btn);
});
document.getElementById("status").innerText = "Pick a store:";
})();
</script>
`));
}
);
// Lists stores reachable with the API key from setup context. The
// store-picker page above calls this.
router.get("/stores", async (_req, res) => {
const ctx = await readSetupContext(dataDir);
if (!ctx?.btcpay_url || !ctx?.api_key) {
console.warn("[btcpay/stores] setup_context_missing — operator may need to restart connect flow");
return res
.status(400)
.json({ error: "setup_context_missing" });
}
// Server-to-server: prefer internal hostname when discovered
// (mDNS .local doesn't resolve inside docker; clearnet works but
// takes the long way round).
const apiBase = (ctx.btcpay_internal_url || ctx.btcpay_url).replace(/\/$/, "");
const url = `${apiBase}/api/v1/stores`;
console.log(`[btcpay/stores] GET ${url}`);
try {
const r = await fetch(url, {
headers: { Authorization: `token ${ctx.api_key}` },
});
console.log(`[btcpay/stores] BTCPay responded ${r.status}`);
if (!r.ok) {
const text = await r.text();
console.warn(`[btcpay/stores] non-ok body: ${text.slice(0, 200)}`);
return res.status(502).json({
error: "btcpay_stores_failed",
message: text.slice(0, 200),
});
}
const stores = await r.json();
console.log(`[btcpay/stores] returned ${Array.isArray(stores) ? stores.length : "?"} stores`);
res.json({ stores });
} catch (err) {
const msg = err?.message || String(err);
const cause = err?.cause?.message || err?.cause?.code || err?.cause;
console.error(
`[btcpay/stores] fetch threw: ${msg}${cause ? ` (cause: ${cause})` : ""} url=${url}`
);
res.status(502).json({
error: "btcpay_stores_failed",
message: msg.slice(0, 200) + (cause ? ` (cause: ${String(cause).slice(0, 120)})` : ""),
});
}
});
// Step 3: finalize. Pin the chosen store, create a webhook
// pointing back at /relay/btcpay/webhook, and write all four
// BTCPay fields to the live config.
router.post("/finalize", express.json(), async (req, res) => {
const storeId = (req.body?.store_id || "").trim();
if (!storeId) {
return res.status(400).json({ error: "store_id_required" });
}
const ctx = await readSetupContext(dataDir);
if (!ctx?.btcpay_url || !ctx?.api_key) {
return res.status(400).json({ error: "setup_context_missing" });
}
const baseRelayUrl = absoluteRelayUrl(req);
const result = await finalizeBtcpay({ ctx, storeId, dataDir, baseRelayUrl });
if (!result.ok) {
return res.status(result.status).json({
error: result.error,
message: result.message,
});
}
res.json({ ok: true, webhook_url: result.webhookUrl });
});
// Auto-discovery: does the StartOS init phase know about a local
// BTCPay install? Reads the file written by startos/init/setup.ts
// via sdk.serviceInterface.getAll. When present, the dashboard
// skips the URL-prompt step and goes straight to /start with the
// discovered URL.
router.get("/discover", async (_req, res) => {
const discoveryPath = path.join(dataDir, "discovered-services.json");
try {
const raw = await fs.readFile(discoveryPath, "utf8");
const parsed = JSON.parse(raw);
if (parsed?.btcpay?.browser_url) {
return res.json({
found: true,
browser_url: parsed.btcpay.browser_url,
discovered_at: parsed.btcpay.discovered_at,
});
}
return res.json({ found: false });
} catch (err) {
// No discovery file yet (fresh install hadn't run setup, or
// BTCPay wasn't installed when setup ran). Treat as not found.
return res.json({ found: false });
}
});
// Operator-initiated recovery: scan recent settled BTCPay invoices
// and grant credits for any that weren't processed via the webhook
// (typical cause: broken webhook URL pointing at unreachable
// mDNS). Idempotent. Returns a summary of what got credited.
router.post("/rescan-invoices", async (_req, res) => {
try {
const result = await rescanSettledInvoices();
if (!result.ok) {
return res.status(502).json(result);
}
res.json(result);
} catch (err) {
res.status(500).json({
error: "rescan_failed",
message: (err?.message || String(err)).slice(0, 200),
});
}
});
// Convenience: tells the dashboard whether BTCPay is fully wired
// up, half-set-up, or empty — so the dashboard can show the right
// CTA ("Connect BTCPay" / "Reconnect" / "Connected").
router.get("/status", async (_req, res) => {
const cfg = await getConfigSnapshot();
res.json({
configured: !!(
cfg.relay_btcpay_base_url &&
cfg.relay_btcpay_store_id &&
cfg.relay_btcpay_api_key &&
cfg.relay_btcpay_webhook_secret
),
base_url: cfg.relay_btcpay_base_url || null,
store_id: cfg.relay_btcpay_store_id || null,
has_api_key: !!cfg.relay_btcpay_api_key,
has_webhook_secret: !!cfg.relay_btcpay_webhook_secret,
});
});
return router;
}
// Setup-context is a transient JSON blob holding partial setup state
// (BTCPay URL + API key + permissions) between the authorize callback
// and the finalize step. Lives on disk only while a setup is in
// progress; finalize wipes it.
function setupContextPath(dataDir) {
return path.join(dataDir, "btcpay-setup.json");
}
async function stashSetupContext(dataDir, ctx) {
const file = setupContextPath(dataDir);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, JSON.stringify(ctx), { mode: 0o600 });
}
async function readSetupContext(dataDir) {
try {
const raw = await fs.readFile(setupContextPath(dataDir), "utf8");
return JSON.parse(raw);
} catch {
return null;
}
}
async function clearSetupContext(dataDir) {
try {
await fs.unlink(setupContextPath(dataDir));
} catch {}
}
// Extract the set of unique store IDs the operator selected on
// BTCPay's authorize page. With `selectiveStores=true`, every granted
// permission is suffixed with `:STOREID` — same store ID on every
// entry when the operator picked just one. Returns an array of
// unique store ids (possibly empty if BTCPay didn't supply them).
function extractStoreIdsFromPermissions(permissions) {
if (!Array.isArray(permissions)) return [];
const ids = new Set();
for (const p of permissions) {
if (typeof p !== "string") continue;
const m = p.match(/^btcpay\.store\.[^:]+:(\S+)$/);
if (m && m[1]) ids.add(m[1]);
}
return Array.from(ids);
}
// Shared finalize: create the webhook on BTCPay, write the four
// BTCPay credentials into the live relay config, clear the setup
// context. Used by the explicit POST /finalize endpoint AND by the
// callback's auto-finalize path when only one store was authorized.
//
// Webhook URL needs to be a hostname BTCPay's container can reach
// over the network. Priority:
// 1. Relay's discovered clearnet URL (e.g. https://relay.keysat.xyz)
// — works because BTCPay container has standard internet DNS.
// 2. baseRelayUrl (the Host header from the operator's browser request)
// — fallback for operators with no clearnet URL set up; only works
// if BTCPay happens to be able to resolve that name.
// We never want to use mDNS .local here — BTCPay's container has no
// avahi so .local won't resolve and every webhook delivery fails
// silently, which is exactly the bug that caused paid invoices to
// not credit the install.
//
// Before creating the new webhook we GET + DELETE any existing
// webhooks on this store pointing at /relay/btcpay/webhook — keeps
// the dashboard's Reconnect button idempotent and prevents BTCPay
// from accumulating dead webhook entries that retry forever.
async function finalizeBtcpay({ ctx, storeId, dataDir, baseRelayUrl }) {
const selfPublicUrl = await readDiscoveredSelfPublicUrl(dataDir);
const webhookBase = (selfPublicUrl || baseRelayUrl).replace(/\/$/, "");
const webhookUrl = `${webhookBase}/relay/btcpay/webhook`;
const webhookSecret = crypto.randomBytes(32).toString("hex");
const apiBase = (ctx.btcpay_internal_url || ctx.btcpay_url).replace(/\/$/, "");
const authHeader = `token ${ctx.api_key}`;
// Delete any existing webhooks on this store that point at our
// webhook path. Otherwise Reconnect leaves the old (possibly
// broken) webhook in place AND adds a new one, and BTCPay sends
// each InvoiceSettled to both.
try {
const listRes = await fetch(
`${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks`,
{ headers: { Authorization: authHeader } }
);
if (listRes.ok) {
const existing = await listRes.json();
if (Array.isArray(existing)) {
for (const w of existing) {
if (typeof w?.url === "string" && /\/relay\/btcpay\/webhook$/i.test(w.url)) {
try {
await fetch(
`${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks/${encodeURIComponent(w.id)}`,
{ method: "DELETE", headers: { Authorization: authHeader } }
);
console.log(`[btcpay/finalize] deleted stale webhook ${w.id} (url=${w.url})`);
} catch (err) {
console.warn(`[btcpay/finalize] failed to delete stale webhook ${w.id}: ${err?.message || err}`);
}
}
}
}
}
} catch (err) {
console.warn(`[btcpay/finalize] webhook cleanup skipped: ${err?.message || err}`);
}
try {
const r = await fetch(
`${apiBase}/api/v1/stores/${encodeURIComponent(storeId)}/webhooks`,
{
method: "POST",
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: webhookUrl,
secret: webhookSecret,
authorizedEvents: { everything: false, specificEvents: ["InvoiceSettled"] },
automaticRedelivery: true,
enabled: true,
}),
}
);
if (!r.ok) {
const text = await r.text();
return {
ok: false,
status: 502,
error: "webhook_create_failed",
message: text.slice(0, 300),
};
}
console.log(`[btcpay/finalize] webhook created: ${webhookUrl}`);
} catch (err) {
return {
ok: false,
status: 502,
error: "webhook_create_failed",
message: (err?.message || String(err)).slice(0, 200),
};
}
const configPath = path.join(dataDir, "config", "relay-config.json");
let existing = {};
try {
existing = JSON.parse(await fs.readFile(configPath, "utf8"));
} catch {}
existing.relay_btcpay_base_url = ctx.btcpay_url;
existing.relay_btcpay_internal_url = ctx.btcpay_internal_url || "";
existing.relay_btcpay_public_url = ctx.btcpay_public_url || "";
existing.relay_btcpay_store_id = storeId;
existing.relay_btcpay_api_key = ctx.api_key;
existing.relay_btcpay_webhook_secret = webhookSecret;
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(existing), { mode: 0o600 });
await clearSetupContext(dataDir);
return { ok: true, webhookUrl };
}
// Pull btcpay.internal_url from the discovered-services.json file
// written by the StartOS setup hook (init/setup.ts). Returns null if
// BTCPay isn't co-installed or discovery hasn't run.
async function readDiscoveredInternalUrl(dataDir) {
try {
const raw = await fs.readFile(
path.join(dataDir, "discovered-services.json"),
"utf8"
);
const parsed = JSON.parse(raw);
return parsed?.btcpay?.internal_url || null;
} catch {
return null;
}
}
// Companion to readDiscoveredInternalUrl: returns the public clearnet
// URL Spark Control discovered for BTCPay. Used to rewrite the
// buyer-facing checkout link.
async function readDiscoveredPublicUrl(dataDir) {
try {
const raw = await fs.readFile(
path.join(dataDir, "discovered-services.json"),
"utf8"
);
const parsed = JSON.parse(raw);
return parsed?.btcpay?.public_url || null;
} catch {
return null;
}
}
// The relay's OWN clearnet URL (e.g. https://relay.keysat.xyz) —
// captured at install time via sdk.serviceInterface.getAllOwn.
// Used to construct the webhook URL we register with BTCPay so
// BTCPay's container can actually reach it.
async function readDiscoveredSelfPublicUrl(dataDir) {
try {
const raw = await fs.readFile(
path.join(dataDir, "discovered-services.json"),
"utf8"
);
const parsed = JSON.parse(raw);
return parsed?.self?.public_url || null;
} catch {
return null;
}
}
// Reverse-engineer the public-facing URL of the relay from the
// incoming request. Used to build the callback + webhook URLs without
// having to ask the operator to type their own hostname in twice.
function absoluteRelayUrl(req) {
const proto =
(req.headers["x-forwarded-proto"] || "").split(",")[0].trim() ||
req.protocol ||
"http";
const host = req.headers["x-forwarded-host"] || req.headers.host;
return `${proto}://${host}`;
}
// Minimal HTML wrapper for the picker page. Matches the dashboard's
// dark palette so the operator doesn't feel like they've fallen off
// the edge of the world.
function html(title, body) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>${title}</title>
<style>
body { background:#0f172a; color:#e2e8f0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; margin:0; padding:40px; }
.wrap { max-width:560px; margin:0 auto; }
h1 { font-size:22px; margin:0 0 16px; }
a { color:#a5b4fc; }
button { font-family:inherit; }
</style></head><body><div class="wrap">${body}</div></body></html>`;
}
+748
View File
@@ -0,0 +1,748 @@
// /relay/credits/* — buy + poll relay credit top-ups via BTCPay.
//
// Three endpoints:
//
// GET /relay/credits/packages (no auth) — operator's
// configured pricing menu.
// The Recap UI fetches this
// before showing the modal so
// it always reflects current
// pricing.
//
// POST /relay/credits/buy (X-Recap-Install-Id required)
// Body: { credits: number }
// Mints a BTCPay invoice tied
// to this install + the chosen
// credit pack. Returns
// { invoice_id, checkout_url,
// sats, credits, expires_at }.
//
// GET /relay/credits/invoice/:id (X-Recap-Install-Id required)
// Polls BTCPay for invoice
// status. Returns
// { status: "new"|"processing"|
// "settled"|"expired"|"invalid",
// credits_remaining }.
//
// POST /relay/btcpay/webhook (no auth — HMAC validated)
// BTCPay calls this on payment
// events. We dedupe by
// invoice_id and only credit
// the install once per invoice.
import express from "express";
import { resolveLicense } from "../keysat-client.js";
import {
addPurchasedCredits,
getOrCreateRow,
computeRemaining,
licenseFingerprint,
extendUserTier,
} from "../credits.js";
import { getConfigSnapshot, getTierQuotas, getCreditPackages } from "../config.js";
import {
createInvoice,
getInvoice,
getInvoicePaymentMethods,
pickLightningFromPaymentMethods,
validateWebhookSignature,
BtcPayError,
} from "../btcpay-client.js";
import { recordCall } from "../audit-log.js";
import { envelope, errorEnvelope } from "./envelope.js";
// In-memory dedup of processed BTCPay invoice ids. BTCPay retries
// webhook deliveries on non-2xx responses or network errors, so the
// same InvoiceSettled event can land more than once. We don't want
// to grant credits twice for one paid invoice.
//
// Cleared on relay restart, which means an unlucky webhook duplicate
// straddling a restart would double-credit. Acceptable tradeoff for
// v1 (operator can manually adjust the ledger if it happens), but
// worth swapping for a persistent set once the relay sees real load.
const processedInvoices = new Set();
export function creditsRouter() {
const router = express.Router();
// ── GET /relay/credits/packages ──────────────────────────────────────
router.get("/credits/packages", async (_req, res) => {
const packages = await getCreditPackages();
res.json({ packages });
});
// ── POST /relay/credits/buy ──────────────────────────────────────────
router.post("/credits/buy", express.json(), async (req, res) => {
const installId = req.header("X-Recap-Install-Id");
const auth = req.header("Authorization");
if (!installId) {
const e = await errorEnvelope({
error: "missing X-Recap-Install-Id header",
statusHint: 400,
});
return res.status(400).json(e.body);
}
const requestedCredits = Number(req.body?.credits);
// Optional: where to send the buyer's browser after they pay.
// Frontend passes its own origin/page so the buyer lands back on
// Recap instead of stuck on BTCPay's "Paid ✓" screen. We trust
// the value blindly because Recap is the one calling us — but
// even so we only forward it to BTCPay's checkout, never use it
// in any sensitive context.
const returnUrl =
typeof req.body?.return_url === "string" && req.body.return_url.startsWith("http")
? req.body.return_url
: null;
if (!Number.isFinite(requestedCredits) || requestedCredits <= 0) {
const e = await errorEnvelope({
error: "credits must be a positive number",
installId,
statusHint: 400,
});
return res.status(400).json(e.body);
}
// Match against the configured package list — buyers can't
// request arbitrary credit-for-sats ratios.
const packages = await getCreditPackages();
const pack = packages.find((p) => p.credits === requestedCredits);
if (!pack) {
const e = await errorEnvelope({
error: `unknown package: ${requestedCredits} credits`,
installId,
statusHint: 400,
});
return res.status(400).json(e.body);
}
const license = await resolveLicense(auth);
const cfg = await getConfigSnapshot();
if (
!cfg.relay_btcpay_base_url ||
!cfg.relay_btcpay_store_id ||
!cfg.relay_btcpay_api_key
) {
const e = await errorEnvelope({
error: "BTCPay is not configured on this relay — contact the operator",
installId,
tier: license.tier,
statusHint: 503,
});
return res.status(503).json(e.body);
}
try {
const buyerFp = licenseFingerprint(license);
const invoice = await createInvoice({
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
storeId: cfg.relay_btcpay_store_id,
apiKey: cfg.relay_btcpay_api_key,
sats: pack.sats,
credits: pack.credits,
installId,
// Stash the buyer's license fingerprint (if any) on the
// invoice so the webhook handler grants credits to their
// license-keyed pool — survives restarts because BTCPay echoes
// the metadata back on every event.
licenseFingerprint: buyerFp,
packageLabel: `${pack.credits} relay credits`,
redirectURL: returnUrl || undefined,
redirectAutomatically: !!returnUrl,
});
await recordCall({
install_id: installId,
license_fingerprint: buyerFp,
tier: license.tier,
pipeline: "credits_purchase",
backend: null,
model: null,
status: "invoice_created",
credit_charged: 0,
duration_ms: 0,
cost_usd: 0,
purchase_credits: pack.credits,
purchase_sats: pack.sats,
invoice_id: invoice.id,
});
// Fetch the per-payment-method breakdown so we can surface
// the raw BOLT11 invoice + Lightning deep-link to the Recap
// UI. Recap renders an inline QR + "Open in wallet" using
// these fields, removing the redirect-to-BTCPay-checkout
// step entirely (Phase 1 of the inline-payment migration).
//
// Best-effort: BTCPay sometimes generates the Lightning
// invoice asynchronously and the destination is empty on the
// first hit. We retry once with a short backoff. If still
// empty (LN not configured on the store, or slow LND), we
// fall through with null — Recap then falls back to the
// legacy "open checkout_url in another tab" path. Never
// fails the purchase because of this.
//
// Diagnostic capture: when bolt11 ends up null we surface
// enough info on the response (under `_debug`) to figure out
// why without operators having to comb through logs. Will be
// removed once Phase 1 is verified working everywhere.
let bolt11 = null;
let lightningPaymentLink = null;
let lnDebug = null;
try {
let methods = await getInvoicePaymentMethods({
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
storeId: cfg.relay_btcpay_store_id,
apiKey: cfg.relay_btcpay_api_key,
invoiceId: invoice.id,
});
let ln = pickLightningFromPaymentMethods(methods);
if (!ln) {
// Retry once after a short backoff — LN invoice generation
// is async on some BTCPay configurations (LND, c-lightning
// over Tor, etc.).
await new Promise((r) => setTimeout(r, 600));
methods = await getInvoicePaymentMethods({
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
storeId: cfg.relay_btcpay_store_id,
apiKey: cfg.relay_btcpay_api_key,
invoiceId: invoice.id,
});
ln = pickLightningFromPaymentMethods(methods);
}
if (ln) {
bolt11 = ln.bolt11;
lightningPaymentLink = ln.paymentLink;
} else {
// Dump exactly what BTCPay returned so we can fix the
// pick-function heuristic for whatever version /
// shape this store uses. Sample first ~3 methods,
// exclude the per-method 'payments' history because
// those can be large.
const sample = (Array.isArray(methods) ? methods : [])
.slice(0, 3)
.map((m) => {
const out = {};
for (const k of Object.keys(m || {})) {
if (k === "payments") continue;
const v = m[k];
if (v === null || ["string", "number", "boolean"].includes(typeof v)) {
out[k] = v;
} else {
out[k] = `<${typeof v}>`;
}
}
return out;
});
lnDebug = {
reason: "no_lightning_method",
methods_count: Array.isArray(methods) ? methods.length : 0,
sample,
};
console.warn(
`[credits/buy] invoice ${invoice.id}: no Lightning method on payment-methods response — sample=${JSON.stringify(sample)}`
);
}
} catch (err) {
lnDebug = {
reason: "fetch_failed",
message: (err?.message || String(err)).slice(0, 300),
status:
err && typeof err === "object" && "status" in err
? err.status
: null,
};
console.warn(
`[credits/buy] invoice ${invoice.id}: payment-methods fetch failed (${err?.message || err}) — Recap will fall back to checkout URL`
);
}
const rawCheckout =
invoice.checkoutLink ||
(invoice.checkout && invoice.checkout.redirectURL) ||
null;
const body = await envelope({
result: {
invoice_id: invoice.id,
// BTCPay returns the checkout URL with the internal
// hostname we called it on (`btcpayserver.startos:23000`).
// Rewrite to the operator's browser-facing URL so the
// buyer's browser can actually reach it.
// Buyer-facing: prefer the clearnet public URL over the
// LAN base URL so buyers from anywhere on the internet
// can pay. Falls back to base URL when no clearnet is set.
checkout_url: rewriteCheckoutUrl(
rawCheckout,
cfg.relay_btcpay_public_url || cfg.relay_btcpay_base_url
),
sats: pack.sats,
credits: pack.credits,
expires_at: invoice.expirationTime || null,
status: invoice.status || "New",
// Inline-payment fields (added in Phase 1). May be null
// if LN isn't configured on the store, or if BTCPay
// hasn't finished generating the LN invoice — Recap is
// built to fall back to checkout_url in those cases.
bolt11,
lightning_payment_link: lightningPaymentLink,
// Mirror BTCPay's invoice-level expiration as the
// canonical "this invoice is no longer payable" deadline.
// The Lightning invoice itself has its own expiry encoded
// in the BOLT11, but BTCPay aligns the two by default and
// surfacing the BTCPay number keeps things consistent
// with the existing expires_at field above.
lightning_expires_at: invoice.expirationTime || null,
// Diagnostic — only set when bolt11 came back null. Lets
// the Recap UI explain WHY the inline path didn't light
// up without operators tailing relay logs. Will be
// removed in a follow-up once Phase 1 is verified.
_ln_debug: lnDebug,
},
installId,
license,
tier: license.tier,
});
res.json(body);
} catch (err) {
console.error(`[credits/buy] failed: ${err?.message || err}`);
const status =
err instanceof BtcPayError && err.status === 401 ? 502 : 502;
const e = await errorEnvelope({
error: err?.message || "btcpay_create_invoice_failed",
installId,
tier: license.tier,
statusHint: status,
});
return res.status(status).json(e.body);
}
});
// ── GET /relay/credits/invoice/:id ───────────────────────────────────
// Poll loop's friend. Returns the BTCPay status PLUS the install's
// updated credit balance so the UI can refresh both in one round-
// trip after settlement.
router.get("/credits/invoice/:id", async (req, res) => {
const installId = req.header("X-Recap-Install-Id");
const auth = req.header("Authorization");
if (!installId) {
const e = await errorEnvelope({
error: "missing X-Recap-Install-Id header",
statusHint: 400,
});
return res.status(400).json(e.body);
}
const invoiceId = (req.params.id || "").trim();
if (!invoiceId) {
const e = await errorEnvelope({
error: "missing invoice id",
installId,
statusHint: 400,
});
return res.status(400).json(e.body);
}
const cfg = await getConfigSnapshot();
const license = await resolveLicense(auth);
try {
const invoice = await getInvoice({
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
storeId: cfg.relay_btcpay_store_id,
apiKey: cfg.relay_btcpay_api_key,
invoiceId,
});
// Guard: only the install that minted this invoice can poll it.
// BTCPay metadata may carry install_id from createInvoice; if
// present, refuse cross-install lookups.
const meta = invoice.metadata || {};
if (meta.install_id && meta.install_id !== installId) {
const e = await errorEnvelope({
error: "invoice belongs to a different install",
installId,
tier: license.tier,
statusHint: 403,
});
return res.status(403).json(e.body);
}
const row = await getOrCreateRow({ installId, license });
const quota = await getTierQuotas();
const balance = computeRemaining(row, quota);
const body = await envelope({
result: {
invoice_id: invoice.id,
status: normalizeStatus(invoice.status),
status_raw: invoice.status,
credits: Number(meta.credits) || null,
sats: invoice.amount ? Number(invoice.amount) : null,
checkout_url: rewriteCheckoutUrl(
invoice.checkoutLink || null,
cfg.relay_btcpay_public_url || cfg.relay_btcpay_base_url
),
balance: {
tier_remaining: balance.remaining,
purchased: balance.purchased,
total: balance.total,
},
},
installId,
license,
tier: license.tier,
});
res.json(body);
} catch (err) {
console.error(`[credits/invoice] failed: ${err?.message || err}`);
const e = await errorEnvelope({
error: err?.message || "btcpay_get_invoice_failed",
installId,
tier: license.tier,
statusHint: 502,
});
return res.status(502).json(e.body);
}
});
// ── POST /relay/btcpay/webhook ───────────────────────────────────────
// BTCPay calls this on every invoice event. We:
// 1. Validate the HMAC-SHA256 signature against the operator-set
// webhook secret.
// 2. Skip anything that isn't an "InvoiceSettled" event.
// 3. Dedupe by invoice id (BTCPay may retry on failures).
// 4. Look up the invoice metadata, then credit the install.
//
// Uses express.raw() so we have the EXACT bytes BTCPay signed —
// any whitespace canonicalization after JSON-parse would break the
// HMAC check.
router.post(
"/btcpay/webhook",
express.raw({ type: "*/*", limit: "1mb" }),
async (req, res) => {
const cfg = await getConfigSnapshot();
const secret = cfg.relay_btcpay_webhook_secret;
if (!secret) {
console.warn("[btcpay/webhook] secret not configured — rejecting");
return res.status(503).json({ error: "webhook_secret_not_configured" });
}
const sig = req.header("BTCPay-Sig") || "";
const ok = validateWebhookSignature({
rawBody: req.body,
signatureHeader: sig,
secret,
});
if (!ok) {
console.warn(
`[btcpay/webhook] signature mismatch (header=${sig.slice(0, 20)}...)`
);
return res.status(401).json({ error: "bad_signature" });
}
let payload;
try {
payload = JSON.parse(req.body.toString("utf8"));
} catch (err) {
return res.status(400).json({ error: "bad_json" });
}
// BTCPay event types we care about. "InvoiceSettled" fires once
// the invoice is fully paid + confirmed. We deliberately ignore
// "InvoiceProcessing" (in-mempool, not yet confirmed) and
// "InvoiceExpired" (timed out without payment).
const type = payload?.type || "";
const invoiceId = payload?.invoiceId || payload?.invoice_id || null;
if (!invoiceId) {
return res.status(200).json({ ok: true, ignored: "no_invoice_id" });
}
if (type !== "InvoiceSettled") {
// Acknowledge to stop BTCPay from retrying, but do nothing.
return res.status(200).json({ ok: true, ignored: `type=${type}` });
}
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoiceId}`;
if (processedInvoices.has(dedupKey)) {
return res
.status(200)
.json({ ok: true, ignored: "already_processed", invoiceId });
}
// Pull the invoice from BTCPay to read its metadata (the webhook
// payload doesn't carry our embedded install_id + credits
// fields directly — they live on the invoice object).
let invoice;
try {
invoice = await getInvoice({
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
storeId: cfg.relay_btcpay_store_id,
apiKey: cfg.relay_btcpay_api_key,
invoiceId,
});
} catch (err) {
console.error(
`[btcpay/webhook] getInvoice failed for ${invoiceId}: ${err?.message || err}`
);
// Return 5xx so BTCPay retries — this is likely a transient
// network error against BTCPay's own API.
return res.status(502).json({ error: "btcpay_lookup_failed" });
}
const meta = invoice.metadata || {};
// Self-serve subscription purchase (Pro/Max) — different metadata shape
// than credit purchases. Extend the buyer's prepaid period and stop.
if (meta.product === "recap_tier_subscription") {
const subUserId = typeof meta.user_id === "string" ? meta.user_id : "";
const subTier = meta.tier;
const periodDays = Number(meta.period_days) || 30;
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
processedInvoices.add(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "bad_tier_metadata", invoiceId });
}
try {
const row = await extendUserTier({
userId: subUserId,
tier: subTier,
periodDays,
});
processedInvoices.add(dedupKey);
console.log(
`[btcpay/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (invoice ${invoiceId}) → expires ${row.subscription_expires_at}`,
);
return res.status(200).json({
ok: true,
tier: subTier,
user: subUserId,
expires_at: row.subscription_expires_at,
});
} catch (err) {
console.error(
`[btcpay/webhook] extendUserTier failed: ${err?.message || err}`,
);
return res.status(500).json({ error: "tier_grant_failed" });
}
}
const installId = meta.install_id;
const credits = Number(meta.credits);
// license_fingerprint was stashed at buy time when the buyer
// had an active paid license. Used here to route the credit to
// their license-keyed pool instead of an install-keyed pool.
// Missing (older invoices, or Core buyers) → falls back to
// install-keyed.
const buyerFp = meta.license_fingerprint || null;
const creditKey = buyerFp ? `lic:${buyerFp}` : null;
if (!installId || !Number.isFinite(credits) || credits <= 0) {
// Not ours — could be an invoice from another product on the
// same store. Mark processed so we don't keep retrying, and
// 200 so BTCPay stops calling.
processedInvoices.add(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "no_recap_metadata" });
}
try {
const newBalance = await addPurchasedCredits({
installId,
creditKey,
amount: credits,
});
processedInvoices.add(dedupKey);
await recordCall({
install_id: installId,
license_fingerprint: buyerFp,
tier: null,
pipeline: "credits_purchase",
backend: null,
model: null,
status: "settled",
credit_charged: 0,
duration_ms: 0,
cost_usd: 0,
purchase_credits: credits,
purchase_sats: Number(invoice.amount) || null,
invoice_id: invoiceId,
purchased_balance_after: newBalance,
});
console.log(
`[btcpay/webhook] credited install ${installId.slice(0, 8)}… with ${credits} credits (invoice ${invoiceId})${creditKey ? ` → pool ${creditKey}` : ""}`
);
return res
.status(200)
.json({ ok: true, credited: credits, install: installId });
} catch (err) {
console.error(
`[btcpay/webhook] addPurchasedCredits failed: ${err?.message || err}`
);
return res
.status(500)
.json({ error: "credit_grant_failed" });
}
}
);
return router;
}
// Rewrite a BTCPay checkout URL's host to the operator's browser-
// facing URL. BTCPay builds the checkoutLink using whatever host the
// invoice-create API call came in on — and since we hit it via
// `http://btcpayserver.startos:23000` (the internal docker network
// hostname), the returned URL is `btcpayserver.startos:23000/i/...`
// which the buyer's browser can't resolve.
//
// Fix: parse the URL, swap origin (scheme + host + port) with the
// operator's `relay_btcpay_base_url` (set by the one-click setup to
// the discovered mDNS / clearnet URL). Path + query are preserved.
// If parsing fails for any reason, return the URL unchanged — better
// to ship a broken link than crash the buy flow.
function rewriteCheckoutUrl(url, browserBase) {
if (!url || !browserBase) return url;
try {
const u = new URL(url);
const b = new URL(browserBase);
u.protocol = b.protocol;
u.hostname = b.hostname;
// Explicitly assign port from the base — including the empty
// string when the base has no port (HTTPS on default 443).
// Using `u.host = b.host` looks equivalent but Node's URL keeps
// the original port when the new host string has none, so a
// rewrite from `btcpayserver.startos:23000` → `btcpay.keysat.xyz`
// ended up as `btcpay.keysat.xyz:23000`. Assigning port directly
// clears it.
u.port = b.port || "";
return u.toString();
} catch {
return url;
}
}
// Recovery: when the BTCPay webhook URL was broken and paid
// invoices never got credited, this scans BTCPay's recent settled
// invoices and grants credits for ones the relay hasn't processed.
// Idempotent via the same processedInvoices dedup the webhook uses,
// so re-running is safe.
//
// Exported so the admin route in btcpay-setup.js can call it. Not
// exposed via /relay/* because it's operator-initiated, not buyer.
export async function rescanSettledInvoices() {
const cfg = await getConfigSnapshot();
if (
!cfg.relay_btcpay_base_url ||
!cfg.relay_btcpay_store_id ||
!cfg.relay_btcpay_api_key
) {
return { ok: false, error: "btcpay_not_configured" };
}
const apiBase = (
cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url
).replace(/\/$/, "");
const sinceMs = Date.now() - 30 * 24 * 3600 * 1000;
let invoices;
try {
const r = await fetch(
`${apiBase}/api/v1/stores/${encodeURIComponent(cfg.relay_btcpay_store_id)}/invoices?take=1000&status=Settled&startDate=${Math.floor(sinceMs / 1000)}`,
{ headers: { Authorization: `token ${cfg.relay_btcpay_api_key}` } }
);
if (!r.ok) {
const text = await r.text();
return {
ok: false,
error: "btcpay_list_invoices_failed",
message: text.slice(0, 300),
};
}
invoices = await r.json();
} catch (err) {
return {
ok: false,
error: "rescan_failed",
message: (err?.message || String(err)).slice(0, 200),
};
}
let credited = 0;
let alreadyProcessed = 0;
let skipped = 0;
const details = [];
for (const invoice of invoices || []) {
const dedupKey = `${cfg.relay_btcpay_store_id}|${invoice.id}`;
if (processedInvoices.has(dedupKey)) {
alreadyProcessed++;
continue;
}
const meta = invoice.metadata || {};
const installId = meta.install_id;
const credits = Number(meta.credits);
const buyerFp = meta.license_fingerprint || null;
const creditKey = buyerFp ? `lic:${buyerFp}` : null;
if (
!installId ||
!Number.isFinite(credits) ||
credits <= 0 ||
meta.product !== "recap_credits"
) {
skipped++;
continue;
}
try {
const newBalance = await addPurchasedCredits({
installId,
creditKey,
amount: credits,
});
processedInvoices.add(dedupKey);
await recordCall({
install_id: installId,
license_fingerprint: buyerFp,
tier: null,
pipeline: "credits_purchase",
backend: null,
model: null,
status: "settled",
credit_charged: 0,
duration_ms: 0,
cost_usd: 0,
purchase_credits: credits,
purchase_sats: Number(invoice.amount) || null,
invoice_id: invoice.id,
purchased_balance_after: newBalance,
recovery_rescan: true,
});
credited++;
details.push({
invoice_id: invoice.id,
install: installId.slice(0, 8),
credits,
new_balance: newBalance,
});
} catch (err) {
console.error(
`[credits/rescan] addPurchasedCredits failed for ${invoice.id}: ${err?.message || err}`
);
skipped++;
}
}
console.log(
`[credits/rescan] credited=${credited} already=${alreadyProcessed} skipped=${skipped}`
);
return {
ok: true,
credited,
already_processed: alreadyProcessed,
skipped,
details,
};
}
// BTCPay's status enum → a coarser one Recap can render off of.
function normalizeStatus(s) {
switch ((s || "").toLowerCase()) {
case "new":
return "new";
case "processing":
return "processing";
case "settled":
case "complete":
return "settled";
case "expired":
return "expired";
case "invalid":
return "invalid";
default:
return (s || "unknown").toLowerCase();
}
}
+394
View File
@@ -0,0 +1,394 @@
// POST /relay/user-tier + GET /relay/user-tier/:userId (core-decoupling)
//
// Operator-only. The cloud Recaps server (recaps.cc) calls these to SET and
// READ a cloud user's Pro/Max tier — the relay is the source of truth for
// cloud tiers, so granting Pro/Max means writing the user's credit row
// here. Authenticated by the shared operator key (X-Recap-Operator-Key);
// no per-user Keysat license is involved.
//
// POST body: { user_id, tier: "core"|"pro"|"max", expires_at?: ISO }
// (self-serve subscription purchase is a later slice; for now tiers are
// operator-set, and "revoke" = set tier back to "core".)
import express from "express";
import { verifyOperatorKey, isSubscriptionExpired } from "../identity.js";
import {
setUserTier,
getUserCreditRow,
computeRemaining,
snapshotAll,
} from "../credits.js";
import {
getTierQuotas,
getTierPricesSats,
getTierPricesFiatCents,
getSubscriptionPeriodDays,
getZapriteConfig,
getConfigSnapshot,
} from "../config.js";
import {
createTierInvoice,
getInvoicePaymentMethods,
pickLightningFromPaymentMethods,
} from "../btcpay-client.js";
import { createOrder as createZapriteOrder } from "../zaprite-client.js";
const TIERS = new Set(["core", "pro", "max"]);
const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
async function reportRow(userId, row) {
const quota = await getTierQuotas();
const balance = computeRemaining(row, quota);
// `tier` is the EFFECTIVE tier (expiry-enforced) — what callers gate on
// and what Recaps caches. `tier_snapshot` is the raw stored value, so the
// operator can still see "paid but lapsed".
const expired = isSubscriptionExpired(row);
const effectiveTier = expired ? "core" : row.tier_snapshot || "core";
return {
ok: true,
user_id: userId,
tier: effectiveTier,
tier_snapshot: row.tier_snapshot || "core",
subscription_expired: expired,
subscription_expires_at: row.subscription_expires_at || null,
credits_remaining: balance.total, // null = unlimited (Max)
tier_remaining: balance.remaining,
purchased_balance: balance.purchased,
};
}
export function userTierRouter() {
const router = express.Router();
router.post("/user-tier", express.json({ limit: "16kb" }), async (req, res) => {
if (!(await verifyOperatorKey(req))) {
return res.status(401).json({ error: "invalid_operator_key" });
}
const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : "";
const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : "";
const expiresAt =
typeof req.body?.expires_at === "string" && req.body.expires_at.trim()
? req.body.expires_at.trim()
: null;
if (!USER_ID_RE.test(userId)) {
return res.status(400).json({ error: "invalid_user_id" });
}
if (!TIERS.has(tier)) {
return res.status(400).json({ error: "tier_must_be_core_pro_or_max" });
}
const row = await setUserTier({ userId, tier, expiresAt });
console.log(`[user-tier] set ${userId}${tier}${expiresAt ? ` (expires ${expiresAt})` : ""}`);
res.json(await reportRow(userId, row));
});
// Create a BTCPay invoice to buy a prepaid period of pro/max for a user.
// The Recaps server calls this (operator-key authed) when a signed-in user
// hits "Pay with Bitcoin"; it returns the checkout URL + invoice id. On
// settlement the BTCPay webhook (routes/credits.js) calls extendUserTier.
router.post("/tier-invoice", express.json({ limit: "16kb" }), async (req, res) => {
if (!(await verifyOperatorKey(req))) {
return res.status(401).json({ error: "invalid_operator_key" });
}
const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : "";
const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : "";
const returnUrl =
typeof req.body?.return_url === "string" ? req.body.return_url.trim() : "";
if (!USER_ID_RE.test(userId)) {
return res.status(400).json({ error: "invalid_user_id" });
}
if (tier !== "pro" && tier !== "max") {
return res.status(400).json({ error: "tier_must_be_pro_or_max" });
}
const prices = await getTierPricesSats();
const sats = prices[tier];
if (!Number.isFinite(sats) || sats <= 0) {
return res.status(400).json({ error: "tier_not_priced" });
}
const periodDays = await getSubscriptionPeriodDays();
const cfg = await getConfigSnapshot();
if (
!cfg.relay_btcpay_base_url ||
!cfg.relay_btcpay_store_id ||
!cfg.relay_btcpay_api_key
) {
return res.status(503).json({ error: "btcpay_not_configured" });
}
try {
const invoice = await createTierInvoice({
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
storeId: cfg.relay_btcpay_store_id,
apiKey: cfg.relay_btcpay_api_key,
sats,
userId,
tier,
periodDays,
redirectURL: returnUrl || undefined,
redirectAutomatically: !!returnUrl,
});
// Prefer the operator's public BTCPay host for the buyer-facing link
// (the invoice may have been created against an internal URL).
let checkoutUrl = invoice.checkoutLink || null;
if (checkoutUrl && cfg.relay_btcpay_public_url) {
try {
const u = new URL(checkoutUrl);
const pub = new URL(cfg.relay_btcpay_public_url);
u.protocol = pub.protocol;
u.host = pub.host;
checkoutUrl = u.toString();
} catch {}
}
// Fetch the Lightning BOLT11 so the Recaps app can render an INLINE
// QR/invoice (no redirect to the hosted BTCPay page) — same as the
// credit-pack flow. BTCPay generates the LN invoice asynchronously on
// some configs, so retry once after a short backoff. On total failure
// bolt11 stays null and the app falls back to the hosted checkout_url.
let bolt11 = null;
let lightningPaymentLink = null;
let lnDebug = null;
try {
const pmArgs = {
baseURL: cfg.relay_btcpay_internal_url || cfg.relay_btcpay_base_url,
storeId: cfg.relay_btcpay_store_id,
apiKey: cfg.relay_btcpay_api_key,
invoiceId: invoice.id,
};
let methods = await getInvoicePaymentMethods(pmArgs);
let ln = pickLightningFromPaymentMethods(methods);
if (!ln) {
await new Promise((r) => setTimeout(r, 600));
methods = await getInvoicePaymentMethods(pmArgs);
ln = pickLightningFromPaymentMethods(methods);
}
if (ln) {
bolt11 = ln.bolt11;
lightningPaymentLink = ln.paymentLink;
} else {
// Capture a sanitized sample of the payment-methods response so
// operators can diagnose why no BOLT11 came back (BTCPay version
// differences, LN not configured, etc.) — same diagnostic the
// credit-pack flow records.
const sample = (Array.isArray(methods) ? methods : [])
.slice(0, 3)
.map((m) => {
const out = {};
for (const k of Object.keys(m || {})) {
if (k === "payments") continue;
const v = m[k];
out[k] =
v === null || ["string", "number", "boolean"].includes(typeof v)
? v
: `<${typeof v}>`;
}
return out;
});
lnDebug = {
reason: "no_lightning_method",
methods_count: Array.isArray(methods) ? methods.length : 0,
sample,
};
console.warn(
`[tier-invoice] invoice ${invoice.id}: no Lightning method on payment-methods — falling back to hosted checkout. sample=${JSON.stringify(sample)}`,
);
}
} catch (err) {
lnDebug = {
reason: "fetch_failed",
message: (err?.message || String(err)).slice(0, 300),
};
console.warn(
`[tier-invoice] invoice ${invoice.id}: payment-methods fetch failed (${err?.message || err}) — falling back to hosted checkout`,
);
}
console.log(
`[tier-invoice] ${tier} ${sats} sats / ${periodDays}d for ${userId.slice(0, 8)}… (invoice ${invoice.id}, ln=${bolt11 ? "yes" : "no"})`,
);
res.json({
ok: true,
invoice_id: invoice.id,
checkout_url: checkoutUrl,
sats,
tier,
period_days: periodDays,
bolt11,
lightning_payment_link: lightningPaymentLink,
lightning_expires_at: invoice.expirationTime || null,
expires_at: invoice.expirationTime || null,
_ln_debug: lnDebug,
});
} catch (err) {
console.error(`[tier-invoice] createTierInvoice failed: ${err?.message || err}`);
res
.status(502)
.json({ error: "invoice_create_failed", message: err?.message || String(err) });
}
});
// Create a Zaprite hosted-checkout order to buy a prepaid Pro/Max period
// for a user with a CARD. Operator-key authed (the Recaps server proxies
// this when a signed-in user clicks "Pay by card"). Returns the checkout
// URL + order id. On settlement the Zaprite webhook (below) re-fetches
// the order and calls extendUserTier — the same landing point as the
// BTCPay rail.
router.post("/tier-zaprite-order", express.json({ limit: "16kb" }), async (req, res) => {
if (!(await verifyOperatorKey(req))) {
return res.status(401).json({ error: "invalid_operator_key" });
}
const userId = typeof req.body?.user_id === "string" ? req.body.user_id.trim() : "";
const tier = typeof req.body?.tier === "string" ? req.body.tier.trim() : "";
const returnUrl =
typeof req.body?.return_url === "string" ? req.body.return_url.trim() : "";
if (!USER_ID_RE.test(userId)) {
return res.status(400).json({ error: "invalid_user_id" });
}
if (tier !== "pro" && tier !== "max") {
return res.status(400).json({ error: "tier_must_be_pro_or_max" });
}
const prices = await getTierPricesFiatCents();
const amount = prices[tier];
if (!Number.isFinite(amount) || amount <= 0) {
return res.status(400).json({ error: "tier_not_priced" });
}
const periodDays = await getSubscriptionPeriodDays();
const zaprite = await getZapriteConfig();
if (!zaprite.apiKey) {
return res.status(503).json({ error: "zaprite_not_configured" });
}
try {
const order = await createZapriteOrder({
baseURL: zaprite.baseUrl,
apiKey: zaprite.apiKey,
amount,
currency: zaprite.currency,
label: `Recaps ${tier.toUpperCase()}${periodDays} days`,
metadata: {
product: "recap_tier_subscription",
user_id: userId,
tier,
period_days: periodDays,
},
redirectUrl: returnUrl || undefined,
});
const checkoutUrl = order?.checkoutUrl || null;
if (!order?.id || !checkoutUrl) {
throw new Error("Zaprite order missing id/checkoutUrl");
}
console.log(
`[tier-zaprite] ${tier} ${amount} ${zaprite.currency} / ${periodDays}d for ${userId.slice(0, 8)}… (order ${order.id})`,
);
res.json({
ok: true,
order_id: order.id,
checkout_url: checkoutUrl,
amount,
currency: zaprite.currency,
tier,
period_days: periodDays,
});
} catch (err) {
console.error(`[tier-zaprite] createOrder failed: ${err?.message || err}`);
res
.status(502)
.json({ error: "zaprite_order_failed", message: err?.message || String(err) });
}
});
// List the buyable subscription plans + their sats prices. Operator-key
// authed (the Recaps server proxies this to its purchase UI so the tier
// prices stay sourced from the relay's config, never hardcoded in the
// app). Returns { ok, period_days, plans: [{tier, sats}] }.
router.get("/tier-plans", async (req, res) => {
if (!(await verifyOperatorKey(req))) {
return res.status(401).json({ error: "invalid_operator_key" });
}
const prices = await getTierPricesSats();
const fiat = await getTierPricesFiatCents();
const periodDays = await getSubscriptionPeriodDays();
const zaprite = await getZapriteConfig();
const quotas = await getTierQuotas();
const cardAvailable = !!zaprite.apiKey;
const plans = ["pro", "max"]
.map((tier) => ({
tier,
sats: prices[tier],
// Card-rail price in the currency's smallest unit (cents for USD).
fiat_amount: fiat[tier],
fiat_currency: zaprite.currency,
// Monthly relay-credit allotment for this tier, sourced from the
// operator's Adjust-Tier-Quotas config. A number is the real
// per-period credit count (e.g. Pro 50); null means unlimited.
// The card shows whichever — so it always reflects the live config.
credits_per_period:
typeof quotas?.[tier]?.monthly === "number"
? quotas[tier].monthly
: null,
}))
.filter((p) => Number.isFinite(p.sats) && p.sats > 0);
res.json({
ok: true,
period_days: periodDays,
plans,
// The UI hides the "Pay by card" link when the operator hasn't
// configured Zaprite (so it never offers a rail that 503s).
card_available: cardAvailable,
});
});
// List cloud users whose prepaid period expires within the next
// `within_days` OR lapsed within the last `lapsed_days`. Operator-key
// authed. The relay owns the expiry (it's the subscription source of
// truth), but not the email — so the Recaps server calls this to decide
// who to send expiry-reminder emails to, then maps user_id → email on
// its side. Returns { ok, now, subscriptions: [{user_id, tier,
// expires_at, expired, days_left}] }, paid tiers only.
router.get("/expiring-subscriptions", async (req, res) => {
if (!(await verifyOperatorKey(req))) {
return res.status(401).json({ error: "invalid_operator_key" });
}
const clampInt = (v, def, lo, hi) => {
const n = parseInt(v, 10);
if (!Number.isFinite(n)) return def;
return Math.max(lo, Math.min(hi, n));
};
const withinDays = clampInt(req.query.within_days, 7, 0, 120);
const lapsedDays = clampInt(req.query.lapsed_days, 3, 0, 120);
const now = Date.now();
const DAY = 86_400_000;
const upperMs = now + withinDays * DAY;
const lowerMs = now - lapsedDays * DAY;
const out = [];
for (const row of snapshotAll()) {
const key = row.credit_key || "";
if (!key.startsWith("user:")) continue;
const tier = row.tier_snapshot || "core";
if (tier !== "pro" && tier !== "max") continue;
const exp = row.subscription_expires_at;
if (!exp) continue; // open-ended grant (operator comp) — never expires
const t = new Date(exp).getTime();
if (!Number.isFinite(t)) continue;
if (t > upperMs || t < lowerMs) continue; // outside the reminder window
out.push({
user_id: key.slice("user:".length),
tier,
expires_at: exp,
expired: t < now,
days_left: Math.ceil((t - now) / DAY),
});
}
res.json({ ok: true, now: new Date(now).toISOString(), subscriptions: out });
});
router.get("/user-tier/:userId", async (req, res) => {
if (!(await verifyOperatorKey(req))) {
return res.status(401).json({ error: "invalid_operator_key" });
}
const userId = (req.params.userId || "").trim();
if (!USER_ID_RE.test(userId)) {
return res.status(400).json({ error: "invalid_user_id" });
}
const row = await getUserCreditRow(userId);
res.json(await reportRow(userId, row));
});
return router;
}
+135
View File
@@ -0,0 +1,135 @@
// POST /relay/zaprite/webhook — card-rail settlement handler.
//
// Zaprite calls this when an order's activity changes. We do NOT trust the
// webhook body to decide whether money landed (Zaprite's webhook-signing
// mechanism isn't publicly documented). Instead we VERIFY by re-fetching
// the order from Zaprite's authenticated API and checking its status —
// the same re-fetch-to-verify pattern the BTCPay handler uses. The body is
// only a nudge that carries the order id.
//
// On a paid order tagged product:"recap_tier_subscription", we extend the
// buyer's prepaid period via extendUserTier — the SAME landing point as
// the BTCPay (Bitcoin) rail, so both rails converge on one tier-grant path.
import express from "express";
import { extendUserTier } from "../credits.js";
import { getZapriteConfig } from "../config.js";
import { getOrder, isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js";
// In-memory dedup of fully-processed orders (mirrors the BTCPay handler's
// processedInvoices). Zaprite retries on non-200, and may fire multiple
// events per order, so we guard the grant. Cleared on restart — a
// re-delivered webhook after restart re-fetches + re-grants, but
// extendUserTier is keyed per order via this set within a process; a
// duplicate grant across a restart is the same harmless "extend by one
// period" the operator-comp path already tolerates. (Acceptable: card
// double-fires across a restart are vanishingly rare.)
const processedZaprite = new Set();
export function zapriteWebhookRouter() {
const router = express.Router();
// express.raw so a malformed/empty body can't 400 the webhook into an
// infinite Zaprite retry loop — we parse defensively and always 200
// unless we genuinely want a retry (transient lookup failure → 5xx).
router.post(
"/zaprite/webhook",
express.raw({ type: "*/*", limit: "1mb" }),
async (req, res) => {
let payload = {};
try {
const txt = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
payload = txt ? JSON.parse(txt) : {};
} catch {
return res.status(200).json({ ok: true, ignored: "bad_json" });
}
const orderId = orderIdFromWebhook(payload);
if (!orderId) {
return res.status(200).json({ ok: true, ignored: "no_order_id" });
}
const dedupKey = `zaprite:${orderId}`;
if (processedZaprite.has(dedupKey)) {
return res
.status(200)
.json({ ok: true, ignored: "already_processed", orderId });
}
const zaprite = await getZapriteConfig();
if (!zaprite.apiKey) {
// Can't verify without the API key. 200 so Zaprite stops retrying.
console.warn("[zaprite/webhook] received but Zaprite not configured");
return res.status(200).json({ ok: true, ignored: "not_configured" });
}
// Re-fetch the order — this is the authoritative status + metadata.
let order;
try {
order = await getOrder({
baseURL: zaprite.baseUrl,
apiKey: zaprite.apiKey,
orderId,
});
} catch (err) {
console.error(
`[zaprite/webhook] getOrder failed for ${orderId}: ${err?.message || err}`,
);
// 5xx → Zaprite retries; likely a transient network/API blip.
return res.status(502).json({ error: "order_lookup_failed" });
}
if (!isOrderPaid(order)) {
// PENDING / PROCESSING / UNDERPAID — ack but don't grant, and DON'T
// mark processed: a later settle webhook for this order should be
// allowed to grant once it's actually paid.
return res.status(200).json({
ok: true,
ignored: `status=${order?.status || "unknown"}`,
orderId,
});
}
const meta = order.metadata || {};
if (meta.product !== "recap_tier_subscription") {
processedZaprite.add(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "not_a_tier_order", orderId });
}
const subUserId = typeof meta.user_id === "string" ? meta.user_id : "";
const subTier = meta.tier;
const periodDays = Number(meta.period_days) || 30;
if (!subUserId || (subTier !== "pro" && subTier !== "max")) {
processedZaprite.add(dedupKey);
return res
.status(200)
.json({ ok: true, ignored: "bad_tier_metadata", orderId });
}
try {
const row = await extendUserTier({
userId: subUserId,
tier: subTier,
periodDays,
});
processedZaprite.add(dedupKey);
console.log(
`[zaprite/webhook] ${subTier} +${periodDays}d for user ${subUserId.slice(0, 8)}… (order ${orderId}) → expires ${row.subscription_expires_at}`,
);
return res.status(200).json({
ok: true,
tier: subTier,
user: subUserId,
expires_at: row.subscription_expires_at,
});
} catch (err) {
console.error(
`[zaprite/webhook] extendUserTier failed: ${err?.message || err}`,
);
return res.status(500).json({ error: "tier_grant_failed" });
}
},
);
return router;
}
+136
View File
@@ -0,0 +1,136 @@
// Node-built-in test runner. Run with `node --test server/test/`.
//
// Targets the credit-key resolver added in the license-keyed-credits
// refactor. The headline guarantee: same license + different
// install_ids resolve to the SAME credit key. Plus a handful of
// adjacent cases worth pinning so the contract doesn't drift.
import { test } from "node:test";
import assert from "node:assert/strict";
import { getCreditKey, licenseFingerprint } from "../credits.js";
test("licenseFingerprint: stable hash from licenseUuid", () => {
const fp1 = licenseFingerprint({
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
});
const fp2 = licenseFingerprint({
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
});
assert.equal(typeof fp1, "string");
assert.equal(fp1.length, 16);
assert.equal(fp1, fp2, "same licenseUuid should yield same fingerprint");
});
test("licenseFingerprint: different UUIDs yield different fingerprints", () => {
const fp1 = licenseFingerprint({
tier: "pro",
licenseUuid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
});
const fp2 = licenseFingerprint({
tier: "pro",
licenseUuid: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
});
assert.notEqual(fp1, fp2);
});
test("licenseFingerprint: missing licenseUuid returns null", () => {
assert.equal(licenseFingerprint(null), null);
assert.equal(licenseFingerprint({ tier: "pro" }), null);
assert.equal(licenseFingerprint({ tier: "pro", licenseUuid: null }), null);
});
test("getCreditKey: Core falls back to inst:<installId>", () => {
const k = getCreditKey({
installId: "abc-123",
license: { tier: "core" },
});
assert.equal(k, "inst:abc-123");
});
test("getCreditKey: anonymous (no license) uses inst:<installId>", () => {
assert.equal(getCreditKey({ installId: "xyz" }), "inst:xyz");
assert.equal(getCreditKey({ installId: "xyz", license: null }), "inst:xyz");
});
test("getCreditKey: Pro license routes to lic:<fingerprint>", () => {
const k = getCreditKey({
installId: "any-install",
license: {
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
},
});
assert.match(k, /^lic:[0-9a-f]{16}$/);
});
// Headline guarantee for the refactor: one license activated on two
// different installs MUST resolve to the same credit-key, so both
// devices share one Pro monthly pool.
test("getCreditKey: same license + different installs → same key", () => {
const license = {
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
};
const k1 = getCreditKey({ installId: "install-A", license });
const k2 = getCreditKey({ installId: "install-B", license });
assert.equal(k1, k2);
assert.match(k1, /^lic:/);
});
test("getCreditKey: same install + different licenses → different keys", () => {
const installId = "shared-install";
const k1 = getCreditKey({
installId,
license: {
tier: "pro",
licenseUuid: "11111111-2222-3333-4444-555555555555",
},
});
const k2 = getCreditKey({
installId,
license: {
tier: "pro",
licenseUuid: "99999999-9999-9999-9999-999999999999",
},
});
assert.notEqual(k1, k2);
});
test("getCreditKey: Max tier also routes to lic:<fingerprint>", () => {
const k = getCreditKey({
installId: "any-install",
license: {
tier: "max",
licenseUuid: "11111111-2222-3333-4444-555555555555",
},
});
assert.match(k, /^lic:[0-9a-f]{16}$/);
});
// Paid tier with no resolvable fingerprint (license object missing
// licenseUuid) should defensively fall back to install-keyed rather
// than throwing or producing a phantom "lic:" key — keeps the ledger
// behaving correctly when the keysat verifier returns degraded data.
test("getCreditKey: paid tier without licenseUuid falls back to inst:", () => {
const k = getCreditKey({
installId: "ins-77",
license: { tier: "pro" }, // no licenseUuid
});
assert.equal(k, "inst:ins-77");
});
test("getCreditKey: throws when neither installId nor a usable license is present", () => {
assert.throws(
() => getCreditKey({}),
/installId required/i,
"should throw on empty input"
);
assert.throws(
() => getCreditKey({ license: { tier: "core" } }),
/installId required/i,
"should throw on Core license with no installId"
);
});
+79
View File
@@ -0,0 +1,79 @@
// Phase 1 of self-serve subscriptions: prepaid-period expiry enforcement +
// the extend-from-current-expiry logic both payment rails will call.
import { test, describe, before } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { identityTier, isSubscriptionExpired } from "../identity.js";
import { initCredits, extendUserTier, getUserCreditRow } from "../credits.js";
const DAY = 24 * 60 * 60 * 1000;
describe("subscription expiry enforcement (pure)", () => {
test("isSubscriptionExpired: null / future / past / invalid", () => {
assert.equal(isSubscriptionExpired({}), false);
assert.equal(isSubscriptionExpired({ subscription_expires_at: null }), false);
assert.equal(
isSubscriptionExpired({ subscription_expires_at: new Date(Date.now() + DAY).toISOString() }),
false,
);
assert.equal(
isSubscriptionExpired({ subscription_expires_at: new Date(Date.now() - DAY).toISOString() }),
true,
);
assert.equal(isSubscriptionExpired({ subscription_expires_at: "not-a-date" }), false);
});
test("identityTier: cloud tier honored until expiry, then Core", () => {
const cloud = { kind: "cloud" };
// No expiry = operator comp grant → never expires.
assert.equal(identityTier(cloud, { tier_snapshot: "max" }), "max");
// Future expiry → tier honored.
assert.equal(
identityTier(cloud, { tier_snapshot: "max", subscription_expires_at: new Date(Date.now() + DAY).toISOString() }),
"max",
);
// Past expiry → effectively Core.
assert.equal(
identityTier(cloud, { tier_snapshot: "max", subscription_expires_at: new Date(Date.now() - DAY).toISOString() }),
"core",
);
});
test("identityTier: license path is unaffected by expiry", () => {
assert.equal(identityTier({ kind: "license", license: { tier: "pro" } }, null), "pro");
});
});
describe("extendUserTier (prepaid periods)", () => {
before(async () => {
await initCredits({ dataDir: mkdtempSync(path.join(tmpdir(), "relay-tier-")) });
});
test("first purchase sets the tier + ~periodDays out", async () => {
const row = await extendUserTier({ userId: "u1", tier: "pro", periodDays: 30 });
assert.equal(row.tier_snapshot, "pro");
const days = (new Date(row.subscription_expires_at).getTime() - Date.now()) / DAY;
assert.ok(days > 29.9 && days < 30.1, `~30 days, got ${days}`);
});
test("early renewal EXTENDS from the current expiry (adds time)", async () => {
const first = await extendUserTier({ userId: "u2", tier: "max", periodDays: 30 });
const firstExp = new Date(first.subscription_expires_at).getTime();
const second = await extendUserTier({ userId: "u2", tier: "max", periodDays: 30 });
const addedDays = (new Date(second.subscription_expires_at).getTime() - firstExp) / DAY;
assert.ok(addedDays > 29.9 && addedDays < 30.1, `added ~30 days, got ${addedDays}`);
});
test("renewing AFTER expiry starts fresh from now", async () => {
const row = await getUserCreditRow("u3");
row.tier_snapshot = "pro";
row.subscription_expires_at = new Date(Date.now() - 5 * DAY).toISOString(); // expired 5d ago
const renewed = await extendUserTier({ userId: "u3", tier: "pro", periodDays: 30 });
const days = (new Date(renewed.subscription_expires_at).getTime() - Date.now()) / DAY;
assert.ok(days > 29.9 && days < 30.1, `fresh ~30 days from now, got ${days}`);
});
});
+52
View File
@@ -0,0 +1,52 @@
// Zaprite (card rail) pure-logic tests: the paid-status gate that decides
// whether a webhook should grant a tier, and the order-id extraction that
// tolerates Zaprite's loosely-documented webhook payload shapes.
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { isOrderPaid, orderIdFromWebhook } from "../zaprite-client.js";
describe("zaprite isOrderPaid", () => {
test("paid statuses grant; everything else does not", () => {
for (const status of ["PAID", "COMPLETE", "OVERPAID"]) {
assert.equal(isOrderPaid({ status }), true, `${status} should be paid`);
}
for (const status of ["PENDING", "PROCESSING", "UNDERPAID", "", "WAT"]) {
assert.equal(isOrderPaid({ status }), false, `${status} should NOT be paid`);
}
});
test("case-insensitive + null-safe", () => {
assert.equal(isOrderPaid({ status: "paid" }), true);
assert.equal(isOrderPaid({ status: "complete" }), true);
assert.equal(isOrderPaid(null), false);
assert.equal(isOrderPaid({}), false);
assert.equal(isOrderPaid(undefined), false);
});
});
describe("zaprite orderIdFromWebhook", () => {
test("extracts id from the common payload shapes", () => {
assert.equal(orderIdFromWebhook({ id: "o1" }), "o1");
assert.equal(orderIdFromWebhook({ orderId: "o2" }), "o2");
assert.equal(orderIdFromWebhook({ order: { id: "o3" } }), "o3");
assert.equal(orderIdFromWebhook({ data: { id: "o4" } }), "o4");
assert.equal(orderIdFromWebhook({ data: { orderId: "o5" } }), "o5");
assert.equal(orderIdFromWebhook({ data: { order: { id: "o6" } } }), "o6");
});
test("prefers explicit orderId over a nested id", () => {
assert.equal(
orderIdFromWebhook({ orderId: "explicit", order: { id: "nested" } }),
"explicit",
);
});
test("returns null when no id is present", () => {
assert.equal(orderIdFromWebhook({}), null);
assert.equal(orderIdFromWebhook(null), null);
assert.equal(orderIdFromWebhook("nope"), null);
assert.equal(orderIdFromWebhook({ foo: "bar" }), null);
});
});
+160
View File
@@ -0,0 +1,160 @@
// Thin wrapper around Zaprite's hosted-checkout API for the card rail of
// the self-serve subscription purchase. Zaprite is the "Pay by card"
// counterpart to BTCPay (the Bitcoin rail) — both end at extendUserTier.
//
// What this module owns:
// 1. createOrder() — POST /v1/orders to mint a hosted checkout priced
// in the operator's fiat currency. Embeds {product, user_id, tier,
// period_days} in order metadata so the webhook can grant the tier.
// 2. getOrder() — GET /v1/orders/{id} to re-fetch an order and
// confirm its paid status server-side. This is how we VERIFY a
// webhook: rather than trust the webhook body (Zaprite's signing
// mechanism isn't publicly documented), we re-fetch the order from
// the authenticated API and check its status — the same
// re-fetch-to-verify pattern the BTCPay handler uses.
// 3. isOrderPaid() — true once an order's status means the buyer's
// money has landed.
//
// Operator config (StartOS "Set Zaprite Connection" action):
// relay_zaprite_base_url default https://api.zaprite.com
// relay_zaprite_api_key Zaprite API key (Settings > API in Zaprite)
// relay_zaprite_currency fiat the card is charged in (default USD)
//
// API shape (api.zaprite.com):
// POST /v1/orders Bearer auth → { id, checkoutUrl, status, metadata }
// body: { amount, currency, label?, redirectUrl?, metadata?, externalUniqId? }
// amount is an integer in the currency's smallest unit (cents for USD).
// GET /v1/orders/{id} Bearer auth → the order object
// status ∈ PENDING | PROCESSING | PAID | OVERPAID | UNDERPAID | COMPLETE
export class ZapriteError extends Error {
constructor(status, message, body) {
super(message);
this.name = "ZapriteError";
this.status = status;
this.body = body;
}
}
// Statuses that mean the buyer has paid in full (or more). PAID = paid
// but awaiting manual fulfillment; COMPLETE = paid and fulfilled;
// OVERPAID = paid more than due. All three are "grant the tier".
// UNDERPAID / PENDING / PROCESSING are NOT sufficient.
const PAID_STATUSES = new Set(["PAID", "COMPLETE", "OVERPAID"]);
export function isOrderPaid(order) {
const status = String(order?.status || "").toUpperCase();
return PAID_STATUSES.has(status);
}
// Create a Zaprite hosted-checkout order for a prepaid Pro/Max period.
// `amount` is in the smallest unit of `currency` (cents for USD).
// `metadata` values are coerced to strings (Zaprite requires string
// values). Returns the raw order object — caller reads id + checkoutUrl.
export async function createOrder({
baseURL,
apiKey,
amount,
currency,
label = "",
metadata = {},
redirectUrl = null,
externalUniqId = null,
}) {
assertConfigured({ baseURL, apiKey });
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error("Zaprite createOrder: amount must be a positive integer");
}
// Zaprite metadata values must be strings (<=1000 chars). Coerce.
const safeMeta = {};
for (const [k, v] of Object.entries(metadata || {})) {
if (v == null) continue;
safeMeta[k] = String(v).slice(0, 1000);
}
const body = {
amount: Math.round(amount),
currency: String(currency || "USD").toUpperCase(),
metadata: safeMeta,
};
if (label) body.label = label;
if (redirectUrl) body.redirectUrl = redirectUrl;
if (externalUniqId) body.externalUniqId = externalUniqId;
const url = `${trimSlash(baseURL)}/v1/orders`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
const text = await res.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {}
if (!res.ok) {
throw new ZapriteError(
res.status,
`Zaprite createOrder ${res.status}: ${text?.slice(0, 300) || res.statusText}`,
parsed,
);
}
return parsed;
}
// Re-fetch a single order by its Zaprite id (or externalUniqId). Used by
// the webhook to confirm a paid status before granting the tier.
export async function getOrder({ baseURL, apiKey, orderId }) {
assertConfigured({ baseURL, apiKey });
if (!orderId) throw new Error("Zaprite getOrder: orderId required");
const url = `${trimSlash(baseURL)}/v1/orders/${encodeURIComponent(orderId)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
const text = await res.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {}
if (!res.ok) {
throw new ZapriteError(
res.status,
`Zaprite getOrder ${res.status}: ${text?.slice(0, 300) || res.statusText}`,
parsed,
);
}
return parsed;
}
// Pull the order id out of a webhook payload. Zaprite's webhook body
// shape isn't strongly documented, so check the common locations rather
// than assume one — we only need the id (the authoritative status comes
// from re-fetching the order).
export function orderIdFromWebhook(payload) {
if (!payload || typeof payload !== "object") return null;
return (
payload.orderId ||
payload.id ||
payload.order?.id ||
payload.data?.id ||
payload.data?.orderId ||
payload.data?.order?.id ||
null
);
}
function assertConfigured({ baseURL, apiKey }) {
if (!baseURL || !apiKey) {
throw new Error(
"Zaprite is not configured — set the API key via the StartOS 'Set Zaprite Connection' action",
);
}
}
function trimSlash(s) {
return (s || "").replace(/\/$/, "");
}