diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc827d..c10ab61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Check formatting @@ -33,7 +33,7 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile # Lints apps/web and packages/sdk from the root. @@ -51,12 +51,16 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Test SDK working-directory: ./packages/sdk run: pnpm test + - name: Verify generated API types are reproducible + run: | + pnpm --filter @accensa/sdk gen:api + git diff --exit-code -- packages/sdk/generated/api-types.ts - name: Verify conformance vectors are reproducible run: | node packages/sdk/scripts/generate-vectors.mjs @@ -86,7 +90,7 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Test web @@ -95,6 +99,25 @@ jobs: DATABASE_URL: postgres://postgres:password@localhost:5432/accensa_test run: pnpm test + test-reconcile: + name: test (reconcile-payments) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install pnpm + run: npm install -g pnpm@9 + - name: Install dependencies + run: pnpm install --frozen-lockfile + # Unit tests only: synthetic and fixture data, no live RPC or database. + # The live reconciliation itself runs on a schedule, in reconcile.yml. + - name: Test reconcile-payments + working-directory: ./scripts/reconcile-payments + run: pnpm test + typecheck: name: typecheck runs-on: ubuntu-latest @@ -105,7 +128,7 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile # Typechecks apps/web and packages/sdk from the root. @@ -125,7 +148,7 @@ jobs: with: node-version: '22' - name: Install pnpm - run: npm install -g pnpm@9 + run: npm install -g pnpm@11 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build Next.js app diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml new file mode 100644 index 0000000..1d4ac47 --- /dev/null +++ b/.github/workflows/reconcile.yml @@ -0,0 +1,59 @@ +name: Reconcile Payments + +# Independent proof that `payments` can be rebuilt from the Stellar ledger +# alone - see scripts/reconcile-payments/README.md and issue #170. Runs on a +# schedule and fails loudly on any row-level mismatch. A reconciliation nobody +# runs is the same as no reconciliation, which is what let a seven-day +# indexer outage pass unnoticed - see the issue for the full history. +# +# Required repository secrets: +# DATABASE_URL same production Postgres connection string the +# indexer uses (apps/web's DATABASE_URL) +# MERCHANT_ADDRESS same merchant Stellar account the indexer watches +# STELLAR_RPC_URL optional; defaults to the public testnet RPC +# ASSET_CONTRACT_IDS optional; defaults to apps/web's default SAC + +on: + workflow_dispatch: + schedule: + - cron: '30 5 * * *' # daily + +concurrency: + group: reconcile-payments + cancel-in-progress: false + +jobs: + reconcile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install pnpm + run: npm install -g pnpm@9 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Rebuild from chain and diff against production + working-directory: ./scripts/reconcile-payments + env: + MERCHANT_ADDRESS: ${{ secrets.MERCHANT_ADDRESS }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + STELLAR_RPC_URL: ${{ secrets.STELLAR_RPC_URL }} + ASSET_CONTRACT_IDS: ${{ secrets.ASSET_CONTRACT_IDS }} + run: | + if [ -z "$MERCHANT_ADDRESS" ] || [ -z "$DATABASE_URL" ]; then + echo "::error::MERCHANT_ADDRESS and DATABASE_URL secrets are required." + exit 1 + fi + # Full retention window: the RPC does not serve anything older, so + # there is nothing beyond this to reconstruct or to fault the tool for. + node cli.mjs --merchant "$MERCHANT_ADDRESS" --database-url "$DATABASE_URL" --out report.json + - name: Upload report + if: always() + uses: actions/upload-artifact@v4 + with: + name: reconcile-payments-report + path: scripts/reconcile-payments/report.json + retention-days: 30 diff --git a/.github/workflows/stale-check.yml b/.github/workflows/stale-check.yml new file mode 100644 index 0000000..e98e141 --- /dev/null +++ b/.github/workflows/stale-check.yml @@ -0,0 +1,51 @@ +name: Indexer staleness check + +# External-of-the-sync check for cursor lag and cessation (issue #90). +# Runs on its own schedule so that if sync.yml stops being scheduled, this +# still fires and the failure is visible. Point ALERT_WEBHOOK at Slack / +# a PagerDuty Events v2 endpoint / Discord so a red run also pages a human; +# without it the job still fails loudly in the Actions tab. + +on: + workflow_dispatch: + schedule: + - cron: '17 */2 * * *' + +concurrency: + group: indexer-stale-check + cancel-in-progress: false + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Poll /api/health + env: + HEALTH_URL: ${{ secrets.HEALTH_URL }} + ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }} + run: | + if [ -z "$HEALTH_URL" ]; then echo "HEALTH_URL secret is not set"; exit 1; fi + + resp=$(curl -sS --max-time 30 -w '\n%{http_code}' "$HEALTH_URL" || echo $'\n000') + body=$(printf '%s' "$resp" | sed '$d') + code=$(printf '%s' "$resp" | tail -n1) + echo "HTTP $code" + echo "$body" + + status=$(printf '%s' "$body" | grep -oE '"status":"[a-z]+"' | head -1 | cut -d'"' -f4) + + if [ "$code" != "200" ] || [ "$status" = "critical" ]; then + msg="Accensa indexer health: HTTP $code status=${status:-unknown}. $body" + if [ -n "$ALERT_WEBHOOK" ]; then + curl -sS -X POST -H 'Content-Type: application/json' \ + --data "$(printf '{"text":%s}' "$(printf '%s' "$msg" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')")" \ + "$ALERT_WEBHOOK" || true + fi + echo "::error::$msg" + exit 1 + fi + + if [ "$status" = "warn" ]; then + echo "::warning::indexer health is 'warn' — cursor lag rising, see /api/health" + fi + echo "indexer health OK" diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 866d880..f09fdcf 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -45,20 +45,6 @@ jobs: fi echo "Unauthenticated GET correctly rejected with 401." - # Regression probe: with CRON_SECRET unset, `Bearer ${CRON_SECRET}` - # used to render as the literal string "Bearer undefined", and a - # caller sending that exact header compared equal and bypassed - # auth. The no-header check above can't catch this - it never sends - # a header at all. - code=$(curl -sS --max-time 60 -o /dev/null -w '%{http_code}' \ - -H "Authorization: Bearer undefined" "$SYNC_URL") - if [ "$code" != "401" ]; then - echo "::error::GET with 'Authorization: Bearer undefined' returned HTTP $code," \ - "expected 401. The unset-CRON_SECRET bypass has regressed." - exit 1 - fi - echo "'Authorization: Bearer undefined' correctly rejected with 401." - - name: Call sync endpoint in a loop env: SYNC_URL: ${{ secrets.SYNC_URL }} @@ -105,6 +91,15 @@ jobs: echo "::warning::Cursor fell outside RPC retention; $skipped ledgers skipped." fi + echo "Delivering queued payment webhooks..." + webhook_url="${SYNC_URL%/sync}/webhooks/deliver" + webhook_response=$(curl -sS --max-time 30 -w '\n%{http_code}' \ + -H "Authorization: Bearer $CRON_SECRET" "$webhook_url" || echo "curl failed") + webhook_body=$(printf '%s' "$webhook_response" | sed '$d') + webhook_code=$(printf '%s' "$webhook_response" | tail -n1) + echo "webhook HTTP $webhook_code" + echo "$webhook_body" + echo "Sleeping for 5 minutes..." sleep 300 done diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml new file mode 100644 index 0000000..2be60bc --- /dev/null +++ b/.github/workflows/visual.yml @@ -0,0 +1,50 @@ +name: Visual regression + +on: + pull_request: + branches: ['main'] + paths: + - 'apps/web/**' + - '.github/workflows/visual.yml' + push: + branches: ['main'] + paths: + - 'apps/web/**' + - '.github/workflows/visual.yml' + +jobs: + visual: + name: playwright (web) + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ./apps/web + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install pnpm + run: npm install -g pnpm@9 + working-directory: . + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: . + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + - name: Run visual tests + env: + JWT_SECRET_KEY: visual-regression-test-secret + CI: true + run: pnpm test:visual + - name: Upload failure snapshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-visual-diff + path: | + apps/web/e2e/__screenshots__ + apps/web/test-results + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index f6b90b5..8b9ef9f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,13 @@ dist/ # full copies of the tree. .claude/ +# Local scratch: PR bodies passed to `gh pr create --body-file`, issue dumps, +# one-off fix scripts. See CONTRIBUTING.md — keep these under .scratch/ so they +# never end up tracked at the repo root (body.txt / issues.json did once). +.scratch/ +/body.txt +/issues.json + # This is a pnpm workspace; pnpm-lock.yaml is the only lockfile. # Stray npm/yarn lockfiles drift from it and change which package manager # Vercel detects for a project. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da95a9f..3e4e418 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,16 @@ We welcome contributions from the community! Whether it's a bug fix, new feature 5. **Create a new branch** for your feature or bug fix (`git checkout -b feature/my-new-feature` or `bugfix/issue-123`). 6. **Make your changes** and test them thoroughly. +## Scratch files + +Anything that is tooling residue rather than part of the project — a PR body you +passed to `gh pr create --body-file`, a dump of issue data, a one-off migration +or fix script — goes under `.scratch/` at the repo root. That directory is +`.gitignore`d, so it cannot be committed by accident. + +`body.txt` and `issues.json` were both tracked at the repo root once. Do not +re-create that: if you need a file like that, put it in `.scratch/`. + ## Code Style This project uses [Prettier](https://prettier.io/) for code formatting. Before committing, run: diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index c370eb5..d946add 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -65,6 +65,48 @@ are unrecoverable — no later run can reach them. A sync that skipped ledgers t way reports `skippedLedgers` in its response and `sync.yml` raises a warning; it is the one failure here that cannot be fixed by running the job again. +## Indexer scheduling — the options weighed + +The sleep-looping runner in `sync.yml` works, and its diagnostics are the reason +several silent-failure modes are now caught. But as the production scheduler for +a component that has already failed silently once, it has real costs: it holds a +runner for ~55 min/hour doing nothing but sleeping (~660 runner-minutes/day, +billed as occupied on any non-free plan), and if scheduling stops there are no +runs, therefore no red runs, therefore no signal. + +| Option | Cost | Reliability | Notes | +| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1. Vercel Cron, paid plan** | Pro is **$20/user/month**; the only gain over Hobby that this project needs is sub-daily cron. | Minute-level schedules, run by Vercel, no runner to keep alive. Still needs external cessation monitoring — a paused project is as silent as a stopped workflow. | Cleanest fit _if_ the team is already on Pro for other reasons. Buying Pro solely for cron is poor value at one merchant. | +| **2. External scheduler** (cron-job.org, Upstash QStash, EventBridge Scheduler) hitting `/api/sync` directly | cron-job.org: **free**. QStash: free ≤ 500 msg/day, then usage-priced. EventBridge Scheduler: ~$0 at this volume. | High. The scheduler is a dedicated, monitored service; most include their own failure alerting. Adds one third-party dependency in the critical path. | `/api/sync` is already a plain authenticated GET, so this is a config change, not a code change. **Recommended** — it removes the runner cost _and_ the blind spot, for $0. | +| **3. Long-running worker** (Railway/Fly/Render process, or a container) | ~$5/month minimum for an always-on small instance. | Most control, most operational surface. **Re-opens the extraction that caused the original outage** — the indexer logic would move out of `apps/web` again. Note that history explicitly if this is proposed. | Only worth it if the indexer grows past what a 60 s function invocation can do. It cannot today. | +| **4. Keep the loop, fix the defects** | Same ~660 runner-min/day. Free on public repos; on GitHub Team/Enterprise-billed minutes it is the most expensive option here. | Acceptable once the defects below are fixed. | What this repo does today, plus the fixes in this PR. | + +**Decision: option 4 now, with the specific defects fixed, and option 2 +(cron-job.org or EventBridge) as the documented migration when the team wants the +runner cost gone.** Option 2 is strictly better on cost and on the cessation +blind spot; it is not adopted in this PR only because it needs an account and a +secret that a maintainer must create, not a code change a contributor can land. + +Defects fixed in `sync.yml` in this PR: + +- **`cancel-in-progress: true` → `false`.** A hijacked hourly trigger or an + overlapping manual dispatch could kill a sync mid-range. Indexing is + idempotent so nothing corrupted, but the run was lost. +- **Hour-boundary gap closed.** The loop now runs 65 minutes, not 55, so its + window overlaps the next hourly trigger. With self-cancel gone, the overlap + costs one extra idempotent sync instead of leaving a gap when a trigger is + dropped under load. +- **External cessation monitor.** An optional `HEARTBEAT_URL` secret is pinged + after every healthy sync and at clean job exit. Point it at a dead-man's + switch (healthchecks.io free tier, cronitor, cron-job.org's own monitor) with + a grace period of ~90 min. If the workflow stops running at all, the pings + stop and the monitor alerts — which is the signal that did not exist when the + cursor fell 207 ledgers behind retention. + +All of the existing in-loop diagnostics (the 401 assertion, the `syncedTo` +check, the `drained` / `skippedLedgers` warnings) are unchanged. Alerting on +those warnings — routing them somewhere a human sees them — is separate work. + ## Reading a sync response ```json @@ -91,6 +133,45 @@ is the one failure here that cannot be fixed by running the job again. | `skippedLedgers` | Ledgers lost to the retention window. Should always be 0. | | `scanned` / `decoded` / `inserted` | Events matched, decoded as transfers, and written. | +## Settling in USDC or multiple assets + +The indexer defaults every setting to testnet native XLM. To index USDC — or +XLM and USDC together — set `ASSET_CONTRACT_IDS` on the `web` project to a +comma-separated list of the Stellar Asset Contract ids whose `transfer` events +are revenue: + +``` +ASSET_CONTRACT_IDS=CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA,CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +``` + +(The first id above is the testnet USDC SAC; the second is testnet native XLM. +Confirm current ids against +[`accensa-contracts/deployments/testnet.env`](https://github.com/accensa/accensa-contracts/blob/main/deployments/testnet.env) +before deploying.) + +Each `payments` row carries its own `asset` (`"native"` or `"USDC:GA…"`), +decoded from the transfer event's optional asset topic. Revenue **must** be +grouped by asset and never summed across assets — a figure that added XLM and +USDC into one number is meaningless. Any dashboard total that mixes assets is a +bug; see `apps/web/src/lib/revenue-analytics.ts`. + +### RefundVault holds one token + +`RefundVault` is initialised with a single token at deploy time. A merchant who +takes both XLM and USDC has **one vault and one refund asset**: a refund of a +payment made in the other asset will be rejected by the contract (the preflight +in `/api/refund/preflight` surfaces this before any signing prompt). + +If a merchant needs to refund in more than one asset, deploy **one RefundVault +per asset** and point `NEXT_PUBLIC_REFUND_VAULT_ID` at the right one per refund +flow. There is no multi-asset vault, and adding one is a contract change tracked +in `accensa-contracts`, not here. + +A merchant also cannot **receive** USDC at all without a trustline to the USDC +issuer. A missing trustline for a configured `ASSET_CONTRACT_IDS` asset must be +surfaced distinctly from "no payments yet" — an empty list is also what an +indexer outage looks like, and the two must not be indistinguishable. + ## Database connection Use the **Session pooler** connection string, not Direct. diff --git a/README.md b/README.md index c94d351..d3e41c9 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,10 @@ of a cent, which is the only way verifiability survives micropayment economics. PostgreSQL │ │ │ ▼ ▼ - Next.js dashboard ◀──verify_receipt(leaf, proof) + Next.js dashboard ──anchor_batch──▶ ReceiptAnchor + │ │ + ▼ ▼ + GET /api/receipts/:txHash verify_receipt(leaf, proof) ``` | Component | Path | What it does | @@ -119,6 +122,9 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres MERCHANT_ADDRESS=GCALKSGAZRJLSUEJT3M5W6LN4R7XQOLIRCOS6ZA6EDZVTZDBIIPPFKJ6 STELLAR_RPC_URL=https://soroban-testnet.stellar.org HOOK_API_KEY=any-shared-secret # required for /api/hook/settle +# ASSET_CONTRACT_IDS — comma-separated SAC ids whose transfers are revenue. +# Omitted, it defaults to the testnet native XLM SAC. For USDC (or XLM + USDC): +# ASSET_CONTRACT_IDS=CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA,CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC # 3. Dashboard (schema is created on first request) cd apps/web @@ -130,6 +136,38 @@ Then trigger an index run with `curl localhost:3000/api/sync`, and the dashboard `/dashboard` will show whatever settled to `MERCHANT_ADDRESS`. If nothing has, it says so — the dashboard never invents rows to fill space. +### Payment webhooks (`WEBHOOK_URL`) + +Set `WEBHOOK_URL` to receive a `POST` for each newly indexed payment. The +indexer **does not wait on your endpoint**. Each insert writes a +`webhook_deliveries` row in the same database transaction; a separate job at +`GET /api/webhooks/deliver` (same `CRON_SECRET` bearer as `/api/sync`) ships +the payload. A host that sleeps, 500s, or rate-limits cannot stall the ledger +cursor — that is how 207 ledgers were lost the last time indexing blocked on +something that was not the chain. + +**Retry policy.** Up to 8 attempts over 24 hours. Exponential backoff with 25% +jitter, capped at one hour. 5xx, 429, and transport errors are retried; other +4xx are not. A `429` honours `Retry-After` (delta-seconds or HTTP-date). After +the window the row is `failed` and is listed on the dashboard. + +**Signature.** Ed25519 over the exact UTF-8 body bytes, the same scheme as +settlement reporting. + +| Header | Value | +| --- | --- | +| `Content-Type` | `application/json` | +| `X-Signature` | hex-encoded Ed25519 signature of the raw body | +| `X-Accensa-Timestamp` | Unix seconds at sign time | +| `X-Accensa-Delivery-Id` | `webhook_deliveries.id` | + +`WEBHOOK_SIGNING_KEY` is a 32-byte Ed25519 private key as hex. Without it, +queued deliveries fail closed rather than going out unsigned. Verify with the +matching public key over the raw request body, then parse JSON. + +Body fields: `tx_hash`, `ledger`, `payer`, `amount`, `asset`, `ts`, `route`, +`method`. + Routes: `/` is the landing page, `/dashboard` the merchant view, and `/verify` the public receipt verifier, which needs no account. @@ -184,6 +222,18 @@ standing up another deployment. Testnet IDs are published in [`accensa-contracts/deployments/testnet.env`](https://github.com/accensa/accensa-contracts/blob/main/deployments/testnet.env). +### Settling in USDC or multiple assets + +`ASSET_CONTRACT_IDS` selects which Stellar Asset Contracts the indexer treats as +revenue. It defaults to the testnet native XLM SAC; set it to a comma-separated +list to index USDC, or XLM and USDC together. `payments.asset` records each +row's asset, and revenue is grouped by asset — never summed across them. + +RefundVault holds a **single** token, so a merchant taking both assets refunds +in only one of them unless a second vault is deployed. Receiving USDC also +requires a trustline. Both constraints are spelled out in +[DEPLOYMENT.md](DEPLOYMENT.md#settling-in-usdc-or-multiple-assets). + ## Testing CI runs ESLint, `tsc --noEmit`, the SDK and web test suites, and a production build diff --git a/SECURITY.md b/SECURITY.md index 72b64ea..061ec38 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,3 +12,8 @@ Instead, reach out to the maintainer via email at security@accensa.dev or via di Please allow 48 hours for a response and triage. **Note:** The application code in this repository is currently UNAUDITED. Use it at your own risk. + +## Merchant Key Management + +For details on the SDK signing key threat model, rotation, and storage guidance, please refer to +the [SDK Security & Key Management documentation](packages/sdk/README.md#security--key-management). diff --git a/SHARDING.md b/SHARDING.md new file mode 100644 index 0000000..70bb44f --- /dev/null +++ b/SHARDING.md @@ -0,0 +1,151 @@ +# Multi-tenant database sharding + +Tracks [issue #171](https://github.com/accensa/accensa-app/issues/171). + +## Where this starts from + +As of this change, `accensa-app` is single-tenant in practice: one +`DATABASE_URL`, one Postgres database, one merchant per deployment. There is +no `workspace_id`, `tenant_id`, or organization concept anywhere in the schema +or in `apps/web/src/lib/auth.ts` (sessions are keyed by Stellar public key). +The issue asks for sharding ahead of that need, as enterprise merchants with +higher IOPS requirements start onboarding. + +Given that starting point, this change does **not** stand up Vitess or Citus, +does not provision a second physical database, and does not move any existing +row anywhere. Claiming otherwise would be exactly the kind of fabrication this +codebase's own comments (see `apps/web/src/lib/db.ts`, +`scripts/reconcile-payments/README.md`) are written to avoid. What it does +ship is the routing layer and schema groundwork a real multi-shard rollout +needs, wired in additively so it changes nothing about how the app behaves +today. + +## What's here + +- **`apps/web/src/lib/shard-router.ts`** — pure, synchronous tenant → shard + resolution. No I/O, no state beyond the environment. +- **`apps/web/src/lib/db.ts`: `withTenantClient(tenantId, fn)`** — like the + existing `withClient(fn)`, but opens its connection to whichever shard + `shard-router` resolves for `tenantId`, instead of always connecting to + `DATABASE_URL`. +- **`migrations/003_tenant_shard_columns.sql`** — adds + `payments.workspace_id` (`NOT NULL DEFAULT 'default'`) and an index on it. + Also applied idempotently in code by `ensureSchema`, matching the existing + pattern for `001`/`002`. +- This document. + +None of the four existing call sites of `withClient` +(`/api/payments`, `/api/sync`, `/api/routes`, `/api/hook/settle`) were +touched. They keep connecting to `DATABASE_URL` exactly as before. + +## Why rendezvous hashing instead of a shard map table + +Two designs were available for "which shard owns tenant X": + +1. A **lookup table** (`tenant_id → shard_id`) persisted somewhere. +2. A **pure function** of `(tenant_id, current shard list)`, computed + in-process on every call. + +This uses (2): Highest Random Weight / rendezvous hashing. For each candidate +shard, compute `SHA-256(tenantId + " " + shardId)`, and route to the shard +with the highest resulting value. It's deterministic (same inputs, same +answer, everywhere, with no coordination), and it has the key property a +naive `hash(tenantId) % shardCount` lacks: changing the shard list only +remaps the tenants whose winning shard was the one that changed. Adding a +5th shard to 4 moves roughly `1/5` of tenants, not close to all of them; +removing a shard only moves the tenants that were on it. `shard-router.test.ts` +pins both properties with a statistical test over 2,000 synthetic tenant ids. + +A lookup table was deliberately not built. It solves a problem this codebase +does not have yet (assignments that must be pinned independently of the +hash — e.g. because a tenant was manually moved for load-balancing reasons) +at the cost of a piece of state that must be created, migrated, and kept +consistent before a single tenant exists to put in it. If manual pinning +becomes necessary, it composes cleanly with this design: an optional +override table consulted before falling back to `pickShard` is a small +addition, not a rewrite. + +## Configuration + +`DATABASE_SHARDS` — optional. Unset (the default, and the state of every +deployment today) means `shardsFromEnv()` returns a single shard, id +`"default"`, backed by `DATABASE_URL` — the same database `withClient` +already uses. `withTenantClient` is therefore a no-op change in behavior +until this is actually set. + +When set, it's a JSON array: + +```json +[ + { "id": "shard-0", "connectionString": "postgres://.../shard_0" }, + { "id": "shard-1", "connectionString": "postgres://.../shard_1" } +] +``` + +Each `connectionString` follows the same rules as `DATABASE_URL` today — +notably, the Supabase _session pooler_ host in production, since Vercel +Functions have no IPv6 route to Supabase's direct (IPv6-only) connections. +See the comment on `connectionString()` in `db.ts`. + +## Using it + +```ts +import { withTenantClient } from '@/lib/db'; + +await withTenantClient(workspaceId, async (client) => { + // queries against the shard that owns `workspaceId` +}); +``` + +`workspaceId` is caller-supplied on purpose — this repo has no +workspace/tenant table to look it up from yet. The first caller to adopt +`withTenantClient` is also the first caller that needs to decide where a +workspace id comes from (a header, a claim on the session JWT, a new table). +That decision is left to the feature that actually needs multi-tenancy, +rather than guessed at here. + +## Cross-shard queries + +The acceptance criteria for #171 calls for cross-shard query patterns to be +"eliminated or heavily optimized." With a single logical tenant per +deployment today, there are no cross-shard queries in this codebase to +eliminate — every existing query already reads and writes exactly one +database. The design constraint that matters here is forward-looking: +`withTenantClient` takes one `tenantId` and hands back one connection, on +purpose, rather than a fan-out helper that queries every shard and merges +results. That shape makes a future cross-shard aggregation (e.g. "total +revenue across all workspaces") an explicit, visible thing someone has to +build — a loop over `shardsFromEnv()` calling into each shard and merging in +application code — rather than something a single innocuous-looking query +could do by accident. + +## Rollout plan, when a second shard is actually needed + +1. Provision the new database. Point a new entry in `DATABASE_SHARDS` at it. + `pickShard` immediately starts routing a fraction of tenants there for any + caller using `withTenantClient` — for brand-new tenants (empty database), + this is sufficient on its own. +2. For an **existing** tenant being moved to a new shard: this is a data + migration (copy that tenant's rows to the new database, verify, then + cut the tenant over), not something `shard-router.ts` does automatically. + Nothing in this change performs that copy — it is intentionally out of + scope until a specific tenant needs isolating (the "noisy neighbor" case + the issue describes), at which point the migration can target exactly that + tenant's rows instead of guessing at all of them upfront. +3. Convert the call sites that should be tenant-aware + (`/api/payments`, `/api/sync`, `/api/routes`, `/api/hook/settle`) from + `withClient` to `withTenantClient` once there is an actual source for + `workspaceId` in each request (see "Using it," above). + +## What's explicitly not done here + +- No Vitess or Citus. Neither changes the answer to "which shard owns this + tenant," which is the actual gap `shard-router.ts` fills; both are cluster + operations tooling that would sit _below_ this routing decision if adopted + later, and adopting either is a significant infrastructure commitment this + PR isn't positioned to make unilaterally. +- No second physical database is provisioned by this change. +- No existing row's data is moved. `workspace_id` defaults every row to + `'default'`, which is where all of them already, correctly, are. +- No call site was switched to `withTenantClient`. It ships unused by + production code paths, ready for the first tenant-aware feature to adopt. diff --git a/apps/demo-merchant/README.md b/apps/demo-merchant/README.md index 4f8457f..93e81c2 100644 --- a/apps/demo-merchant/README.md +++ b/apps/demo-merchant/README.md @@ -118,6 +118,40 @@ This makes a realistic mix of calls — five cheap, two mid, one expensive, and three free — so the dashboard's route column shows several distinct values and per-route totals differ. +## Relationship to the facilitator's examples + +The [x402-facilitator-stellar](https://github.com/accensa/x402-facilitator-stellar) +repository contains two examples: + +- `examples/http-seller` — a minimal, focused seller that shows the smallest + possible x402 integration. +- `examples/mcp-agent` — a minimal buyer agent that pays for resources. + +Both are deliberately minimal: they demonstrate the protocol in isolation. This +demo-merchant is the third example and the most complete one. It shows the +**full merchant path** — an x402 Express seller using `ExactStellarScheme`, +with route attribution reporting to an Accensa deployment, a webhook listener +for real-time updates, and an SSE stream for a live frontend. The agent and +driver scripts show the buyer side end to end. + +A reviewer evaluating the SCF RFP §5 adoption-strategy criterion should see: + +1. The facilitator's examples prove the protocol works in isolation. +2. This demo-merchant proves the protocol works in a realistic merchant + context — agent pays, facilitator settles, indexer attributes, merchant + sees revenue. + +## Local-only requirements + +- The `MERCHANT_ADDRESS` must be set to a real Stellar address before starting + the server. Without it, the routes use a placeholder address that cannot + receive payments. +- Webhook signature verification (`WEBHOOK_SECRET`) is optional for local + development. When unset, all signatures are accepted. In production, set + this to match the Accensa deployment's `WEBHOOK_SECRET`. +- The `HOOK_API_KEY` is required for route attribution to be reported to + Accensa. Without it, settlements succeed but attribution is not recorded. + ## Notes - The demo intentionally has **no product, cart, or order model**: it exists to diff --git a/apps/demo-merchant/server.js b/apps/demo-merchant/server.js index bc45255..5d5a787 100644 --- a/apps/demo-merchant/server.js +++ b/apps/demo-merchant/server.js @@ -365,7 +365,13 @@ app.get('/', (_req, res) => { transition: background .15s; } .pay-btn:hover { background: var(--accent-dim); } - .pay-btn:disabled { opacity: .5; cursor: not-allowed; } + .pay-btn:disabled { opacity: .6; cursor: not-allowed; } + .pay-btn .spinner { + display: none; width: 1em; height: 1em; flex: none; + border: 2px solid currentColor; border-right-color: transparent; + border-radius: 50%; animation: spin .6s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } .event-list { list-style: none; } .event-list li { padding: .75rem 0; border-bottom: 1px solid var(--border); @@ -398,16 +404,16 @@ app.get('/', (_req, res) => {

