commit 601c85f23693415897ddde579fb3a9ad8a6f1822 Author: Avi Date: Wed Sep 23 18:57:11 2026 -0500 Initial commit: SolLunar Kitchen under version control diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..513af21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +.next/ +dist/ +build/ +venv/ +.venv/ +__pycache__/ +*.pyc +.gradle/ +target/ +*.log +.DS_Store +.env +*.key +*.pem +*.jks +tsconfig.tsbuildinfo +instance/ diff --git a/SolLunar_svg_bw_notext.jpeg b/SolLunar_svg_bw_notext.jpeg new file mode 100644 index 0000000..711caf2 Binary files /dev/null and b/SolLunar_svg_bw_notext.jpeg differ diff --git a/_shot_cosmic.html b/_shot_cosmic.html new file mode 100644 index 0000000..af021c6 --- /dev/null +++ b/_shot_cosmic.html @@ -0,0 +1,972 @@ + + + + + + + + + + + +KITCHEN 484 · SOLARPUNK SUMMIT + + + + + + + + + +
+
+ +
KITCHEN 484SOLARPUNK SUMMIT
+
+ +
--:--
+ + +
+
+ +
+ +
+
+
+ NO SEED OILS + GRASS FED + VEG FRIENDLY + CASHLESS · BTC +
+

WHEN'S FOOD ON?

+

Live schedule for the Solarpunk Summit kitchen. See when each stand is serving, pre-order for a window, and pay with Bitcoin — no cash, no cards.

+
+
+
+

Serving schedule

+
+
+
+
+
+ + + + +
KITCHEN 484 · Off-grid · Zero waste · Powered by BTCPay
+
+ + + + +
+ + +
+ + + + +
+ + + + diff --git a/backups/home.html.pre-reorder-20260831-061852 b/backups/home.html.pre-reorder-20260831-061852 new file mode 100644 index 0000000..2748f68 --- /dev/null +++ b/backups/home.html.pre-reorder-20260831-061852 @@ -0,0 +1,583 @@ + + + + + + + + + + + +K484 · A Private Membership Association + + + + + + + +
+
+ +
KITCHEN 484SOLARPUNK SUMMIT
+
+ + Join + Food Menu +
+
+
+ +
+ +
SOLARPUNK SUMMIT · Oct 8–12
+

K484

+
A Private Membership Association
+

Five days. Off-grid kitchen. No seed oils, all organic grass-fed, gluten free and vegetarian friendly. See when the food is on, pre-order, and pay with Bitcoin — no cash, no cards.

+
+ No Seed Oils + All Organic Grass Fed + Gluten Free & Vegetarian Friendly + Off Grid — Zero Waste +
+ +
+ + + +
+ +
+
+
+

How It Works

+
Five days, three steps
+
+
+
+
01
+

Check the Schedule

+

Every stand posts its serving window for each day. The live timeline shows what's open right now and what's coming.

+
+
+
02
+

Pre-Order Your Window

+

Add items to your order, pick a pickup window, and name your order so we can call it out at the stand.

+
+
+
03
+

Pay with Bitcoin

+

Pay on-chain or via Lightning through BTCPay Server. Get your pickup code the moment your payment confirms.

+
+
+
+
+ +
+ +
+
+
+

Membership

+
Kitchen 484 · Private Membership Association
+
+ +
+
+ Note: This is a private, members-only association. Participation is absolutely voluntary. Membership is granted for the duration of the festival (24 hours per day, Oct 8–12), beginning upon acceptance of this application. +
+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
Sign here with mouse or finger
+
+
+ Signature required to apply + +
+
+ +
+

K484 Membership Covenant

+

By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:

+
    +
  1. I am joining a private, members-only association, not a public business.
  2. +
  3. Access to kitchen activities, meals, and services is limited to accepted members only.
  4. +
  5. I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.
  6. +
  7. Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.
  8. +
  9. I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.
  10. +
  11. I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.
  12. +
+
+ +
+ + +
+ +
+ + Browse the Food Menu First +
+
+
+ + +
+
+ + + + + + diff --git a/backups/home.html.pre-reorder-20260831-061942 b/backups/home.html.pre-reorder-20260831-061942 new file mode 100644 index 0000000..2748f68 --- /dev/null +++ b/backups/home.html.pre-reorder-20260831-061942 @@ -0,0 +1,583 @@ + + + + + + + + + + + +K484 · A Private Membership Association + + + + + + + +
+
+ +
KITCHEN 484SOLARPUNK SUMMIT
+
+ + Join + Food Menu +
+
+
+ +
+ +
SOLARPUNK SUMMIT · Oct 8–12
+

K484

+
A Private Membership Association
+

Five days. Off-grid kitchen. No seed oils, all organic grass-fed, gluten free and vegetarian friendly. See when the food is on, pre-order, and pay with Bitcoin — no cash, no cards.

+
+ No Seed Oils + All Organic Grass Fed + Gluten Free & Vegetarian Friendly + Off Grid — Zero Waste +
+ +
+ + + +
+ +
+
+
+

How It Works

+
Five days, three steps
+
+
+
+
01
+

Check the Schedule

+

Every stand posts its serving window for each day. The live timeline shows what's open right now and what's coming.

+
+
+
02
+

Pre-Order Your Window

+

Add items to your order, pick a pickup window, and name your order so we can call it out at the stand.

+
+
+
03
+

Pay with Bitcoin

+

Pay on-chain or via Lightning through BTCPay Server. Get your pickup code the moment your payment confirms.

+
+
+
+
+ +
+ +
+
+
+

Membership

+
Kitchen 484 · Private Membership Association
+
+ +
+
+ Note: This is a private, members-only association. Participation is absolutely voluntary. Membership is granted for the duration of the festival (24 hours per day, Oct 8–12), beginning upon acceptance of this application. +
+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
Sign here with mouse or finger
+
+
+ Signature required to apply + +
+
+ +
+

K484 Membership Covenant

+

By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:

+
    +
  1. I am joining a private, members-only association, not a public business.
  2. +
  3. Access to kitchen activities, meals, and services is limited to accepted members only.
  4. +
  5. I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.
  6. +
  7. Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.
  8. +
  9. I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.
  10. +
  11. I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.
  12. +
