Skip to content

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
lightninglabs:masterfrom
loka-network:loka
Open

feat: add yaml tags to config fields and normalize paths for sqlite and admin settings#236
ai-chen2050 wants to merge 33 commits into
lightninglabs:masterfrom
loka-network:loka

Conversation

@ai-chen2050
Copy link
Copy Markdown

No description provided.

ai-chen2050 and others added 30 commits April 21, 2026 18:29
- 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>
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>
ai-chen2050 and others added 3 commits May 8, 2026 11:01
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant