feat: add yaml tags to config fields and normalize paths for sqlite and admin settings#236
Open
ai-chen2050 wants to merge 33 commits into
Open
feat: add yaml tags to config fields and normalize paths for sqlite and admin settings#236ai-chen2050 wants to merge 33 commits into
ai-chen2050 wants to merge 33 commits into
Conversation
…nd admin settings
…ion and config templates
- Rename cmd/aperture → cmd/prism and cmd/aperturecli → cmd/prismcli; produces ./prism (daemon) and ./prismcli (admin CLI). - Rename skills/aperture → skills/prism with updated frontmatter. - Switch default data dir from ~/.aperture to ~/.prism; default config filename to prism.yaml, log to prism.log, sqlite db to prism.db. - Update CLI defaultMacaroon to ~/.prism/admin.macaroon and rewrite user-facing Use/Short/Long/version strings to prismcli. - MCP server advertises itself as "prismcli"; Claude Code config key changed from "aperture" to "prism". - Point Dockerfile git clone to github.com/loka-network/loka-prism-l402. - Update Makefile build/install/clean targets and .gitignore entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scripts/itest_prism_sui.sh: automated end-to-end smoke test against the sui-adapted lnd (alice). Stands up an isolated prism on :18080 and verifies admin REST/gRPC, L402 challenge generation, and invoice issuance via AddInvoice. No payment required. - scripts/manual_pay_through_prism.sh: end-to-end payment walk using the running prism on :8080 (user's sample-conf-tmp.yaml) and bob (second lnd from itest_sui_single_coin.sh). Parses the 402 challenge, pays the invoice via bob over the alice<->bob channel, replays the request with the LSAT token, then queries prism's admin API to show the settled transaction, issued token, and revenue stats. Useful for manually observing payment data in the admin API or dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Show in sample-conf.yaml how to configure a single service with free, token-required-but-no-invoice, and paid paths within one entry. Documents the distinction between authwhitelistpaths (bypass auth entirely) and authskipinvoicecreationpaths (still require a valid L402 token, but return 401 instead of 402 to avoid breaking streaming clients that can't consume a payment challenge). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/serve_demo_backend.sh starts a Python http.server on :9998 with fixture files (index.html, data.json, probe) so the default paid-flow demo in manual_pay_through_prism.sh can round-trip end-to-end: 402 challenge → Lightning payment → LSAT replay → HTTP 200 with real backend content. Pair with sample-conf-tmp.yaml service1 pointed at 127.0.0.1:9998 (protocol http). Fixture files are idempotent — the script only writes them if missing, so edits stick across restarts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expose the underlying lightning chain via the admin API and use it in the dashboard to pick the unit label (SUI for the Sui-adapted lnd, sats for bitcoin). Backend - adminrpc: add chain field to GetInfoResponse. - aperture startup: open a read-only lnd client (readonly.macaroon) and call GetInfo once to cache chains[0].chain on the Aperture struct. invoice.macaroon lacks info:read so we use the readonly one. Best- effort: on failure we proceed with an empty chain and log a warning. - admin.ServerConfig: add Chain, populate in GetInfo handler. - createAdminServer: thread the chain from aperture startup through. Frontend - lib/currency.ts: new formatter utility with chainKind/unitLabel/ baseUnitLabel/formatAmount. Sui path divides raw MIST by 1e9 and trims trailing zeros so the dashboard shows e.g. "0.000001 SUI" instead of "1000 MIST". Bitcoin path keeps "sats" (which are small enough to stay readable as integers). - lib/types.ts: add chain to InfoResponse. - app/page.tsx, app/transactions/page.tsx, app/services/page.tsx, app/services/detail/page.tsx: pull chain via useInfo, replace hardcoded "sats" in KPI tiles, service price cells, transaction tables, and toast messages. - components/ActivityChart.tsx, components/RevenueChart.tsx: accept chain prop, format tooltip and axis label accordingly. Form inputs still use baseUnitLabel (MIST on Sui) so the user types the exact integer stored by lnd; display contexts scale into SUI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bump sample config prices from sat-scale (0/1/1000) to MIST-scale
(10^6–10^7) so the defaults are meaningful on Sui where 1 MIST is
10^-9 SUI. Comment each price with the SUI equivalent so a reader can
tell what the integer represents without doing the math.
sample-conf.yaml
service1 price: 0 → 10_000_000 MIST (0.01 SUI)
service2 price: 1 → 1_000_000 MIST (0.001 SUI)
mixed-api price: 1000 → 10_000_000 MIST (0.01 SUI)
scripts/itest_prism_sui.sh
itest-service price: 10 → 10_000_000 MIST
Also reword the price comment on service1 from "value in satoshis" to
an explicit chain-base-unit explanation (sats for bitcoin, MIST for
sui) so the YAML is self-documenting after the chain-aware display
change.
Sizing rationale: at ~$1/SUI, 0.01 SUI is roughly $0.01 per request —
a reasonable micropayment default. Users can scale up/down per service.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling to manual_pay_through_prism.sh, but exercises the MPP scheme
(draft-httpauth-payment-00) instead of L402. Useful when prism is
configured with `authenticator.enablempp: true` and a service whose
`authscheme` is `mpp` or `l402+mpp` — the script:
1. Fires an unauthenticated request and verifies that the 402
response now carries three WWW-Authenticate challenges (LSAT,
L402, Payment).
2. Parses the `Payment` challenge's auth-params, base64url-decodes
the `request=` field to extract the BOLT11 invoice and payment
hash.
3. Pays the MPP invoice from bob (second lnd node).
4. Builds the Authorization credential: echoes all challenge params
unchanged and attaches a `payload.preimage` proof, base64url-
encodes the JSON envelope, and sends it as
`Authorization: Payment <token>`.
5. Verifies prism accepts the credential (HTTP 200) and returns a
`Payment-Receipt` header, which the script decodes to show
status / method / reference / challengeId.
Preflight step auto-flips the target service's `auth_scheme` to
`AUTH_SCHEME_L402_MPP` via the admin REST API if it isn't already set,
so the test is idempotent against whatever current state the service
store is in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/manual_pay_mpp_session.sh (new)
Drives the MPP session lifecycle end-to-end against a running prism:
open (pay 0.2 SUI deposit + return invoice) → bearer × N (drain the
session balance, no Lightning calls) → close (prism pays the client's
amountless ReturnInvoice with the unspent balance) → verifies the
refund actually landed on bob via lookupinvoice, and that a bearer
replay after close is rejected.
scripts/manual_pay_mpp.sh (fixed)
When sessions are enabled alongside the charge intent, the 402
response carries two Payment challenges (intent=charge AND
intent=session). The previous header picker grabbed the first one
naively and sometimes landed on the session challenge, which has a
different request shape (depositInvoice instead of methodDetails.
invoice) and caused the test to crash. Now we collect every Payment
challenge line and filter to intent="charge" so the test is robust
whether sessions are on or not.
auth/mpp_session_authenticator.go
networkToChainParams silently fell back to MainNetParams for any
network name outside the bitcoin set (mainnet/testnet/regtest/
signet). Sui-adapted lnd identifies its networks as "devnet" and
"localnet" but still encodes BOLT11 invoices with the bitcoin
regtest HRP ("lnbcrt..."), so zpay32 rejected return invoices with
"unknown multiplier t" during session open. Map devnet/localnet to
RegressionNetParams; add a simnet case for completeness. Without
this fix, MPP sessions could not be opened on any Sui deployment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every 402 challenge writes a pending row to the L402 transactions store, but only one of the sibling invoices issued for a dual-scheme service (L402 + MPP) gets paid — the rest stay pending forever, skewing admin reports and obscuring real business metrics. Wire up the challenger's invoice stream so irrelevant invoices (CANCELED, or past-expiry + unpaid) flip their matching transaction rows to a new "expired" state instead of accumulating. aperturedb/l402_transactions.go ExpireTransaction(ctx, hash) — mirrors SettleTransaction but sets state='expired' and leaves settled_at NULL so revenue reports that key off settled_at keep excluding these rows. challenger/lnd.go WithExpirationCallback(fn) option, symmetrical to WithSettlementCallback. Adds an expirationQueue + seenExpired dedupe map. Both the startup ListInvoices sweep and the live SubscribeInvoices stream now call the callback for invoices that invoiceIrrelevant() says are terminal and not settled. Stop() drains the new queue. aperture.go buildChallengerOpts registers WithExpirationCallback(ExpireTransaction) alongside the existing settlement callback. Known caveat: lnd's SubscribeInvoices stream doesn't publish CANCELED state changes (it only fires on add_index or settle_index bumps), so real-time expiry is only caught during the startup sweep that runs on every prism restart. A periodic background sweep or SubscribeSingleInvoice (requires invoicesrpc, not available on Sui-LND) could make this real- time; out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MPP prepaid session lifecycle (deposit / bearer / topUp / close) is
currently invisible to the admin dashboard — the L402 transactions table
only tracks charge-intent invoices, so a server running mostly sessions
looks like it has zero revenue in /stats. Expose session activity
through two new admin endpoints and a dedicated dashboard page.
Backend
adminrpc/admin.proto
+ rpc ListSessions(ListSessionsRequest) → ListSessionsResponse
+ rpc GetSessionStats(GetSessionStatsRequest) → GetSessionStatsResponse
+ MPPSession, ListSessionsRequest/Response, GetSessionStatsResponse
messages. balance_sats field precomputed server-side so clients
don't have to do the subtraction.
adminrpc/admin.yaml
REST routes: GET /api/admin/sessions, /api/admin/sessions/stats.
aperturedb/sqlc/queries/mpp_sessions.sql
ListMPPSessions (paginated, optional status filter), CountMPPSessions,
GetMPPSessionAggregateStats. The aggregate query uses portable
SUM(CASE WHEN) instead of COUNT(*) FILTER so it runs on both postgres
and sqlite (sqlite doesn't support FILTER).
aperturedb/mpp_sessions.go
ListSessions(ctx, status, limit, offset) + GetStats(ctx) methods on
MPPSessionsStore. MPPSessionsDB interface extended.
admin/server.go
ListSessions / GetSessionStats handlers. Return Unimplemented (501
over REST) when the server was started without sessions enabled.
aperture.go
mppSessionStore variable typed as *MPPSessionsStore (still satisfies
auth.SessionStore). Threaded through createAdminServer into
admin.ServerConfig.SessionStore. Dashboard proxy allowedQueryParams
whitelist accepts "status".
Frontend
dashboard/lib/types.ts
MPPSession, ListSessionsResponse, SessionStatsResponse interfaces.
dashboard/lib/api.ts
useSessions(), useSessionStats() SWR hooks. 501 response maps to null
so the UI can render a clean "not enabled" state.
dashboard/app/sessions/page.tsx (new)
4 KPI cards (open / total / revenue spent / open balance),
open|closed|all filter tabs, paginated table. Amounts render via the
existing chain-aware formatAmount helper, so bitcoin deployments
show sats and sui deployments show SUI.
dashboard/app/layout.tsx
Sessions nav item appears only when info.mpp_enabled &&
info.sessions_enabled, so single-scheme deployments aren't cluttered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs/admin-api.md
* Endpoint table picks up /api/admin/sessions and
/api/admin/sessions/stats.
* Transactions docs call out the new "expired" state (reconciled on
prism startup from lnd.ListInvoices), note filtering by
state=expired is supported, and explain the pending-accumulation
pattern caused by dual-scheme challenges.
* New "Sessions (MPP prepaid)" section with: 501 behavior when
sessions are disabled, explicit units callout (chain's base unit,
sats for bitcoin / MIST for sui), list + stats examples with full
response JSON, field-level notes (balance_sats is precomputed,
total_spent_sats is real revenue, etc.), plus a "why a separate
endpoint" rationale that points readers at the sum formula for
full-payment visibility across both L402 charges and session
debits.
* Info endpoint row mentions the chain field so readers know they
can tell bitcoin vs sui deployments apart from GetInfo alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…h and surface payment amounts Rename Sibling scripts follow the manual_pay_<scheme>.sh convention (manual_pay_mpp.sh, manual_pay_mpp_session.sh). The L402 one was still named after its old "through prism" phrasing, which makes it harder to tell at a glance which protocol each script exercises. Rename it and propagate the new name to in-script docstrings and serve_demo_backend.sh / manual_pay_mpp.sh that cross-reference it. Amount visibility Both manual_pay_l402.sh and manual_pay_mpp_session.sh now print what bob is about to spend and, for sessions, what prism will refund, so the operator can see the full money trail before confirming — instead of inferring it from the decoded BOLT11 invoice and the server's depositMultiplier. The session script gains a "[2b/7] Amount plan" banner listing deposit, per-request cost, planned total spend (per-request × BEARER_CALLS), and expected refund, all rendered in the chain's display unit (SUI on sui-lnd, sats on bitcoin) via admin GetInfo. The l402 script adds a one-liner next to the decoded invoice showing the SUI equivalent alongside the raw MIST value. All numbers are still server-decided (services[0].price × sessiondepositmultiplier + BEARER_CALLS); the scripts just make them visible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…venue
The Total Revenue KPI on the dashboard home was only summing
l402_transactions (settled L402 and MPP-charge invoices). MPP prepaid
session bearer debits — which can easily be the majority of revenue on a
session-first deployment — live in the separate mpp_sessions table and
were completely absent from the top-line number, silently under-
reporting actual revenue.
Pull both /api/admin/stats and /api/admin/sessions/stats on the home
page and display the combined total. Under the main figure, show the
breakdown as "<l402> L402 + <session> session" so the operator can see
where revenue is coming from.
Degrades cleanly:
* MPP sessions disabled → useSessionStats() returns null → only
L402 revenue shown, no breakdown line (identical to pre-change
behavior on single-scheme deployments).
* Sessions enabled but no spend yet → breakdown line hidden to
avoid "<x> L402 + 0 session" clutter.
Backend /api/admin/stats semantics unchanged — external consumers
(Grafana scrapers, the admin CLI's stats command) keep seeing the
original L402-only aggregate. The merge is strictly a frontend display
choice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ployments Prism was designed (via its lightninglabs/aperture lineage) as a single- operator-multi-services gateway — one global lnd collects all payments, revenue is split off-band. That model makes the gateway operator a custodian of every merchant's funds, triggering VASP/MSB/CASP obligations in most jurisdictions as soon as third-party merchants are involved. Introduce an optional per-service `payment:` block that routes a service's invoices through the merchant's own lnd, so payments land directly in the merchant's wallet and the gateway never takes custody. The two modes coexist in one deployment: services without a payment block continue using the global lnd unchanged. mint/mint.go New ServiceAwareChallenger interface embedding the existing Challenger. MintL402 picks the most-expensive service's name and routes through NewChallengeForService when the challenger supports it, falling back to the plain NewChallenge path otherwise. auth/interface.go newChallengeFor helper shared by both MPP authenticators; encapsulates the type-assertion dance so call sites stay clean. auth/mpp_authenticator.go, auth/mpp_session_authenticator.go FreshChallengeHeader now threads serviceName through to the challenger so MPP charge and MPP session invoices also land in the right wallet. proxy/service.go New PaymentBackend struct (LndHost + TLSPath + MacPath); optional Payment field on Service. YAML field names line up with go-yaml's lowercased-field-name default so no yaml tags are needed. challenger/router.go (new) RouterChallenger implements both plain Challenger and ServiceAwareChallenger. Keeps a default challenger plus a per-service map; tracks hash→sub-challenger mapping so VerifyInvoiceStatus routes verify calls to the right lnd after restart, falling back to a scan across all sub-challengers for hashes lost from the in-memory map (e.g. invoices minted before the last process restart). EnsureDistinctMerchants sanity-checks that no two services share the same (lndhost, macpath) — would silently pool funds and defeat the isolation. aperture.go buildPerServiceChallengers walks the service config and constructs one LndChallenger per service with a payment block. When any service opts in, the aperture's challenger becomes a RouterChallenger; when none do, it keeps the plain LndChallenger for zero behavior change on legacy deployments. Merchants hand the gateway operator a minimum-privilege macaroon — invoices:read, invoices:write, info:read only — so the gateway has exactly the capability surface it needs (create/verify invoices, read chain for unit labelling) and nothing more. The worst case if that macaroon leaks: attacker can create fake invoices that nobody has to pay; zero value movable. This commit exposes the feature via YAML config only. Extending the admin API (proto + DB migration + prismcli + dashboard form) so merchants can be onboarded through /api/admin/services is a follow-up. .gitignore Keep the payment-architecture compliance writeup local, don't push. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the per-service lnd routing feature landed in the previous
commit — how to configure it, how merchants bake the minimum-privilege
macaroon they hand over, what that macaroon can and cannot do if
leaked, and how to migrate from the legacy single-lnd mode.
docs/admin-api.md
* New "Multi-merchant (per-service lnd routing)" section covering
the feature end-to-end.
* Macaroon baking walkthrough: `lncli bakemacaroon` with
invoices:read invoices:write info:read, plus --ip_address pinning
and --expiry=7776000 (90 days) for defense in depth.
* Permission capability table: which RPCs the minimum macaroon
allows and which it rejects (no SendPayment, no channel control,
no wallet access, no signing). Makes the blast radius on leak
explicit.
* Hardening checklist on the merchant side (network ACL, TLS
pinning, log watching, rotation cadence).
* Migration guide from single-lnd mode to multi-merchant mode; two
modes coexist so merchants can be moved one at a time.
sample-conf.yaml
* Commented-out multi-merchant example service block showing the
payment: lndhost/tlspath/macpath shape, pointing readers at the
admin-api.md section for the full onboarding steps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
update the dependency
Extends Phase B of the multi-merchant routing work (#55966ad) so merchant-specific lnd overrides can be managed at runtime through the admin API, not just via YAML. - DB migration 000008 adds payment_lndhost / payment_tlspath / payment_macpath columns to services (NOT NULL, default ''). UpsertService and sqlc types thread the new fields through. - adminrpc: new PaymentBackend message, optional payment field on Service / CreateServiceRequest / UpdateServiceRequest, plus a clear_payment boolean on UpdateServiceRequest (mutually exclusive with setting payment) for removing an override explicitly. - admin/server.go: proxyServiceToProto centralises the mapping and validatePaymentBackend enforces all-or-nothing at the handler edge. - aperture.go: mergeServicesFromDB now runs before buildPerServiceChallengers at startup, so services created via the admin API (and their payment overrides) participate in the challenger router on the next restart — previously the router only considered YAML-defined services and DB-only services silently fell back to the global lnd. Changes take effect on restart (router is built at startup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the payment backend (multi-merchant per-service lnd override) through the operator-facing tools so merchants can be onboarded without editing prism's YAML + restart. Complements the admin-API plumbing in bb777f3. prismcli: - services create / update: --payment-lndhost / --payment-tlspath / --payment-macpath (all-or-nothing, validated before the RPC). - services update: --clear-payment removes an existing override; mutually exclusive with --payment-*. - services list: new PAYMENT_LND column highlights which services route to a dedicated merchant lnd. Dashboard: - /services create form gains a "Payment backend (optional)" group under Advanced options, with client-side all-or-nothing validation. The services table shows an inline `↪ lnd <host>` hint under the address when an override is active. - /services/detail gets a full-width Payment Backend card: shows lnd_host / tls_path / mac_path when set (plus a Clear-override button that issues clear_payment=true), and a helper note when not set. - PaymentBackend type + optional payment fields on Service and ServiceCreateRequest; updateService accepts payment / clear_payment. Changes take effect on the next prism restart (challenger router is built at startup). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The admin REST API (grpc-gateway) expects snake_case field names on the proto messages, so host_regexp / path_regexp. The dashboard was sending hostregexp / pathregexp instead, so any create/update that included a non-default host or path regex was rejected with `proto: unknown field "hostregexp"`. Service read-path types (Service in types.ts) already used snake_case, so lists rendered correctly — the bug only surfaced on writes. Renames the field names in ServiceCreateRequest, updateService's body type, the create form's initial + bound state on /services, and the inline edit callbacks on /services/detail. Local state variables (hostregexpValue / pathregexpValue) are left alone since they don't appear in request bodies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a service omits `price` in the YAML, prepareServices was filling in 1 satoshi as the default. On sui that renders as 0.000000001 SUI (1 MIST), which is never what an operator wants — unconfigured services on a sui deployment looked broken in the dashboard. Make the fallback chain-dependent: bitcoin / unknown chain → 1 sat sui → 10_000_000 MIST (= 0.01 SUI) The sui default matches the sample-conf example so an unconfigured price gives the same micropayment scale as an explicitly configured one. Plumbed `chain` through proxy.New / Proxy.UpdateServices / prepareServices; aperture.go now passes a.lndChain at startup. Empty chain (lnd unreachable at startup) keeps the bitcoin default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Loka rebrand earlier kept the dashboard navbar reading "APERTURE" even though everything else was renamed. Bring the top-left brand in line with the binary names (prism / prismcli) and the repo name. Other Aperture references (welcome heading, tooltip help text, error messages) are left as-is for now — they read naturally as references to the upstream project's behavior, not the brand. Touching them is a separate copy pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uting Sibling to manual_pay_l402.sh, but exercises the multi-merchant path: service2 in sample-conf-tmp.yaml has a payment override pointing at bob's lnd, so the L402 challenge invoice should be issued by bob (not the global authenticator alice). The roles flip — alice is now the payer (the only node with a channel to bob). The script adds a routing-correctness assertion that the original script doesn't need: decode the 402 invoice and verify the destination pubkey == bob's identity_pubkey. If the per-service override didn't load (e.g. payment block still commented out, prism not restarted), the destination would be alice and the test fails loudly instead of silently exercising single-lnd mode. Also prints the wire-level HTTP request before each curl so the operator can see exactly what hits the gateway: TCP target = gateway host:port, Host header = virtual hostname for hostregexp matching, path forwarded as-is to the matched service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The placeholder service3 was using a host:port-style hostregexp
("service3.com:8083"), an invalid 123.456.789 address, a long-expired
valid_until, and a typo'd dynamicprice key (enbled: true) that meant
the example silently fell back to static pricing. None of it was a
working reference.
Rewrite it as a production-shape subdomain example: the gateway is
reached at the apex prism.loka.cash, and each service is mounted on
its own subdomain (service3.prism.loka.cash here). With a wildcard A
record pointing all *.prism.loka.cash names at the gateway, clients
just call the URL directly and HTTP picks up the Host header from
DNS — no manual -H "Host: ..." rewriting like in local tests.
Side-effect cleanups while in the area:
• enbled → enabled (typo that disabled the price client)
• drop expired valid_until 2020-01-01 (misleading)
• address 123.456.789:8082 → 10.0.0.42:8082 (private-net example)
• grpcaddress 123.456.789:8083 → pricer.internal:10010
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When prism runs as multiple replicas behind a load balancer, an admin API call to create or update a service only mutates the in-memory state of the replica that handled the write. Other replicas keep serving the stale config until they restart, which means the LB's round-robin will route ~(N-1)/N of requests for a brand-new service into 403 dead-ends. Add a backend-agnostic poller: each replica re-reads the merged service list (config-file + DB rows) at ServicePollInterval (default 30s, 0 disables) and reloads the proxy via UpdateServices when a stable hash of the routing-relevant fields changes. The hash filters out spurious reloads when nothing material changed — the proxy rebuild is non-trivial (per-service challengers, gRPC clients to merchant lnds) so we only swap when needed. Future work: a Postgres LISTEN/NOTIFY path for sub-second propagation on the postgres backend; sqlite/etcd would still rely on this poller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The startup-time invoice sweep (commit e5eb67a) only catches state changes that happened while prism was offline. Once the live SubscribeInvoices stream is up it carries every event in real time — on the happy path. The catch is that the stream can drop transiently (lnd restart, network blip), and in HA setups a replica may have been offline at the moment a sibling-replica's invoice expired. Both cases leave stale "pending" rows in the L402 transaction store that admin reports then have to reason about. Add an InvoiceReconciler interface and a `Reconcile()` method on LndChallenger that re-runs the same classify/enqueue logic as the startup sweep. LNCChallenger forwards to its embedded LND challenger; RouterChallenger fans out to every wrapped challenger (default + per-service merchant lnds). Aperture wires it as a background ticker driven by InvoiceReconcileInterval (default 5m, 0 disables). The reconcile is idempotent two ways: - settled: SQL UPDATE is conditional on state='pending', noop on already-settled rows - expired: per-hash dedupe map (markSeenExpired) prevents the same hash from going through onExpired twice So a tighter interval just costs a bit more lnd ListInvoices traffic; it can never produce duplicate DB writes or skewed reports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local 3-replica prism stack behind nginx, sharing state via Postgres
and a named docker volume. Used to validate the HA mode end-to-end
(round-robin, failover, settlement idempotency across replicas) and
as the reference for what a production-shape multi-replica deployment
needs to get right.
The README's "Shared state requirements" section calls out three
things that bite operators who think HA is only a DB story:
1. Admin macaroon root key lives at <macpath>.rootkey on disk, not
in the DB. Replicas with separate basedirs each generate their
own rootkey and the macaroons they issue won't authenticate
against each other. Mitigation: pin admin.macaroonpath to a
shared volume.
2. Service config writes via admin API only update the receiving
replica's in-memory routing table. Mitigation: the new
ServicePollInterval poller (default 30s).
3. Live SubscribeInvoices events can be missed under transient
disconnects. Mitigation: the new InvoiceReconcileInterval
periodic sweep (default 5m).
The compose file wires all three correctly and the prism.yaml in this
directory shows the minimum config needed for a multi-replica setup.
Also adds .dockerignore at repo root (slims the build context — the
go cache + dashboard node_modules used to push it past 1 GB) and a
gitignore entry so the self-signed nginx tls.crt/tls.key don't slip
into commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling to deploy/ha-prototype/ but adapted for the common case
where prism is co-deployed on a host that already runs
agents-pay-service: shared Postgres instance, shared system nginx,
no bundled containers for either. The 3 prism replicas still run
in docker-compose, sharing state via a docker volume for the admin
macaroon and via the host's Postgres for everything else.
Files:
• docker-compose.yml — only prism-1/2/3, ports published to
127.0.0.1:8081/82/83 so the host nginx can upstream-proxy to
them. No bundled postgres/nginx services.
• prism.yaml.example — postgres host is host.docker.internal,
insecure: true (host nginx terminates TLS), macaroonpath
pinned to the shared volume, ServicePollInterval/
InvoiceReconcileInterval at sane prod defaults.
• nginx-prism.conf — drop-in server block for the host's nginx;
upstream lists the three loopback ports, Host header
pass-through (prism's hostregexp depends on it), gRPC /
WebSocket / SSE upgrades, certbot-managed cert paths.
• bootstrap.sh — idempotent one-time DBA script. Creates the
`prism` Postgres user + database (owner = prism), grants
schema privileges (PG15+ requires explicit GRANT on public
schema), then verifies the new credentials can log in. Reads
the password from PRISM_DB_PASSWORD env or prompts. Tables
are not touched here — prism builds those on first start via
its golang-migrate setup, same as in dev.
• README.md — full step-by-step including the pg_hba.conf and
listen_addresses changes the script intentionally leaves to
operator judgement, why the prism PG user is non-superuser
by design (compromise of one tenant shouldn't reach
agents-pay's database), and the certbot HTTP-01 vs DNS-01
trade-off for *.prism.loka.cash certs.
Also adds deploy/production/prism.yaml to .gitignore — the live
config holds the Postgres password so only the .example template
ships in the tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Services list on the dashboard homepage rendered service_breakdown.total_revenue_sats via rev.toLocaleString() — the raw MIST integer with no unit conversion or label. So 0.02 SUI earned displayed as "20,000,000 earned" while the adjacent Price column on the same row used formatAmount/unitLabel and rendered correctly. Switch the earned span to the same formatAmount(rev, chain).value + unitLabel(chain) helpers, so chain=sui divides by 10^9 (= 0.02 SUI earned) and chain=btc keeps the sat formatting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`GET /api/admin/services` was admin-gated, which forced any end-user client that wanted to render an L402 service picker to ship an admin macaroon — exactly the wrong key to hand out widely. Add `/adminrpc.Admin/ListServices` to `unauthenticatedMethods` so it joins `GetHealth` as a no-auth read. Mutation endpoints (CreateService, UpdateService, DeleteService, RevokeToken) stay admin-gated; the interceptor's structure made the change a one-line whitelist with clear comment + a unit-test loop that asserts both methods bypass auth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.