+
+ +
+ + +
+ +
+ + Browse the Food Menu First +
+
+
+ + +
+
+ + + + + + diff --git a/backups/icon-192.png.20260830-220331 b/backups/icon-192.png.20260830-220331 new file mode 100644 index 0000000..6835fb7 Binary files /dev/null and b/backups/icon-192.png.20260830-220331 differ diff --git a/backups/icon-192.png.20260830-221227 b/backups/icon-192.png.20260830-221227 new file mode 100644 index 0000000..ffbcf22 Binary files /dev/null and b/backups/icon-192.png.20260830-221227 differ diff --git a/backups/icon-192.png.crisp-20260829-195243 b/backups/icon-192.png.crisp-20260829-195243 new file mode 100644 index 0000000..99e5fcd Binary files /dev/null and b/backups/icon-192.png.crisp-20260829-195243 differ diff --git a/backups/icon-512-maskable.png.20260830-220331 b/backups/icon-512-maskable.png.20260830-220331 new file mode 100644 index 0000000..85dea9d Binary files /dev/null and b/backups/icon-512-maskable.png.20260830-220331 differ diff --git a/backups/icon-512-maskable.png.20260830-221227 b/backups/icon-512-maskable.png.20260830-221227 new file mode 100644 index 0000000..b2c33e4 Binary files /dev/null and b/backups/icon-512-maskable.png.20260830-221227 differ diff --git a/backups/icon-512.png.20260830-220331 b/backups/icon-512.png.20260830-220331 new file mode 100644 index 0000000..98c8244 Binary files /dev/null and b/backups/icon-512.png.20260830-220331 differ diff --git a/backups/icon-512.png.20260830-221227 b/backups/icon-512.png.20260830-221227 new file mode 100644 index 0000000..b2c33e4 Binary files /dev/null and b/backups/icon-512.png.20260830-221227 differ diff --git a/backups/icon.png.20260830-220331 b/backups/icon.png.20260830-220331 new file mode 100644 index 0000000..828a28f Binary files /dev/null and b/backups/icon.png.20260830-220331 differ diff --git a/backups/icon.png.20260830-221227 b/backups/icon.png.20260830-221227 new file mode 100644 index 0000000..a4e82a0 Binary files /dev/null and b/backups/icon.png.20260830-221227 differ diff --git a/backups/icon.png.crisp-20260829-195243 b/backups/icon.png.crisp-20260829-195243 new file mode 100644 index 0000000..03fac4b Binary files /dev/null and b/backups/icon.png.crisp-20260829-195243 differ diff --git a/backups/index.html.pre-cosmic-20260831-050953 b/backups/index.html.pre-cosmic-20260831-050953 new file mode 100644 index 0000000..2345c09 --- /dev/null +++ b/backups/index.html.pre-cosmic-20260831-050953 @@ -0,0 +1,848 @@ + + + + + + + + + + + +FEST 484 · SolLunar KITCHEN + + + + + + + +
+
+ +
FEST 484SolLunar KITCHEN
+
+ +
--:--
+ +
+
+ +
+ +
+
+
+ NO SEED OILS + GRASS FED + VEG FRIENDLY + CASHLESS · BTC +
+

WHEN'S FOOD ON?

+

Live schedule for the SolLunar kitchen. See when each stand is serving, pre-order for a window, and pay with Bitcoin — no cash, no cards.

+
+
+
+

Serving schedule

+
+
+
+
+
+ + + + +
FEST 484 · Off-grid · Zero waste · Powered by BTCPay
+
+ + + + +
+ + +
+ + + + +
+ + + + diff --git a/backups/index.html.pre-cosmic-removal-20260831-061257 b/backups/index.html.pre-cosmic-removal-20260831-061257 new file mode 100644 index 0000000..a8c5983 --- /dev/null +++ b/backups/index.html.pre-cosmic-removal-20260831-061257 @@ -0,0 +1,972 @@ + + + + + + + + + + + +KITCHEN 484 · SOLARPUNK SUMMIT + + + + + + + + + +
+
+ +
KITCHEN 484SOLARPUNK SUMMIT
+
+ +
--:--
+ + +
+
+ +
+ +
+
+
+ NO SEED OILS + GRASS FED + VEG FRIENDLY + CASHLESS · BTC +
+

WHEN'S FOOD ON?

+

Live schedule for the Solarpunk Summit kitchen. See when each stand is serving, pre-order for a window, and pay with Bitcoin — no cash, no cards.

+
+
+
+

Serving schedule

+
+
+
+
+
+ + + + +
KITCHEN 484 · Off-grid · Zero waste · Powered by BTCPay
+
+ + + + +
+ + +
+ + + + +
+ + + + diff --git a/backups/logo.png.20260830-220331 b/backups/logo.png.20260830-220331 new file mode 100644 index 0000000..ee470da Binary files /dev/null and b/backups/logo.png.20260830-220331 differ diff --git a/backups/logo.png.20260830-221227 b/backups/logo.png.20260830-221227 new file mode 100644 index 0000000..6ea59b8 Binary files /dev/null and b/backups/logo.png.20260830-221227 differ diff --git a/backups/logo.png.crisp-20260829-195243 b/backups/logo.png.crisp-20260829-195243 new file mode 100644 index 0000000..03fac4b Binary files /dev/null and b/backups/logo.png.crisp-20260829-195243 differ diff --git a/home.html b/home.html new file mode 100644 index 0000000..3290edf --- /dev/null +++ b/home.html @@ -0,0 +1,470 @@ + + + + + + + + + + + +K484 · A Private Membership Association + + + + + + + + +
+
+ +
KITCHEN 484SOLARPUNK SUMMIT
+
+ + Join + Food Menu +
+
+
+ +
+ +
SOLARPUNK SUMMIT · Oct 8–12
+

K484

+
A Private Membership Association
+

Five days. Off-grid kitchen. No seed oils, all organic grass-fed, gluten free and vegetarian friendly. See when the food is on, pre-order, and pay with Bitcoin — no cash, no cards.

+
+ No Seed Oils + All Organic Grass Fed + Gluten Free & Vegetarian Friendly + Off Grid — Zero Waste +
+ +
+ + +
+
+
+

Membership

+
Kitchen 484 · Private Membership Association
+
+ +
+
+ Note: This is a private, members-only association. Participation is absolutely voluntary. Membership is granted for the duration of the festival (24 hours per day, Oct 8–12), beginning upon acceptance of this application. +
+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
Sign here with mouse or finger
+
+
+ Signature required to apply + +
+
+ +
+