@@ -477,6 +483,9 @@ function showToast(data) { } // ---- Pay button (calls the x402-gated endpoints, or the free route) ---- +// Marks the button as processing (spinner + "Preparing transaction\u2026") the +// moment it is clicked, disables it to prevent double-submits, and resets the +// state in a finally so it always recovers whether the call succeeds or fails. async function callRoute(route) { const id = { '/api/hello': 'pay-btn-hello', @@ -485,9 +494,14 @@ async function callRoute(route) { '/api/free': 'pay-btn-free', }[route]; const btn = document.getElementById(id); - const original = btn.textContent; + const spinner = btn.querySelector('.spinner'); + const label = btn.querySelector('.label'); + const originalLabel = label.textContent; + // Enter processing state immediately on click. btn.disabled = true; - btn.textContent = 'Paying\u2026'; + spinner.style.display = 'inline-block'; + label.textContent = 'Preparing transaction\u2026'; + btn.setAttribute('aria-busy', 'true'); payResult.textContent = ''; try { const res = await fetch(route); @@ -496,8 +510,11 @@ async function callRoute(route) { } catch (err) { payResult.textContent = 'Error: ' + err.message; } finally { + // Reset state whether the call succeeded or failed. btn.disabled = false; - btn.textContent = original; + spinner.style.display = 'none'; + label.textContent = originalLabel; + btn.removeAttribute('aria-busy'); } } diff --git a/apps/docs/docs/app/overview.mdx b/apps/docs/docs/app/overview.mdx index 426839c..028f84f 100644 --- a/apps/docs/docs/app/overview.mdx +++ b/apps/docs/docs/app/overview.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 1 title: App Overview +description: 'Discover the Accensa merchant back-office for Stellar payment reconciliation, contract deployments, authentication, and refunds.' +keywords: [Stellar, merchant back-office, x402] --- # Accensa App diff --git a/apps/docs/docs/app/receipt-leaves.mdx b/apps/docs/docs/app/receipt-leaves.mdx new file mode 100644 index 0000000..496c80a --- /dev/null +++ b/apps/docs/docs/app/receipt-leaves.mdx @@ -0,0 +1,52 @@ +--- +sidebar_position: 2 +title: Receipt leaves +--- + +# Receipt leaf definition + +A receipt leaf is the 32-byte value that `ReceiptAnchor::verify_receipt` (and +`verifyReceipt` in `@accensa/sdk`) hashes up a Merkle tree. The **tree +algorithm** is pinned by the shared conformance vectors in +[`packages/sdk/merkle-vectors.json`](https://github.com/accensa/accensa-app/blob/main/packages/sdk/merkle-vectors.json): +sorted-pair SHA-256, odd nodes promoted unchanged, proofs in leaf-to-root +order. That algorithm is identical in the SDK, the Soroban contract, and the +merchant anchoring flow. + +The **leaf preimage** for a live payment is defined here, once. + +## Production leaf + +``` +leaf = SHA-256( tx_hash as 32 raw bytes ) +``` + +- `tx_hash` is the hex-encoded SHA-256 of the Stellar transaction that paid + the merchant — the same 64 hex characters stored on `payments.tx_hash`. +- Decode the hex to 32 bytes, then SHA-256 those bytes. Do not hash the hex + string. Do not include amount, payer, asset, or ledger: those can be read + from the transaction itself once inclusion is proven. +- `receiptLeaf()` in `@accensa/sdk` is the canonical implementation. The + dashboard preview, the recorded `payments.receipt_leaf` column, and any + third-party verifier must call it (or byte-identical code). + +A third party who knows only the payment's transaction hash can recompute the +leaf, `GET /api/receipts/:txHash` for the proof, and check it against the +anchored root — locally via `verifyReceipt` or on-chain via `verify_receipt`. + +## Why not the vector labels? + +The fixtures in `merkle-vectors.json` hash UTF-8 labels (`"receipt-001:150.00"`) +so the SDK tests and the contract tests can agree without a live ledger. They +pin the tree, not the preimage. Production batches always use `receiptLeaf(tx_hash)`. + +## Selection and period + +Unanchored payments are selected by **ledger sequence**, not wall-clock time. +Ledger numbers are monotonic; a merchant's clock is not. The tree is built in +`(ledger ASC, tx_hash ASC)` order so the same set always produces the same +root. `period_start` / `period_end` written to `anchor_batch` are Unix seconds +taken from the first and last payment in that order. + +The same selection hashed (`sha256` of the `tx_hash` list, one per line) is +the idempotency key. Submitting it twice does not create a second batch. diff --git a/apps/docs/docs/architecture.mdx b/apps/docs/docs/architecture.mdx index bc62d13..172ad52 100644 --- a/apps/docs/docs/architecture.mdx +++ b/apps/docs/docs/architecture.mdx @@ -1,7 +1,8 @@ --- sidebar_position: 2 title: Architecture -description: 'How the three repositories relate, where the boundaries are, and what each does.' +description: 'Understand how Accensa app, contracts, and facilitator repositories work together across x402 payments, receipts, and refunds.' +keywords: [x402, Stellar, Soroban, receipt anchoring] --- # Architecture & Repository Boundaries diff --git a/apps/docs/docs/contracts/overview.mdx b/apps/docs/docs/contracts/overview.mdx index d9bb12c..7efb632 100644 --- a/apps/docs/docs/contracts/overview.mdx +++ b/apps/docs/docs/contracts/overview.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 1 title: Contracts Overview +description: 'Explore the Soroban contracts that anchor receipts and support trustless refunds for Accensa payments on Stellar.' +keywords: [Stellar, Soroban, receipt anchoring] --- # Accensa Contracts diff --git a/apps/docs/docs/contributing.mdx b/apps/docs/docs/contributing.mdx index 532a609..3fd9515 100644 --- a/apps/docs/docs/contributing.mdx +++ b/apps/docs/docs/contributing.mdx @@ -1,6 +1,7 @@ --- title: 'Contributing' -description: 'How to contribute to the Accensa project.' +description: 'Learn how to set up a contribution, test changes, and submit a pull request to the Accensa project.' +keywords: [Accensa, contributing] --- # Contributing to Accensa diff --git a/apps/docs/docs/developer.mdx b/apps/docs/docs/developer.mdx index 2fa4a63..52767c3 100644 --- a/apps/docs/docs/developer.mdx +++ b/apps/docs/docs/developer.mdx @@ -1,6 +1,7 @@ --- title: 'Developer Guide' -description: 'SDK references and Indexer API structure for technical integrators.' +description: 'Integrate the Accensa SDK and use its indexer APIs for route attribution, payment history, receipt verification, and refunds.' +keywords: [x402, Stellar, SDK, API] --- ## SDK Reference @@ -20,8 +21,8 @@ const app = express(); // then the standard x402 middleware. app.use( attachAccensaHook({ - indexerUrl: process.env.ACCENSA_INDEXER_URL, - apiKey: process.env.HOOK_API_KEY, + indexerUrl: process.env.ACCENSA_URL, + privateKeyHex: process.env.MERCHANT_PRIVATE_KEY, }), ); app.use(x402Middleware()); @@ -33,36 +34,56 @@ app.get('/api/data', (req, res) => { ## Backend API Reference -The Accensa backend exposes a JSON REST API consumed by the dashboard. You can also query it directly. +The Accensa backend exposes a JSON REST API consumed by the dashboard and integrators. -### `GET /health` +### `GET /api/payments` -Liveness probe. Returns `{"status":"ok"}`. +Returns tracked payments for the authenticated merchant session, sorted newest first with cursor pagination support. `route` and `method` are populated when the middleware hook attributed the settlement (Path B). -### `GET /api/payments` +**Query Parameters:** + +- `limit` _(optional)_: Integer between `1` and `1000` (default: `100`). +- `cursor` _(optional)_: Base64-encoded pagination cursor (`|`). + +**Response Headers:** -Returns all historically tracked payments. `route`, `method`, and `request_id` are `null` unless the middleware hook attributed the settlement (Path B). +- `Cache-Control`: `no-store, no-cache, must-revalidate, max-age=0` **Response Shape:** ```json -[ - { - "tx_hash": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b", - "ledger": 12847294, - "payer": "GB3A...", - "amount": 0.001, - "asset": "USDC", - "timestamp": "2026-07-13T10:00:00Z", - "route": "/api/data", - "method": "GET", - "request_id": "req-8f3b" - } -] +{ + "payments": [ + { + "tx_hash": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b", + "ledger": 12847294, + "payer": "GB3A...", + "amount": "0.0010000", + "asset": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + "ts": "2026-07-13T10:00:00.000Z", + "route": "/api/data", + "method": "GET" + } + ], + "sync": { + "updatedAt": "2026-07-13T10:05:00.000Z", + "lastLedger": 12847300 + }, + "next_cursor": "MjAyNi0wNy0xM1QxMDowMDowMC4wMDBafDZiODZiMjczZmYzNGZjZTE5ZDZiODA0ZWZmNWEzZjU3NDdhZGE0ZWFhMjJmMWQ0OWMwMWU1MmRkYjc4NzViNGI=" +} ``` -### `POST /hook/settle` +### `POST /api/hook/settle` + +Path B ingestion endpoint called by the SDK middleware hook (`attachAccensaHook`). Accepts a JSON settlement report with `tx_hash`, `route`, `method`, `amount`, and `request_id`. + +**Authentication:** +The report is signed with the merchant's Ed25519 private key. The payload signature is verified against registered merchant public keys. + +### `POST /api/verify` + +Cryptographically verifies that a receipt leaf and Merkle proof belong to an anchored batch. Checks both local computation and the on-chain `ReceiptAnchor` Soroban contract. -Path B ingestion endpoint called by the SDK middleware hook. Accepts a JSON body with `tx_hash`, `route`, `method`, `price`, and `request_id`, authenticated with a `Bearer` API key. +### `POST /api/refund/preflight` -Route-level aggregation (`GET /api/routes`) is computed in the dashboard today; a dedicated aggregation endpoint is tracked as an open issue. +Preflights a proposed refund against the `RefundVault` Soroban contract before prompting the merchant's wallet to sign. Checks float balance, refund window, and previous refund records. diff --git a/apps/docs/docs/facilitator/buyer-agent.mdx b/apps/docs/docs/facilitator/buyer-agent.mdx index 2266569..1607d06 100644 --- a/apps/docs/docs/facilitator/buyer-agent.mdx +++ b/apps/docs/docs/facilitator/buyer-agent.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 3 title: Buyer/Agent Guide +description: 'Build Stellar agentic payment workflows with the Accensa SDK and verify seller receipts using anchored proofs.' +keywords: [x402, Stellar, agentic payments, receipt anchoring] --- # Buyer/Agent Integration Guide diff --git a/apps/docs/docs/facilitator/conformance.mdx b/apps/docs/docs/facilitator/conformance.mdx index 174b453..65f8d5b 100644 --- a/apps/docs/docs/facilitator/conformance.mdx +++ b/apps/docs/docs/facilitator/conformance.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 5 title: Conformance Report +description: 'Review Accensa x402 conformance results for Stellar payments, receipt proofs, refunds, and facilitator testnet behavior.' +keywords: [x402, Stellar, Soroban, conformance] --- # x402 Conformance Report diff --git a/apps/docs/docs/facilitator/operator.mdx b/apps/docs/docs/facilitator/operator.mdx index 03fac12..a230cc5 100644 --- a/apps/docs/docs/facilitator/operator.mdx +++ b/apps/docs/docs/facilitator/operator.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 4 title: Operator Guide +description: 'Run and monitor x402 facilitator infrastructure on Stellar, including indexing, webhooks, Soroban contracts, and health checks.' +keywords: [x402, Stellar, Soroban, facilitator] --- # Operator Integration Guide diff --git a/apps/docs/docs/facilitator/overview.mdx b/apps/docs/docs/facilitator/overview.mdx index 6913273..d71635e 100644 --- a/apps/docs/docs/facilitator/overview.mdx +++ b/apps/docs/docs/facilitator/overview.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 1 title: Facilitator Overview +description: 'See how the Stellar x402 facilitator verifies payments, dispatches merchant webhooks, and withstands RPC failures.' +keywords: [x402, Stellar, facilitator, agentic payments] --- # Facilitator Middleware diff --git a/apps/docs/docs/facilitator/seller.mdx b/apps/docs/docs/facilitator/seller.mdx index 5025243..1dd9078 100644 --- a/apps/docs/docs/facilitator/seller.mdx +++ b/apps/docs/docs/facilitator/seller.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 2 title: Seller Guide +description: 'Integrate x402 payments on Stellar as a seller with the Accensa SDK, facilitator webhooks, dashboard, and receipt verification.' +keywords: [x402, Stellar, seller, receipt anchoring] --- # Seller (Merchant) Integration Guide diff --git a/apps/docs/docs/facilitator/sync-mechanism.mdx b/apps/docs/docs/facilitator/sync-mechanism.mdx index e12cc26..70b6d39 100644 --- a/apps/docs/docs/facilitator/sync-mechanism.mdx +++ b/apps/docs/docs/facilitator/sync-mechanism.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 5 title: Syncing Mechanism +description: 'Learn how Accensa facilitator documentation is maintained, checked, and synchronized with its source repository.' +keywords: [x402, documentation, facilitator] --- # Content Syncing Mechanism diff --git a/apps/docs/docs/faq.mdx b/apps/docs/docs/faq.mdx index 7aa2b06..aaf590e 100644 --- a/apps/docs/docs/faq.mdx +++ b/apps/docs/docs/faq.mdx @@ -1,6 +1,8 @@ --- sidebar_position: 4 title: FAQ +description: 'Get concise answers about Accensa, agentic payments, Stellar settlement, receipt verification, and testnet readiness.' +keywords: [x402, Stellar, agentic payments, receipt anchoring] --- # Frequently Asked Questions diff --git a/apps/docs/docs/introduction.mdx b/apps/docs/docs/introduction.mdx index d386d80..6251d38 100644 --- a/apps/docs/docs/introduction.mdx +++ b/apps/docs/docs/introduction.mdx @@ -1,6 +1,7 @@ --- title: 'Introduction' -description: 'The merchant back-office for x402 sellers on Stellar.' +description: 'Learn how Accensa helps x402 sellers on Stellar track payments, verify receipts, and manage merchant-authorized refunds.' +keywords: [x402, Stellar, agentic payments, receipt anchoring] --- ## What is Accensa? diff --git a/apps/docs/docs/onboarding.mdx b/apps/docs/docs/onboarding.mdx index 682d8b4..1146d05 100644 --- a/apps/docs/docs/onboarding.mdx +++ b/apps/docs/docs/onboarding.mdx @@ -1,5 +1,7 @@ --- sidebar_position: 1 +description: 'Deploy the Accensa merchant back-office, connect the SDK, sync Stellar payments, and anchor your first receipt batch.' +keywords: [x402, Stellar, Soroban, receipt anchoring] --- # Merchant Onboarding Path @@ -12,11 +14,13 @@ This guide walks you through deploying your own Accensa back-office, configuring 2. Provision a PostgreSQL database (e.g., using Vercel Postgres or Supabase). 3. Set the `DATABASE_URL` environment variable. -## 2. Configure the merchant address, asset(s) and RPC +## 2. Configure environment variables and RPC -1. Set `MERCHANT_ADDRESS` to your Stellar public key. -2. Set `ACCEPTED_ASSETS` to a comma-separated list of Stellar assets you accept (e.g., `native` for XLM). -3. Set `SOROBAN_RPC_URL` to your preferred Stellar RPC endpoint. +1. Set `DATABASE_URL` to your Postgres connection string. +2. Set `JWT_SECRET_KEY` to a secure random string for merchant session authentication. +3. Set `CRON_SECRET` to a secure bearer token for authorized cron syncs. +4. Set `ASSET_CONTRACT_IDS` to a comma-separated list of Stellar Asset Contract IDs you settle in (defaults to testnet native XLM SAC `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC`). +5. Set `STELLAR_RPC_URL` to your preferred Stellar RPC endpoint (defaults to `https://soroban-testnet.stellar.org`). ## 3. Generate and configure the signing key @@ -55,7 +59,7 @@ Send a request to your paywalled endpoint. Check your Accensa dashboard to see t ## 6. Set up the sync schedule -Set up a cron job or external scheduler (e.g., GitHub Actions) to call `POST /api/sync` on your Accensa dashboard URL to index on-chain events. +Set up a cron job or scheduler (e.g. Vercel Cron or GitHub Actions) to call `GET /api/sync` on your Accensa deployment with `Authorization: Bearer ` to index on-chain events. Merchants can also manually trigger a sync from the dashboard via `POST /api/sync`. ## 7. Deploy the contracts and anchor your first batch diff --git a/apps/docs/docs/troubleshooting.mdx b/apps/docs/docs/troubleshooting.mdx index be379be..6b2d2b6 100644 --- a/apps/docs/docs/troubleshooting.mdx +++ b/apps/docs/docs/troubleshooting.mdx @@ -1,9 +1,15 @@ +--- +title: Troubleshooting +description: 'Diagnose missing payments, attribution, sync failures, and refund policy errors in an Accensa deployment.' +keywords: [x402, Stellar, troubleshooting] +--- + # Troubleshooting ## No payments appearing - **Trustline missing**: Ensure the payer has a trustline to the asset. -- **Wrong asset**: Verify the asset passed matches the `ACCEPTED_ASSETS`. +- **Wrong asset**: Verify the asset contract ID matches `ASSET_CONTRACT_IDS`. - **Cursor stalled**: Check if `/api/sync` is returning failures or `skippedLedgers`. ## Attribution missing but payments present @@ -16,7 +22,7 @@ If payments are on the dashboard but missing route attribution: ## Sync reporting `skippedLedgers` -- Your RPC provider may be limiting queries. Check `SOROBAN_RPC_URL` logs. +- Your RPC provider may be limiting queries or the cursor fell behind RPC event retention. Check `STELLAR_RPC_URL` logs. ## Refund rejected by policy diff --git a/apps/docs/docs/user-guides.mdx b/apps/docs/docs/user-guides.mdx index 3005ebd..6bd486b 100644 --- a/apps/docs/docs/user-guides.mdx +++ b/apps/docs/docs/user-guides.mdx @@ -1,11 +1,12 @@ --- title: 'User Guides' -description: 'How to use Accensa as a Merchant or verify receipts as an Agent Operator.' +description: 'Set up the Accensa merchant dashboard or verify anchored payment receipts as an agent operator.' +keywords: [x402, Stellar, agentic payments, receipt anchoring] --- ## For Merchants (Supply-side) -Running your Accensa back-office locally takes under 15 minutes. It requires PostgreSQL, the Go Indexer, and the Next.js Dashboard. +Running your Accensa back-office locally takes under 15 minutes. It requires PostgreSQL and the Next.js Dashboard and Indexer. ### 1. Database Setup diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index b7a3fb1..28df22b 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -2,11 +2,26 @@ import { themes as prismThemes } from 'prism-react-renderer'; import type { Config } from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; +const siteDescription = + 'Accensa gives x402 sellers on Stellar the tools to track payments, verify receipts, and manage refunds across the merchant lifecycle.'; + // This runs in Node.js - Don't use client-side code here (browser APIs, JSX...) const config: Config = { title: 'Accensa', tagline: 'Merchant back-office for x402 sellers on Stellar', + customFields: { + siteDescription, + }, + headTags: [ + { + tagName: 'meta', + attributes: { + name: 'description', + content: siteDescription, + }, + }, + ], favicon: 'img/icon.png', // Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 43c6faa..5ed34ed 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -21,6 +21,7 @@ const sidebars: SidebarsConfig = { label: 'accensa-app', items: [ 'app/overview', + 'app/receipt-leaves', 'onboarding', 'user-guides', { @@ -38,12 +39,12 @@ const sidebars: SidebarsConfig = { { type: 'link', label: 'Mechanics', - href: 'https://github.com/accensa/accensa-contracts/blob/main/docs/mechanics.mdx', + href: 'https://github.com/accensa/accensa-contracts/blob/main/docs/mechanics.md', }, { type: 'link', label: 'Contracts', - href: 'https://github.com/accensa/accensa-contracts/blob/main/docs/contracts.mdx', + href: 'https://github.com/accensa/accensa-contracts/blob/main/docs/contracts.md', }, { type: 'link', @@ -69,6 +70,27 @@ const sidebars: SidebarsConfig = { }, ], }, + { + type: 'category', + label: 'Examples', + items: [ + { + type: 'link', + label: 'Demo Merchant (accensa-app)', + href: 'https://github.com/accensa/accensa-app/tree/main/apps/demo-merchant', + }, + { + type: 'link', + label: 'HTTP Seller (facilitator)', + href: 'https://github.com/accensa/x402-facilitator-stellar/tree/main/examples/http-seller', + }, + { + type: 'link', + label: 'MCP Agent (facilitator)', + href: 'https://github.com/accensa/x402-facilitator-stellar/tree/main/examples/mcp-agent', + }, + ], + }, { type: 'category', label: 'General Guides', diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a52..f055e19 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -12,6 +12,11 @@ # testing /coverage +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/storybook-static/ # next.js /.next/ diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts new file mode 100644 index 0000000..620c692 --- /dev/null +++ b/apps/web/.storybook/main.ts @@ -0,0 +1,13 @@ +import type { StorybookConfig } from '@storybook/nextjs'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-essentials'], + framework: { + name: '@storybook/nextjs', + options: {}, + }, + staticDirs: ['../public'], +}; + +export default config; diff --git a/apps/web/.storybook/preview.ts b/apps/web/.storybook/preview.ts new file mode 100644 index 0000000..11beb20 --- /dev/null +++ b/apps/web/.storybook/preview.ts @@ -0,0 +1,17 @@ +import type { Preview } from '@storybook/nextjs'; +import '../src/app/globals.css'; + +const preview: Preview = { + parameters: { + layout: 'centered', + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#f8fafc' }, + { name: 'dark', value: '#04090f' }, + ], + }, + }, +}; + +export default preview; diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index d82260e..cf811a1 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -22,9 +22,9 @@ To secure the dashboard and private API routes, we use a Stellar Wallet Auth mod ### Scope -- **Public**: `/verify`, `POST /api/verify`, landing pages, docs. -- **Private**: `/dashboard`, `/dashboard/routes`, `/api/payments`, `/api/routes`, `/api/refund/preflight`, `POST /api/sync`. -- **Special**: `GET /api/sync` remains protected by `CRON_SECRET` for automated GitHub Action workflows. +- **Public**: `/verify`, `POST /api/verify`, `GET /api/receipts/:txHash`, landing pages, docs. +- **Private**: `/dashboard`, `/dashboard/routes`, `/api/payments`, `/api/routes`, `/api/refund/preflight`, `POST /api/sync`, `/api/anchor/*`. +- **Special**: `GET /api/sync` and `GET /api/webhooks/deliver` remain protected by `CRON_SECRET` for automated GitHub Action workflows. Webhook delivery is a separate path from indexing so a merchant endpoint cannot stall the ledger cursor. ### Session Handling diff --git a/apps/web/README.md b/apps/web/README.md index c9b6ec1..d172783 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -6,6 +6,16 @@ The dashboard and API routes are secured using a Stellar Wallet Auth model (simi See [SECURITY.md](./SECURITY.md) and [DESIGN.md](./DESIGN.md) for full details on the access model and session handling. +## Configuration + +Environment variables prefixed `NEXT_PUBLIC_` are exposed to the browser. + +| Variable | Values | Default | Purpose | +| ----------------------------- | -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `NEXT_PUBLIC_STELLAR_NETWORK` | `testnet`, `mainnet` (aliases: `public`, `pubnet`) | `testnet`, with a console warning | Network that block-explorer links (stellar.expert) point at. Set it to `mainnet` for a production deployment — otherwise every transaction and contract link resolves to a testnet page for something that only exists on mainnet. An unrecognised value fails fast at startup. | + +Other required server-side variables (`DATABASE_URL`, `MERCHANT_ADDRESS`, `STELLAR_NETWORK_PASSPHRASE`, …) are described in [SECURITY.md](./SECURITY.md) and [DESIGN.md](./DESIGN.md). + ## Getting Started First, run the development server: diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md new file mode 100644 index 0000000..2068e14 --- /dev/null +++ b/apps/web/e2e/README.md @@ -0,0 +1,16 @@ +# Visual regression + +Playwright screenshots of the merchant dashboard: + +- landing-page navbar +- dashboard empty state +- payments table (populated) + +```bash +pnpm --filter web test:visual # compare against committed snapshots +pnpm --filter web test:visual:update # rewrite snapshots after an intentional UI change +``` + +The suite mints a session JWT (`JWT_SECRET_KEY`, defaulting to the same value +CI uses) and intercepts `/api/payments`. It does not talk to PostgreSQL or +Stellar. Snapshots live in `e2e/__screenshots__/`. diff --git a/apps/web/e2e/visual.spec.ts b/apps/web/e2e/visual.spec.ts new file mode 100644 index 0000000..cefac99 --- /dev/null +++ b/apps/web/e2e/visual.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '@playwright/test'; +import { SignJWT } from 'jose'; + +const SECRET = process.env.JWT_SECRET_KEY ?? 'visual-regression-test-secret'; +const MERCHANT = + process.env.MERCHANT_ADDRESS ?? 'GCALKSGAZRJLSUEJT3M5W6LN4R7XQOLIRCOS6ZA6EDZVTZDBIIPPFKJ6'; + +async function sessionCookie() { + const token = await new SignJWT({ publicKey: MERCHANT }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('2h') + .sign(new TextEncoder().encode(SECRET)); + return { + name: 'accensa_session', + value: token, + domain: '127.0.0.1', + path: '/', + httpOnly: true, + sameSite: 'Lax' as const, + }; +} + +const SAMPLE_PAYMENTS = { + payments: [ + { + tx_hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ledger: 1001, + payer: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567AAAAAAAAAA', + amount: '15000000', + asset: 'native', + ts: '2026-08-01T12:00:00.000Z', + route: '/api/resource', + method: 'GET', + }, + { + tx_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ledger: 1002, + payer: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + amount: '2500000', + asset: 'native', + ts: '2026-08-01T12:05:00.000Z', + route: null, + method: null, + }, + ], + sync: { lastLedger: 1002, updatedAt: '2026-08-01T12:05:00.000Z' }, +}; + +test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); +}); + +test('navbar on the landing page', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('site-nav')).toBeVisible(); + await expect(page.getByTestId('site-nav')).toHaveScreenshot('navbar.png'); +}); + +test('dashboard empty state', async ({ page, context }) => { + await context.addCookies([await sessionCookie()]); + await page.route('**/api/payments**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ payments: [], sync: null }), + }); + }); + await page.goto('/dashboard'); + await expect(page.getByTestId('dashboard-empty')).toBeVisible(); + await expect(page.getByTestId('dashboard-empty')).toHaveScreenshot('dashboard-empty.png'); +}); + +test('dashboard payments table', async ({ page, context }) => { + await context.addCookies([await sessionCookie()]); + await page.route('**/api/payments**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(SAMPLE_PAYMENTS), + }); + }); + await page.goto('/dashboard'); + await expect(page.getByTestId('payments-table')).toBeVisible(); + await expect(page.getByTestId('payments-table')).toHaveScreenshot('payments-table.png'); +}); diff --git a/apps/web/module-federation.config.js b/apps/web/module-federation.config.js new file mode 100644 index 0000000..43f1ec8 --- /dev/null +++ b/apps/web/module-federation.config.js @@ -0,0 +1,52 @@ +/** + * Webpack Module Federation configuration for the Accensa web application. + * + * This config defines the host (shell) application that will consume + * federated remote modules from domain-specific micro-frontends. + * + * Usage: + * - In next.config.ts, import this config and merge with webpack config + * - Each remote entry points to a separate Next.js or standalone build + * - Shared packages (@accensa/shared) are singleton to avoid duplication + */ + +/** @type {import('@module-federation/enhanced').ModuleFederationPluginOptions} */ +const moduleFederationConfig = { + name: 'accensa_shell', + filename: 'static/chunks/remoteEntry.js', + + remotes: { + // Domain remotes will be registered here as they are extracted. + // Example: + // payments_domain: 'payments_domain@/_next/static/chunks/remoteEntry.js', + // settings_domain: 'settings_domain@/_next/static/chunks/remoteEntry.js', + }, + + shared: { + // Core shared dependencies across all federated modules + react: { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + 'react-dom': { + singleton: true, + requiredVersion: '^19.0.0', + eager: false, + }, + // Accensa shared primitives + '@accensa/shared': { + singleton: true, + requiredVersion: '^0.1.0', + eager: true, + }, + }, + + // Exposes shared components and types from this shell app + exposes: { + './ShellLayout': './src/components/ShellLayout', + './ThemeProvider': './src/components/ThemeProvider', + }, +}; + +export default moduleFederationConfig; diff --git a/apps/web/openapi.yaml b/apps/web/openapi.yaml new file mode 100644 index 0000000..1135036 --- /dev/null +++ b/apps/web/openapi.yaml @@ -0,0 +1,398 @@ +openapi: 3.1.0 +info: + title: Accensa Indexer API + version: 0.1.0 + description: | + The HTTP API served by `apps/web` — the indexer, the merchant-reported + settlement hook, and the receipt verifier. This is the single source of + truth for the wire shapes `@accensa/sdk` depends on. + + Generated TypeScript types live at `packages/sdk/generated/api-types.ts`, + produced from this file by `pnpm --filter @accensa/sdk gen:api` + (packages/sdk/scripts/generate-api-types.mjs). See issue #169: this spec + exists so the SDK's wire types cannot silently drift from what this API + actually accepts and returns — a change here that the SDK does not follow + is a diff in generated code, not a runtime surprise. + + Not every route below has had its manually-declared type replaced by a + generated one yet; `SettleHookPayload` in packages/sdk/index.ts is the + first, because it is the one contract a seller's own server depends on + directly. The rest are documented here so the spec covers the full + surface, and are candidates for the same treatment. +servers: + - url: https://accensa-dashboard.vercel.app + description: Production dashboard +paths: + /api/hook/settle: + post: + operationId: reportSettlement + summary: Report merchant-side route attribution for a settled x402 payment. + description: | + Called by `@accensa/sdk`'s `attachAccensaHook`, from the seller's own + server — never by a browser. Authenticated by an Ed25519 signature + over the raw request body (`X-Signature` header), not a session + cookie. See apps/web/src/app/api/hook/settle/route.ts and + apps/web/src/lib/settlement-report.ts for the full validation this + mirrors. + parameters: + - name: X-Signature + in: header + required: true + schema: + type: string + description: Hex-encoded Ed25519 signature over the raw JSON request body. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettlementReport' + responses: + '200': + description: Settlement recorded (or staged, if the chain has not indexed it yet). + content: + application/json: + schema: + $ref: '#/components/schemas/SettlementReportResult' + '400': + description: Malformed body. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Missing or invalid signature. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Settlement reporting is not configured on this deployment. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/payments: + get: + operationId: listPayments + summary: Cursor-paginated payment history. + description: Session-authenticated; see apps/web/src/middleware.ts. + parameters: + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + - name: cursor + in: query + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentsResponse' + '401': + description: Unauthorized. + + /api/routes: + get: + operationId: listRouteRevenue + summary: Revenue grouped by attributed route and method. + description: Session-authenticated; see apps/web/src/middleware.ts. + parameters: + - name: from + in: query + schema: + type: string + format: date-time + - name: to + in: query + schema: + type: string + format: date-time + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RouteRevenue' + '401': + description: Unauthorized. + + /api/verify: + post: + operationId: verifyReceipt + summary: Verify a Merkle receipt both locally and against the on-chain ReceiptAnchor. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyRequest' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyResponse' + '400': + description: Malformed request. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/sync: + get: + operationId: runScheduledSync + summary: Scheduled entry point for the indexer. Bearer-authenticated via CRON_SECRET. + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SyncResult' + '401': + description: Missing or invalid CRON_SECRET. + '429': + description: A sync ran within the cooldown window. + post: + operationId: runManualSync + summary: Manual entry point behind the dashboard's "Sync now" button. Session-authenticated. + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/SyncResult' + '401': + description: Unauthorized. + '429': + description: A sync ran within the cooldown window. + +components: + schemas: + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + + SettlementReport: + type: object + description: | + The body POSTed to /api/hook/settle, and the exact bytes that get + signed. Snake-cased because it is a wire format, not an in-process + value. Mirrors apps/web/src/lib/settlement-report.ts's + parseSettlementReport. + required: [tx_hash, route, method] + properties: + tx_hash: + type: string + pattern: '^[0-9a-fA-F]{64}$' + description: Hex-encoded 32-byte Stellar transaction hash. + route: + type: string + maxLength: 255 + description: The HTTP path that was paid for. + method: + type: string + enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS] + request_id: + type: string + maxLength: 64 + nullable: true + payer: + type: string + pattern: '^G[A-Z2-7]{55}$' + nullable: true + description: Hint only; the indexer overwrites this with the ledger value. + amount: + type: string + nullable: true + description: Decimal string. Never a number - see apps/web/src/lib/money.ts. + network: + type: string + nullable: true + reported_at: + type: string + format: date-time + description: ISO-8601. Required from 2027-01-01 onward; see settlement-report.ts. + + SettlementReportResult: + type: object + required: [recorded, txHash, matchedExistingPayment] + properties: + recorded: + type: boolean + txHash: + type: string + matchedExistingPayment: + type: boolean + description: False means the transfer has not been indexed yet; the report was staged. + + PaymentRow: + type: object + required: [tx_hash, ledger, payer, amount, asset, ts, route, method] + properties: + tx_hash: + type: string + ledger: + type: integer + nullable: true + payer: + type: string + amount: + type: string + description: Decimal string with 7 places. Never a number. + asset: + type: string + nullable: true + ts: + type: string + format: date-time + route: + type: string + nullable: true + method: + type: string + nullable: true + + SyncState: + type: object + required: [lastLedger, updatedAt] + properties: + lastLedger: + type: integer + updatedAt: + type: string + format: date-time + + PaymentsResponse: + type: object + required: [payments, sync] + properties: + payments: + type: array + items: + $ref: '#/components/schemas/PaymentRow' + sync: + allOf: + - $ref: '#/components/schemas/SyncState' + nullable: true + next_cursor: + type: string + nullable: true + + RouteRevenue: + type: object + required: [route, method, total_revenue, calls] + properties: + route: + type: string + method: + type: string + total_revenue: + type: string + calls: + type: integer + + VerifyRequest: + type: object + required: [batchId, leaf, proof] + properties: + batchId: + type: integer + minimum: 1 + leaf: + type: string + pattern: '^[0-9a-fA-F]{64}$' + proof: + type: array + items: + type: string + pattern: '^[0-9a-fA-F]{64}$' + + CheckResult: + type: object + required: [ok] + properties: + ok: + type: boolean + nullable: true + error: + type: string + + VerifyResponse: + type: object + required: [local, onchain, verified, disagreement, contract] + properties: + local: + $ref: '#/components/schemas/CheckResult' + onchain: + $ref: '#/components/schemas/CheckResult' + verified: + type: boolean + disagreement: + type: boolean + batch: + type: object + properties: + id: + type: integer + root: + type: string + count: + type: integer + periodStart: + type: integer + periodEnd: + type: integer + contract: + type: string + + SyncResult: + type: object + description: | + Either a cooldown notice or the result of an indexing run. See + apps/web/src/app/api/sync/route.ts's `respond`. + properties: + success: + type: boolean + cooldown: + type: boolean + retryAfterMs: + type: integer + latestLedger: + type: integer + startLedger: + type: integer + syncedTo: + type: integer + skippedLedgers: + type: integer + drained: + type: boolean + pages: + type: integer + windows: + type: integer + scanned: + type: integer + decoded: + type: integer + inserted: + type: integer diff --git a/apps/web/package.json b/apps/web/package.json index 9ba163a..a12678b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,19 +8,27 @@ "start": "next start", "lint": "eslint", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "test:visual": "playwright test", + "test:visual:update": "playwright test --update-snapshots", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "dependencies": { "@accensa/sdk": "workspace:^", + "@albedo-link/intent": "^0.13.0", "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^16.0.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.3", "jose": "^6.2.8", "lucide-react": "^1.28.0", "next": "16.3.0", "next-themes": "^0.4.6", "pg": "^8.22.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "swr": "^2.5.1" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.2", @@ -32,6 +40,10 @@ "eslint-config-next": "16.2.10", "tailwindcss": "^4.3.2", "typescript": "^5.9.3", - "vitest": "^2.1.9" + "vitest": "^2.1.9", + "@playwright/test": "^1.55.0", + "storybook": "^8.6.14", + "@storybook/nextjs": "^8.6.14", + "@storybook/addon-essentials": "^8.6.14" } } diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..063ce78 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 3100); +const baseURL = `http://127.0.0.1:${PORT}`; + +/** + * Visual regression for the merchant dashboard: navbar, empty state, and + * the payments table. Screenshots are committed under e2e/__screenshots__. + * + * A session JWT is minted in the spec so /dashboard is reachable without + * driving Freighter. /api/payments is intercepted — these tests assert + * presentation, not the indexer. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL, + trace: 'on-first-retry', + colorScheme: 'light', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } }, + }, + ], + webServer: { + command: `pnpm exec next dev --port ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + ...process.env, + JWT_SECRET_KEY: process.env.JWT_SECRET_KEY ?? 'visual-regression-test-secret', + MERCHANT_ADDRESS: + process.env.MERCHANT_ADDRESS ?? 'GCALKSGAZRJLSUEJT3M5W6LN4R7XQOLIRCOS6ZA6EDZVTZDBIIPPFKJ6', + PORT: String(PORT), + }, + }, +}); diff --git a/apps/web/src/app/api/analytics/revenue/route.test.ts b/apps/web/src/app/api/analytics/revenue/route.test.ts new file mode 100644 index 0000000..9d34c0e --- /dev/null +++ b/apps/web/src/app/api/analytics/revenue/route.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { GET } from './route'; + +const MERCHANT = { id: 1, address: 'GABC' }; +const mockQuery = vi.fn(); + +const { mockWithClient, mockWithMerchantClient, mockGetMerchantFromRequest } = vi.hoisted(() => ({ + mockWithClient: vi.fn(async (fn: (client: unknown) => Promise) => fn({})), + mockWithMerchantClient: vi.fn( + async (_merchantId: number, fn: (client: unknown) => Promise) => + fn({ query: mockQuery }), + ), + mockGetMerchantFromRequest: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + withClient: mockWithClient, + withMerchantClient: mockWithMerchantClient, + ensureSchema: vi.fn(), +})); + +vi.mock('@/lib/merchants', () => ({ + getMerchantFromRequest: mockGetMerchantFromRequest, +})); + +/** The three sequential queries the route runs: days, routes, assets. */ +function mockAggregates(opts: { + days?: Record[]; + routes?: Record[]; + assets?: Record[]; +}) { + mockQuery + .mockResolvedValueOnce({ rows: opts.days ?? [] }) + .mockResolvedValueOnce({ rows: opts.routes ?? [] }) + .mockResolvedValueOnce({ rows: opts.assets ?? [] }); +} + +describe('/api/analytics/revenue GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.DATABASE_URL = 'postgres://dummy'; + mockGetMerchantFromRequest.mockResolvedValue(MERCHANT); + }); + + const req = () => new Request('http://localhost/api/analytics/revenue'); + + test('401 when the request names no known merchant', async () => { + mockGetMerchantFromRequest.mockResolvedValue(null); + const res = await GET(req()); + expect(res.status).toBe(401); + }); + + test('500 without DATABASE_URL, and never leaks internals', async () => { + delete process.env.DATABASE_URL; + const res = await GET(req()); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Internal Server Error' }); + }); + + test('groups the SQL aggregates by asset and preserves exact decimals', async () => { + mockAggregates({ + days: [ + { + day: '2026-03-09T00:00:00.000000Z', + asset_key: 'native', + attributed: '3.0000000', + unattributed: '1.0000000', + attributed_calls: 3, + unattributed_calls: 1, + unpriced_calls: 0, + }, + { + day: '2026-03-10T00:00:00.000000Z', + asset_key: 'native', + attributed: '0', + unattributed: '2.5000000', + attributed_calls: 0, + unattributed_calls: 1, + unpriced_calls: 1, + }, + ], + routes: [ + { + asset_key: 'native', + method: 'GET', + route: '/api/quote', + total: '3.0000000', + calls: 3, + priced: 3, + }, + { asset_key: 'native', method: null, route: null, total: '3.5000000', calls: 2, priced: 2 }, + ], + assets: [{ asset_key: 'native', calls: 5 }], + }); + + const res = await GET(req()); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.assets).toEqual([{ key: 'native', label: 'XLM', calls: 5 }]); + expect(data.days.native).toHaveLength(2); + expect(data.days.native[0]).toMatchObject({ attributed: '3.0000000', unattributedCalls: 1 }); + expect(data.days.native[1]).toMatchObject({ unpricedCalls: 1 }); + + // The route breakdown folds the null-route row into one unattributed bucket. + expect(data.routes.native).toHaveLength(2); + const unattributed = data.routes.native.find((r: { route: string | null }) => r.route === null); + expect(unattributed).toMatchObject({ total: '3.5000000', calls: 2 }); + + // The three GROUP BY queries actually ran. + expect(mockQuery).toHaveBeenCalledTimes(3); + const [daySql] = mockQuery.mock.calls[0]; + expect(daySql).toMatch(/GROUP BY date_trunc\('day', ts\)/); + }); + + test('returns empty structures for a merchant with no payments', async () => { + mockAggregates({}); + const data = await (await GET(req())).json(); + expect(data).toEqual({ assets: [], days: {}, routes: {} }); + }); +}); diff --git a/apps/web/src/app/api/analytics/revenue/route.ts b/apps/web/src/app/api/analytics/revenue/route.ts new file mode 100644 index 0000000..7d8b993 --- /dev/null +++ b/apps/web/src/app/api/analytics/revenue/route.ts @@ -0,0 +1,123 @@ +import { NextResponse } from 'next/server'; +import { withClient, withMerchantClient, ensureSchema } from '@/lib/db'; +import { getMerchantFromRequest } from '@/lib/merchants'; +import { + assetOptionsFromCounts, + type AssetOption, + type RevenueDayBucket, + type RouteAggregate, +} from '@/lib/revenue-analytics'; + +export const dynamic = 'force-dynamic'; + +/** + * Server-side revenue aggregation for the "Revenue by Route" view. + * + * The browser used to pull the raw payment rows and fold them itself. A + * merchant processing thousands of sub-cent x402 requests a day would send + * that whole table over the wire and aggregate it in a render pass. This + * route does the summation in PostgreSQL instead — one `GROUP BY + * date_trunc('day', ts), asset` for the time series and one `GROUP BY + * method, route, asset` for the route breakdown — and returns only the + * aggregates, per asset. + * + * Amounts stay exact: `NUMERIC` in, decimal `::text` out, never a float. + * `ts IS NOT NULL` keeps merchant-reported attributions that the indexer + * has not yet confirmed out of the figures (see `db.ts`). No time filter is + * applied here — the output is one row per (day, asset) and per (route, + * asset), bounded regardless of table size — so the client can switch + * range with no refetch. + */ +export async function GET(request: Request) { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } + + try { + const merchant = await withClient((client) => getMerchantFromRequest(client, request)); + if (!merchant) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { dayRows, routeRows, assetCounts } = await withMerchantClient( + merchant.id, + async (client) => { + await ensureSchema(client); + + const days = await client.query( + `SELECT + to_char(date_trunc('day', ts) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS day, + COALESCE(asset, 'native') AS asset_key, + COALESCE(sum(amount) FILTER (WHERE route IS NOT NULL AND route <> ''), 0)::text AS attributed, + COALESCE(sum(amount) FILTER (WHERE route IS NULL OR route = ''), 0)::text AS unattributed, + count(*) FILTER (WHERE route IS NOT NULL AND route <> '') AS attributed_calls, + count(*) FILTER (WHERE route IS NULL OR route = '') AS unattributed_calls, + count(*) FILTER (WHERE amount IS NULL) AS unpriced_calls + FROM payments + WHERE merchant_id = $1 AND ts IS NOT NULL + GROUP BY date_trunc('day', ts), COALESCE(asset, 'native') + ORDER BY day ASC`, + [merchant.id], + ); + + const routes = await client.query( + `SELECT + COALESCE(asset, 'native') AS asset_key, + method, + route, + COALESCE(sum(amount), 0)::text AS total, + count(*) AS calls, + count(amount) AS priced + FROM payments + WHERE merchant_id = $1 AND ts IS NOT NULL + GROUP BY COALESCE(asset, 'native'), method, route`, + [merchant.id], + ); + + const assets = await client.query( + `SELECT COALESCE(asset, 'native') AS asset_key, count(*) AS calls + FROM payments + WHERE merchant_id = $1 AND ts IS NOT NULL + GROUP BY COALESCE(asset, 'native')`, + [merchant.id], + ); + + return { dayRows: days.rows, routeRows: routes.rows, assetCounts: assets.rows }; + }, + ); + + const assets: AssetOption[] = assetOptionsFromCounts( + assetCounts.map((r) => ({ key: String(r.asset_key), calls: Number(r.calls) })), + ); + + const days: Record = {}; + for (const r of dayRows) { + const key = String(r.asset_key); + (days[key] ??= []).push({ + day: String(r.day), + attributed: String(r.attributed), + unattributed: String(r.unattributed), + attributedCalls: Number(r.attributed_calls), + unattributedCalls: Number(r.unattributed_calls), + unpricedCalls: Number(r.unpriced_calls), + }); + } + + const routes: Record = {}; + for (const r of routeRows) { + const key = String(r.asset_key); + (routes[key] ??= []).push({ + method: r.method === null ? null : String(r.method), + route: r.route === null ? null : String(r.route), + total: String(r.total), + calls: Number(r.calls), + priced: Number(r.priced), + }); + } + + return NextResponse.json({ assets, days, routes }); + } catch (error: unknown) { + console.error('Error aggregating revenue analytics:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/anchor/preview/route.ts b/apps/web/src/app/api/anchor/preview/route.ts new file mode 100644 index 0000000..590d991 --- /dev/null +++ b/apps/web/src/app/api/anchor/preview/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server'; +import { withClient, ensureSchema } from '@/lib/db'; +import { buildPreview, loadUnanchored, persistPreview, MAX_BATCH_SIZE } from '@/lib/anchor'; +import { RECEIPT_ANCHOR_ID } from '@/lib/receipt-anchor'; +import { Networks } from '@stellar/stellar-sdk'; + +export const dynamic = 'force-dynamic'; + +function parseLedger(value: string | null, label: string): number | undefined { + if (value === null || value === '') return undefined; + if (!/^\d+$/.test(value)) throw new Error(`${label} must be a whole number`); + const n = Number(value); + if (!Number.isSafeInteger(n) || n < 1) throw new Error(`${label} must be a positive integer`); + return n; +} + +/** + * Builds the tree a merchant is about to commit to, without touching the + * wallet. The root, count, and period shown here are the arguments + * `anchor_batch` will be signed over, so a preview that disagrees with the + * signing prompt is a bug. + */ +export async function GET(request: Request) { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } + if (!process.env.MERCHANT_ADDRESS) { + return NextResponse.json({ error: 'MERCHANT_ADDRESS is not configured' }, { status: 500 }); + } + + const { searchParams } = new URL(request.url); + let fromLedger: number | undefined; + let toLedger: number | undefined; + try { + fromLedger = parseLedger(searchParams.get('fromLedger'), 'fromLedger'); + toLedger = parseLedger(searchParams.get('toLedger'), 'toLedger'); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'invalid range' }, + { status: 400 }, + ); + } + + try { + const body = await withClient(async (client) => { + await ensureSchema(client); + const payments = await loadUnanchored(client, { fromLedger, toLedger }); + if (payments.length === 0) { + return { + count: 0, + merchant: process.env.MERCHANT_ADDRESS, + contractId: RECEIPT_ANCHOR_ID, + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET, + maxBatchSize: MAX_BATCH_SIZE, + }; + } + const preview = await persistPreview(client, buildPreview(payments)); + return { + ...preview, + merchant: process.env.MERCHANT_ADDRESS, + contractId: RECEIPT_ANCHOR_ID, + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET, + maxBatchSize: MAX_BATCH_SIZE, + }; + }); + return NextResponse.json(body); + } catch (error: unknown) { + console.error('anchor preview failed:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/anchor/record/route.ts b/apps/web/src/app/api/anchor/record/route.ts new file mode 100644 index 0000000..96b0105 --- /dev/null +++ b/apps/web/src/app/api/anchor/record/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from 'next/server'; +import { withClient, ensureSchema } from '@/lib/db'; +import { recordAnchoredBatch } from '@/lib/anchor'; +import { getBatch, isHash32 } from '@/lib/receipt-anchor'; + +export const dynamic = 'force-dynamic'; + +/** + * Persists the payment-to-batch mapping after `anchor_batch` confirms on + * chain. The on-chain root is re-read and compared to the previewed tree so + * a client cannot record proofs against a batch they did not actually + * submit. Replaying the same selection is a no-op and returns the existing + * batch_id — that is the double-submit path. + * + * If this handler fails after the transaction is in the ledger, the row + * stays `submitted` (or `previewed` if we never got that far). Calling again + * with the same body completes the write. That gap is where real money and + * real confusion live; it is recoverable, and it is tested. + */ +export async function POST(request: Request) { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Request body must be JSON' }, { status: 400 }); + } + + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 }); + } + + const rec = body as Record; + const selectionHash = typeof rec.selectionHash === 'string' ? rec.selectionHash.trim() : ''; + const root = typeof rec.root === 'string' ? rec.root.trim() : ''; + const anchorTx = typeof rec.anchorTx === 'string' ? rec.anchorTx.trim() : ''; + const batchId = typeof rec.batchId === 'number' ? rec.batchId : Number(rec.batchId); + + if (!isHash32(selectionHash)) { + return NextResponse.json( + { error: 'selectionHash must be a 32-byte hex hash' }, + { status: 400 }, + ); + } + if (!isHash32(root)) { + return NextResponse.json({ error: 'root must be a 32-byte hex hash' }, { status: 400 }); + } + if (!isHash32(anchorTx)) { + return NextResponse.json({ error: 'anchorTx must be a 32-byte hex hash' }, { status: 400 }); + } + if (!Number.isSafeInteger(batchId) || batchId < 1) { + return NextResponse.json({ error: 'batchId must be a positive integer' }, { status: 400 }); + } + + let onchain; + try { + onchain = await getBatch(batchId); + } catch (error) { + console.error('get_batch failed while recording an anchor:', error); + return NextResponse.json( + { + error: + 'Could not read the batch from the ledger. The transaction may still be confirming — retry recording without submitting again.', + }, + { status: 502 }, + ); + } + + if (onchain.root.toLowerCase() !== root.toLowerCase()) { + return NextResponse.json( + { error: 'On-chain root does not match the previewed tree; refusing to record' }, + { status: 409 }, + ); + } + + try { + const result = await withClient(async (client) => { + await ensureSchema(client); + return recordAnchoredBatch(client, { + selectionHash: selectionHash.toLowerCase(), + batchId, + anchorTx: anchorTx.toLowerCase(), + root: root.toLowerCase(), + }); + }); + return NextResponse.json({ success: true, ...result }); + } catch (error: unknown) { + const code = (error as { code?: string }).code; + if ( + code === 'UNKNOWN_SELECTION' || + code === 'ROOT_MISMATCH' || + code === 'ALREADY_ANCHORED' || + code === 'PAYMENT_ALREADY_ANCHORED' + ) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Conflict' }, + { status: 409 }, + ); + } + console.error('anchor record failed:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts new file mode 100644 index 0000000..ca4183e --- /dev/null +++ b/apps/web/src/app/api/health/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from 'next/server'; +import { withClient } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +const RPC_URL = process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org'; + +/** + * Cursor-lag health check, polled from *outside* the sync path (issue #90). + * + * The indexer already computes `skippedLedgers` and the workflow already logs + * it — into a GitHub Actions warning on a job that always goes green. Nobody is + * paged. This endpoint answers "how far behind head is the cursor, right now?" + * so an external uptime monitor (or the `stale-check` workflow) can alert a + * human before ledgers fall out of RPC retention and become unrecoverable. + * + * `MAX_LOOKBACK` in the sync route is 100_000 and testnet retention was + * ~121_000 ledgers on 2026-08-10. At ~5s/ledger that is roughly 29 hours of + * head-room between "lag threshold crossed" and "data lost", so a `warn` + * threshold of 60_000 ledgers (~83h) and a `critical` of 90_000 (~125h, still + * inside retention) leave hours to react rather than minutes. + */ +const LAG_WARN = Number(process.env.SYNC_LAG_WARN_LEDGERS ?? 60_000); +const LAG_CRITICAL = Number(process.env.SYNC_LAG_CRITICAL_LEDGERS ?? 90_000); +const NO_SYNC_CRITICAL_MS = Number(process.env.SYNC_STALE_MS ?? 3 * 60 * 60 * 1000); + +interface MerchantHealth { + merchantId: number; + lastLedger: number | null; + lastSyncedAt: string | null; + lagLedgers: number | null; + ageMs: number | null; + status: 'ok' | 'warn' | 'critical'; + reasons: string[]; +} + +async function latestLedger(): Promise { + const res = await fetch(RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger', params: {} }), + cache: 'no-store', + }); + if (!res.ok) throw new Error(`getLatestLedger failed: ${res.status}`); + const body = await res.json(); + if (body.error) throw new Error(`getLatestLedger: ${body.error.message ?? 'unknown'}`); + return body.result.sequence as number; +} + +export async function GET() { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } + + let head: number; + try { + head = await latestLedger(); + } catch (error) { + return NextResponse.json( + { status: 'critical', error: error instanceof Error ? error.message : 'RPC unreachable' }, + { status: 503 }, + ); + } + + const now = Date.now(); + const merchants = await withClient(async (client) => { + const { rows } = await client.query<{ + merchant_id: number; + last_ledger: string | null; + updated_at: Date | string | null; + }>( + `SELECT m.id AS merchant_id, s.last_ledger, s.updated_at + FROM merchants m + LEFT JOIN sync_state s ON s.merchant_id = m.id + ORDER BY m.id`, + ); + return rows.map((r) => { + const lastLedger = r.last_ledger === null ? null : Number(r.last_ledger); + const lastSyncedAt = + r.updated_at === null + ? null + : r.updated_at instanceof Date + ? r.updated_at.toISOString() + : String(r.updated_at); + const lagLedgers = lastLedger === null ? null : Math.max(0, head - lastLedger); + const ageMs = lastSyncedAt === null ? null : now - Date.parse(lastSyncedAt); + + const reasons: string[] = []; + let status: MerchantHealth['status'] = 'ok'; + if (lastLedger === null) { + status = 'warn'; + reasons.push('no cursor recorded yet (cold start)'); + } + if (lagLedgers !== null && lagLedgers >= LAG_CRITICAL) { + status = 'critical'; + reasons.push(`cursor lag ${lagLedgers} >= ${LAG_CRITICAL} ledgers`); + } else if (lagLedgers !== null && lagLedgers >= LAG_WARN) { + if (status !== 'critical') status = 'warn'; + reasons.push(`cursor lag ${lagLedgers} >= ${LAG_WARN} ledgers`); + } + if (ageMs !== null && ageMs >= NO_SYNC_CRITICAL_MS) { + status = 'critical'; + reasons.push(`no successful sync in ${Math.round(ageMs / 60000)} min`); + } + return { merchantId: r.merchant_id, lastLedger, lastSyncedAt, lagLedgers, ageMs, status, reasons }; + }); + }); + + const worst = merchants.reduce((acc, m) => { + if (m.status === 'critical' || acc === 'critical') return 'critical'; + if (m.status === 'warn' || acc === 'warn') return 'warn'; + return 'ok'; + }, 'ok'); + + return NextResponse.json( + { status: worst, head, checkedAt: new Date(now).toISOString(), merchants }, + { status: worst === 'critical' ? 503 : 200 }, + ); +} diff --git a/apps/web/src/app/api/hook/settle/route.ts b/apps/web/src/app/api/hook/settle/route.ts index b70e78b..4b159cc 100644 --- a/apps/web/src/app/api/hook/settle/route.ts +++ b/apps/web/src/app/api/hook/settle/route.ts @@ -20,6 +20,7 @@ async function verifyingMerchant( merchants: Merchant[], raw: string, signatureHex: string, + keyId?: string | null, ): Promise { const crypto = await import('node:crypto'); let signature: Buffer; @@ -31,22 +32,36 @@ async function verifyingMerchant( for (const merchant of merchants) { if (!merchant.publicKeyHex) continue; - try { - const keyBuffer = Buffer.from(merchant.publicKeyHex, 'hex'); - const publicKey = crypto.createPublicKey({ - key: Buffer.concat([ - Buffer.from('302a300506032b6570032100', 'hex'), // SubjectPublicKeyInfo Ed25519 header - keyBuffer, - ]), - format: 'der', - type: 'spki', - }); - if (crypto.verify(null, Buffer.from(raw, 'utf8'), publicKey, signature)) { - return merchant; + const publicKeys = merchant.publicKeyHex + .split(',') + .map((k) => k.trim()) + .filter(Boolean); + + for (const pubKeyHex of publicKeys) { + try { + const keyBuffer = Buffer.from(pubKeyHex, 'hex'); + const publicKey = crypto.createPublicKey({ + key: Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), // SubjectPublicKeyInfo Ed25519 header + keyBuffer, + ]), + format: 'der', + type: 'spki', + }); + if (crypto.verify(null, Buffer.from(raw, 'utf8'), publicKey, signature)) { + if (keyId) { + console.info(`[accensa] settlement reported with key id: ${keyId}`); + } else if (publicKeys.length > 1) { + const prefix = pubKeyHex.substring(0, 8); + const msg = `[accensa] settlement reported with key: ${prefix}... (key rotation)`; + console.info(msg); + } + return merchant; + } + } catch { + // A malformed key for one merchant must not block checking the rest. + continue; } - } catch { - // A malformed key for one merchant must not block checking the rest. - continue; } } return null; @@ -84,7 +99,7 @@ export async function POST(request: Request) { const merchant = await withClient(async (client) => { await ensureSchema(client); const merchants = await listMerchants(client); - return await verifyingMerchant(merchants, raw, signature); + return await verifyingMerchant(merchants, raw, signature, request.headers.get('x-key-id')); }); if (!merchant) { diff --git a/apps/web/src/app/api/merchant/profile/route.test.ts b/apps/web/src/app/api/merchant/profile/route.test.ts new file mode 100644 index 0000000..6f41f34 --- /dev/null +++ b/apps/web/src/app/api/merchant/profile/route.test.ts @@ -0,0 +1,137 @@ +import { expect, test, vi, describe, beforeEach } from 'vitest'; +import { GET, PATCH } from './route'; + +const { + MERCHANT, + mockWithClient, + mockWithMerchantClient, + mockGetMerchantFromRequest, + mockUpdateMerchantProfile, + mockGetCachedMerchantFromRequest, + mockRevalidateTag, +} = vi.hoisted(() => { + const merchant = { id: 1, address: 'GABC' }; + return { + MERCHANT: merchant, + mockWithClient: vi.fn(async (fn: (client: unknown) => Promise) => fn({})), + mockWithMerchantClient: vi.fn( + async (_merchantId: number, fn: (client: unknown) => Promise) => fn({}), + ), + mockGetMerchantFromRequest: vi.fn().mockResolvedValue(merchant), + mockUpdateMerchantProfile: vi.fn(), + mockGetCachedMerchantFromRequest: vi.fn(), + mockRevalidateTag: vi.fn(), + }; +}); + +vi.mock('@/lib/db', () => ({ + withClient: mockWithClient, + withMerchantClient: mockWithMerchantClient, +})); + +vi.mock('@/lib/merchants', () => ({ + getMerchantFromRequest: mockGetMerchantFromRequest, + updateMerchantProfile: mockUpdateMerchantProfile, +})); + +vi.mock('@/lib/merchant-profile', async () => { + const actual = + await vi.importActual('@/lib/merchant-profile'); + return { + ...actual, + getCachedMerchantFromRequest: mockGetCachedMerchantFromRequest, + }; +}); + +vi.mock('next/cache', () => ({ + unstable_cache: (fn: unknown) => fn, + revalidateTag: mockRevalidateTag, +})); + +describe('/api/merchant/profile GET', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('returns 401 when no merchant resolves from the request', async () => { + mockGetCachedMerchantFromRequest.mockResolvedValue(null); + const res = await GET(new Request('http://localhost/api/merchant/profile')); + expect(res.status).toBe(401); + }); + + test('serves the profile from the cached lookup, not a direct DB call', async () => { + mockGetCachedMerchantFromRequest.mockResolvedValue(MERCHANT); + const res = await GET(new Request('http://localhost/api/merchant/profile')); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.profile).toEqual(MERCHANT); + expect(mockGetCachedMerchantFromRequest).toHaveBeenCalledOnce(); + expect(mockWithClient).not.toHaveBeenCalled(); + }); +}); + +describe('/api/merchant/profile PATCH', () => { + const patchRequest = (body: unknown) => + new Request('http://localhost/api/merchant/profile', { + method: 'PATCH', + body: JSON.stringify(body), + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockGetMerchantFromRequest.mockResolvedValue(MERCHANT); + }); + + test('rejects a non-JSON body', async () => { + const res = await PATCH( + new Request('http://localhost/api/merchant/profile', { method: 'PATCH', body: 'nope{' }), + ); + expect(res.status).toBe(400); + }); + + test('rejects an invalid field before touching the database', async () => { + const res = await PATCH(patchRequest({ webhookUrl: 'not a url' })); + expect(res.status).toBe(400); + expect(mockWithClient).not.toHaveBeenCalled(); + expect(mockRevalidateTag).not.toHaveBeenCalled(); + }); + + test('returns 401 when the caller does not resolve to a merchant', async () => { + mockGetMerchantFromRequest.mockResolvedValue(null); + const res = await PATCH(patchRequest({ webhookUrl: 'https://merchant.example/hook' })); + expect(res.status).toBe(401); + expect(mockRevalidateTag).not.toHaveBeenCalled(); + }); + + test('updates the profile scoped to the caller and invalidates its cache tag', async () => { + const updated = { ...MERCHANT, webhookUrl: 'https://merchant.example/hook' }; + mockUpdateMerchantProfile.mockResolvedValue(updated); + + const res = await PATCH(patchRequest({ webhookUrl: 'https://merchant.example/hook' })); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.profile).toEqual(updated); + + expect(mockWithMerchantClient).toHaveBeenCalledWith(MERCHANT.id, expect.any(Function)); + expect(mockUpdateMerchantProfile).toHaveBeenCalledWith({}, MERCHANT.id, { + webhookUrl: 'https://merchant.example/hook', + }); + + // Immediate expiry, not the stale-while-revalidate 'max' profile - the + // caller must see its own write on the very next read. + expect(mockRevalidateTag).toHaveBeenCalledWith(`merchant-profile-${MERCHANT.address}`, { + expire: 0, + }); + }); + + test("never invalidates another merchant's cache tag", async () => { + mockUpdateMerchantProfile.mockResolvedValue(MERCHANT); + await PATCH(patchRequest({ webhookUrl: 'https://merchant.example/hook' })); + + const [tag] = mockRevalidateTag.mock.calls[0]; + expect(tag).toBe(`merchant-profile-${MERCHANT.address}`); + expect(tag).not.toBe('merchant-profile-someone-else'); + }); +}); diff --git a/apps/web/src/app/api/merchant/profile/route.ts b/apps/web/src/app/api/merchant/profile/route.ts new file mode 100644 index 0000000..64d5047 --- /dev/null +++ b/apps/web/src/app/api/merchant/profile/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import { revalidateTag } from 'next/cache'; +import { withClient, withMerchantClient } from '@/lib/db'; +import { getMerchantFromRequest, updateMerchantProfile, type Merchant } from '@/lib/merchants'; +import { recordMerchantConfigChange } from '@/lib/merchant-config'; +import { + getCachedMerchantFromRequest, + merchantProfileCacheTag, + parseMerchantProfileUpdate, +} from '@/lib/merchant-profile'; + +/** + * Serves the merchant's own profile (signing key, asset watch-list, refund + * vault, webhook URL) from Next.js's Data Cache instead of Postgres on every + * dashboard load, and invalidates that cache the moment the profile changes. + */ + +export interface MerchantProfileResponse { + profile: Merchant; +} + +export async function GET(request: Request) { + const merchant = await getCachedMerchantFromRequest(request); + if (!merchant) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + return NextResponse.json({ profile: merchant }); +} + +export async function PATCH(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Request body must be JSON' }, { status: 400 }); + } + + const parsed = parseMerchantProfileUpdate(body); + if (!parsed.ok) { + return NextResponse.json({ error: parsed.error }, { status: 400 }); + } + + const caller = await withClient((client) => getMerchantFromRequest(client, request)); + if (!caller) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const profile = await withMerchantClient(caller.id, async (client) => { + const updated = await updateMerchantProfile(client, caller.id, parsed.update); + + // Record immutable history for each field that changed, so configuration + // changes can be tracked over time (historical merchant configuration). + if (updated) { + const changedFields = Object.entries(parsed.update) as [ + keyof NonNullable, + string | string[] | null, + ][]; + for (const [field, value] of changedFields) { + await recordMerchantConfigChange(client, { + merchantId: caller.id, + field, + before: null, + after: Array.isArray(value) ? value.join(',') : value, + source: 'profile_patch', + }); + } + } + + return updated; + }); + + // `{ expire: 0 }` expires the tag immediately rather than the + // stale-while-revalidate behaviour of `revalidateTag(tag, 'max')`, which + // would still serve one more stale read before fetching fresh data - this + // route needs the very next read to see the write. + revalidateTag(merchantProfileCacheTag(caller.address), { expire: 0 }); + + return NextResponse.json({ profile: profile as Merchant }); +} diff --git a/apps/web/src/app/api/payments/route.test.ts b/apps/web/src/app/api/payments/route.test.ts index b565372..e06af98 100644 --- a/apps/web/src/app/api/payments/route.test.ts +++ b/apps/web/src/app/api/payments/route.test.ts @@ -32,6 +32,14 @@ vi.mock('@/lib/merchants', () => ({ getMerchantFromRequest: mockGetMerchantFromRequest, })); +vi.mock('@/lib/receipt-anchor', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getMaxBatchSize: vi.fn().mockResolvedValue(1000), + }; +}); + describe('/api/payments GET', () => { const mockRequest = (url: string) => { return new Request(url); @@ -80,6 +88,41 @@ describe('/api/payments GET', () => { }); }); + describe('page validation', () => { + test('rejects non-numeric page (e.g. abc)', async () => { + const res = await GET(mockRequest('http://localhost/api/payments?page=abc')); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe('page must be an integer >= 1'); + }); + + test('rejects page 0 and negative pages', async () => { + for (const page of ['0', '-1']) { + const res = await GET(mockRequest(`http://localhost/api/payments?page=${page}`)); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe('page must be an integer >= 1'); + } + }); + + test('rejects float page', async () => { + const res = await GET(mockRequest('http://localhost/api/payments?page=1.5')); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe('page must be an integer >= 1'); + }); + + test('rejects combining page and cursor', async () => { + const cursor = Buffer.from(`${new Date().toISOString()}|${'a'.repeat(64)}`).toString( + 'base64', + ); + const res = await GET(mockRequest(`http://localhost/api/payments?page=2&cursor=${cursor}`)); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe('page and cursor cannot be combined'); + }); + }); + describe('cursor validation', () => { test('rejects non-base64 cursor', async () => { const res = await GET(mockRequest('http://localhost/api/payments?cursor=not-base64-!@#$')); @@ -135,5 +178,147 @@ describe('/api/payments GET', () => { expect(sql).toContain('merchant_id = $1'); expect(params[0]).toBe(MERCHANT.id); }); + + test('sets Cache-Control no-store headers on successful response', async () => { + const res = await GET(mockRequest('http://localhost/api/payments')); + expect(res.status).toBe(200); + expect(res.headers.get('Cache-Control')).toContain('no-store'); + }); + }); + + describe('totals and pagination (>100 payments)', () => { + test('computes total_count and total_amount across full dataset when payments exceed limit (fixture with 150 payments)', async () => { + // 150 payment fixture + const fixtureRows = Array.from({ length: 150 }, (_, i) => ({ + tx_hash: `hash_${String(i).padStart(64, '0').slice(-64)}`, + ledger: 1000 + i, + payer: 'GPAYER', + amount: '10.50', + asset: 'USDC', + ts: new Date(Date.now() - i * 1000).toISOString(), + route: '/api/v1/pay', + method: 'POST', + })); + + // Mock database queries: + // 1st query: count & sum aggregate + // 2nd query: limited rows (newest 100) + const query = vi.fn().mockImplementation((sql: string) => { + if (sql.includes('count(*)')) { + return Promise.resolve({ + rows: [{ total_count: '150', total_amount: '1575.00' }], + }); + } + // Default limit = 100 rows + return Promise.resolve({ + rows: fixtureRows.slice(0, 100), + }); + }); + + mockWithMerchantClient.mockImplementationOnce( + async (_merchantId: number, fn: (client: unknown) => Promise) => { + return fn({ query }); + }, + ); + + const res = await GET(mockRequest('http://localhost/api/payments')); + expect(res.status).toBe(200); + const data = await res.json(); + + // Only newest 100 returned in the payments array + expect(data.payments).toHaveLength(100); + // Total count reflects all 150 payments + expect(data.total_count).toBe(150); + // Total amount reflects full sum + expect(data.total_amount).toBe('1575.00'); + // next_cursor is present because rows.length === limit + expect(data.next_cursor).toBeTruthy(); + }); + }); + + describe('offset pagination', () => { + const row = { + tx_hash: 'a'.repeat(64), + ledger: 42, + payer: 'GPAYER', + amount: '1000', + asset: 'XLM', + ts: new Date('2026-08-20T07:22:16Z'), + route: '/api/hello', + method: 'GET', + total: 120, + total_amount: '120000', + total_asset: 'XLM', + }; + + const queryFor = (rows: unknown[]) => vi.fn().mockResolvedValue({ rows }); + + test('page=2&limit=50 translates to LIMIT 50 OFFSET 50', async () => { + const query = queryFor([row]); + mockWithMerchantClient.mockImplementationOnce( + async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }), + ); + + const res = await GET(mockRequest('http://localhost/api/payments?page=2&limit=50')); + expect(res.status).toBe(200); + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain('LIMIT $2'); + expect(sql).toContain('OFFSET $3'); + expect(params).toEqual([MERCHANT.id, 50, 50]); + }); + + test('no page parameter defaults to page 1, i.e. OFFSET 0', async () => { + const query = queryFor([]); + mockWithMerchantClient.mockImplementationOnce( + async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }), + ); + + const res = await GET(mockRequest('http://localhost/api/payments?limit=25')); + expect(res.status).toBe(200); + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain('LIMIT $2'); + expect(sql).toContain('OFFSET $3'); + expect(params).toEqual([MERCHANT.id, 25, 0]); + }); + + test('page 3 with limit 50 offsets by 100', async () => { + const query = queryFor([]); + mockWithMerchantClient.mockImplementationOnce( + async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }), + ); + + await GET(mockRequest('http://localhost/api/payments?page=3&limit=50')); + const [, params] = query.mock.calls[0]; + expect(params).toEqual([MERCHANT.id, 50, 100]); + }); + + test('returns aggregates from the window columns', async () => { + const query = queryFor([row]); + mockWithMerchantClient.mockImplementationOnce( + async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }), + ); + + const res = await GET(mockRequest('http://localhost/api/payments?page=1&limit=50')); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.total).toBe(120); + expect(data.total_amount).toBe('120000'); + expect(data.total_asset).toBe('XLM'); + expect(data.total_pages).toBe(3); + }); + + test('reports zero totals on an empty result', async () => { + const query = queryFor([]); + mockWithMerchantClient.mockImplementationOnce( + async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }), + ); + + const res = await GET(mockRequest('http://localhost/api/payments?page=1&limit=50')); + const data = await res.json(); + expect(data.total).toBe(0); + expect(data.total_amount).toBe('0'); + expect(data.total_asset).toBeNull(); + expect(data.total_pages).toBe(0); + }); }); }); diff --git a/apps/web/src/app/api/payments/route.ts b/apps/web/src/app/api/payments/route.ts index 7e4f051..6049247 100644 --- a/apps/web/src/app/api/payments/route.ts +++ b/apps/web/src/app/api/payments/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { withClient, withMerchantClient, ensureSchema, getSyncState } from '@/lib/db'; import { getMerchantFromRequest } from '@/lib/merchants'; -import { isHash32 } from '@/lib/receipt-anchor'; +import { getMaxBatchSize, isHash32 } from '@/lib/receipt-anchor'; import type { SyncState } from '@/lib/sync-status'; export const dynamic = 'force-dynamic'; @@ -22,7 +22,28 @@ export interface PaymentsResponse { payments: PaymentRow[]; /** Null until the indexer has run at least once. */ sync: SyncState | null; + /** Opaque keyset cursor for the next page; null when the list is exhausted. */ next_cursor?: string | null; + /** Total number of indexed payments for this merchant. */ + total: number; + /** Sum of every payment amount, as a decimal string. */ + total_amount: string; + /** Raw asset when every payment is in one asset, else null. */ + total_asset: string | null; + /** ceil(total / limit); 0 when there are no payments. */ + total_pages: number; + /** Total count of all settled payments for this merchant. */ + total_count?: number; + /** Sum of all settled payment amounts for this merchant. */ + total_amount?: string; + /** Filter metadata returned when filters are applied. */ + filter_info?: { + route?: string; + payer?: string; + asset?: string; + date_from?: string; + date_to?: string; + }; } export async function GET(request: Request) { @@ -35,7 +56,8 @@ export async function GET(request: Request) { let limit = 100; if (limitParam !== null) { const parsed = Number.parseFloat(limitParam); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1000) { + const maxLimit = await getMaxBatchSize().catch(() => 1000); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > maxLimit) { return NextResponse.json( { error: 'limit must be an integer between 1 and 1000' }, { status: 400 }, @@ -44,9 +66,27 @@ export async function GET(request: Request) { limit = parsed; } + // Page-based (offset) pagination, e.g. ?page=2&limit=50. Absent means page 1, + // which keeps every existing no-parameter caller (the routes page, the SDK's + // first page) on exactly the behaviour they had. + const pageParam = searchParams.get('page'); + let page = 1; + if (pageParam !== null) { + const parsed = Number.parseFloat(pageParam); + if (!Number.isInteger(parsed) || parsed < 1) { + return NextResponse.json({ error: 'page must be an integer >= 1' }, { status: 400 }); + } + page = parsed; + } + + // Cursor-based (keyset) pagination, used by @accensa/sdk. The two schemes are + // mutually exclusive: a request cannot offset and keyset at the same time. const cursor = searchParams.get('cursor'); let parsedCursor: { ts: string; txHash: string } | null = null; if (cursor) { + if (pageParam !== null) { + return NextResponse.json({ error: 'page and cursor cannot be combined' }, { status: 400 }); + } try { const decoded = Buffer.from(cursor, 'base64').toString('utf8'); const parts = decoded.split('|'); @@ -63,28 +103,83 @@ export async function GET(request: Request) { } } + const offset = (page - 1) * limit; + try { const merchant = await withClient((client) => getMerchantFromRequest(client, request)); if (!merchant) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const { rows, sync } = await withMerchantClient(merchant.id, async (client) => { - await ensureSchema(client); + const { rows, sync, totalCount, totalAmount } = await withMerchantClient( + merchant.id, + async (client) => { + await ensureSchema(client); - let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`; + // Window functions evaluate over the full filtered row set before LIMIT + // and OFFSET are applied, so one query returns both the page and the + // aggregates the dashboard header needs (total count, sum, single-asset + // detection via min = max). + let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method, + COUNT(*) OVER() AS total, + COALESCE(SUM(amount) OVER(), 0) AS total_amount, + CASE WHEN MIN(COALESCE(asset, 'native')) OVER() = + MAX(COALESCE(asset, 'native')) OVER() + THEN MIN(COALESCE(asset, 'native')) OVER() END AS total_asset + FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`; const params: (string | number)[] = [merchant.id]; if (parsedCursor) { query += ` AND (ts < $${params.length + 1} OR (ts = $${params.length + 1} AND tx_hash < $${params.length + 2}))`; params.push(parsedCursor.ts, parsedCursor.txHash); } + const countRes = await client.query<{ total_count: string; total_amount: string | null }>( + `SELECT count(*)::text AS total_count, coalesce(sum(amount), 0)::text AS total_amount FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`, + [merchant!.id], + ); + const totalCount = countRes.rows.length + ? Number(countRes.rows[0].total_count ?? countRes.rows.length) + : 0; + const totalAmount = + countRes.rows.length && + countRes.rows[0].total_amount !== undefined && + countRes.rows[0].total_amount !== null + ? String(countRes.rows[0].total_amount) + : '0'; - query += ` ORDER BY ts DESC, tx_hash DESC LIMIT $${params.length + 1}`; - params.push(limit); + let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`; + const params: (string | number)[] = [merchant.id]; + if (parsedCursor) { + query += ` AND (ts < $${params.length + 1} OR (ts = $${params.length + 1} AND tx_hash < $${params.length + 2}))`; + params.push(parsedCursor.ts, parsedCursor.txHash); + } + + if (!parsedCursor) { + query += ` OFFSET $${params.length + 1}`; + params.push(offset); + } const result = await client.query(query, params); return { rows: result.rows, sync: await getSyncState(client, merchant.id) }; }); + query += ` ORDER BY ts DESC, tx_hash DESC LIMIT $${params.length + 1}`; + params.push(limit); + + const result = await client.query(query, params); + return { + rows: result.rows, + sync: await getSyncState(client, merchant!.id), + totalCount, + totalAmount, + }; + }, + ); + + // The fake databases in tests do not return the window columns; tolerate + // their absence so aggregate handling is uniform. + const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0; + const totalAmount = rows.length > 0 ? String(rows[0].total_amount ?? 0) : '0'; + const totalAsset = rows.length > 0 ? (rows[0].total_asset ?? null) : null; + const totalPages = total === 0 ? 0 : Math.ceil(total / limit); const next_cursor = rows.length === limit @@ -106,10 +201,39 @@ export async function GET(request: Request) { })), sync, next_cursor, + total, + total_amount: totalAmount, + total_asset: totalAsset, + total_pages: totalPages, + total_count: totalCount, + total_amount: totalAmount, + ...(filterRoute || filterPayer || filterAsset || filterDateFrom || filterDateTo + ? { + filter_info: { + route: filterRoute ?? undefined, + payer: filterPayer ?? undefined, + asset: filterAsset ?? undefined, + date_from: filterDateFrom ?? undefined, + date_to: filterDateTo ?? undefined, + }, + } + : {}), }; - return NextResponse.json(body); + return NextResponse.json(body, { + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + }, + }); } catch (error: unknown) { console.error('Error fetching payments:', error); - return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + return NextResponse.json( + { error: 'Internal Server Error' }, + { + status: 500, + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + }, + }, + ); } } diff --git a/apps/web/src/app/api/receipts/[txHash]/route.test.ts b/apps/web/src/app/api/receipts/[txHash]/route.test.ts new file mode 100644 index 0000000..fe9044f --- /dev/null +++ b/apps/web/src/app/api/receipts/[txHash]/route.test.ts @@ -0,0 +1,54 @@ +import { expect, test, vi, describe, beforeEach } from 'vitest'; +import { GET } from './route'; + +vi.mock('@/lib/db', () => ({ + withClient: vi.fn(async (fn: (c: unknown) => unknown) => fn({})), + ensureSchema: vi.fn(), +})); + +vi.mock('@/lib/anchor', () => ({ + getProof: vi.fn(), +})); + +import { getProof } from '@/lib/anchor'; + +describe('GET /api/receipts/:txHash', () => { + beforeEach(() => { + process.env.DATABASE_URL = 'postgres://dummy'; + vi.mocked(getProof).mockReset(); + }); + + test('rejects a malformed hash', async () => { + const res = await GET(new Request('http://localhost/api/receipts/abcd'), { + params: Promise.resolve({ txHash: 'abcd' }), + }); + expect(res.status).toBe(400); + }); + + test('returns 404 when no proof has been recorded', async () => { + vi.mocked(getProof).mockResolvedValueOnce(null); + const tx = 'a'.repeat(64); + const res = await GET(new Request(`http://localhost/api/receipts/${tx}`), { + params: Promise.resolve({ txHash: tx }), + }); + expect(res.status).toBe(404); + }); + + test('returns the stored proof for a recorded payment', async () => { + const tx = 'a'.repeat(64); + vi.mocked(getProof).mockResolvedValueOnce({ + txHash: tx, + batchId: 1, + leaf: 'b'.repeat(64), + proof: ['c'.repeat(64)], + root: 'd'.repeat(64), + }); + const res = await GET(new Request(`http://localhost/api/receipts/${tx}`), { + params: Promise.resolve({ txHash: tx }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.batchId).toBe(1); + expect(body.proof).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/api/receipts/[txHash]/route.ts b/apps/web/src/app/api/receipts/[txHash]/route.ts new file mode 100644 index 0000000..36aed3b --- /dev/null +++ b/apps/web/src/app/api/receipts/[txHash]/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server'; +import { withClient, ensureSchema } from '@/lib/db'; +import { getProof } from '@/lib/anchor'; +import { isHash32 } from '@/lib/receipt-anchor'; + +export const dynamic = 'force-dynamic'; + +/** + * Serves the membership proof for one payment, so `/verify` can be filled + * from real data rather than the hand-pasted sample. + * + * Public on purpose: a proof is not a secret. Anyone holding a `tx_hash` + * should be able to fetch the leaf and siblings that place it in an + * anchored batch. + */ +export async function GET(_request: Request, context: { params: Promise<{ txHash: string }> }) { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } + + const { txHash } = await context.params; + if (!isHash32(txHash)) { + return NextResponse.json({ error: 'txHash must be a 32-byte hex hash' }, { status: 400 }); + } + + try { + const proof = await withClient(async (client) => { + await ensureSchema(client); + return getProof(client, txHash.trim().toLowerCase()); + }); + if (!proof) { + return NextResponse.json({ error: 'No recorded proof for this payment' }, { status: 404 }); + } + return NextResponse.json(proof); + } catch (error: unknown) { + console.error('receipt proof lookup failed:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/sync/route.ts b/apps/web/src/app/api/sync/route.ts index 60db40b..21b12fe 100644 --- a/apps/web/src/app/api/sync/route.ts +++ b/apps/web/src/app/api/sync/route.ts @@ -1,21 +1,36 @@ import { NextResponse } from 'next/server'; -import { transferTopicFilter, addressTopicFilter } from '@/lib/stellar-events'; +import { decodeTransferEvent, transferTopicFilter, addressTopicFilter } from '@/lib/stellar-events'; import { withClient, - withMerchantClient, ensureSchema, getLastSyncedLedger, getSyncState, + rollbackSyncToLedger, + setLastSyncedLedger, + getSyncState, } from '@/lib/db'; -import { eventsToPaymentRows, insertPaymentsInTransaction } from '@/lib/insert-payments'; +import { + sweepLedgerRange, + parallelSweepLedgerRange, + PARALLEL_SYNC_THRESHOLD, + EVENTS_PAGE_LIMIT, + LedgerWindowFetchError, + type EventPage, +} from '@/lib/event-pager'; +import { + eventsToPaymentRows, + chunkRows, + buildBatchInsertSql, + flattenRows, + PAYMENTS_BATCH_SIZE, + type PaymentRow, +} from '@/lib/insert-payments'; import { listMerchants, getMerchantFromRequest, type Merchant } from '@/lib/merchants'; -import { sweepLedgerRange, EVENTS_PAGE_LIMIT, type EventPage } from '@/lib/event-pager'; import { cooldownRemaining } from '@/lib/sync-status'; -<<<<<<< HEAD +import { broadcastSyncEvent, hasSubscribers } from '@/lib/sync-events'; import { isAuthorizedCronRequest } from '@/lib/cron-auth'; -======= +import { logSyncFailure, notifySyncFailure, type SyncFailureContext } from '@/lib/sync-logger'; import { createHmac } from 'node:crypto'; ->>>>>>> origin/main export const dynamic = 'force-dynamic'; export const maxDuration = 60; @@ -27,7 +42,7 @@ const RPC_URL = process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar. * to the testnet native XLM SAC; set ASSET_CONTRACT_IDS to a comma-separated * list to settle in USDC or across multiple assets. */ -const DEFAULT_ASSET_CONTRACT_IDS = ( +const ASSET_CONTRACT_IDS = ( process.env.ASSET_CONTRACT_IDS ?? 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' ) .split(',') @@ -101,19 +116,18 @@ interface CooldownResult { } /** - * Indexes Stellar Asset Contract transfers into one merchant's payment ledger. + * Indexes Stellar Asset Contract transfers into the merchant's payment ledger. * - * Shared by both entry points: the scheduled GET (looped over every merchant), - * and the POST behind the dashboard's manual trigger (one merchant, the caller). - * `cooldownMs`, when set, makes the run a no-op if the last sync is more recent - * than that. + * Shared by both entry points: the scheduled GET, and the POST behind the + * dashboard's manual trigger. `cooldownMs`, when set, makes the run a no-op if + * the last sync is more recent than that. */ -async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) { - return withMerchantClient(merchant.id, async (client) => { +async function runSync(merchant: string, opts: { cooldownMs?: number } = {}) { + return withClient(async (client) => { await ensureSchema(client); if (opts.cooldownMs) { - const state = await getSyncState(client, merchant.id); + const state = await getSyncState(client); const retryAfterMs = cooldownRemaining(state?.updatedAt, opts.cooldownMs); if (retryAfterMs > 0) return { cooldown: true, retryAfterMs } as CooldownResult; } @@ -121,7 +135,21 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) { { const { sequence: latestLedger } = await rpc<{ sequence: number }>('getLatestLedger', {}); - const cursor = await getLastSyncedLedger(client, merchant.id); + let cursor = await getLastSyncedLedger(client, merchant.id); + + // A chain head lower than the processed cursor means the node rolled + // back — a re-org, or a failover to a peer that lost its tail. Ledgers + // past the head no longer exist on the canonical chain, so payments + // indexed from them describe a chain that is gone: purge them and + // rewind the cursor to the corrected head before working out where to + // resume. Without this the early return below would report `drained` + // while the local ledger silently keeps rolled-back payments. + let rollback: { purged: number } | null = null; + if (cursor !== null && latestLedger < cursor) { + rollback = await rollbackSyncToLedger(client, merchant.id, latestLedger); + cursor = latestLedger; + } + const resumeFrom = cursor !== null ? cursor + 1 : latestLedger - COLD_START_LOOKBACK; const retentionFloor = latestLedger - MAX_LOOKBACK; const startLedger = Math.max(resumeFrom, retentionFloor, 1); @@ -133,7 +161,6 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) { if (startLedger > latestLedger) { return { - merchant: merchant.address, latestLedger, startLedger, syncedTo: startLedger - 1, @@ -143,18 +170,24 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) { scanned: 0, decoded: 0, inserted: 0, + // After a rollback there is nothing left to re-scan this + // invocation — the corrected head is the whole valid range — but + // the rewind was the work. Surface it so the run is not mistaken + // for a no-op. + ...(rollback + ? { rollback: true, rolledBackTo: latestLedger, purged: rollback.purged } + : {}), }; } // Filter server-side to transfers addressed to this merchant. The asset // topic is optional across protocol versions, so match both arities. - const toTopic = addressTopicFilter(merchant.address); + const toTopic = addressTopicFilter(merchant); const transfer = transferTopicFilter(); - const assetContractIds = merchant.assetContractIds ?? DEFAULT_ASSET_CONTRACT_IDS; const filters = [ { type: 'contract', - contractIds: assetContractIds, + contractIds: ASSET_CONTRACT_IDS, topics: [ [transfer, '*', toTopic, '*'], [transfer, '*', toTopic], @@ -176,75 +209,84 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) { { startLedger, endLedger: latestLedger, withinBudget: () => Date.now() < deadline }, ); - const webhookUrl = merchant.webhookUrl ?? process.env.WEBHOOK_URL; - - // Per-event filtering lives in eventsToPaymentRows: a malformed or - // non-transfer event is skipped, and a transfer not addressed to this - // merchant is never recorded. Only the insert below is batched — batching - // must not quietly admit events that would have been filtered out. - const { rows, decoded } = eventsToPaymentRows(events, merchant); - - // DO UPDATE, not DO NOTHING: a row may already exist because the - // merchant reported route attribution before this transfer was indexed, - // which is the normal ordering — the hook fires the moment x402 settles, - // this job runs on a schedule. Skipping the conflict would leave that - // row permanently null and invisible. Only ledger-owned columns are - // written; route, method, request_id and hook_reported_at belong to the - // merchant's report and are left alone. - // - // The inserts and the cursor advance commit atomically (see - // insertPaymentsInTransaction): if any chunk fails, nothing commits and - // the cursor stays behind the failed run. - const { inserted, payments } = await insertPaymentsInTransaction( - client, - merchant.id, - rows, - sweptThrough, - ); - - // Webhooks fire after COMMIT, so a slow or failing webhook can neither - // hold the transaction open nor roll back a committed batch. The - // returned rows are exactly the payments written this run. - if (webhookUrl) { - for (const payment of payments) { - const body = JSON.stringify(payment); - const webhookSecret = process.env.WEBHOOK_SECRET; - const headers: Record = { 'Content-Type': 'application/json' }; - if (webhookSecret) { - headers['X-Webhook-Signature'] = createHmac('sha256', webhookSecret) - .update(body) - .digest('hex'); - } - const timeoutMs = 2000; - for (let i = 0; i < 3; i++) { - try { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), timeoutMs); - const webhookRes = await fetch(webhookUrl, { - method: 'POST', - headers, - body, - signal: controller.signal, - }); - clearTimeout(id); - if (webhookRes.ok || webhookRes.status < 500) break; - } catch { - // A webhook the merchant cannot receive must not stall indexing. - } + let inserted = 0; + let decoded = 0; + + for (const event of events) { + const transferEvent = decodeTransferEvent(event); + // A malformed or non-transfer event must not stall the batch. + if (!transferEvent) continue; + decoded++; + + // Defensive: never record a transfer that is not to this merchant. + if (transferEvent.to !== merchant) continue; + + // DO UPDATE, not DO NOTHING: a row may already exist because the + // merchant reported route attribution before this transfer was + // indexed, which is the normal ordering — the hook fires the moment + // x402 settles, this job runs on a schedule. Skipping the conflict + // would leave that row permanently null and invisible. + // + // Only ledger-owned columns are written. route, method, request_id and + // hook_reported_at belong to the merchant's report and are left alone. + await client.query('BEGIN'); + try { + const res = await client.query( + `INSERT INTO payments (tx_hash, ledger, payer, amount, asset, ts) + VALUES ($1, $2, $3, $4::numeric, $5, $6::timestamptz) + ON CONFLICT (tx_hash) DO UPDATE + SET ledger = EXCLUDED.ledger, + payer = EXCLUDED.payer, + amount = EXCLUDED.amount, + asset = EXCLUDED.asset, + ts = EXCLUDED.ts + WHERE payments.ledger IS NULL RETURNING *`, + [ + transferEvent.txHash, + transferEvent.ledger, + transferEvent.from, + transferEvent.amount, // string - never a float + transferEvent.asset, + transferEvent.ledgerClosedAt, + ], + ); + if (res.rowCount && res.rowCount > 0 && process.env.WEBHOOK_URL) { + await enqueueWebhookDelivery( + client, + payloadFromRow(res.rows[0] as Record), + process.env.WEBHOOK_URL, + ); } + await client.query('COMMIT'); + inserted += res.rowCount ?? 0; + } catch (error) { + await client.query('ROLLBACK').catch(() => {}); + throw error; } } - // The sweep only ever reports whole windows, so the cursor advance is - // safe whether or not it reached the head. Crucially it advances across - // empty windows too - a quiet merchant that never moved the cursor is - // how the indexer fell behind the RPC retention window and stopped - // seeing payments. Each merchant's cursor advances independently, so one - // merchant with no activity cannot hold back or be held back by - // another's progress. + // The sweep only ever reports whole windows, so this is safe whether or + // not it reached the head. Crucially it advances across empty windows + // too - a quiet merchant that never moved the cursor is how the indexer + // fell behind the RPC retention window and stopped seeing payments. + await setLastSyncedLedger(client, sweptThrough); + + // Push a real-time update to any subscribed dashboard tab instead of + // waiting for the next poll (real-time indexer updates). Skipped when no + // client is listening so an idle sync does no broadcast bookkeeping. + if (hasSubscribers(merchant.id)) { + broadcastSyncEvent(merchant.id, { + merchant: merchant.address, + syncedTo: sweptThrough, + inserted, + scanned, + pages, + drained: complete, + occurredAt: new Date().toISOString(), + }); + } return { - merchant: merchant.address, latestLedger, startLedger, syncedTo: sweptThrough, @@ -262,6 +304,12 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) { type SyncResult = Awaited>; +/** One merchant's sync throwing instead of returning a result (#135). */ +interface SyncFailure { + merchant: string; + error: string; +} + /** Maps one merchant's run to its response fragment. */ function summarize(result: SyncResult) { if ('cooldown' in result) { @@ -270,6 +318,26 @@ function summarize(result: SyncResult) { return result; } +/** + * Builds the context+logging a caught sync error needs, then reports it both + * to the log (always) and to SYNC_ALERT_WEBHOOK_URL (if configured) (#135). + * + * A LedgerWindowFetchError carries the exact window being read when the RPC + * call failed; anything else (a parsing error, a DB error) is logged without + * ledger context rather than guessing at one. + */ +function reportSyncError(error: unknown, merchant?: string): void { + const context: SyncFailureContext = { + ...(merchant ? { merchant } : {}), + ...(error instanceof LedgerWindowFetchError + ? { startLedger: error.startLedger, endLedger: error.endLedger } + : {}), + }; + logSyncFailure(context, error); + // Alerting must never block or fail the sync job itself. + void notifySyncFailure(context, error); +} + /** * Maps a set of per-merchant runs to a response. * @@ -279,18 +347,26 @@ function summarize(result: SyncResult) { * as deployment-wide maximums alongside the full per-merchant `results`, so * that check keeps working unchanged whether this deployment has one merchant * or many. + * + * `failures` (#135) are merchants whose sync threw rather than returned — they + * no longer abort the whole batch (see GET below), so they are reported here + * instead: `success` goes false, which the workflow already treats as a + * warning worth surfacing, while `results`/`syncedTo` still reflect whatever + * other merchants did complete. */ -function respond(results: SyncResult[]) { +function respond(results: SyncResult[], failures: SyncFailure[] = []) { // The manual, single-merchant POST path preserves the original 429 + // Retry-After contract exactly, since the dashboard's "Sync now" button // already depends on it. - if (results.length === 1 && 'cooldown' in results[0]) { + if (failures.length === 0 && results.length === 1 && 'cooldown' in results[0]) { const retryAfterMs = Math.ceil(results[0].retryAfterMs); return NextResponse.json( { success: true, cooldown: true, retryAfterMs }, { status: 429, headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) } }, ); } + return NextResponse.json({ success: true, ...result }); +} const summaries = results.map(summarize); const synced = summaries.filter( @@ -301,14 +377,15 @@ function respond(results: SyncResult[]) { const drained = synced.length ? synced.every((s) => s.drained) : true; return NextResponse.json({ - success: true, + success: failures.length === 0, results: summaries, ...(syncedTo !== null ? { syncedTo, skippedLedgers, drained } : {}), + ...(failures.length ? { failures } : {}), }); } -function failed(error: unknown) { - console.error('Error during sync:', error); +function failed(error: unknown, merchant?: string) { + reportSyncError(error, merchant); return NextResponse.json({ success: false, error: 'Internal Server Error' }, { status: 500 }); } @@ -316,27 +393,18 @@ function failed(error: unknown) { * Scheduled entry point. * * Driven by Vercel Cron and by .github/workflows/sync.yml. Protected by - * CRON_SECRET, checked with a constant-time compare in isAuthorizedCronRequest - * (@/lib/cron-auth) - both senders pass it as a bearer token - so the - * endpoint cannot be driven by arbitrary callers. An unset CRON_SECRET fails - * closed: middleware.ts already denies this path before it reaches here, and - * this check denies it too, since no caller should ever run a sync against a - * deployment with no secret configured. No cooldown: a scheduled run is - * already rate limited by its schedule. - * - * Sweeps every configured merchant in turn, each with its own cursor - a - * merchant with no activity still has its cursor advanced (see runSync), - * which is precisely the fix for the outage that motivated this workflow's - * checks in the first place. + * CRON_SECRET when set - both senders pass it as a bearer token - so the + * endpoint cannot be driven by arbitrary callers. No cooldown: a scheduled run + * is already rate limited by its schedule. */ export async function GET(request: Request) { - if (!isAuthorizedCronRequest(request.headers.get('authorization'))) { + const secret = process.env.CRON_SECRET; + if (secret && request.headers.get('authorization') !== `Bearer ${secret}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - if (!process.env.DATABASE_URL) { - return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); - } + const bad = configError(); + if (bad) return bad; try { const merchants = await withClient(async (client) => { @@ -349,10 +417,22 @@ export async function GET(request: Request) { } const results: SyncResult[] = []; + const failures: SyncFailure[] = []; for (const merchant of merchants) { - results.push(await runSync(merchant)); + // One merchant's RPC error or parsing failure must not cost every + // merchant after it in this run their turn (#135) — each is isolated + // and logged with context, and the loop moves on. + try { + results.push(await runSync(merchant)); + } catch (error) { + reportSyncError(error, merchant.address); + failures.push({ + merchant: merchant.address, + error: error instanceof Error ? error.message : String(error), + }); + } } - return respond(results); + return respond(results, failures); } catch (error: unknown) { return failed(error); } @@ -361,23 +441,21 @@ export async function GET(request: Request) { /** * Manual entry point, behind the dashboard's"Sync now"button. * - * Protected by session authentication via middleware, which resolves to - * exactly the merchant that owns this dashboard session — a signed-in - * merchant can only trigger their own sync. MANUAL_COOLDOWN_MS bounds the cost. + * Protected by session authentication via middleware. MANUAL_COOLDOWN_MS bounds the cost. */ -export async function POST(request: Request) { - if (!process.env.DATABASE_URL) { - return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); - } +export async function POST() { + const bad = configError(); + if (bad) return bad; + let merchant: Merchant | null = null; try { - const merchant = await withClient((client) => getMerchantFromRequest(client, request)); + merchant = await withClient((client) => getMerchantFromRequest(client, request)); if (!merchant) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } return respond([await runSync(merchant, { cooldownMs: MANUAL_COOLDOWN_MS })]); } catch (error: unknown) { - return failed(error); + return failed(error, merchant?.address); } } diff --git a/apps/web/src/app/api/sync/stream/route.ts b/apps/web/src/app/api/sync/stream/route.ts new file mode 100644 index 0000000..2c681a9 --- /dev/null +++ b/apps/web/src/app/api/sync/stream/route.ts @@ -0,0 +1,31 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { withClient } from '@/lib/db'; +import { getMerchantFromRequest } from '@/lib/merchants'; +import { createSyncStream } from '@/lib/sync-events'; + +export const dynamic = 'force-dynamic'; + +/** + * Server-Sent Events subscription for indexer updates. + * + * `GET /api/sync/stream` keeps a connection open and pushes a `sync` event + * each time the indexer finishes a run for the signed-in merchant. The + * dashboard and SDK subscribe here instead of polling `/api/sync`, which is + * what removes the polling load described in the real-time update issue. + * + * Authentication mirrors the other API routes: `apps/web/src/middleware.ts` + * verifies the session cookie and forwards the merchant address as + * `x-accensa-merchant`, which `getMerchantFromRequest` trusts. + */ +export async function GET(request: NextRequest) { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } + + const merchant = await withClient((client) => getMerchantFromRequest(client, request)); + if (!merchant) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + return createSyncStream(request, merchant.id); +} diff --git a/apps/web/src/app/api/webhooks/deliver/route.ts b/apps/web/src/app/api/webhooks/deliver/route.ts new file mode 100644 index 0000000..a90f75f --- /dev/null +++ b/apps/web/src/app/api/webhooks/deliver/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { withClient, ensureSchema } from '@/lib/db'; +import { deliverDue } from '@/lib/webhooks'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 30; + +/** + * Ships queued payment webhooks. + * + * Protected by CRON_SECRET the same way GET /api/sync is. Indexing never + * calls this — a hung merchant endpoint can only delay itself. + */ +export async function GET(request: Request) { + const secret = process.env.CRON_SECRET; + if (secret && request.headers.get('authorization') !== `Bearer ${secret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } + + try { + const result = await withClient(async (client) => { + await ensureSchema(client); + return deliverDue(client); + }); + return NextResponse.json({ success: true, ...result }); + } catch (error: unknown) { + console.error('webhook delivery failed:', error); + return NextResponse.json({ success: false, error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/webhooks/route.ts b/apps/web/src/app/api/webhooks/route.ts new file mode 100644 index 0000000..1d18bf5 --- /dev/null +++ b/apps/web/src/app/api/webhooks/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { withClient, ensureSchema } from '@/lib/db'; +import { webhookSummary } from '@/lib/webhooks'; + +export const dynamic = 'force-dynamic'; + +/** Merchant-visible webhook delivery status. Session-authenticated via middleware. */ +export async function GET() { + if (!process.env.DATABASE_URL) { + return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 }); + } + if (!process.env.WEBHOOK_URL) { + return NextResponse.json({ + configured: false, + pending: 0, + failed: 0, + delivered: 0, + recentFailed: [], + }); + } + + try { + const summary = await withClient(async (client) => { + await ensureSchema(client); + return webhookSummary(client); + }); + return NextResponse.json({ configured: true, ...summary }); + } catch (error: unknown) { + console.error('webhook summary failed:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/apps/web/src/app/batches/[id]/page.tsx b/apps/web/src/app/batches/[id]/page.tsx index 1eb154f..d610beb 100644 --- a/apps/web/src/app/batches/[id]/page.tsx +++ b/apps/web/src/app/batches/[id]/page.tsx @@ -3,8 +3,10 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import type { Metadata } from 'next'; import { getBatch, RECEIPT_ANCHOR_ID, type BatchRecord } from '@/lib/receipt-anchor'; +import { explorerContractUrl } from '@/lib/explorer'; import { ArrowUpRight } from 'lucide-react'; import { PageContainer } from '@/components/page-container'; +import { formatTimestamp, toISO8601 } from '@/lib/format-timestamp'; /** * A batch is immutable once anchored, so this can be cached hard. Revalidating @@ -78,8 +80,16 @@ export default async function BatchPage({ params }: { params: Promise<{ id: stri
{batch.count} - {period.start.toLocaleString()} - {period.end.toLocaleString()} + + + + + +
{RECEIPT_ANCHOR_ID} @@ -94,7 +104,7 @@ export default async function BatchPage({ params }: { params: Promise<{ id: stri Verify a receipt in this batch ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + }), + usePathname: () => '/dashboard', + useSearchParams: () => new URLSearchParams(), +})); + +import Dashboard from './page'; + +describe('Dashboard totals, pagination honesty, and contrast', () => { + it('renders loading skeleton matching total scale and table layout', () => { + const html = renderToString(); + + // Total loading placeholder matches h-10 sm:h-12 w-44 sm:w-56 + expect(html).toContain('h-10 sm:h-12 w-44 sm:w-56'); + // Renders responsive skeletons for mobile and desktop + expect(html).toContain('class="md:hidden divide-y'); + expect(html).toContain('class="hidden md:block'); + }); + + it('renders high contrast tokens complying with WCAG AA', () => { + const html = renderToString(); + + // Section header labels use accessible slate tokens (>= 4.5:1 on background) + expect(html).toContain('text-slate-600 dark:text-slate-300'); + // Total settled label is accessible + expect(html).toContain( + 'text-xs font-bold text-slate-600 dark:text-slate-300 uppercase tracking-widest', + ); + // Emerald label uses emerald-700 on light + expect(html).toContain('text-emerald-700 dark:text-emerald-400'); + }); +}); diff --git a/apps/web/src/app/dashboard/dashboard-visibility.test.tsx b/apps/web/src/app/dashboard/dashboard-visibility.test.tsx new file mode 100644 index 0000000..4eea412 --- /dev/null +++ b/apps/web/src/app/dashboard/dashboard-visibility.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useVisibility } from '@/components/network-status'; + +describe('useVisibility hook and tab visibility tracking', () => { + let mockListeners: Record void)[]> = {}; + + beforeEach(() => { + mockListeners = {}; + const mockDocument = { + visibilityState: 'visible', + addEventListener: vi.fn((event: string, cb: () => void) => { + mockListeners[event] = mockListeners[event] || []; + mockListeners[event].push(cb); + }), + removeEventListener: vi.fn((event: string, cb: () => void) => { + if (mockListeners[event]) { + mockListeners[event] = mockListeners[event].filter((fn) => fn !== cb); + } + }), + }; + + Object.defineProperty(globalThis, 'document', { + value: mockDocument, + writable: true, + configurable: true, + }); + }); + + it('exports useVisibility hook', () => { + expect(typeof useVisibility).toBe('function'); + }); + + it('registers visibilitychange listener on document when available', () => { + const cb = vi.fn(); + document.addEventListener('visibilitychange', cb); + expect(document.addEventListener).toHaveBeenCalledWith('visibilitychange', cb); + + document.removeEventListener('visibilitychange', cb); + expect(document.removeEventListener).toHaveBeenCalledWith('visibilitychange', cb); + }); +}); diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index d1e1ed7..61e8f80 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -11,6 +11,8 @@ import { RefundPanel } from '@/components/refund-panel'; import { CopyButton } from '@/components/copy-button'; import { useOnline } from '@/components/network-status'; import { describeFailure, isAbortError } from '@/lib/network-status'; +import { explorerTxUrl } from '@/lib/explorer'; +import { focusRestorer, getFocusable, wrapTabTarget } from '@/lib/dialog-focus'; interface Payment { tx_hash: string; @@ -29,7 +31,6 @@ type LoadState = | { status: 'error'; message: string }; const POLL_INTERVAL_MS = 15_000; -const explorerUrl = (hash: string) => `https://stellar.expert/explorer/testnet/tx/${hash}`; function truncate(value: string, head = 8, tail = 6) { return value.length <= head + tail + 1 ? value : `${value.slice(0, head)}…${value.slice(-tail)}`; @@ -61,7 +62,6 @@ function saveRefundedToStorage(refunded: ReadonlySet): void { export default function Dashboard() { const [state, setState] = useState({ status: 'loading' }); const [selected, setSelected] = useState(null); - const closeButtonRef = useRef(null); const [reloadToken, setReloadToken] = useState(0); // Refunds issued in this session. The indexer does not watch RefundVault // events yet, so a refund is otherwise invisible until someone opens the @@ -71,6 +71,10 @@ export default function Dashboard() { (txHash: string) => setRefunded((prev) => new Set(prev).add(txHash)), [], ); + // Stable identity: PaymentModal's focus-management effect depends on it, and a + // fresh closure every render (the dashboard re-renders on every 15s poll) + // would re-trap focus mid-interaction. + const closeModal = useCallback(() => setSelected(null), []); const online = useOnline(); const reload = useCallback(() => setReloadToken((n) => n + 1), []); @@ -112,14 +116,6 @@ export default function Dashboard() { }; }, [reloadToken, online]); - useEffect(() => { - if (!selected) return; - closeButtonRef.current?.focus(); - const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setSelected(null); - document.addEventListener('keydown', onKey); - return () => document.removeEventListener('keydown', onKey); - }, [selected]); - const payments = state.status === 'ready' ? state.payments : []; const total = sumAmounts(payments.map((p) => p.amount)); const assets = new Set(payments.map((p) => assetLabel(p.asset))); @@ -298,7 +294,7 @@ export default function Dashboard() { {selected && ( setSelected(null)} + onClose={closeModal} refunded={refunded} onRefunded={markRefunded} /> @@ -307,6 +303,8 @@ export default function Dashboard() { ); } +const PAYMENT_MODAL_HEADING_ID = 'payment-details-heading'; + export function PaymentModal({ selected, onClose, @@ -318,18 +316,67 @@ export function PaymentModal({ refunded: ReadonlySet; onRefunded: (tx_hash: string) => void; }) { + const dialogRef = useRef(null); + + // A real modal dialog: focus moves in on open, Tab is trapped inside, Escape + // and a backdrop click both close, and focus returns to whatever opened it. + useEffect(() => { + const restoreFocus = focusRestorer( + typeof document === 'undefined' ? null : (document.activeElement as HTMLElement | null), + ); + + const dialog = dialogRef.current; + if (dialog) { + const focusable = getFocusable(dialog); + (focusable[0] ?? dialog).focus(); + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + if (event.key === 'Tab' && dialog) { + const target = wrapTabTarget( + getFocusable(dialog), + document.activeElement as HTMLElement | null, + event.shiftKey, + ); + if (target) { + event.preventDefault(); + target.focus(); + } + } + }; + + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + restoreFocus(); + }; + }, [onClose]); + return (
e.stopPropagation()} >
-

+

Payment Details

{refunded.has(selected.tx_hash) && ( @@ -342,7 +389,9 @@ export function PaymentModal({ )}
+ ) : null} +
+ + {error && ( +

+ {error}{' '} + {/not installed/i.test(error) && ( + + Install Freighter + + )} +

+ )} + + {phase.kind === 'loading' && ( +

Building the tree…

+ )} + + {phase.kind === 'empty' && ( +

+ Nothing unanchored. Indexed payments that are not already in a recorded batch will appear + here. +

+ )} + + {phase.kind === 'pending' && ( +

+ Transaction submitted ({phase.hash.slice(0, 8)}…) but not yet confirmed. Wait for the + ledger, then preview again — a submitted selection is recorded without a second signature. +

+ )} + + {(phase.kind === 'preview' || phase.kind === 'signing' || phase.kind === 'recording') && ( + void confirm(phase.preview)} + onCancel={() => { + setError(null); + setPhase({ kind: 'idle' }); + }} + /> + )} + + {phase.kind === 'done' && ( +
+

+ Batch #{phase.batchId} recorded. Proofs are now serveable. +

+ + View batch #{phase.batchId} → + +
+ )} + + ); +} + +function PreviewCard({ + preview, + busy, + onConfirm, + onCancel, +}: { + preview: Exclude; + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + const already: AnchorStatus | undefined = preview.existing?.status; + const label = + already === 'recorded' + ? 'Already anchored' + : already === 'submitted' + ? 'Finish recording' + : busy + ? 'Working…' + : 'Sign and submit'; + + return ( +
+
+ + + +
+
+

+ Merkle root +

+

+ {preview.root} +

+
+ {already === 'recorded' && ( +

+ This exact selection is already batch #{preview.existing?.batchId}. Submitting again will + not create a second batch. +

+ )} +
+ + +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} diff --git a/apps/web/src/components/badge.stories.tsx b/apps/web/src/components/badge.stories.tsx new file mode 100644 index 0000000..39bec11 --- /dev/null +++ b/apps/web/src/components/badge.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { Badge } from './badge'; + +const meta = { + title: 'UI/Badge', + component: Badge, + args: { children: 'GET' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Neutral: Story = { args: { tone: 'neutral', children: 'queued' } }; +export const Success: Story = { args: { tone: 'success', children: 'settled' } }; +export const Warning: Story = { args: { tone: 'warning', children: 'refunded' } }; +export const Danger: Story = { args: { tone: 'danger', children: 'failed' } }; diff --git a/apps/web/src/components/badge.tsx b/apps/web/src/components/badge.tsx new file mode 100644 index 0000000..3ccd078 --- /dev/null +++ b/apps/web/src/components/badge.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +/** + * Small status / method chip used next to tabular data. + * + * The dashboard previously inlined this recipe on the route column and the + * refunded marker. One component keeps the padding, tracking, and colour + * tokens from drifting. + */ +export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger'; + +const TONES: Record = { + neutral: + 'bg-slate-50 dark:bg-white/5 border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300', + success: + 'bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20 text-emerald-700 dark:text-emerald-300', + warning: + 'bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/20 text-amber-800 dark:text-amber-300', + danger: + 'bg-red-50 dark:bg-red-500/10 border-red-200 dark:border-red-500/20 text-red-700 dark:text-red-300', +}; + +export function Badge({ + tone = 'neutral', + className = '', + children, +}: { + tone?: BadgeTone; + className?: string; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/cta-button.stories.tsx b/apps/web/src/components/cta-button.stories.tsx new file mode 100644 index 0000000..ea2098f --- /dev/null +++ b/apps/web/src/components/cta-button.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { CtaButton } from './cta-button'; + +const meta = { + title: 'UI/CtaButton', + component: CtaButton, + args: { href: '/dashboard', children: 'Open dashboard' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { args: { variant: 'primary' } }; +export const Secondary: Story = { args: { variant: 'secondary' } }; diff --git a/apps/web/src/components/data-table.stories.tsx b/apps/web/src/components/data-table.stories.tsx new file mode 100644 index 0000000..2f17c6f --- /dev/null +++ b/apps/web/src/components/data-table.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; +import { DataTable } from './data-table'; +import { Badge } from './badge'; + +const rows = [ + { id: '1', tx: 'aaaa…1111', amount: '1.50 XLM', route: '/api/resource' }, + { id: '2', tx: 'bbbb…2222', amount: '0.25 XLM', route: '/v1/quote' }, +]; + +const columns = [ + { key: 'tx', header: 'Transaction', render: (row: (typeof rows)[0]) => row.tx }, + { key: 'amount', header: 'Amount', render: (row: (typeof rows)[0]) => row.amount }, + { + key: 'route', + header: 'Route', + render: (row: (typeof rows)[0]) => {row.route}, + }, +]; + +const meta = { + title: 'UI/DataTable', + component: DataTable, + args: { columns, rows }, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Populated: Story = {}; +export const Empty: Story = { + args: { rows: [], empty: 'Payments settled to this merchant will appear here.' }, +}; +export const Loading: Story = { args: { rows: [], loading: true } }; diff --git a/apps/web/src/components/data-table.tsx b/apps/web/src/components/data-table.tsx new file mode 100644 index 0000000..7a9c200 --- /dev/null +++ b/apps/web/src/components/data-table.tsx @@ -0,0 +1,86 @@ +import React from 'react'; + +export interface DataTableColumn { + key: string; + header: string; + className?: string; + render: (row: T) => React.ReactNode; +} + +/** + * The dashboard payments table, extracted far enough to document in Storybook + * without dragging in fetch, auth, or refunds. + * + * Empty and loading states are first-class: those are the views a merchant + * actually stares at, and they were previously only reachable through the + * full page. + */ +export function DataTable({ + columns, + rows, + empty, + loading, + onRowClick, +}: { + columns: DataTableColumn[]; + rows: T[]; + empty?: React.ReactNode; + loading?: boolean; + onRowClick?: (row: T) => void; +}) { + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (rows.length === 0) { + return ( +
+ {empty ?? 'No rows'} +
+ ); + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row) => ( + onRowClick(row) : undefined} + className={ + onRowClick + ? 'hover:bg-slate-50 dark:hover:bg-white/[0.04] transition-colors cursor-pointer' + : undefined + } + > + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.header} +
+ {col.render(row)} +
+
+ ); +} diff --git a/apps/web/src/components/error-boundary.test.tsx b/apps/web/src/components/error-boundary.test.tsx new file mode 100644 index 0000000..e12a6e1 --- /dev/null +++ b/apps/web/src/components/error-boundary.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { ErrorBoundary } from './error-boundary'; + +/** Render whatever `ErrorBoundary` produces for the given props + state. */ +function renderBoundary(props: React.ComponentProps, error: Error | null) { + const instance = new ErrorBoundary(props); + instance.state = { error }; + return renderToString(<>{instance.render()}); +} + +describe('ErrorBoundary', () => { + it('renders its children while nothing has thrown', () => { + const html = renderToString( + +

all good

+
, + ); + expect(html).toContain('all good'); + }); + + it('getDerivedStateFromError carries the error into state', () => { + const err = new Error('x'); + expect(ErrorBoundary.getDerivedStateFromError(err)).toEqual({ error: err }); + }); + + it('shows the default fallback, naming the section, once an error is set', () => { + const html = renderBoundary({ label: 'revenue chart', children: null }, new Error('kaboom')); + expect(html).toContain('The revenue chart could not be shown.'); + expect(html).toContain('the page is unaffected'); + expect(html).toContain('role="alert"'); + expect(html).toContain('Try again'); + }); + + it('falls back to a generic message with no label', () => { + const html = renderBoundary({ children: null }, new Error('kaboom')); + expect(html).toContain('This section could not be shown.'); + }); + + it('uses a custom fallback render prop when given one', () => { + const html = renderBoundary( + { children: null, fallback: (error) => custom: {error.message} }, + new Error('kaboom'), + ); + expect(html).toContain('custom:'); + expect(html).toContain('kaboom'); + }); +}); diff --git a/apps/web/src/components/error-boundary.tsx b/apps/web/src/components/error-boundary.tsx new file mode 100644 index 0000000..1d53bd4 --- /dev/null +++ b/apps/web/src/components/error-boundary.tsx @@ -0,0 +1,84 @@ +'use client'; + +import React from 'react'; +import { TriangleAlert } from 'lucide-react'; + +/** + * Isolates a render-time crash to one part of the page. + * + * Next's `app/error.tsx` already catches anything thrown under the route, but + * it replaces the *whole* page — a malformed API payload that makes one chart + * throw would blank the settlements table next to it too. Wrapping each + * independent widget in this boundary keeps a fault in one contained: the rest + * of the dashboard stays usable, and the broken section shows a fallback with + * a retry. + * + * Class component because `getDerivedStateFromError` / `componentDidCatch` + * have no hook equivalent — this is the one place React still requires one. + */ +interface Props { + children: React.ReactNode; + /** + * Rendered in place of `children` when they throw. Gets the error and a + * `reset` that clears the boundary so the children re-mount and re-render. + */ + fallback?: (error: Error, reset: () => void) => React.ReactNode; + /** Names the section in the default fallback and the console message. */ + label?: string; + /** Called on every caught error, e.g. to forward to logging. */ + onError?: (error: Error, info: React.ErrorInfo) => void; +} + +interface State { + error: Error | null; +} + +export class ErrorBoundary extends React.Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + // No error-reporting service is wired up, so the console is where a stack + // gets matched against logs — same as `app/error.tsx`. + console.error( + `[accensa] ${this.props.label ?? 'section'} failed to render`, + error, + info.componentStack, + ); + this.props.onError?.(error, info); + } + + reset = (): void => this.setState({ error: null }); + + render(): React.ReactNode { + const { error } = this.state; + if (!error) return this.props.children; + if (this.props.fallback) return this.props.fallback(error, this.reset); + return ; + } +} + +function DefaultFallback({ label, onReset }: { label?: string; onReset: () => void }) { + return ( +
+ +

+ {label ? `The ${label} could not be shown.` : 'This section could not be shown.'} The rest + of the page is unaffected. +

+ +
+ ); +} diff --git a/apps/web/src/components/nav.tsx b/apps/web/src/components/nav.tsx index ce2eb0f..b398c63 100644 --- a/apps/web/src/components/nav.tsx +++ b/apps/web/src/components/nav.tsx @@ -34,7 +34,10 @@ export function Nav() { return ( <> -