v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
-- Initial schema for the licensing service.
|
||||
--
|
||||
-- SQLite is used in WAL mode; all tables are intentionally flat and indexed
|
||||
-- for the common query paths (validate by key_id, list by product, look up by
|
||||
-- invoice_id from BTCPay webhooks).
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
slug TEXT NOT NULL UNIQUE, -- human-friendly id used in URLs
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price_sats INTEGER NOT NULL, -- price in satoshis
|
||||
active INTEGER NOT NULL DEFAULT 1, -- boolean; 0 hides from listings
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}', -- arbitrary developer metadata
|
||||
created_at TEXT NOT NULL, -- ISO-8601 UTC
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_products_active ON products(active);
|
||||
|
||||
-- Invoices track BTCPay payment attempts. One invoice maps to at most one
|
||||
-- license. If payment never completes, the invoice just sits in 'pending' /
|
||||
-- 'expired' and no license is ever issued.
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
id TEXT PRIMARY KEY, -- UUID v4 (our id)
|
||||
btcpay_invoice_id TEXT NOT NULL UNIQUE, -- id from BTCPay Server
|
||||
product_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- pending | settled | expired | invalid
|
||||
buyer_email TEXT, -- optional, supplied at purchase
|
||||
buyer_note TEXT, -- optional purchase note
|
||||
amount_sats INTEGER NOT NULL,
|
||||
checkout_url TEXT NOT NULL, -- BTCPay checkout URL
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_btcpay_id ON invoices(btcpay_invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
|
||||
|
||||
-- Licenses are the issued proofs-of-purchase. The `key_id` is what a client
|
||||
-- presents when validating; the actual user-facing license key string is a
|
||||
-- signed envelope containing this id plus metadata (see crypto module).
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id TEXT PRIMARY KEY, -- UUID v4, also the `license_id` in the signed payload
|
||||
product_id TEXT NOT NULL,
|
||||
invoice_id TEXT UNIQUE, -- NULL for manually-issued / comped licenses
|
||||
status TEXT NOT NULL, -- active | revoked
|
||||
fingerprint TEXT, -- optional machine fingerprint locked on first validation
|
||||
bound_identity TEXT, -- optional user identity (email, pubkey, etc.) locked on first use
|
||||
issued_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
revocation_reason TEXT,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
FOREIGN KEY (product_id) REFERENCES products(id),
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_product ON licenses(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
|
||||
|
||||
-- Audit log of validation attempts. Useful for abuse detection and for
|
||||
-- developers building rate-limiting policies on top.
|
||||
CREATE TABLE IF NOT EXISTS validation_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
license_id TEXT,
|
||||
product_id TEXT,
|
||||
fingerprint TEXT,
|
||||
result TEXT NOT NULL, -- ok | bad_signature | revoked | product_mismatch | fingerprint_mismatch | not_found
|
||||
client_ip TEXT,
|
||||
user_agent TEXT,
|
||||
occurred_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_validation_license ON validation_log(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_validation_time ON validation_log(occurred_at);
|
||||
|
||||
-- Server-wide signing key. Stored here (rather than on disk) so a SQLite
|
||||
-- backup captures the full server state. The private key is PEM-encoded.
|
||||
-- Generated on first boot if no row exists.
|
||||
CREATE TABLE IF NOT EXISTS server_keys (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton
|
||||
algorithm TEXT NOT NULL, -- 'ed25519'
|
||||
public_key_pem TEXT NOT NULL,
|
||||
private_key_pem TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- BTCPay connection state.
|
||||
--
|
||||
-- Before v0.1 this lived purely in environment variables; now it's persisted
|
||||
-- in the DB so the operator can connect to BTCPay via the one-click authorize
|
||||
-- flow instead of pasting an API key into an env file.
|
||||
--
|
||||
-- A single row (id = 1). Rows are upserted on connect / reset.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS btcpay_config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton
|
||||
base_url TEXT NOT NULL, -- BTCPay base URL
|
||||
api_key TEXT NOT NULL, -- issued by authorize flow
|
||||
store_id TEXT NOT NULL, -- selected store id
|
||||
webhook_id TEXT, -- BTCPay webhook id (for update/delete)
|
||||
webhook_secret TEXT NOT NULL, -- HMAC-SHA256 secret shared with BTCPay
|
||||
connected_at TEXT NOT NULL -- ISO-8601 UTC
|
||||
);
|
||||
|
||||
-- CSRF tokens for an in-flight authorize round trip. The service generates one
|
||||
-- when the operator clicks "Connect BTCPay", then validates it on the redirect
|
||||
-- callback. Short-lived; pruned by timestamp.
|
||||
CREATE TABLE IF NOT EXISTS btcpay_authorize_state (
|
||||
state_token TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_btcpay_authorize_state_time
|
||||
ON btcpay_authorize_state(created_at);
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Expanded features: policies, machines, entitlements, expiry + grace,
|
||||
-- suspension, outbound webhooks, admin audit log, and token-bucket rate
|
||||
-- limiting. This migration is additive — v1 licenses issued before it was
|
||||
-- applied still work, because the missing columns get sensible defaults.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Policies (Keygen-style license templates)
|
||||
--
|
||||
-- A policy encapsulates "how should licenses of this shape behave" so the
|
||||
-- developer doesn't have to hand-pick values on every issuance. Example
|
||||
-- policies for a single product: "Pro Perpetual", "Pro Annual",
|
||||
-- "Pro 14-day Trial".
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS policies (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
product_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- human-readable, e.g. "Pro Perpetual"
|
||||
slug TEXT NOT NULL, -- short machine-id, unique within product
|
||||
duration_seconds INTEGER NOT NULL DEFAULT 0, -- 0 = perpetual; else seconds from issuance to expiry
|
||||
grace_seconds INTEGER NOT NULL DEFAULT 0, -- additional seconds after expiry where validate still returns ok with a warning
|
||||
max_machines INTEGER NOT NULL DEFAULT 1, -- concurrent-activation cap; 1 mimics "one seat", 0 = unlimited
|
||||
is_trial INTEGER NOT NULL DEFAULT 0, -- 0/1; trials get FLAG_TRIAL in signed payload
|
||||
price_sats_override INTEGER, -- if set, overrides product.price_sats for invoices using this policy
|
||||
entitlements_json TEXT NOT NULL DEFAULT '[]', -- JSON array of feature slugs baked into every license
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}', -- free-form developer metadata
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id),
|
||||
UNIQUE (product_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_product ON policies(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_active ON policies(active);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Licenses — extended
|
||||
--
|
||||
-- New columns for expiry, grace, suspension, entitlements cache, seat cap,
|
||||
-- trial flag, and an optional Nostr npub (we'll use this later for DM-based
|
||||
-- key delivery / recovery). None of these columns are required; older rows
|
||||
-- get sensible defaults via DEFAULT clauses.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE licenses ADD COLUMN policy_id TEXT REFERENCES policies(id);
|
||||
ALTER TABLE licenses ADD COLUMN expires_at TEXT; -- ISO-8601 UTC; NULL = perpetual
|
||||
ALTER TABLE licenses ADD COLUMN grace_seconds INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE licenses ADD COLUMN max_machines INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE licenses ADD COLUMN suspended_at TEXT;
|
||||
ALTER TABLE licenses ADD COLUMN suspension_reason TEXT;
|
||||
ALTER TABLE licenses ADD COLUMN entitlements_json TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE licenses ADD COLUMN is_trial INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE licenses ADD COLUMN nostr_npub TEXT;
|
||||
ALTER TABLE licenses ADD COLUMN buyer_email TEXT; -- denormalized from invoice for admin search; NULL for comps without email
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_policy ON licenses(policy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_expires ON licenses(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_buyer_email ON licenses(buyer_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_nostr_npub ON licenses(nostr_npub);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Machines (multi-seat activation model)
|
||||
--
|
||||
-- Replaces the single-column `fingerprint` on licenses for licenses that
|
||||
-- allow more than one concurrent machine. Older code paths that only look at
|
||||
-- licenses.fingerprint still work for single-seat licenses, but validate.rs
|
||||
-- now also consults this table when max_machines != 1.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
license_id TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL, -- raw client-supplied id (we never stored the hash server-side; we store raw to allow rebind)
|
||||
fingerprint_hash TEXT NOT NULL, -- hex of SHA-256(fingerprint); indexed for fast lookup
|
||||
hostname TEXT, -- optional human-friendly label the client may supply
|
||||
platform TEXT, -- optional "linux-x64", "darwin-arm64", etc.
|
||||
ip_last_seen TEXT,
|
||||
activated_at TEXT NOT NULL,
|
||||
last_heartbeat_at TEXT,
|
||||
deactivated_at TEXT, -- NULL = active
|
||||
deactivation_reason TEXT,
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_machines_license_fp ON machines(license_id, fingerprint_hash) WHERE deactivated_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_license ON machines(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_heartbeat ON machines(last_heartbeat_at);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Outbound webhooks
|
||||
--
|
||||
-- Mirror of BTCPay's model: an endpoint is a URL + signing secret; each
|
||||
-- delivery gets logged so admins can debug and retry.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS webhook_endpoints (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL, -- HMAC-SHA256 key (random, 32 bytes, hex)
|
||||
event_types TEXT NOT NULL DEFAULT '["*"]', -- JSON array of subscribed event types; "*" = all
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_endpoints_active ON webhook_endpoints(active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
endpoint_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, -- license.issued, license.revoked, license.suspended, machine.activated, invoice.settled, etc.
|
||||
payload_json TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT, -- NULL once delivered or permanently failed
|
||||
last_status_code INTEGER,
|
||||
last_error TEXT,
|
||||
delivered_at TEXT, -- NULL until success
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (endpoint_id) REFERENCES webhook_endpoints(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_endpoint ON webhook_deliveries(endpoint_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_next ON webhook_deliveries(next_attempt_at) WHERE delivered_at IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Admin audit log
|
||||
--
|
||||
-- Every mutation initiated through the admin API (product create, license
|
||||
-- revoke, suspension, policy change, webhook edit, BTCPay reconnect, manual
|
||||
-- issuance, etc.) writes one row. The API key used is hashed before storage
|
||||
-- so the log alone can't be used to recover the key.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_kind TEXT NOT NULL, -- 'admin_api_key' | 'system' | 'btcpay_webhook'
|
||||
actor_hash TEXT, -- SHA-256 of the actor's credential, or NULL for system
|
||||
action TEXT NOT NULL, -- dotted event name: product.create, license.revoke, etc.
|
||||
target_kind TEXT, -- 'product' | 'license' | 'policy' | 'machine' | 'webhook' | 'invoice' | NULL
|
||||
target_id TEXT,
|
||||
request_ip TEXT,
|
||||
user_agent TEXT,
|
||||
details_json TEXT NOT NULL DEFAULT '{}',
|
||||
occurred_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_occurred ON audit_log(occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_log(target_kind, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Token-bucket rate limiting
|
||||
--
|
||||
-- We keep one row per (bucket_kind, bucket_key) so that e.g. per-IP validate
|
||||
-- buckets and per-license heartbeat buckets are stored in the same table.
|
||||
-- The refill happens lazily on every hit (classic token-bucket algorithm)
|
||||
-- so there's no background filler task to worry about.
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS rate_buckets (
|
||||
bucket_kind TEXT NOT NULL, -- 'validate_ip', 'validate_license', 'heartbeat_license', 'admin_ip', ...
|
||||
bucket_key TEXT NOT NULL, -- the IP, license_id, etc.
|
||||
tokens_remaining REAL NOT NULL,
|
||||
capacity REAL NOT NULL,
|
||||
refill_per_second REAL NOT NULL,
|
||||
last_refill_at TEXT NOT NULL, -- ISO-8601; refill math runs off this
|
||||
PRIMARY KEY (bucket_kind, bucket_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_buckets_refill ON rate_buckets(last_refill_at);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Validation log — extended
|
||||
--
|
||||
-- Add columns for the new reject reasons (expired, suspended, too_many_machines)
|
||||
-- so admins can tell at a glance why a check failed. The `result` column was
|
||||
-- already TEXT so we just start writing new values to it.
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE validation_log ADD COLUMN machine_id TEXT; -- the machines.id that was matched / created, if any
|
||||
ALTER TABLE validation_log ADD COLUMN reason_detail TEXT; -- optional extra string, e.g. "grace period remaining: 3d"
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Discount / referral codes.
|
||||
--
|
||||
-- A `discount_code` is a redeemable token (e.g. "FOUNDERS50") that reduces
|
||||
-- the price of a purchase. A code can be either a percentage off (basis
|
||||
-- points: 5000 = 50%) or a fixed sats off, can target a specific product
|
||||
-- or policy or be universal, can have an optional usage cap and expiry,
|
||||
-- and carries an optional `referrer_label` for tracking purposes (campaign
|
||||
-- name, partner email, npub — free-form, not a separate user record).
|
||||
--
|
||||
-- Atomicity: `used_count` is incremented at purchase-start time via a
|
||||
-- conditional UPDATE that gates on the cap. A `discount_redemptions` row
|
||||
-- is inserted with status='pending' alongside the increment. The
|
||||
-- redemption transitions to 'redeemed' on invoice settlement, or
|
||||
-- 'cancelled' on invoice expiry/invalid (with a corresponding decrement
|
||||
-- of used_count so freed slots become available again).
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discount_codes (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
code TEXT NOT NULL UNIQUE, -- normalized to UPPERCASE on insert; case-insensitive lookup
|
||||
kind TEXT NOT NULL, -- 'percent' | 'fixed_sats' | 'free_license'
|
||||
amount INTEGER NOT NULL, -- basis points if percent, sats if fixed_sats, ignored if free_license (set to 0)
|
||||
max_uses INTEGER, -- NULL = unlimited
|
||||
used_count INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT, -- ISO-8601 UTC; NULL = never
|
||||
applies_to_product_id TEXT, -- NULL = any product
|
||||
applies_to_policy_id TEXT, -- NULL = any policy
|
||||
referrer_label TEXT, -- optional, e.g. 'twitter-launch', 'alice@example.com'
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (applies_to_product_id) REFERENCES products(id),
|
||||
FOREIGN KEY (applies_to_policy_id) REFERENCES policies(id),
|
||||
CHECK (kind IN ('percent', 'fixed_sats', 'free_license')),
|
||||
CHECK (amount >= 0),
|
||||
CHECK (used_count >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_codes_active ON discount_codes(active);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_codes_product ON discount_codes(applies_to_product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_codes_policy ON discount_codes(applies_to_policy_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_codes_expires ON discount_codes(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS discount_redemptions (
|
||||
id TEXT PRIMARY KEY, -- UUID v4
|
||||
code_id TEXT NOT NULL,
|
||||
invoice_id TEXT NOT NULL, -- references invoices(id)
|
||||
license_id TEXT, -- populated when license is issued
|
||||
status TEXT NOT NULL, -- 'pending' | 'redeemed' | 'cancelled'
|
||||
discount_applied_sats INTEGER NOT NULL, -- base - final
|
||||
base_price_sats INTEGER NOT NULL, -- snapshot of product price at reservation time
|
||||
final_price_sats INTEGER NOT NULL, -- what BTCPay was actually charged
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (code_id) REFERENCES discount_codes(id),
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id),
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id),
|
||||
CHECK (status IN ('pending', 'redeemed', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_code ON discount_redemptions(code_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_invoice ON discount_redemptions(invoice_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_license ON discount_redemptions(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_discount_redemptions_status ON discount_redemptions(status);
|
||||
|
||||
-- One redemption per invoice — a buyer can apply at most one code per
|
||||
-- purchase. If they want to layer codes, they'll need a v0.2 feature.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_discount_redemptions_one_per_invoice
|
||||
ON discount_redemptions(invoice_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Runtime-mutable settings, intentionally separated from the
|
||||
-- startup-only env-var config in `Config::from_env`. Anything that
|
||||
-- should be live-editable through admin actions or the future web UI —
|
||||
-- and survive a daemon restart — goes here.
|
||||
--
|
||||
-- The table is a generic key/value store rather than dedicated columns
|
||||
-- because the set of settings will grow over time, and the cost of a
|
||||
-- key/value pattern with at most a few dozen rows is nil.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Migration 0006: tip-recipient on policy.
|
||||
--
|
||||
-- Lets the operator configure a Lightning recipient + percentage on each
|
||||
-- policy. When a license issued under that policy settles, the daemon
|
||||
-- tries to send a Lightning tip of (license_price_sats * tip_pct_bps / 10000)
|
||||
-- to tip_recipient via the operator's BTCPay Lightning node.
|
||||
--
|
||||
-- All three fields are nullable / zero-default. Existing policies are
|
||||
-- unaffected: with NULL recipient the issuance hook is a no-op.
|
||||
--
|
||||
-- Recipient can be a Lightning Address (e.g. tip@keysat.xyz). LNURL-pay
|
||||
-- support may be added later; the current implementation resolves only
|
||||
-- Lightning Addresses via the .well-known/lnurlp/<user> endpoint.
|
||||
|
||||
ALTER TABLE policies ADD COLUMN tip_recipient TEXT;
|
||||
ALTER TABLE policies ADD COLUMN tip_pct_bps INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE policies ADD COLUMN tip_label TEXT;
|
||||
|
||||
-- Audit log for tip attempts. Insert one row per try, success or failure.
|
||||
-- Operators consult this for accounting and for debugging when a tip
|
||||
-- doesn't fire as expected.
|
||||
CREATE TABLE IF NOT EXISTS tip_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
license_id TEXT NOT NULL,
|
||||
policy_id TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL,
|
||||
amount_sats INTEGER NOT NULL,
|
||||
pct_bps INTEGER NOT NULL,
|
||||
label TEXT,
|
||||
-- 'sent' | 'failed' | 'skipped' (e.g. zero amount, no LN node)
|
||||
status TEXT NOT NULL,
|
||||
-- Error or success detail message.
|
||||
detail TEXT,
|
||||
-- Lightning payment hash on success, null on failure.
|
||||
payment_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id),
|
||||
FOREIGN KEY (policy_id) REFERENCES policies(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_attempts_license ON tip_attempts(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_attempts_recipient ON tip_attempts(recipient);
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_attempts_created ON tip_attempts(created_at);
|
||||
Reference in New Issue
Block a user