K484 Membership Covenant

+

By submitting this application and becoming a member of Kitchen 484, I acknowledge and agree that:

+
    +
  1. I am joining a private, members-only association, not a public business.
  2. +
  3. Access to kitchen activities, meals, and services is limited to accepted members only.
  4. +
  5. I agree to abide by the rules, governance, and confidentiality requirements outlined in this Covenant.
  6. +
  7. Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by Kitchen 484.
  8. +
  9. I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of Kitchen 484 and other members.
  10. +
  11. I acknowledge that Kitchen 484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.
  12. +
+
+ +
+ + +
+ +
+ + Browse the Food Menu First +
+
+
+ + +
+
+
+ +
+
+
+

How It Works

+
Five days, three steps
+
+
+
+
01
+

Check the Schedule

+

Every stand posts its serving window for each day. The live timeline shows what's open right now and what's coming.

+
+
+
02
+

Pre-Order Your Window

+

Add items to your order, pick a pickup window, and name your order so we can call it out at the stand.

+
+
+
03
+

Pay with Bitcoin

+

Pay on-chain or via Lightning through BTCPay Server. Get your pickup code the moment your payment confirms.

+
+
+
+
+ +
+ + + + + + + diff --git a/icon-192.png b/icon-192.png new file mode 100644 index 0000000..ad3b0e6 Binary files /dev/null and b/icon-192.png differ diff --git a/icon-512-maskable.png b/icon-512-maskable.png new file mode 100644 index 0000000..41384e0 Binary files /dev/null and b/icon-512-maskable.png differ diff --git a/icon-512.png b/icon-512.png new file mode 100644 index 0000000..999a10b Binary files /dev/null and b/icon-512.png differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..b5c0041 Binary files /dev/null and b/icon.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..df173c1 --- /dev/null +++ b/index.html @@ -0,0 +1,853 @@ + + + + + + + + + + + +KITCHEN 484 · SOLARPUNK SUMMIT + + + + + + + + +
+
+ +
KITCHEN 484SOLARPUNK SUMMIT
+
+ +
--:--
+ +
+
+ +
+ +
+
+
+ NO SEED OILS + GRASS FED + VEG FRIENDLY + CASHLESS · BTC +
+

WHEN'S FOOD ON?

+

Live schedule for the Solarpunk Summit kitchen. See when each stand is serving, pre-order for a window, and pay with Bitcoin — no cash, no cards.

+
+
+
+

Serving schedule

+
+
+
+
+
+ + + + +
KITCHEN 484 · Off-grid · Zero waste · Powered by BTCPay
+
+ + + + +
+ + +
+ + + + +
+ + + + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..d2931d8 Binary files /dev/null and b/logo.png differ diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..db8fbc7 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "KITCHEN 484 · SOLARPUNK SUMMIT", + "short_name": "KITCHEN 484", + "description": "Solarpunk Summit kitchen — see when food is on, pre-order, and pay with Bitcoin.", + "start_url": "/", + "display": "standalone", + "background_color": "#FFFFFF", + "theme_color": "#FFFFFF", + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..ad407ed --- /dev/null +++ b/server/.env.example @@ -0,0 +1,15 @@ +# Copy to .env and fill in. The server also accepts settings at runtime +# via the ⚙️ settings modal (POST /api/settings), which writes .env for you. +PORT=8787 + +# Your BTCPay Server (regtest: http://localhost:15808, mainnet: https://btcpay.yourhost.com) +BTCPAY_URL= +BTCPAY_STORE= +BTCPAY_API_KEY= + +*** Set automatically by POST /api/webhook/register, or paste it manually +# after creating a webhook in BTCPay (Store → Settings → Webhooks). +WEBHOOK_SECRET= + +*** Public URL of THIS backend (what BTCPay can reach), e.g. https://kitchen.example.com +WEBHOOK_PUBLIC_URL= diff --git a/server/docker-compose.btcpay.yml b/server/docker-compose.btcpay.yml new file mode 100644 index 0000000..cdf4002 --- /dev/null +++ b/server/docker-compose.btcpay.yml @@ -0,0 +1,188 @@ +# FEST 484 / SolLunar Kitchen — local BTCPay regtest environment +# ------------------------------------------------------------------ +# Self-contained regtest Bitcoin network + BTCPay Server + merchant +# Lightning (c-lightning). Based on the official BTCPayServer test +# compose (BTCPayServer.Tests/docker-compose.yml) with the test-only +# services removed and a BTCPay Server app container added. +# +# Start: docker compose -f docker-compose.btcpay.yml up -d +# BTCPay UI: http://localhost:15808 (first run: account setup wizard) +# Mail UI: http://localhost:34218 (catches the setup email) +# Stop: docker compose -f docker-compose.btcpay.yml down +# Reset: docker compose -f docker-compose.btcpay.yml down -v (wipes data!) +# +# RAM: ~1.2-1.5 GB while running. + +services: + + btcpayserver: + image: btcpayserver/btcpayserver:2.4.3 + restart: unless-stopped + ports: + - "15808:80" + environment: + BTCPAY_HOSTS: "127.0.0.1:15808" + NBITCOIN_NETWORK: "regtest" + BTCPAY_POSTGRES: "Server=postgres;Port=5432;Database=btcpayserver;Username=postgres;Include Error Detail=true;" + NBXPLORER_HOST: "http://nbxplorer:32838/" + # Merchant Lightning via c-lightning unix socket (shared volume below) + BTCPAY_BTCLIGHTNING: "type=clightning;server=unix:///etc/merchant_lightning/lightning-rpc" + # Mail via Mailpit (no real SMTP needed) + SMTP_SERVER: "mailpit:1025" + SMTP_USERNAME: "" + SMTP_PASSWORD: "" + SMTP_SECURITY: "NONE" + volumes: + - "btcpay_data:/home/btcpayserver/.btcpay" + - "merchant_lightningd_datadir:/etc/merchant_lightning" + depends_on: + - nbxplorer + - postgres + - merchant_lightningd + + nbxplorer: + image: nicolasdorier/nbxplorer:2.6.10 + restart: unless-stopped + ports: + - "32838:32838" + expose: + - "32838" + environment: + NBXPLORER_NETWORK: regtest + NBXPLORER_CHAINS: "btc" + NBXPLORER_BTCRPCURL: http://bitcoind:43782/ + NBXPLORER_BTCNODEENDPOINT: bitcoind:39388 + NBXPLORER_BTCRPCUSER: ceiwHEbqWI83 + NBXPLORER_BTCRPCPASSWORD: "DwubwWsoo3" + NBXPLORER_BIND: 0.0.0.0:32838 + NBXPLORER_MINGAPSIZE: 5 + NBXPLORER_MAXGAPSIZE: 10 + NBXPLORER_VERBOSE: 1 + NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer + NBXPLORER_EXPOSERPC: 1 + NBXPLORER_NOAUTH: 1 + depends_on: + - bitcoind + + bitcoind: + restart: unless-stopped + image: btcpayserver/bitcoin:31.0 + environment: + BITCOIN_NETWORK: regtest + BITCOIN_WALLETDIR: "/data/wallets" + BITCOIN_EXTRA_ARGS: |- + rpcuser=ceiwHEbqWI83 + rpcpassword=DwubwWsoo3 + rpcport=43782 + rpcbind=0.0.0.0:43782 + rpcallowip=0.0.0.0/0 + port=39388 + whitelist=0.0.0.0/0 + zmqpubrawblock=tcp://0.0.0.0:28332 + zmqpubrawtx=tcp://0.0.0.0:28333 + deprecatedrpc=signrawtransaction + fallbackfee=0.0002 + minrelaytxfee=0.00001000 + unsafesqlitesync=1 + ports: + - "43782:43782" # RPC + - "39388:39388" # P2P + expose: + - "43782" + - "39388" + - "28332" + - "28333" + volumes: + - "bitcoin_datadir:/data" + + # Merchant Lightning node (c-lightning) — provides the Lightning payment + # option in BTCPay. Shares its datadir with the btcpayserver container so + # BTCPay can reach the lightning-rpc unix socket. + merchant_lightningd: + image: btcpayserver/lightning:v26.06.1 + stop_signal: SIGKILL + restart: unless-stopped + environment: + EXPOSE_TCP: "true" + LIGHTNINGD_CHAIN: "btc" + LIGHTNINGD_NETWORK: "regtest" + LIGHTNINGD_OPT: | + developer + bitcoin-datadir=/etc/bitcoin + bitcoin-rpcconnect=bitcoind + announce-addr=merchant_lightningd:9735 + log-level=debug + funding-confirms=1 + dev-fast-gossip + dev-bitcoind-poll=1 + ports: + - "30993:9835" # REST API + - "30893:9735" # v1 P2P + expose: + - "9735" + - "9835" + volumes: + - "bitcoin_datadir:/etc/bitcoin" + - "merchant_lightningd_datadir:/root/.lightning" + depends_on: + - bitcoind + + # Customer Lightning node — ONLY needed to pay Lightning test invoices. + # Remove this service to save ~300MB RAM if you only test onchain BTC. + customer_lightningd: + image: btcpayserver/lightning:v26.06.1 + stop_signal: SIGKILL + restart: unless-stopped + environment: + EXPOSE_TCP: "true" + LIGHTNINGD_CHAIN: "btc" + LIGHTNINGD_NETWORK: "regtest" + LIGHTNINGD_OPT: | + developer + bitcoin-datadir=/etc/bitcoin + bitcoin-rpcconnect=bitcoind + announce-addr=customer_lightningd:9735 + log-level=debug + funding-confirms=1 + dev-fast-gossip + dev-bitcoind-poll=1 + ports: + - "30992:9835" # REST API + - "30892:9735" # v1 P2P + expose: + - "9735" + - "9835" + volumes: + - "bitcoin_datadir:/etc/bitcoin" + - "customer_lightningd_datadir:/root/.lightning" + depends_on: + - bitcoind + + postgres: + image: postgres:18.1 + environment: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - "39372:5432" + command: ["-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] + expose: + - "5432" + volumes: + - "postgres_test_datadir:/var/lib/postgresql" + + # Mail catcher for BTCPay's setup email + mailpit: + image: axllent/mailpit:v1.27 + ports: + - "34218:8025" # web UI + - "34219:1025" # SMTP + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + +volumes: + bitcoin_datadir: + btcpay_data: + merchant_lightningd_datadir: + customer_lightningd_datadir: + postgres_test_datadir: diff --git a/server/e2e-test.js b/server/e2e-test.js new file mode 100644 index 0000000..3e4b4a6 --- /dev/null +++ b/server/e2e-test.js @@ -0,0 +1,121 @@ +/** + * E2E test: boots mock-btcpay + server, then exercises: + * health → settings save → webhook auto-register → create invoice → + * poll status → (mock settles + fires signed webhook) → status Paid → SSE saw the update + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; + +const PORT = 8787, MOCK_PORT = 8899, BASE = `http://localhost:${PORT}`; +let failures = 0; +function check(name, cond, extra = '') { + console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra ? ` [${extra}]` : '')); + if (!cond) failures++; +} +async function j(method, path, body) { + const r = await fetch(BASE + path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: r.status, body: await r.json().catch(() => ({})) }; +} + +function boot(file, env) { + const c = spawn(process.execPath, [file], { + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + c.stdout.on('data', d => process.env.QUIET || console.log(' [' + file + '] ' + d.toString().trim())); + c.stderr.on('data', d => process.stderr.write(d)); + return c; +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +/* boot */ +const mock = boot('mock-btcpay.js', { MOCK_PORT: String(MOCK_PORT), MOCK_SETTLE_MS: '6000' }); +const srv = boot('server.js', { + PORT: String(PORT), + DATA_FILE: '/tmp/fest484-e2e-invoices.json', + WEBHOOK_PUBLIC_URL: BASE, + MOCK_API_KEY: 'mock-store-key', +}); +try { fs.rmSync('/tmp/fest484-e2e-invoices.json'); } catch { } +await sleep(1200); + +try { +/* 1 health */ +const h = await j('GET', '/health'); +check('health ok', h.status === 200 && h.body.ok === true); + +/* 2 save settings (points at the mock) */ +const s = await j('POST', '/api/settings', { url: `http://localhost:${MOCK_PORT}`, store: 'mock-store-1234', apiKey: 'mock-store-key' }); +check('settings saved', s.status === 200 && s.body.ok === true, JSON.stringify(s.body)); + +/* 3 auto-register webhook (mock returns a secret) */ +const w = await j('POST', '/api/webhook/register', {}); +check('webhook registered', w.status === 200 && w.body.ok === true, JSON.stringify(w.body)); + +/* 4 create invoice */ +const inv = await j('POST', '/api/invoices', { + amount: 24.5, currency: 'USD', orderCode: 'F484-TEST1', description: 'E2E test order', + metadata: { pickup: '11:00 – 12:00', name: 'E2E' }, +}); +check('invoice created', inv.status === 201 && inv.body.id, inv.body.id || JSON.stringify(inv.body)); +check('has bolt11', typeof inv.body.bolt11 === 'string' && inv.body.bolt11.startsWith('lnbc')); +check('has btc address', typeof inv.body.btcAddress === 'string' && inv.body.btcAddress.startsWith('bc1')); + +/* 5 open SSE and wait for the status event */ +const sseEvents = []; +const ac = new AbortController(); +const es = fetch(BASE + '/api/invoices/' + inv.body.id + '/events', { signal: ac.signal }) + .then(async r => { + const reader = r.body.getReader(); + const dec = new TextDecoder(); + let buf = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let i; + while ((i = buf.indexOf('\n\n')) >= 0) { + const frame = buf.slice(0, i); buf = buf.slice(i + 2); + for (const line of frame.split('\n')) if (line.startsWith('data: ')) sseEvents.push(JSON.parse(line.slice(6))); + } + } + }).catch(() => { }); +await sleep(300); +check('sse initial status New', sseEvents.some(e => e.type === 'status' && e.status === 'New'), JSON.stringify(sseEvents)); + +/* 6 poll until Paid (webhook from mock settles it after ~6s) */ +let paid = null; +for (let i = 0; i < 20; i++) { + const st = await j('GET', '/api/invoices/' + inv.body.id); + if (st.body.status === 'Paid') { paid = st.body; break; } + await sleep(1000); +} +check('invoice reached Paid (webhook or poll)', Boolean(paid)); +check('sse received Paid event', sseEvents.some(e => e.type === 'status' && e.status === 'Paid'), JSON.stringify(sseEvents)); +ac.abort(); + +/* 7 webhook rejects bad signature */ +const bad = await fetch(BASE + '/api/btcpay/webhook', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'BTCPay-Sig': 'sha256=' + 'ab'.repeat(32) }, + body: JSON.stringify({ event: 'InvoiceSettled', invoice: inv.body.id }), +}); +check('webhook rejects bad sig (401)', bad.status === 401); + +/* 8 rate limit sanity (not critical) */ +const rl = await j('POST', '/api/invoices', { amount: 1, currency: 'USD' }); +check('invoice create still works for 2nd invoice', rl.status === 201); + +console.log(failures === 0 ? '\nE2E: ALL PASS ✅' : `\nE2E: ${failures} FAILURE(S) ❌`); +process.exit(failures === 0 ? 0 : 1); +} catch (e) { + console.error('E2E crashed:', e); + process.exit(1); +} finally { + mock.kill('SIGTERM'); + srv.kill('SIGTERM'); +} diff --git a/server/mock-btcpay.js b/server/mock-btcpay.js new file mode 100644 index 0000000..b2c7539 --- /dev/null +++ b/server/mock-btcpay.js @@ -0,0 +1,124 @@ +/** + * Mock BTCPay Server — enough of the Greenfield API to exercise the + * payment backend and frontend: GET /api/v1/stores/:id, + * POST /api/v1/stores/:id/invoices, GET /api/v1/invoices/:id, + * POST /api/v1/stores/:id/webhooks. + * + * Simulates a payment: once an invoice exists, after MOCK_SETTLE_MS (or when + * you hit POST /mock/settle/:id) it flips to Settled and fires the webhook. + */ +import http from 'node:http'; +import crypto from 'node:crypto'; + +const PORT = Number(process.env.MOCK_PORT || 8899); +const SETTLE_MS = Number(process.env.MOCK_SETTLE_MS || 8000); +const API_KEY = process.env.MOCK_API_KEY || 'mock-store-key'; +const STORE_ID = process.env.MOCK_STORE_ID || 'mock-store-1234'; + +const invoices = new Map(); +let webhookSecret = 'mock-webhook-secret'; +let webhookUrl = process.env.MOCK_WEBHOOK_URL || 'http://localhost:8787/api/btcpay/webhook'; + +function json(res, code, obj) { + const b = JSON.stringify(obj); + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(b); +} +function readBody(req) { + return new Promise((resolve, reject) => { + let d = ''; req.on('data', c => d += c); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch { reject(new Error('bad json')); } }); req.on('error', reject); + }); +} +function authed(req) { + const h = req.headers['authorization'] || ''; + return h === 'token ' + API_KEY; +} + +const server = http.createServer(async (req, res) => { + const u = new URL(req.url, 'http://localhost'); + const p = u.pathname; + try { + let m; + if (req.method === 'GET' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)$/))) { + if (m[1] !== STORE_ID) return json(res, 404, { message: 'store not found' }); + if (!authed(req)) return json(res, 401, { message: 'unauthorized' }); + return json(res, 200, { id: STORE_ID, name: 'KITCHEN 484 (mock)', network: 'mainnet' }); + } + if (req.method === 'POST' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)\/invoices$/))) { + if (!authed(req)) return json(res, 401, { message: 'unauthorized' }); + const body = await readBody(req); + const id = crypto.randomUUID(); + const bolt11 = 'lnbc' + Math.round(body.amount * 1e8) + 'nMOCKBOLT11' + id.replace(/-/g, '').slice(0, 20); + const addr = 'bc1qmock' + id.replace(/-/g, '').slice(0, 30); + const rec = { + id, + status: 'New', + checkoutUrl: `https://mock.btcpay/checkout/${id}`, + paymentUrl: `https://mock.btcpay/pay/${id}`, + amount: body.amount, + currency: body.currency, + metadata: body.metadata || {}, + paymentMethods: [ + { cryptoCode: 'BTC', data: { address: addr } }, + { cryptoCode: 'LIGHTNING', bolt11 }, + ], + }; + invoices.set(id, rec); + console.log(`[mock] invoice ${id} ${body.amount} ${body.currency}`); + setTimeout(() => settle(id, 'timer'), SETTLE_MS); + return json(res, 201, rec); + } + if (req.method === 'GET' && (m = p.match(/^\/api\/v1\/invoices\/([^/]+)$/))) { + const rec = invoices.get(m[1]); + if (!rec) return json(res, 404, { message: 'not found' }); + return json(res, 200, rec); + } + if (req.method === 'POST' && (m = p.match(/^\/api\/v1\/stores\/([^/]+)\/webhooks$/))) { + if (!authed(req)) return json(res, 401, { message: 'unauthorized' }); + const body = await readBody(req); + webhookUrl = body.url || webhookUrl; + webhookSecret = body.secret || crypto.randomBytes(16).toString('hex'); + console.log(`[mock] webhook registered → ${webhookUrl} secret=${webhookSecret.slice(0, 8)}…`); + return json(res, 200, { id: 'wh-mock-1', secret: webhookSecret, url: webhookUrl }); + } + if (req.method === 'POST' && p === '/mock/settle') { + // body {id} or path /mock/settle/:id + const body = await readBody(req).catch(() => ({})); + const id = body.id; + if (id) return settle(id, 'manual'), json(res, 200, { ok: true }); + return json(res, 400, { message: 'need {id}' }); + } + if (req.method === 'POST' && (m = p.match(/^\/mock\/settle\/([^/]+)$/))) { + settle(m[1], 'manual'); + return json(res, 200, { ok: true }); + } + json(res, 404, { message: 'mock: unknown route ' + req.method + ' ' + p }); + } catch (e) { + json(res, 500, { message: e.message }); + } +}); + +function settle(id, via) { + const rec = invoices.get(id); + if (!rec || rec.status === 'Settled') return; + rec.status = 'Settled'; + console.log(`[mock] settling ${id} via ${via} → firing webhook to ${webhookUrl}`); + const payload = JSON.stringify({ + event: 'InvoiceSettled', + invoice: id, + storeId: STORE_ID, + status: 'Settled', + amount: rec.amount, + currency: rec.currency, + }); + fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'BTCPay-Sig': 'sha256=' + crypto.createHmac('sha256', webhookSecret).update(payload).digest('hex'), + }, + body: payload, + }).catch(e => console.error('[mock] webhook delivery failed:', e.message)); +} + +server.listen(PORT, () => console.log(`Mock BTCPay on :${PORT} (store=${STORE_ID}, settles after ${SETTLE_MS}ms)`)); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..49c702a --- /dev/null +++ b/server/package.json @@ -0,0 +1,16 @@ +{ + "name": "kitchen484-btc-pay", + "version": "1.0.0", + "private": true, + "description": "BTCPay proxy backend for KITCHEN 484 / SOLARPUNK SUMMIT — keeps the BTCPay API key server-side", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "mock": "node mock-btcpay.js", + "test:e2e": "node e2e-test.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..dbaeebb --- /dev/null +++ b/server/server.js @@ -0,0 +1,425 @@ +/** + * KITCHEN 484 / SOLARPUNK SUMMIT — BTCPay payment backend + * ------------------------------------------------------------------ + * A tiny Node (no dependencies) proxy that: + * - keeps the BTCPay API key SERVER-SIDE (never sent to the browser) + * - creates BTCPay invoices (onchain BTC + Lightning bolt11) + * - reports status via polling AND Server-Sent Events + * - receives BTCPay webhooks (HMAC-SHA256 verified via BTCPay-Sig) + * - serves the static site from the parent directory + * + * Env (also persisted to .env next to this file): + * PORT listen port (default 8787) + * BTCPAY_URL e.g. https://btcpay.example.com + * BTCPAY_STORE store id + * BTCPAY_API_KEY store api key (token) + * WEBHOOK_SECRET secret used by BTCPay to sign webhook deliveries + * WEBHOOK_PUBLIC_URL your public origin, e.g. https://kitchen.example.com + * (used when auto-registering the webhook) + * DATA_FILE where invoices are persisted (default .data/invoices.json) + */ +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STATIC_DIR = path.resolve(__dirname, '..'); +const ENV_FILE = path.join(__dirname, '.env'); + +/* ----------------------------- config ----------------------------- */ +function loadEnv() { + if (!fs.existsSync(ENV_FILE)) return {}; + const out = {}; + for (const line of fs.readFileSync(ENV_FILE, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, ''); + } + return out; +} +function saveEnv(obj) { + const lines = Object.entries(obj).map(([k, v]) => `${k}=${v}`); + fs.writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 }); +} +const cfg = { + port: Number(process.env.PORT || 8787), + url: process.env.BTCPAY_URL || '', + store: process.env.BTCPAY_STORE || '', + apiKey: process.env.BTCPAY_API_KEY || '', + webhookSecret: process.env.WEBHOOK_SECRET || '', + publicUrl: process.env.WEBHOOK_PUBLIC_URL || '', + dataFile: process.env.DATA_FILE || path.join(__dirname, '.data/invoices.json'), +}; +Object.assign(cfg, loadEnv()); + +function configured() { + return Boolean(cfg.url && cfg.store && cfg.apiKey); +} + +/* --------------------------- persistence --------------------------- */ +const invoices = new Map(); // btcpayInvoiceId -> record +function loadInvoices() { + try { + for (const rec of JSON.parse(fs.readFileSync(cfg.dataFile, 'utf8'))) { + invoices.set(rec.id, rec); + } + } catch { /* first run */ } +} +function persist() { + fs.mkdirSync(path.dirname(cfg.dataFile), { recursive: true }); + fs.writeFileSync(cfg.dataFile, JSON.stringify([...invoices.values()], null, 2)); +} +loadInvoices(); + +/* ------------------------- BTCPay API client ------------------------ */ +async function btcpay(pathname, { method = 'GET', body } = {}) { + const base = cfg.url.replace(/\/+$/, ''); + const res = await fetch(base + pathname, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: 'token ' + cfg.apiKey, + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json; + try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; } + if (!res.ok) { + const err = new Error(`BTCPay ${res.status}: ${JSON.stringify(json).slice(0, 300)}`); + err.status = res.status; + throw err; + } + return json; +} + +/* ------------------------------ helpers ----------------------------- */ +const PAID = new Set(['Paid', 'Complete', 'Settled', 'Confirmed']); +const DEAD = new Set(['Expired', 'Invalid', 'Cancelled', 'Failed']); +function normStatus(s) { + s = String(s || 'New'); + if (PAID.has(s)) return 'Paid'; + if (DEAD.has(s)) return 'Expired'; + return s; // New | Processing | … +} +function sseClients() { + // Map invoiceId -> Set; plus a '*' key for global listeners + return global.__sse; +} +const sse = new Map(); +global.__sse = sse; +function broadcast(id, payload) { + for (const key of [id, '*']) { + const set = sse.get(key); + if (!set) continue; + const msg = `data: ${JSON.stringify(payload)}\n\n`; + for (const res of set) { try { res.write(msg); } catch { set.delete(res); } } + } +} +function updateInvoice(id, status, extra = {}) { + const rec = invoices.get(id); + if (!rec) return; + const next = normStatus(status); + if (next !== rec.status) { + rec.status = next; + rec.updatedAt = Date.now(); + persist(); + broadcast(id, { type: 'status', id, status: next }); + } + Object.assign(rec, extra); + persist(); +} + +/* simple per-IP rate limit for invoice creation */ +const hitTimes = new Map(); +function rateLimit(ip, max = 10, windowMs = 60_000) { + const now = Date.now(); + const arr = (hitTimes.get(ip) || []).filter(t => now - t < windowMs); + if (arr.length >= max) return false; + arr.push(now); + hitTimes.set(ip, arr); + return true; +} + +/* ----------------------------- static ------------------------------ */ +const MIME = { + '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', + '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', + '.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain', +}; +function serveStatic(req, res, url) { + let p = decodeURIComponent(url.pathname); + if (p === '/') p = '/index.html'; + const file = path.normalize(path.join(STATIC_DIR, p)); + if (!file.startsWith(STATIC_DIR)) { res.writeHead(403); return res.end('forbidden'); } + fs.readFile(file, (err, data) => { + if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('not found'); } + res.writeHead(200, { 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream' }); + res.end(data); + }); +} + +/* ------------------------------ http ------------------------------- */ +function json(res, code, obj) { + const body = JSON.stringify(obj); + res.writeHead(code, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'no-store', + }); + res.end(body); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + const ip = req.socket.remoteAddress || 'unknown'; + try { + /* CORS preflight */ + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }); + return res.end(); + } + + const p = url.pathname; + + /* ---------- health ---------- */ + if (p === '/health') { + return json(res, 200, { ok: true, configured: configured(), time: Date.now() }); + } + + /* ---------- settings ---------- */ + if (p === '/api/settings' && req.method === 'GET') { + return json(res, 200, { configured: configured(), url: cfg.url || null, store: cfg.store || null }); + } + if (p === '/api/settings' && req.method === 'POST') { + // body: {url, store, apiKey} — validates the connection, then persists + const body = await readBody(req); + const u = String(body.url || '').trim(); + const store = String(body.store || '').trim(); + const key = String(body.apiKey || '').trim(); + if (!u || !store) return json(res, 400, { ok: false, error: 'url and store are required' }); + let probe = null; + try { + const base = u.replace(/\/+$/, ''); + const r = await fetch(base + '/api/v1/stores/' + encodeURIComponent(store), { + headers: { Authorization: 'token ' + key, 'Content-Type': 'application/json' }, + }); + const t = await r.text(); + let j = {}; try { j = JSON.parse(t); } catch { /* ignore */ } + if (!r.ok) throw new Error(`HTTP ${r.status} ${t.slice(0, 160)}`); + probe = { storeId: j.id, storeName: j.name || null, network: j.network || null }; + } catch (e) { + return json(res, 400, { ok: false, error: 'Could not verify store: ' + e.message }); + } + cfg.url = u; cfg.store = store; cfg.apiKey = key; + persistConfig(); + return json(res, 200, { ok: true, ...probe }); + } + + /* ---------- webhook secret (manual mode) ---------- */ + if (p === '/api/webhook-secret' && req.method === 'POST') { + const body = await readBody(req); + cfg.webhookSecret = String(body.secret || '').trim(); + persistConfig(); + return json(res, 200, { ok: true }); + } + + /* ---------- auto-register webhook ---------- */ + if (p === '/api/webhook/register' && req.method === 'POST') { + if (!configured()) return json(res, 400, { ok: false, error: 'BTCPay not configured' }); + const publicUrl = cfg.publicUrl ? cfg.publicUrl.replace(/\/+$/, '') : ''; + if (!publicUrl) return json(res, 400, { ok: false, error: 'Set WEBHOOK_PUBLIC_URL in .env first' }); + const wh = await btcpay(`/api/v1/stores/${cfg.store}/webhooks`, { + method: 'POST', + body: { + url: `${publicUrl}/api/btcpay/webhook`, + enabled: true, + automaticRedelivery: true, + authorizedEvents: { invoiceSettled: true, invoiceExpired: true, invoiceInvalid: true, invoiceReceivedPayment: true }, + }, + }); + cfg.webhookSecret = wh.secret || cfg.webhookSecret; + persistConfig(); + return json(res, 200, { ok: true, webhookId: wh.id, secretSet: Boolean(wh.secret) }); + } + + /* ---------- create invoice ---------- */ + if (p === '/api/invoices' && req.method === 'POST') { + if (!configured()) return json(res, 503, { error: 'BTCPay not configured — save settings first' }); + if (!rateLimit(ip)) return json(res, 429, { error: 'Too many invoices, slow down' }); + const body = await readBody(req); + const amount = Number(body.amount); + if (!Number.isFinite(amount) || amount <= 0) return json(res, 400, { error: 'amount must be a positive number' }); + const currency = String(body.currency || 'USD').toUpperCase(); + const orderCode = String(body.orderCode || '').slice(0, 64); + const description = String(body.description || 'KITCHEN 484 order').slice(0, 512); + const inv = await btcpay(`/api/v1/stores/${cfg.store}/invoices`, { + method: 'POST', + body: { + amount: Math.round(amount * 1e8) / 1e8, + currency, + description, + expirationInterval: 30 * 60, // 30 min + metadata: { orderCode, ...pick(body.metadata, ['pickup', 'name', 'items', 'day']) }, + }, + }); + const id = inv.id; + const rec = { + id, + orderCode, + amount, + currency, + status: normStatus(inv.status), + bolt11: extractBolt11(inv), + btcAddress: extractBtcAddress(inv), + checkoutUrl: inv.checkoutUrl || inv.paymentUrl || null, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + invoices.set(id, rec); + persist(); + broadcast(id, { type: 'created', id, status: rec.status }); + return json(res, 201, publicInvoice(rec)); + } + + /* ---------- invoice status (polling) ---------- */ + let m; + if ((m = p.match(/^\/api\/invoices\/([^/]+)$/)) && req.method === 'GET') { + const rec = invoices.get(m[1]); + if (!rec) return json(res, 404, { error: 'unknown invoice' }); + return json(res, 200, publicInvoice(rec)); + } + + /* ---------- invoice status (SSE) ---------- */ + if ((m = p.match(/^\/api\/invoices\/([^/]+)\/events$/)) && req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + }); + res.write(`retry: 3000\n\n`); + const id = m[1]; + const set = new Set([res]); + sse.set(id, set); + const rec = invoices.get(id); + if (rec) res.write(`data: ${JSON.stringify({ type: 'status', id, status: rec.status })}\n\n`); + const ping = setInterval(() => { try { res.write(`: ping\n\n`); } catch { /* closed */ } }, 25_000); + req.on('close', () => { + clearInterval(ping); + set.delete(res); + if (set.size === 0) sse.delete(id); + }); + return; + } + + /* ---------- BTCPay webhook ---------- */ + if (p === '/api/btcpay/webhook' && req.method === 'POST') { + const raw = await readRaw(req); + const sig = req.headers['btcpay-sig']; + if (!cfg.webhookSecret || !verifyBtcpaySig(raw, sig, cfg.webhookSecret)) { + return json(res, 401, { error: 'bad signature' }); + } + let data; try { data = JSON.parse(raw.toString('utf8')); } catch { data = {}; } + const invId = data.invoice; // BTCPay sends the invoice id in the payload + const rec = invId && invoices.get(String(invId)); + if (!rec) { + // Could be an invoice created before a restart; accept and log it. + console.log('[webhook] unknown invoice', invId, 'event', data.event); + return json(res, 200, { ok: true, unknown: true }); + } + const event = String(data.event || ''); + let status = normStatus(data.status || rec.status); + if (event === 'InvoiceSettled' || event === 'invoice_settled') status = 'Paid'; + if (event === 'InvoiceExpired') status = 'Expired'; + if (event === 'InvoiceInvalid') status = 'Expired'; + updateInvoice(rec.id, status, { event, receivedAt: Date.now() }); + console.log(`[webhook] ${rec.id} ${event} → ${rec.status}`); + return json(res, 200, { ok: true }); + } + + /* ---------- everything else: static site ---------- */ + if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res, url); + res.writeHead(405, { 'Access-Control-Allow-Origin': '*' }); + return res.end('method not allowed'); + } catch (e) { + const code = e.status || 500; + console.error('[error]', e.message); + return json(res, code, { error: e.message || 'internal error' }); + } +}); + +function persistConfig() { + const cur = loadEnv(); + saveEnv({ + BTCPAY_URL: cfg.url, + BTCPAY_STORE: cfg.store, + BTCPAY_API_KEY: cfg.apiKey, + WEBHOOK_SECRET: cfg.webhookSecret, + WEBHOOK_PUBLIC_URL: cfg.publicUrl, + ...pick(cur, ['PORT', 'DATA_FILE']), + }); +} +function readBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', c => { data += c; if (data.length > 1e6) { reject(new Error('body too large')); req.destroy(); } }); + req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { reject(new Error('bad json')); } }); + req.on('error', reject); + }); +} +function readRaw(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} +function pick(obj, keys) { + const out = {}; + for (const k of keys) if (obj && obj[k] !== undefined) out[k] = obj[k]; + return out; +} +function publicInvoice(rec) { + return { + id: rec.id, orderCode: rec.orderCode, amount: rec.amount, currency: rec.currency, + status: rec.status, bolt11: rec.bolt11 || null, btcAddress: rec.btcAddress || null, + checkoutUrl: rec.checkoutUrl || null, createdAt: rec.createdAt, updatedAt: rec.updatedAt, + }; +} +function extractBolt11(inv) { + const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'LIGHTNING'); + return pm && (pm.bolt11 || (pm.data && pm.data.bolt11)) || null; +} +function extractBtcAddress(inv) { + const pm = (inv.paymentMethods || []).find(x => x.cryptoCode === 'BTC'); + if (pm && pm.data && pm.data.address) return pm.data.address; + if (pm && pm.address) return pm.address; + if (typeof inv.paymentAddresses === 'string') return inv.paymentAddresses; + if (inv.paymentAddresses && inv.paymentAddresses.BTC) return inv.paymentAddresses.BTC; + return null; +} +/** BTCPay webhook signature: BTCPay-Sig: sha256= */ +function verifyBtcpaySig(rawBody, sigHeader, secret) { + if (!sigHeader || !String(sigHeader).startsWith('sha256=')) return false; + const given = String(sigHeader).slice('sha256='.length); + const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(given, 'hex'), Buffer.from(expected, 'hex')); + } catch { return false; } +} + +server.listen(cfg.port, () => { + console.log(`KITCHEN 484 pay backend listening on :${cfg.port}`); + console.log(configured() + ? `BTCPay: ${cfg.url} store=${cfg.store} webhookSecret=${cfg.webhookSecret ? 'set' : 'MISSING'}` + : 'BTCPay NOT configured — POST /api/settings with {url, store, apiKey}'); + if (cfg.publicUrl) console.log(`Public origin for webhook: ${cfg.publicUrl}/api/btcpay/webhook`); +}); diff --git a/sol_lunar_bw.png b/sol_lunar_bw.png new file mode 100644 index 0000000..8a1b80a Binary files /dev/null and b/sol_lunar_bw.png differ diff --git a/sol_lunar_bw_emblem.png b/sol_lunar_bw_emblem.png new file mode 100644 index 0000000..2ea50d6 Binary files /dev/null and b/sol_lunar_bw_emblem.png differ diff --git a/sol_lunar_icon.svg b/sol_lunar_icon.svg new file mode 100644 index 0000000..570d155 --- /dev/null +++ b/sol_lunar_icon.svg @@ -0,0 +1,4 @@ + + + +